From 6f0b4b4905a2b002ab9b3514b701b2f8d9d4eadf Mon Sep 17 00:00:00 2001 From: tsegaran Date: Thu, 4 Jun 2020 13:38:10 -0700 Subject: [PATCH] JSON gateway for mobilecoind (#195) * JSON gateway for mobilecoind * fmt and clippy * First round of fixes based on comments * Move verbs to the end * Allow JSON from transfer call to be passed directly to status * Change call name * Updates README to match new endpoints * Return and parse full receipt * Separates reading request code from transfer, other cleanups * Add sender_tx_receipt as part of transfer result --- Cargo.lock | 18 ++ Cargo.toml | 1 + api/src/conversions.rs | 8 + mobilecoind-json/Cargo.toml | 19 ++ mobilecoind-json/README.md | 74 ++++++ mobilecoind-json/src/main.rs | 444 +++++++++++++++++++++++++++++++++++ 6 files changed, 564 insertions(+) create mode 100644 mobilecoind-json/Cargo.toml create mode 100644 mobilecoind-json/README.md create mode 100644 mobilecoind-json/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 6f2951893b..8403b83ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3423,6 +3423,24 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "mobilecoind-json" +version = "0.1.0" +dependencies = [ + "grpcio", + "hex 0.4.2", + "mc-api", + "mc-common", + "mc-mobilecoind-api", + "mc-util-grpc", + "protobuf", + "rocket", + "rocket_contrib", + "serde", + "serde_derive", + "structopt", +] + [[package]] name = "more-asserts" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7949af0b9c..cd0afacdfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "ledger/from-archive", "ledger/sync", "mobilecoind", + "mobilecoind-json", "mobilecoind/api", "peers", "peers/test-utils", diff --git a/api/src/conversions.rs b/api/src/conversions.rs index 2f2bf70594..03137faa3d 100644 --- a/api/src/conversions.rs +++ b/api/src/conversions.rs @@ -740,6 +740,14 @@ pub fn block_num_to_s3block_path(block_index: mc_transaction_core::BlockIndex) - path } +impl From> for external::KeyImage { + fn from(src: Vec) -> Self { + let mut key_image = external::KeyImage::new(); + key_image.set_data(src); + key_image + } +} + #[cfg(test)] mod conversion_tests { use super::*; diff --git a/mobilecoind-json/Cargo.toml b/mobilecoind-json/Cargo.toml new file mode 100644 index 0000000000..676bb3e351 --- /dev/null +++ b/mobilecoind-json/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mobilecoind-json" +version = "0.1.0" +authors = ["MobileCoin"] +edition = "2018" + +[dependencies] +grpcio = "0.5.1" +rocket = { version = "0.4.4", default-features = false } +rocket_contrib = { version = "0.4.4", default-features = false, features = ["json"] } +hex = "0.4" +mc-api = { path = "../api" } +mc-common = { path = "../common", features = ["log"] } +mc-util-grpc = { path = "../util/grpc" } +mc-mobilecoind-api = { path = "../mobilecoind/api" } +protobuf = "2.12" +serde = "1.0" +serde_derive = "1.0" +structopt = "0.3" \ No newline at end of file diff --git a/mobilecoind-json/README.md b/mobilecoind-json/README.md new file mode 100644 index 0000000000..23ab0f84ea --- /dev/null +++ b/mobilecoind-json/README.md @@ -0,0 +1,74 @@ +## mobilecoind-json + +This is a standalone executable which provides a simple HTTP JSON API wrapping the [mobilecoind](../mobilecoind) gRPC API. + +It should be run alongside `mobilecoind`. + +### Launching +Since it is just web server converting JSON requests to gRPC, and it's set up +with the mobilecoind defaults, it can simply be launched with: +``` +cargo run +``` + +Options are: + +- `--listen_host` - hostname for webserver, default `127.0.0.1` +- `--listen_port` - port for webserver, default `9090` +- `--mobilecoind_host` - hostname:port for mobilecoind gRPC, default `127.0.0.1:4444` +- `--use_ssl` - connect to mobilecoind using SSL, default is false + +### Usage with cURL + +#### Generate a new master key +``` +$ curl localhost:9090/entropy +{"entropy":"706db549844bc7b5c8328368d4b8276e9aa03a26ac02474d54aa99b7c3369e2e"} +``` +#### Add a monitor for a key over a range of subaddress indices +``` +$ curl localhost:9090/monitors -d '{"entropy": "706db549844bc7b5c8328368d4b8276e9aa03a26ac02474d54aa99b7c3369e2e", "first_subaddress": 0, "num_subaddresses": 10}' -X POST -H 'Content-Type: application/json' +{"monitor_id":"fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872"} +``` + +#### Get the status of an existing monitor +``` +$ curl localhost:9090/monitors/fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872 +{"first_subaddress":0,"num_subaddresses":10,"first_block":0,"next_block":2068} +``` + +#### Check the balance for a monitor and subaddress index +``` +$ curl localhost:9090/monitors/fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872/0/balance +{"balance":199999999999990} +``` +#### Generate a request code for a monitor and subaddress +``` +$ curl localhost:9090/monitors/fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872/0/request-code -X POST -d '{"value": 10, "memo": "Please pay me"}' -H 'Content-Type: application/json' +{"request_code":"HUGpTreNKe4ziGAwDNYeW1iayWJgZ4DgiYRk9fw8E7f21PXQRUt4kbFsWBxzcJj12K6atUMuAyRNnwCybw5oJcm6xYXazdZzx4Tc5QuKdFdH2XSuUYM8pgQ1jq2ZBBi"} +``` + +``` +$ curl localhost:9090/monitors/fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872/1/request-code -X POST -d '{}' -H 'Content-Type: application/json' +{"request_code":"2dmFbXtoY78h6K5xsK1NyTHmVGk6oiqBaEYGvJeSLFsCxkL4Ed1vjxEjtwg65QWR8nBdyXnwjyFo6rHEiHmFcsFysjapemAgxWyTda9FVsSFEF"} +``` + +#### Read all the information in a request code +``` +$ curl localhost:9090/read-request/HUGpTreNKe4ziGAwDNYeW1iayWJgZ4DgiYRk9fw8E7f21PXQRUt4kbFsWBxzcJj12K6atUMuAyRNnwCybw5oJcm6xYXazdZzx4Tc5QuKdFdH2XSuUYM8pgQ1jq2ZBBi +{"receiver":{"view_public_key":"40f884563ff10fb1b37b589036db9abbf1ab7afcf88f17a4ea6ec0077e883263","spend_public_key":"ecf9f2fdb8714afd16446d530cf27f2775d9e356e17a6bba8ad395d16d1bbd45","fog_fqdn":""},"value":"10","memo":"Please pay me"} +``` +This JSON can be passed directly to `transfer` or you can change the amount if desired. + +#### Transfer money from a monitor/subaddress to a request code +Using the information in the `read-request`, make a transfer to another address. +``` +$ curl localhost:9090//monitors/fca4ffa1a1b1faf8ad775d0cf020426ba7f161720403a76126bc8e40550d9872/0/transfer -d '{"receiver":{"view_public_key":"40f884563ff10fb1b37b589036db9abbf1ab7afcf88f17a4ea6ec0077e883263","spend_public_key":"ecf9f2fdb8714afd16446d530cf27f2775d9e356e17a6bba8ad395d16d1bbd45","fog_fqdn":""},"value":"10","memo":"Please pay me"}' -X POST -H 'Content-Type: application/json' +{"sender_tx_receipt":{"key_images":["dc8a91dbacad97b59e9709379c279a28b3c35262f6744226d15ee87be6bbf132","7e22679d8e3c14ba9c6c45256902e7af8e82644618e65a4589bab268bfde4b61"],"tombstone":2121}} +``` +#### Check the status of a transfer with a key image and tombstone block +The return value from `transfer` can be passed directly directly to `get-transfer-status` +``` +$ curl localhost:9090/check-transfer-status -d '{"sender_tx_receipt":{"key_images":["dc8a91dbacad97b59e9709379c279a28b3c35262f6744226d15ee87be6bbf132","7e22679d8e3c14ba9c6c45256902e7af8e82644618e65a4589bab268bfde4b61"],"tombstone":2121}}' -X POST -H 'Content-Type: application/json' +{"status":"verified"} +``` \ No newline at end of file diff --git a/mobilecoind-json/src/main.rs b/mobilecoind-json/src/main.rs new file mode 100644 index 0000000000..a121ebf5d9 --- /dev/null +++ b/mobilecoind-json/src/main.rs @@ -0,0 +1,444 @@ +#![feature(proc_macro_hygiene, decl_macro)] + +use grpcio::{ChannelBuilder, ChannelCredentialsBuilder}; +use protobuf::RepeatedField; + +use mc_api::external::{KeyImage, RistrettoPublic}; +use mc_common::logger::{create_app_logger, log, o}; +use mc_mobilecoind_api::{mobilecoind_api_grpc::MobilecoindApiClient, PublicAddress}; +use rocket::{get, post, routes}; +use rocket_contrib::json::Json; +use serde_derive::{Deserialize, Serialize}; +use std::sync::Arc; +use structopt::StructOpt; + +/// Command line config, set with defaults that will work with +/// a standard mobilecoind instance +#[derive(Clone, Debug, StructOpt)] +#[structopt(name = "mobilecoind-json", about = "A REST frontend for mobilecoind")] +pub struct Config { + /// Host to listen on. + #[structopt(long, default_value = "127.0.0.1")] + pub listen_host: String, + + /// Port to start webserver on. + #[structopt(long, default_value = "9090")] + pub listen_port: u16, + + /// MobileCoinD URI. + #[structopt(long, default_value = "127.0.0.1:4444")] + pub mobilecoind_host: String, + + /// SSL + #[structopt(long)] + pub use_ssl: bool, +} + +/// Connection to the mobilecoind client +struct State { + pub mobilecoind_api_client: MobilecoindApiClient, +} + +#[derive(Serialize, Default)] +struct JsonEntropyResponse { + entropy: String, +} + +/// Requests a new root entropy from mobilecoind +#[get("/entropy")] +fn entropy(state: rocket::State) -> Result, String> { + let resp = state + .mobilecoind_api_client + .generate_entropy(&mc_mobilecoind_api::Empty::new()) + .map_err(|err| format!("Failed getting entropy: {}", err))?; + Ok(Json(JsonEntropyResponse { + entropy: hex::encode(resp.entropy), + })) +} + +#[derive(Deserialize, Default)] +struct JsonMonitorRequest { + entropy: String, + first_subaddress: u64, + num_subaddresses: u64, +} + +#[derive(Serialize, Default)] +struct JsonMonitorResponse { + monitor_id: String, +} + +/// Creates a monitor. Data for the key and range is POSTed using the struct above. +#[post("/monitors", format = "json", data = "")] +fn create_monitor( + state: rocket::State, + monitor: Json, +) -> Result, String> { + let entropy = hex::decode(&monitor.entropy) + .map_err(|err| format!("Failed to decode hex key: {}", err))?; + + let mut req = mc_mobilecoind_api::GetAccountKeyRequest::new(); + req.set_entropy(entropy.to_vec()); + + let mut resp = state + .mobilecoind_api_client + .get_account_key(&req) + .map_err(|err| format!("Failed getting account key for entropy: {}", err))?; + + let account_key = resp.take_account_key(); + + let mut req = mc_mobilecoind_api::AddMonitorRequest::new(); + req.set_account_key(account_key); + req.set_first_subaddress(monitor.first_subaddress); + req.set_num_subaddresses(monitor.num_subaddresses); + req.set_first_block(0); + + let monitor_response = state + .mobilecoind_api_client + .add_monitor(&req) + .map_err(|err| format!("Failed adding monitor: {}", err))?; + + Ok(Json(JsonMonitorResponse { + monitor_id: hex::encode(monitor_response.monitor_id), + })) +} + +#[derive(Serialize, Default)] +struct JsonMonitorStatusResponse { + first_subaddress: u64, + num_subaddresses: u64, + first_block: u64, + next_block: u64, +} + +/// Get the current status of a created monitor +#[get("/monitors/")] +fn monitor_status( + state: rocket::State, + monitor_hex: String, +) -> Result, String> { + let monitor_id = + hex::decode(monitor_hex).map_err(|err| format!("Failed to decode monitor hex: {}", err))?; + + let mut req = mc_mobilecoind_api::GetMonitorStatusRequest::new(); + req.set_monitor_id(monitor_id); + + let resp = state + .mobilecoind_api_client + .get_monitor_status(&req) + .map_err(|err| format!("Failed getting monitor status: {}", err))?; + + let status = resp.get_status(); + + Ok(Json(JsonMonitorStatusResponse { + first_subaddress: status.get_first_subaddress(), + num_subaddresses: status.get_num_subaddresses(), + first_block: status.get_first_block(), + next_block: status.get_next_block(), + })) +} + +#[derive(Serialize, Default)] +struct JsonBalanceResponse { + balance: String, +} + +/// Balance check using a created monitor and subaddress index +#[get("/monitors///balance")] +fn balance( + state: rocket::State, + monitor_hex: String, + subaddress_index: u64, +) -> Result, String> { + let monitor_id = + hex::decode(monitor_hex).map_err(|err| format!("Failed to decode monitor hex: {}", err))?; + + let mut req = mc_mobilecoind_api::GetBalanceRequest::new(); + req.set_monitor_id(monitor_id); + req.set_subaddress_index(subaddress_index); + + let resp = state + .mobilecoind_api_client + .get_balance(&req) + .map_err(|err| format!("Failed getting balance: {}", err))?; + let balance = resp.get_balance(); + Ok(Json(JsonBalanceResponse { + balance: balance.to_string(), + })) +} + +#[derive(Deserialize)] +struct JsonRequestCodeRequest { + value: Option, + memo: Option, +} + +#[derive(Serialize, Default)] +struct JsonRequestCodeResponse { + request_code: String, +} + +/// Generates a request code with an optional value and memo +#[post( + "/monitors///request-code", + format = "json", + data = "" +)] +fn request_code( + state: rocket::State, + monitor_hex: String, + subaddress_index: u64, + extra: Json, +) -> Result, String> { + let monitor_id = + hex::decode(monitor_hex).map_err(|err| format!("Failed to decode monitor hex: {}", err))?; + + // Get our public address. + let mut req = mc_mobilecoind_api::GetPublicAddressRequest::new(); + req.set_monitor_id(monitor_id); + req.set_subaddress_index(subaddress_index); + + let resp = state + .mobilecoind_api_client + .get_public_address(&req) + .map_err(|err| format!("Failed getting public address: {}", err))?; + + let public_address = resp.get_public_address().clone(); + + // Generate b58 code + let mut req = mc_mobilecoind_api::GetRequestCodeRequest::new(); + req.set_receiver(public_address); + if let Some(value) = extra.value { + req.set_value(value); + } + if let Some(memo) = extra.memo.clone() { + req.set_memo(memo); + } + + let resp = state + .mobilecoind_api_client + .get_request_code(&req) + .map_err(|err| format!("Failed getting request code: {}", err))?; + + Ok(Json(JsonRequestCodeResponse { + request_code: String::from(resp.get_b58_code()), + })) +} + +#[derive(Deserialize, Serialize, Default)] +struct JsonPublicAddress { + view_public_key: String, + spend_public_key: String, + fog_fqdn: String, +} + +#[derive(Deserialize, Serialize, Default)] +struct JsonReadRequestResponse { + receiver: JsonPublicAddress, + value: String, + memo: String, +} + +/// Retrieves the data in a request code +#[get("/read-request/")] +fn read_request( + state: rocket::State, + request_code: String, +) -> Result, String> { + let mut req = mc_mobilecoind_api::ReadRequestCodeRequest::new(); + req.set_b58_code(request_code); + let resp = state + .mobilecoind_api_client + .read_request_code(&req) + .map_err(|err| format!("Failed reading request code: {}", err))?; + + let receiver = resp.get_receiver(); + + // The response contains the public keys encoded in the read request, as well as a memo and + // requested value. This can be used as-is in the transfer call below, or the value can be + // modified. + Ok(Json(JsonReadRequestResponse { + receiver: JsonPublicAddress { + view_public_key: hex::encode(receiver.get_view_public_key().get_data().to_vec()), + spend_public_key: hex::encode(receiver.get_spend_public_key().get_data().to_vec()), + fog_fqdn: String::from(receiver.get_fog_fqdn()), + }, + value: resp.get_value().to_string(), + memo: resp.get_memo().to_string(), + })) +} + +#[derive(Deserialize, Serialize)] +struct JsonSenderTxReceipt { + key_images: Vec, + tombstone: u64, +} + +#[derive(Deserialize, Serialize)] +struct JsonTransferResponse { + sender_tx_receipt: JsonSenderTxReceipt, +} + +/// Performs a transfer from a monitor and subaddress. The public keys and amount are in the POST data. +#[post( + "/monitors///transfer", + format = "json", + data = "" +)] +fn transfer( + state: rocket::State, + monitor_hex: String, + subaddress_index: u64, + transfer: Json, +) -> Result, String> { + let monitor_id = + hex::decode(monitor_hex).map_err(|err| format!("Failed to decode monitor hex: {}", err))?; + + // Decode the keys + let mut view_public_key = RistrettoPublic::new(); + view_public_key.set_data( + hex::decode(&transfer.receiver.view_public_key) + .map_err(|err| format!("Failed to decode view key hex: {}", err))?, + ); + let mut spend_public_key = RistrettoPublic::new(); + spend_public_key.set_data( + hex::decode(&transfer.receiver.spend_public_key) + .map_err(|err| format!("Failed to decode spend key hex: {}", err))?, + ); + + // Reconstruct the public address as a protobuf + let mut public_address = PublicAddress::new(); + public_address.set_view_public_key(view_public_key); + public_address.set_spend_public_key(spend_public_key); + public_address.set_fog_fqdn(transfer.receiver.fog_fqdn.clone()); + + // Generate an outlay + let mut outlay = mc_mobilecoind_api::Outlay::new(); + outlay.set_receiver(public_address); + outlay.set_value( + transfer + .value + .parse::() + .map_err(|err| format!("Failed to parse amount: {}", err))?, + ); + + // Send the payment request + let mut req = mc_mobilecoind_api::SendPaymentRequest::new(); + req.set_sender_monitor_id(monitor_id); + req.set_sender_subaddress(subaddress_index); + req.set_outlay_list(RepeatedField::from_vec(vec![outlay])); + + let resp = state + .mobilecoind_api_client + .send_payment(&req) + .map_err(|err| format!("Failed to send payment: {}", err))?; + + // The receipt from the payment request can be used by the status check below + let receipt = resp.get_sender_tx_receipt(); + Ok(Json(JsonTransferResponse { + sender_tx_receipt: JsonSenderTxReceipt { + key_images: receipt + .get_key_image_list() + .iter() + .map(|key_image| hex::encode(key_image.get_data())) + .collect(), + tombstone: receipt.get_tombstone(), + }, + })) +} + +#[derive(Serialize, Default)] +struct JsonStatusResponse { + status: String, +} + +/// Checks the status of a transfer given a key image and tombstone block +#[post("/check-transfer-status", format = "json", data = "")] +fn check_transfer_status( + state: rocket::State, + receipt: Json, +) -> Result, String> { + let mut sender_receipt = mc_mobilecoind_api::SenderTxReceipt::new(); + let mut key_images = Vec::new(); + for key_image_hex in &receipt.sender_tx_receipt.key_images { + key_images.push(KeyImage::from( + hex::decode(&key_image_hex).map_err(|err| format!("{}", err))?, + )) + } + + sender_receipt.set_key_image_list(RepeatedField::from_vec(key_images)); + sender_receipt.set_tombstone(receipt.sender_tx_receipt.tombstone); + + let mut req = mc_mobilecoind_api::GetTxStatusAsSenderRequest::new(); + req.set_receipt(sender_receipt); + + let resp = state + .mobilecoind_api_client + .get_tx_status_as_sender(&req) + .map_err(|err| format!("Failed getting status: {}", err))?; + + let status_str = match resp.get_status() { + mc_mobilecoind_api::TxStatus::Unknown => "unknown", + mc_mobilecoind_api::TxStatus::Verified => "verified", + mc_mobilecoind_api::TxStatus::TombstoneBlockExceeded => "failed", + }; + + Ok(Json(JsonStatusResponse { + status: String::from(status_str), + })) +} + +fn main() { + mc_common::setup_panic_handler(); + let _sentry_guard = mc_common::sentry::init(); + + let config = Config::from_args(); + + let (logger, _global_logger_guard) = create_app_logger(o!()); + log::info!( + logger, + "Starting mobilecoind HTTP gateway on {}:{}, connecting to {}", + config.listen_host, + config.listen_port, + config.mobilecoind_host + ); + + // Set up the gRPC connection to the mobilecoind client + let env = Arc::new(grpcio::EnvBuilder::new().build()); + let ch_builder = ChannelBuilder::new(env) + .max_receive_message_len(std::i32::MAX) + .max_send_message_len(std::i32::MAX); + + let ch = if config.use_ssl { + let creds = ChannelCredentialsBuilder::new().build(); + ch_builder.secure_connect(&config.mobilecoind_host, creds) + } else { + ch_builder.connect(&config.mobilecoind_host) + }; + + let mobilecoind_api_client = MobilecoindApiClient::new(ch); + + let rocket_config = rocket::Config::build(rocket::config::Environment::Production) + .address(&config.listen_host) + .port(config.listen_port) + .unwrap(); + + rocket::custom(rocket_config) + .mount( + "/", + routes![ + entropy, + create_monitor, + monitor_status, + balance, + request_code, + read_request, + transfer, + check_transfer_status + ], + ) + .manage(State { + mobilecoind_api_client, + }) + .launch(); +}