From 2e02d67dbc1dc30d84f605a9bd07fbb5aca379e4 Mon Sep 17 00:00:00 2001 From: scilio Date: Sun, 31 Jul 2022 23:17:08 -0400 Subject: [PATCH] separate rpc and server logic --- Cargo.lock | 9 +- Cargo.toml | 1 + src/main.rs | 5 +- src/node.rs | 113 ++++++------ src/onion.rs | 12 +- src/rpc.rs | 272 +++++++++++++++++++++++++++++ src/secp.rs | 1 + src/server.rs | 470 ++++++++++++++++++++++---------------------------- src/wallet.rs | 54 +++--- 9 files changed, 594 insertions(+), 343 deletions(-) create mode 100644 src/rpc.rs diff --git a/Cargo.lock b/Cargo.lock index 3e3cbeb..de4c36d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2503,6 +2503,7 @@ dependencies = [ "serde_derive", "serde_json", "sha2 0.10.0", + "thiserror", "tokio 1.12.0", "toml", ] @@ -3879,18 +3880,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.30" +version = "1.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.30" +version = "1.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" dependencies = [ "proc-macro2 1.0.34", "quote 1.0.10", diff --git a/Cargo.toml b/Cargo.toml index 6b85996..7a4e3d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ serde = { version = "1", features= ["derive"]} serde_derive = "1" serde_json = "1" sha2 = "0.10.0" +thiserror = "1.0.31" tokio = { version = "1", features = ["full"] } toml = "0.5" grin_secp256k1zkp = { version = "0.7.11", features = ["bullet-proof-sizing"]} diff --git a/src/main.rs b/src/main.rs index ff9f871..c601d23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod config; mod error; mod node; mod onion; +mod rpc; mod secp; mod server; mod types; @@ -137,8 +138,8 @@ fn real_main() -> Result<(), Box> { stop_state_clone.stop(); }); - // Start the mwixnet server - server::listen(server_config, Arc::new(wallet), Arc::new(node), stop_state) + // Start the mwixnet JSON-RPC HTTP server + rpc::listen(server_config, Arc::new(wallet), Arc::new(node), stop_state) } async fn build_signals_fut() { diff --git a/src/node.rs b/src/node.rs index 7837c52..a32ea22 100644 --- a/src/node.rs +++ b/src/node.rs @@ -9,9 +9,8 @@ use grin_core::core::{Input, OutputFeatures, Transaction}; use grin_util::ToHex; use serde_json::json; -use std::collections::HashMap; use std::net::SocketAddr; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; pub trait GrinNode: Send + Sync { /// Retrieves the unspent output with a matching commitment @@ -147,62 +146,74 @@ impl GrinNode for HttpGrinNode { } } -/// Implementation of 'GrinNode' trait that mocks a grin node instance. -/// Use only for testing purposes. -pub struct MockGrinNode { - utxos: HashMap, - txns_posted: RwLock>, -} +#[cfg(test)] +pub mod mock { + use super::GrinNode; + use crate::error::Result; + use crate::secp::Commitment; -impl MockGrinNode { - pub fn new() -> MockGrinNode { - MockGrinNode { - utxos: HashMap::new(), - txns_posted: RwLock::new(Vec::new()), + use grin_api::{OutputPrintable, OutputType}; + use grin_core::core::Transaction; + use std::collections::HashMap; + use std::sync::RwLock; + + /// Implementation of 'GrinNode' trait that mocks a grin node instance. + /// Use only for testing purposes. + pub struct MockGrinNode { + utxos: HashMap, + txns_posted: RwLock>, + } + + impl MockGrinNode { + pub fn new() -> MockGrinNode { + MockGrinNode { + utxos: HashMap::new(), + txns_posted: RwLock::new(Vec::new()), + } + } + + pub fn add_utxo(&mut self, output_commit: &Commitment, utxo: &OutputPrintable) { + self.utxos.insert(output_commit.clone(), utxo.clone()); + } + + pub fn add_default_utxo(&mut self, output_commit: &Commitment) { + let utxo = OutputPrintable { + output_type: OutputType::Transaction, + commit: output_commit.to_owned(), + spent: false, + proof: None, + proof_hash: String::from(""), + block_height: None, + merkle_proof: None, + mmr_index: 0, + }; + + self.add_utxo(&output_commit, &utxo); + } + + pub fn get_posted_txns(&self) -> Vec { + let read = self.txns_posted.read().unwrap(); + read.clone() } } - pub fn add_utxo(&mut self, output_commit: &Commitment, utxo: &OutputPrintable) { - self.utxos.insert(output_commit.clone(), utxo.clone()); - } + impl GrinNode for MockGrinNode { + fn get_utxo(&self, output_commit: &Commitment) -> Result> { + if let Some(utxo) = self.utxos.get(&output_commit) { + return Ok(Some(utxo.clone())); + } - pub fn add_default_utxo(&mut self, output_commit: &Commitment) { - let utxo = OutputPrintable { - output_type: OutputType::Transaction, - commit: output_commit.to_owned(), - spent: false, - proof: None, - proof_hash: String::from(""), - block_height: None, - merkle_proof: None, - mmr_index: 0, - }; - - self.add_utxo(&output_commit, &utxo); - } - - pub fn get_posted_txns(&self) -> Vec { - let read = self.txns_posted.read().unwrap(); - read.clone() - } -} - -impl GrinNode for MockGrinNode { - fn get_utxo(&self, output_commit: &Commitment) -> Result> { - if let Some(utxo) = self.utxos.get(&output_commit) { - return Ok(Some(utxo.clone())); + Ok(None) } - Ok(None) - } + fn get_chain_height(&self) -> Result { + Ok(100) + } - fn get_chain_height(&self) -> Result { - Ok(100) - } - - fn post_tx(&self, tx: &Transaction) -> Result<()> { - let mut write = self.txns_posted.write().unwrap(); - write.push(tx.clone()); - Ok(()) + fn post_tx(&self, tx: &Transaction) -> Result<()> { + let mut write = self.txns_posted.write().unwrap(); + write.push(tx.clone()); + Ok(()) + } } } diff --git a/src/onion.rs b/src/onion.rs index 68b3833..7a7c430 100644 --- a/src/onion.rs +++ b/src/onion.rs @@ -16,7 +16,7 @@ type HmacSha256 = Hmac; type RawBytes = Vec; /// A data packet with layers of encryption -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Onion { /// The onion originator's portion of the shared secret pub ephemeral_pubkey: PublicKey, @@ -257,6 +257,16 @@ pub mod test_util { }; Ok(onion) } + + /// Calculates the expected next ephemeral pubkey after peeling a layer off of the Onion. + pub fn next_ephemeral_pubkey(onion: &Onion, server_key: &SecretKey) -> Result { + let secp = Secp256k1::new(); + let mut ephemeral_pubkey = onion.ephemeral_pubkey.clone(); + let shared_secret = SharedSecret::new(&secp, &ephemeral_pubkey, &server_key); + let blinding_factor = super::calc_blinding_factor(&shared_secret, &ephemeral_pubkey)?; + ephemeral_pubkey.mul_assign(&secp, &blinding_factor)?; + Ok(ephemeral_pubkey) + } } #[cfg(test)] diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..bc8d879 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,272 @@ +use crate::config::ServerConfig; +use crate::node::GrinNode; +use crate::onion::Onion; +use crate::secp::{self, ComSignature}; +use crate::server::{Server, ServerImpl, SwapError}; +use crate::wallet::Wallet; + +use grin_util::StopState; +use jsonrpc_core::{Result, Value}; +use jsonrpc_derive::rpc; +use jsonrpc_http_server::jsonrpc_core::*; +use jsonrpc_http_server::*; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use std::thread::{sleep, spawn}; +use std::time::Duration; + +#[derive(Serialize, Deserialize)] +pub struct SwapReq { + onion: Onion, + #[serde(with = "secp::comsig_serde")] + comsig: ComSignature, +} + +#[rpc(server)] +pub trait API { + #[rpc(name = "swap")] + fn swap(&self, swap: SwapReq) -> Result; + + // milestone 3: Used by mwixnet coinswap servers to communicate with each other + // fn derive_outputs(&self, entries: Vec) -> Result; + // fn derive_kernel(&self, tx: Tx) -> Result; +} + +#[derive(Clone)] +struct RPCServer { + server_config: ServerConfig, + server: Arc>, +} + +impl RPCServer { + /// Spin up an instance of the JSON-RPC HTTP server. + fn start_http(&self) -> jsonrpc_http_server::Server { + let mut io = IoHandler::new(); + io.extend_with(RPCServer::to_delegate(self.clone())); + + ServerBuilder::new(io) + .cors(DomainsValidation::Disabled) + .request_middleware(|request: hyper::Request| { + if request.uri() == "/v1" { + request.into() + } else { + jsonrpc_http_server::Response::bad_request("Only v1 supported").into() + } + }) + .start_http(&self.server_config.addr) + .expect("Unable to start RPC server") + } +} + +impl From for Error { + fn from(e: SwapError) -> Self { + match e { + SwapError::UnknownError(_) => Error { + message: e.to_string(), + code: ErrorCode::InternalError, + data: None, + }, + _ => Error::invalid_params(e.to_string()), + } + } +} + +impl API for RPCServer { + /// Implements the 'swap' API + fn swap(&self, swap: SwapReq) -> Result { + self.server + .lock() + .unwrap() + .swap(&swap.onion, &swap.comsig)?; + Ok(Value::String("success".into())) + } +} + +/// Spin up the JSON-RPC web server +pub fn listen( + server_config: ServerConfig, + wallet: Arc, + node: Arc, + stop_state: Arc, +) -> std::result::Result<(), Box> { + let server = ServerImpl::new(server_config.clone(), wallet.clone(), node.clone()); + let server = Arc::new(Mutex::new(server)); + + let rpc_server = RPCServer { + server_config: server_config.clone(), + server: server.clone(), + }; + + let http_server = rpc_server.start_http(); + println!("Server listening on {}", server_config.addr); + + let close_handle = http_server.close_handle(); + let round_handle = spawn(move || { + let mut secs = 0; + loop { + if stop_state.is_stopped() { + close_handle.close(); + break; + } + + sleep(Duration::from_secs(1)); + secs = (secs + 1) % server_config.interval_s; + + if secs == 0 { + let _ = server.lock().unwrap().execute_round(); + secs = 0; + } + } + }); + + http_server.wait(); + round_handle.join().unwrap(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::config::ServerConfig; + use crate::onion::test_util; + use crate::rpc::{RPCServer, SwapReq}; + use crate::secp::{self, ComSignature}; + use crate::server::mock::MockServer; + use crate::server::{Server, SwapError}; + + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + use hyper::{Body, Client, Request, Response}; + use tokio::runtime::Runtime; + + async fn body_to_string(req: Response) -> String { + let body_bytes = hyper::body::to_bytes(req.into_body()).await.unwrap(); + String::from_utf8(body_bytes.to_vec()).unwrap() + } + + /// Spin up a temporary web service, query the API, then cleanup and return response + fn make_request( + server: Arc>, + req: String, + ) -> Result> { + let server_config = ServerConfig { + key: secp::random_secret(), + interval_s: 1, + addr: TcpListener::bind("127.0.0.1:0")?.local_addr()?, + grin_node_url: "127.0.0.1:3413".parse()?, + grin_node_secret_path: None, + wallet_owner_url: "127.0.0.1:3420".parse()?, + wallet_owner_secret_path: None, + }; + + let rpc_server = RPCServer { + server_config: server_config.clone(), + server: server.clone(), + }; + + // Start the JSON-RPC server + let http_server = rpc_server.start_http(); + + let uri = format!("http://{}/v1", server_config.addr); + + let threaded_rt = Runtime::new()?; + let do_request = async move { + let request = Request::post(uri) + .header("Content-Type", "application/json") + .body(Body::from(req)) + .unwrap(); + + Client::new().request(request).await + }; + + let response = threaded_rt.block_on(do_request)?; + let response_str: String = threaded_rt.block_on(body_to_string(response)); + + // Wait for shutdown + threaded_rt.shutdown_background(); + + // Execute one round + server.lock().unwrap().execute_round()?; + + // Stop the server + http_server.close(); + + Ok(response_str) + } + + // todo: Test all error types + + /// Demonstrates a successful swap response + #[test] + fn swap_success() -> Result<(), Box> { + let commitment = secp::commit(1234, &secp::random_secret())?; + let onion = test_util::create_onion(&commitment, &secp::random_secret(), &vec![])?; + let comsig = ComSignature::sign(1234, &secp::random_secret(), &onion.serialize()?)?; + let swap = SwapReq { + onion: onion.clone(), + comsig, + }; + + let server: Arc> = Arc::new(Mutex::new(MockServer::new())); + + let req = format!( + "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", + serde_json::json!(swap) + ); + println!("Request: {}", req); + let response = make_request(server, req)?; + let expected = "{\"jsonrpc\":\"2.0\",\"result\":\"success\",\"id\":\"1\"}\n"; + assert_eq!(response, expected); + + Ok(()) + } + + #[test] + fn swap_bad_request() -> Result<(), Box> { + let server: Arc> = Arc::new(Mutex::new(MockServer::new())); + + let params = "{ \"param\": \"Not a valid Swap request\" }"; + let req = format!( + "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", + params + ); + let response = make_request(server, req)?; + let expected = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32602,\"message\":\"Invalid params: missing field `onion`.\"},\"id\":\"1\"}\n"; + assert_eq!(response, expected); + Ok(()) + } + + /// Returns "Commitment not found" when there's no matching output in the UTXO set. + #[test] + fn swap_utxo_missing() -> Result<(), Box> { + let commitment = secp::commit(1234, &secp::random_secret())?; + let onion = test_util::create_onion(&commitment, &secp::random_secret(), &vec![])?; + let comsig = ComSignature::sign(1234, &secp::random_secret(), &onion.serialize()?)?; + let swap = SwapReq { + onion: onion.clone(), + comsig, + }; + + let mut server = MockServer::new(); + server.set_response( + &onion, + SwapError::CoinNotFound { + commit: commitment.clone(), + }, + ); + let server: Arc> = Arc::new(Mutex::new(server)); + + let req = format!( + "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", + serde_json::json!(swap) + ); + let response = make_request(server, req)?; + let expected = format!( + "{{\"jsonrpc\":\"2.0\",\"error\":{{\"code\":-32602,\"message\":\"Output {:?} does not exist, or is already spent.\"}},\"id\":\"1\"}}\n", + commitment + ); + assert_eq!(response, expected); + Ok(()) + } +} diff --git a/src/secp.rs b/src/secp.rs index 67221c2..8196c4c 100644 --- a/src/secp.rs +++ b/src/secp.rs @@ -16,6 +16,7 @@ use grin_core::ser::{self, Readable, Reader, Writeable, Writer}; use secp256k1zkp::rand::thread_rng; /// A generalized Schnorr signature with a pedersen commitment value & blinding factors as the keys +#[derive(Clone)] pub struct ComSignature { pub_nonce: Commitment, s: SecretKey, diff --git a/src/server.rs b/src/server.rs index 5cfe8c5..55e378c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,16 +6,11 @@ use crate::wallet::{self, Wallet}; use grin_core::core::{Input, Output, OutputFeatures, TransactionBody}; use grin_core::global::DEFAULT_ACCEPT_FEE_BASE; -use grin_util::StopState; use itertools::Itertools; -use jsonrpc_core::{Result, Value}; -use jsonrpc_derive::rpc; -use jsonrpc_http_server::jsonrpc_core::*; -use jsonrpc_http_server::*; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::result::Result; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use thiserror::Error; #[derive(Clone, Debug, PartialEq)] struct Submission { @@ -33,34 +28,49 @@ struct Submission { onion: Onion, } -#[derive(Serialize, Deserialize)] -pub struct SwapReq { - onion: Onion, - #[serde(with = "secp::comsig_serde")] - comsig: ComSignature, +/// Swap error types +#[derive(Clone, Error, Debug, PartialEq)] +pub enum SwapError { + #[error("Invalid number of payloads provided (expected {expected:?}, found {found:?})")] + InvalidPayloadLength { expected: usize, found: usize }, + #[error("Commitment Signature is invalid")] + InvalidComSignature, + #[error("Rangeproof is missing or invalid")] + InvalidRangeproof, + #[error("Output {commit:?} does not exist, or is already spent.")] + CoinNotFound { commit: Commitment }, + #[error("Output {commit:?} is already in the swap list.")] + AlreadySwapped { commit: Commitment }, + #[error("Failed to peel onion layer: {0}")] + PeelOnionFailure(String), + #[error("Fee too low (expected >= {minimum_fee:?}, actual {actual_fee:?})")] + FeeTooLow { minimum_fee: u64, actual_fee: u64 }, + #[error("{0}")] + UnknownError(String), } -#[rpc(server)] -pub trait RPCServer { - #[rpc(name = "swap")] - fn swap(&self, swap: SwapReq) -> Result; +pub trait Server: Send + Sync { + fn swap(&self, onion: &Onion, comsig: &ComSignature) -> Result<(), SwapError>; - // milestone 3: Used by mwixnet coinswap servers to communicate with each other - // fn derive_outputs(&self, entries: Vec) -> Result; - // fn derive_kernel(&self, tx: Tx) -> Result; + fn execute_round(&self) -> crate::error::Result<()>; } #[derive(Clone)] -struct Server { +pub struct ServerImpl { server_config: ServerConfig, wallet: Arc, node: Arc, submissions: Arc>>, } -impl Server { - fn new(server_config: ServerConfig, wallet: Arc, node: Arc) -> Self { - Server { +impl ServerImpl { + /// Create a new MWixnet server + pub fn new( + server_config: ServerConfig, + wallet: Arc, + node: Arc, + ) -> Self { + ServerImpl { server_config, wallet, node, @@ -68,24 +78,6 @@ impl Server { } } - /// Spin up an instance of the JSON-RPC HTTP server. - fn start_rpc(&self) -> jsonrpc_http_server::Server { - let mut io = IoHandler::new(); - io.extend_with(Server::to_delegate(self.clone())); - - ServerBuilder::new(io) - .cors(DomainsValidation::Disabled) - .request_middleware(|request: hyper::Request| { - if request.uri() == "/v1" { - request.into() - } else { - jsonrpc_http_server::Response::bad_request("Only v1 supported").into() - } - }) - .start_http(&self.server_config.addr) - .expect("Unable to start RPC server") - } - /// The fee base to use. For now, just using the default. fn get_fee_base(&self) -> u64 { DEFAULT_ACCEPT_FEE_BASE @@ -96,6 +88,83 @@ impl Server { fn get_minimum_swap_fee(&self) -> u64 { TransactionBody::weight_by_iok(1, 1, 1) * self.get_fee_base() } +} + +impl Server for ServerImpl { + /// Submit a new output to be swapped. + fn swap(&self, onion: &Onion, comsig: &ComSignature) -> Result<(), SwapError> { + // milestone 3: check that enc_payloads length matches number of configured servers + if onion.enc_payloads.len() != 1 { + return Err(SwapError::InvalidPayloadLength { + expected: 1, + found: onion.enc_payloads.len(), + }); + } + + // Verify commitment signature to ensure caller owns the output + let serialized_onion = onion + .serialize() + .map_err(|e| SwapError::UnknownError(e.to_string()))?; + let _ = comsig + .verify(&onion.commit, &serialized_onion) + .map_err(|_| SwapError::InvalidComSignature)?; + + // Verify that commitment is unspent + let input = node::build_input(&self.node, &onion.commit) + .map_err(|e| SwapError::UnknownError(e.to_string()))?; + let input = input.ok_or(SwapError::CoinNotFound { + commit: onion.commit.clone(), + })?; + + let peeled = onion + .peel_layer(&self.server_config.key) + .map_err(|e| SwapError::PeelOnionFailure(e.message()))?; + + // Verify the fee meets the minimum + let fee: u64 = peeled.0.fee.into(); + if fee < self.get_minimum_swap_fee() { + return Err(SwapError::FeeTooLow { + minimum_fee: self.get_minimum_swap_fee(), + actual_fee: fee, + }); + } + + // Calculate final output commitment + let output_commit = secp::add_excess(&onion.commit, &peeled.0.excess) + .map_err(|e| SwapError::UnknownError(e.to_string()))?; + let output_commit = secp::sub_value(&output_commit, fee) + .map_err(|e| SwapError::UnknownError(e.to_string()))?; + + // Verify the bullet proof and build the final output + if let Some(r) = peeled.0.rangeproof { + let secp = Secp256k1::with_caps(secp256k1zkp::ContextFlag::Commit); + secp.verify_bullet_proof(output_commit, r, None) + .map_err(|_| SwapError::InvalidRangeproof)?; + } else { + // milestone 3: only the last hop will have a rangeproof + return Err(SwapError::InvalidRangeproof); + } + + let mut locked = self.submissions.lock().unwrap(); + if locked.contains_key(&onion.commit) { + return Err(SwapError::AlreadySwapped { + commit: onion.commit.clone(), + }); + } + + locked.insert( + onion.commit, + Submission { + excess: peeled.0.excess, + output_commit, + rangeproof: peeled.0.rangeproof, + input, + fee, + onion: peeled.1, + }, + ); + Ok(()) + } /// Iterate through all saved submissions, filter out any inputs that are no longer spendable, /// and assemble the coinswap transaction, posting the transaction to the configured node. @@ -158,203 +227,73 @@ impl Server { } } -impl RPCServer for Server { - /// Implements the 'swap' API - fn swap(&self, swap: SwapReq) -> Result { - // milestone 3: check that enc_payloads length matches number of configured servers - if swap.onion.enc_payloads.len() != 1 { - return Err(Error::invalid_params( - "Multi server not supported until milestone 3", - )); - } +#[cfg(test)] +pub mod mock { + use super::{Server, SwapError}; + use crate::onion::Onion; + use crate::secp::ComSignature; - // Verify commitment signature to ensure caller owns the output - let serialized_onion = swap.onion.serialize().map_err(|e| Error { - message: e.to_string(), - code: ErrorCode::InternalError, - data: None, - })?; - let _ = swap - .comsig - .verify(&swap.onion.commit, &serialized_onion) - .map_err(|_| Error::invalid_params("ComSignature invalid"))?; + use std::collections::HashMap; - // Verify that commitment is unspent - let input = node::build_input(&self.node, &swap.onion.commit).map_err(|e| Error { - message: e.to_string(), - code: ErrorCode::InternalError, - data: None, - })?; - let input = input.ok_or(Error::invalid_params("Commitment not found"))?; - - let peeled = swap - .onion - .peel_layer(&self.server_config.key) - .map_err(|e| Error::invalid_params(e.message()))?; - - // Verify the fee meets the minimum - let fee: u64 = peeled.0.fee.into(); - if fee < self.get_minimum_swap_fee() { - return Err(Error::invalid_params("Fee does not meet minimum")); - } - - // Calculate final output commitment - let output_commit = - secp::add_excess(&swap.onion.commit, &peeled.0.excess).map_err(|e| Error { - message: e.to_string(), - code: ErrorCode::InternalError, - data: None, - })?; - let output_commit = secp::sub_value(&output_commit, fee).map_err(|e| Error { - message: e.to_string(), - code: ErrorCode::InternalError, - data: None, - })?; - - // Verify the bullet proof and build the final output - if let Some(r) = peeled.0.rangeproof { - let secp = Secp256k1::with_caps(secp256k1zkp::ContextFlag::Commit); - secp.verify_bullet_proof(output_commit, r, None) - .map_err(|_| Error::invalid_params("RangeProof invalid"))?; - } else { - // milestone 3: only the last hop will have a rangeproof - return Err(Error::invalid_params("Rangeproof expected")); - } - - let mut locked = self.submissions.lock().unwrap(); - if locked.contains_key(&swap.onion.commit) { - return Err(Error::invalid_params("swap already called for coin")); - } - - locked.insert( - swap.onion.commit, - Submission { - excess: peeled.0.excess, - output_commit, - rangeproof: peeled.0.rangeproof, - input, - fee, - onion: peeled.1, - }, - ); - Ok(Value::String("success".into())) + pub struct MockServer { + errors: HashMap, } -} -/// Spin up the JSON-RPC web server -pub fn listen( - server_config: ServerConfig, - wallet: Arc, - node: Arc, - stop_state: Arc, -) -> std::result::Result<(), Box> { - let server = Arc::new(Server::new( - server_config.clone(), - wallet.clone(), - node.clone(), - )); - - let rpc_server = server.start_rpc(); - println!("Server listening on {}", server.server_config.addr); - - let close_handle = rpc_server.close_handle(); - let round_handle = std::thread::spawn(move || { - let mut secs = 0; - loop { - if stop_state.is_stopped() { - close_handle.close(); - break; - } - - std::thread::sleep(Duration::from_secs(1)); - secs = (secs + 1) % server.server_config.interval_s; - - if secs == 0 { - let _ = server.execute_round(); - secs = 0; + impl MockServer { + pub fn new() -> MockServer { + MockServer { + errors: HashMap::new(), } } - }); - rpc_server.wait(); - round_handle.join().unwrap(); + pub fn set_response(&mut self, onion: &Onion, e: SwapError) { + self.errors.insert(onion.clone(), e); + } + } - Ok(()) + impl Server for MockServer { + fn swap(&self, onion: &Onion, _comsig: &ComSignature) -> Result<(), SwapError> { + if let Some(e) = self.errors.get(&onion) { + return Err(e.clone()); + } + + Ok(()) + } + + fn execute_round(&self) -> crate::error::Result<()> { + Ok(()) + } + } } #[cfg(test)] mod tests { use crate::config::ServerConfig; - use crate::node::{GrinNode, MockGrinNode}; + use crate::node::mock::MockGrinNode; use crate::onion::test_util::{self, Hop}; - use crate::secp::{self, ComSignature, PublicKey, Secp256k1}; - use crate::server::{self, SwapReq}; + use crate::onion::Onion; + use crate::secp::{self, ComSignature, PublicKey, Secp256k1, SecretKey}; + use crate::server::{Server, ServerImpl, Submission, SwapError}; use crate::types::Payload; - use crate::wallet::{MockWallet, Wallet}; + use crate::wallet::mock::MockWallet; - use grin_core::core::{Committed, FeeFields, Transaction}; + use grin_core::core::{Committed, FeeFields, Input, OutputFeatures, Transaction}; use std::net::TcpListener; use std::sync::Arc; - use hyper::{Body, Client, Request, Response}; - use tokio::runtime; - - async fn body_to_string(req: Response) -> String { - let body_bytes = hyper::body::to_bytes(req.into_body()).await.unwrap(); - String::from_utf8(body_bytes.to_vec()).unwrap() - } - - /// Spin up a temporary web service, query the API, then cleanup and return response - fn make_request( - server_key: secp::SecretKey, - wallet: Arc, - node: Arc, - req: String, - ) -> Result> { - let server_config = ServerConfig { - key: server_key, + fn new_config(server_key: &SecretKey) -> ServerConfig { + ServerConfig { + key: server_key.clone(), interval_s: 1, - addr: TcpListener::bind("127.0.0.1:0")?.local_addr()?, - grin_node_url: "127.0.0.1:3413".parse()?, + addr: TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(), + grin_node_url: "127.0.0.1:3413".parse().unwrap(), grin_node_secret_path: None, - wallet_owner_url: "127.0.0.1:3420".parse()?, + wallet_owner_url: "127.0.0.1:3420".parse().unwrap(), wallet_owner_secret_path: None, - }; - - let server_impl = Arc::new(server::ServerImpl::new( - server_config.clone(), - wallet.clone(), - node.clone(), - )); - - // Start the server - let rpc_server = server_impl.start_server(); - - let uri = format!("http://{}/v1", server_config.addr); - - let threaded_rt = runtime::Runtime::new()?; - let do_request = async move { - let request = Request::post(uri) - .header("Content-Type", "application/json") - .body(Body::from(req)) - .unwrap(); - - Client::new().request(request).await - }; - - let response = threaded_rt.block_on(do_request)?; - let response_str: String = threaded_rt.block_on(body_to_string(response)); - - // Wait for shutdown - threaded_rt.shutdown_background(); - - // Execute one round - server_impl.execute_round()?; - - // Stop the server - rpc_server.close(); - - Ok(response_str) + } } /// Single hop to demonstrate request validation and onion unwrapping. @@ -366,7 +305,7 @@ mod tests { let value: u64 = 200_000_000; let fee: u64 = 50_000_000; let blind = secp::random_secret(); - let commitment = secp::commit(value, &blind)?; + let input_commit = secp::commit(value, &blind)?; let hop_excess = secp::random_secret(); let nonce = secp::random_secret(); @@ -384,40 +323,59 @@ mod tests { let hop = Hop { pubkey: PublicKey::from_secret_key(&secp, &server_key)?, payload: Payload { - excess: hop_excess, + excess: hop_excess.clone(), fee: FeeFields::from(fee as u32), rangeproof: Some(proof), }, }; let hops: Vec = vec![hop]; let session_key = secp::random_secret(); - let onion_packet = test_util::create_onion(&commitment, &session_key, &hops)?; + let onion_packet = test_util::create_onion(&input_commit, &session_key, &hops)?; let comsig = ComSignature::sign(value, &blind, &onion_packet.serialize()?)?; - let swap = SwapReq { - onion: onion_packet, - comsig, - }; - let wallet = MockWallet {}; + let wallet = Arc::new(MockWallet {}); let mut mut_node = MockGrinNode::new(); - mut_node.add_default_utxo(&commitment); + mut_node.add_default_utxo(&input_commit); let node = Arc::new(mut_node); - let req = format!( - "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", - serde_json::json!(swap) - ); - println!("Request: {}", req); - let response = make_request(server_key, Arc::new(wallet), node.clone(), req)?; - let expected = "{\"jsonrpc\":\"2.0\",\"result\":\"success\",\"id\":\"1\"}\n"; - assert_eq!(response, expected); + let server = ServerImpl::new(new_config(&server_key), wallet.clone(), node.clone()); + server.swap(&onion_packet, &comsig)?; + + // Make sure entry is added to server. + let output_commit = secp::add_excess(&input_commit, &hop_excess)?; + let output_commit = secp::sub_value(&output_commit, fee)?; + + let expected = Submission { + excess: hop_excess.clone(), + output_commit: output_commit.clone(), + rangeproof: Some(proof), + input: Input::new(OutputFeatures::Plain, input_commit.clone()), + fee, + onion: Onion { + ephemeral_pubkey: test_util::next_ephemeral_pubkey(&onion_packet, &server_key)?, + commit: output_commit.clone(), + enc_payloads: vec![], + }, + }; + + { + let submissions = server.submissions.lock().unwrap(); + assert_eq!(1, submissions.len()); + assert!(submissions.contains_key(&input_commit)); + assert_eq!(&expected, submissions.get(&input_commit).unwrap()); + } + + server.execute_round()?; + + // Make sure entry is removed from server.submissions + assert!(server.submissions.lock().unwrap().is_empty()); // check that the transaction was posted let posted_txns = node.get_posted_txns(); assert_eq!(posted_txns.len(), 1); let posted_txn: Transaction = posted_txns.into_iter().next().unwrap(); - let input_commit = posted_txn.inputs_committed().into_iter().next().unwrap(); - assert_eq!(input_commit, commitment); + let posted_input = posted_txn.inputs_committed().into_iter().next().unwrap(); + assert_eq!(input_commit, posted_input); Ok(()) } @@ -445,39 +403,25 @@ mod tests { let session_key = secp::random_secret(); let onion_packet = test_util::create_onion(&commitment, &session_key, &hops)?; let comsig = ComSignature::sign(value, &blind, &onion_packet.serialize()?)?; - let swap = SwapReq { - onion: onion_packet, - comsig, - }; - let wallet = MockWallet {}; - let node = MockGrinNode::new(); + let wallet = Arc::new(MockWallet {}); + let node = Arc::new(MockGrinNode::new()); - let req = format!( - "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", - serde_json::json!(swap) + let server = ServerImpl::new(new_config(&server_key), wallet.clone(), node.clone()); + let result = server.swap(&onion_packet, &comsig); + assert!(result.is_err()); + assert_eq!( + SwapError::CoinNotFound { + commit: commitment.clone() + }, + result.unwrap_err() ); - let response = make_request(server_key, Arc::new(wallet), Arc::new(node), req)?; - let expected = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32602,\"message\":\"Commitment not found\"},\"id\":\"1\"}\n"; - assert_eq!(response, expected); + + // Make sure no entry is added to server.submissions + assert!(server.submissions.lock().unwrap().is_empty()); + Ok(()) } // TODO: Test bulletproof verification and test minimum fee - - #[test] - fn swap_bad_request() -> Result<(), Box> { - let wallet = MockWallet {}; - let node = MockGrinNode::new(); - - let params = "{ \"param\": \"Not a valid Swap request\" }"; - let req = format!( - "{{\"jsonrpc\": \"2.0\", \"method\": \"swap\", \"params\": [{}], \"id\": \"1\"}}", - params - ); - let response = make_request(secp::random_secret(), Arc::new(wallet), Arc::new(node), req)?; - let expected = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32602,\"message\":\"Invalid params: missing field `onion`.\"},\"id\":\"1\"}\n"; - assert_eq!(response, expected); - Ok(()) - } } diff --git a/src/wallet.rs b/src/wallet.rs index 93a0d59..8b3aa1b 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -1,11 +1,10 @@ use crate::error::{ErrorKind, Result}; -use crate::secp::{self}; +use crate::secp; use grin_api::client; use grin_api::json_rpc::{build_request, Request, Response}; use grin_core::core::{ - FeeFields, Input, Inputs, KernelFeatures, Output, OutputFeatures, Transaction, TransactionBody, - TxKernel, + FeeFields, Input, Inputs, KernelFeatures, Output, Transaction, TransactionBody, TxKernel, }; use grin_core::libtx::secp_ser; use grin_keychain::BlindingFactor; @@ -222,25 +221,36 @@ impl Wallet for HttpWallet { } } -/// HTTP (JSONRPC) implementation of the 'Wallet' trait. -#[derive(Clone)] -pub struct MockWallet {} +#[cfg(test)] +pub mod mock { + use super::Wallet; + use crate::error::Result; + use crate::secp; -impl Wallet for MockWallet { - /// Builds an 'Output' for the wallet using the 'build_output' RPC API. - fn build_output(&self, amount: u64) -> Result<(BlindingFactor, Output)> { - let secp = Secp256k1::new(); - let blind = secp::random_secret(); - let commit = secp::commit(amount, &blind)?; - let proof = secp.bullet_proof( - amount, - blind.clone(), - secp::random_secret(), - secp::random_secret(), - None, - None, - ); - let output = Output::new(OutputFeatures::Plain, commit.clone(), proof); - Ok((BlindingFactor::from_secret_key(blind), output)) + use grin_core::core::{Output, OutputFeatures}; + use grin_keychain::BlindingFactor; + use secp256k1zkp::Secp256k1; + + /// HTTP (JSONRPC) implementation of the 'Wallet' trait. + #[derive(Clone)] + pub struct MockWallet {} + + impl Wallet for MockWallet { + /// Builds an 'Output' for the wallet using the 'build_output' RPC API. + fn build_output(&self, amount: u64) -> Result<(BlindingFactor, Output)> { + let secp = Secp256k1::new(); + let blind = secp::random_secret(); + let commit = secp::commit(amount, &blind)?; + let proof = secp.bullet_proof( + amount, + blind.clone(), + secp::random_secret(), + secp::random_secret(), + None, + None, + ); + let output = Output::new(OutputFeatures::Plain, commit.clone(), proof); + Ok((BlindingFactor::from_secret_key(blind), output)) + } } }