From adfbde6e2494290099511c39976915522bfd6239 Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Tue, 2 May 2023 02:39:08 -0400 Subject: [PATCH] Support arbitrary RPC providers in monero-serai Sets a clean path for no-std premised RPCs (buffers to an external RPC impl)/ Tor-based RPCs/client-side load balancing/... --- Cargo.lock | 1 + coins/monero/Cargo.toml | 1 + coins/monero/src/rpc.rs | 135 +++++++++++++------- coins/monero/src/wallet/decoys.rs | 10 +- coins/monero/src/wallet/scan.rs | 16 ++- coins/monero/src/wallet/send/mod.rs | 10 +- coins/monero/src/wallet/send/multisig.rs | 6 +- coins/monero/tests/runner.rs | 10 +- coins/monero/tests/send.rs | 2 +- coins/monero/tests/wallet2_compatibility.rs | 6 +- 10 files changed, 123 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2791aaf..edcc0a66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5270,6 +5270,7 @@ dependencies = [ name = "monero-serai" version = "0.1.4-alpha" dependencies = [ + "async-trait", "base58-monero", "crc", "curve25519-dalek 3.2.0", diff --git a/coins/monero/Cargo.toml b/coins/monero/Cargo.toml index 30809ae8..f3e80546 100644 --- a/coins/monero/Cargo.toml +++ b/coins/monero/Cargo.toml @@ -15,6 +15,7 @@ rustdoc-args = ["--cfg", "docsrs"] futures = "0.3" lazy_static = "1" +async-trait = "0.1" thiserror = "1" rand_core = "0.6" diff --git a/coins/monero/src/rpc.rs b/coins/monero/src/rpc.rs index de3dcaf0..c6cda880 100644 --- a/coins/monero/src/rpc.rs +++ b/coins/monero/src/rpc.rs @@ -1,5 +1,6 @@ use std::fmt::Debug; +use async_trait::async_trait; use thiserror::Error; use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY}; @@ -8,7 +9,7 @@ use serde::{Serialize, Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; use digest_auth::AuthContext; -use reqwest::{Client, RequestBuilder}; +use reqwest::Client; use crate::{ Protocol, @@ -73,18 +74,27 @@ fn rpc_point(point: &str) -> Result { .ok_or_else(|| RpcError::InvalidPoint(point.to_string())) } +#[async_trait] +pub trait RpcConnection: Clone + Debug { + /// Perform a POST request to the specified route with the specified body. + /// + /// The implementor is left to handle anything such as authentication. + async fn post(&self, route: &str, body: Vec) -> Result, RpcError>; +} + #[derive(Clone, Debug)] -pub struct Rpc { +pub struct HttpRpc { client: Client, userpass: Option<(String, String)>, url: String, } -impl Rpc { - /// Create a new RPC connection. +impl HttpRpc { + /// Create a new HTTP(S) RPC connection. + /// /// A daemon requiring authentication can be used via including the username and password in the /// URL. - pub fn new(mut url: String) -> Result { + pub fn new(mut url: String) -> Result, RpcError> { // Parse out the username and password let userpass = if url.contains('@') { let url_clone = url; @@ -114,26 +124,80 @@ impl Rpc { None }; - Ok(Rpc { client: Client::new(), userpass, url }) + Ok(Rpc(HttpRpc { client: Client::new(), userpass, url })) } +} - /// Perform a RPC call to the specified method with the provided parameters. - /// This is NOT a JSON-RPC call, which use a method of "json_rpc" and are available via +#[async_trait] +impl RpcConnection for HttpRpc { + async fn post(&self, route: &str, body: Vec) -> Result, RpcError> { + let mut builder = self.client.post(self.url.clone() + "/" + route).body(body); + + if let Some((user, pass)) = &self.userpass { + let req = self.client.post(&self.url).send().await.map_err(|_| RpcError::InvalidNode)?; + // Only provide authentication if this daemon actually expects it + if let Some(header) = req.headers().get("www-authenticate") { + builder = builder.header( + "Authorization", + digest_auth::parse(header.to_str().map_err(|_| RpcError::InvalidNode)?) + .map_err(|_| RpcError::InvalidNode)? + .respond(&AuthContext::new_post::<_, _, _, &[u8]>( + user, + pass, + "/".to_string() + route, + None, + )) + .map_err(|_| RpcError::InvalidNode)? + .to_header_string(), + ); + } + } + + Ok( + builder + .send() + .await + .map_err(|_| RpcError::ConnectionError)? + .bytes() + .await + .map_err(|_| RpcError::ConnectionError)? + .slice(..) + .to_vec(), + ) + } +} + +#[derive(Clone, Debug)] +pub struct Rpc(R); +impl Rpc { + /// Perform a RPC call to the specified route with the provided parameters. + /// + /// This is NOT a JSON-RPC call. They use a route of "json_rpc" and are available via /// `json_rpc_call`. pub async fn rpc_call( &self, - method: &str, + route: &str, params: Option, ) -> Result { - let mut builder = self.client.post(self.url.clone() + "/" + method); - if let Some(params) = params.as_ref() { - builder = builder.json(params); - } - - self.call_tail(method, builder).await + self + .call_tail( + route, + self + .0 + .post( + route, + if let Some(params) = params { + serde_json::to_string(¶ms).unwrap().into_bytes() + } else { + vec![] + }, + ) + .await?, + ) + .await } - /// Perform a JSON-RPC call to the specified method with the provided parameters + /// Perform a JSON-RPC call with the specified method with the provided parameters pub async fn json_rpc_call( &self, method: &str, @@ -146,48 +210,25 @@ impl Rpc { Ok(self.rpc_call::<_, JsonRpcResponse>("json_rpc", Some(req)).await?.result) } - /// Perform a binary call to the specified method with the provided parameters. + /// Perform a binary call to the specified route with the provided parameters. pub async fn bin_call( &self, - method: &str, + route: &str, params: Vec, ) -> Result { - let builder = self.client.post(self.url.clone() + "/" + method).body(params.clone()); - self.call_tail(method, builder.header("Content-Type", "application/octet-stream")).await + self.call_tail(route, self.0.post(route, params).await?).await } async fn call_tail( &self, - method: &str, - mut builder: RequestBuilder, + route: &str, + res: Vec, ) -> Result { - if let Some((user, pass)) = &self.userpass { - let req = self.client.post(&self.url).send().await.map_err(|_| RpcError::InvalidNode)?; - // Only provide authentication if this daemon actually expects it - if let Some(header) = req.headers().get("www-authenticate") { - builder = builder.header( - "Authorization", - digest_auth::parse(header.to_str().map_err(|_| RpcError::InvalidNode)?) - .map_err(|_| RpcError::InvalidNode)? - .respond(&AuthContext::new_post::<_, _, _, &[u8]>( - user, - pass, - "/".to_string() + method, - None, - )) - .map_err(|_| RpcError::InvalidNode)? - .to_header_string(), - ); - } - } - - let res = builder.send().await.map_err(|_| RpcError::ConnectionError)?; - - Ok(if !method.ends_with(".bin") { - serde_json::from_str(&res.text().await.map_err(|_| RpcError::ConnectionError)?) + Ok(if !route.ends_with(".bin") { + serde_json::from_str(std::str::from_utf8(&res).map_err(|_| RpcError::InvalidNode)?) .map_err(|_| RpcError::InternalError("Failed to parse JSON response"))? } else { - monero_epee_bin_serde::from_bytes(&res.bytes().await.map_err(|_| RpcError::ConnectionError)?) + monero_epee_bin_serde::from_bytes(&res) .map_err(|_| RpcError::InternalError("Failed to parse binary response"))? }) } diff --git a/coins/monero/src/wallet/decoys.rs b/coins/monero/src/wallet/decoys.rs index f82d8262..07b05d42 100644 --- a/coins/monero/src/wallet/decoys.rs +++ b/coins/monero/src/wallet/decoys.rs @@ -13,7 +13,7 @@ use curve25519_dalek::edwards::EdwardsPoint; use crate::{ wallet::SpendableOutput, - rpc::{RpcError, Rpc}, + rpc::{RpcError, RpcConnection, Rpc}, }; const LOCK_WINDOW: usize = 10; @@ -31,9 +31,9 @@ lazy_static! { } #[allow(clippy::too_many_arguments)] -async fn select_n<'a, R: RngCore + CryptoRng>( +async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>( rng: &mut R, - rpc: &Rpc, + rpc: &Rpc, distribution: &MutexGuard<'a, Vec>, height: usize, high: u64, @@ -137,9 +137,9 @@ impl Decoys { } /// Select decoys using the same distribution as Monero. - pub async fn select( + pub async fn select( rng: &mut R, - rpc: &Rpc, + rpc: &Rpc, ring_len: usize, height: usize, inputs: &[SpendableOutput], diff --git a/coins/monero/src/wallet/scan.rs b/coins/monero/src/wallet/scan.rs index 4a9fecc8..9ae046ab 100644 --- a/coins/monero/src/wallet/scan.rs +++ b/coins/monero/src/wallet/scan.rs @@ -10,7 +10,7 @@ use crate::{ serialize::{read_byte, read_u32, read_u64, read_bytes, read_scalar, read_point, read_raw_vec}, transaction::{Input, Timelock, Transaction}, block::Block, - rpc::{Rpc, RpcError}, + rpc::{RpcError, RpcConnection, Rpc}, wallet::{ PaymentId, Extra, address::SubaddressIndex, Scanner, uniqueness, shared_key, amount_decryption, commitment_mask, @@ -195,13 +195,19 @@ pub struct SpendableOutput { impl SpendableOutput { /// Update the spendable output's global index. This is intended to be called if a /// re-organization occurred. - pub async fn refresh_global_index(&mut self, rpc: &Rpc) -> Result<(), RpcError> { + pub async fn refresh_global_index( + &mut self, + rpc: &Rpc, + ) -> Result<(), RpcError> { self.global_index = rpc.get_o_indexes(self.output.absolute.tx).await?[usize::from(self.output.absolute.o)]; Ok(()) } - pub async fn from(rpc: &Rpc, output: ReceivedOutput) -> Result { + pub async fn from( + rpc: &Rpc, + output: ReceivedOutput, + ) -> Result { let mut output = SpendableOutput { output, global_index: 0 }; output.refresh_global_index(rpc).await?; Ok(output) @@ -408,9 +414,9 @@ impl Scanner { /// transactions is a dead giveaway for which transactions you successfully scanned. This /// function obtains the output indexes for the miner transaction, incrementing from there /// instead. - pub async fn scan( + pub async fn scan( &mut self, - rpc: &Rpc, + rpc: &Rpc, block: &Block, ) -> Result>, RpcError> { let mut index = rpc.get_o_indexes(block.miner_tx.hash()).await?[0]; diff --git a/coins/monero/src/wallet/send/mod.rs b/coins/monero/src/wallet/send/mod.rs index 23c5702b..d71398b1 100644 --- a/coins/monero/src/wallet/send/mod.rs +++ b/coins/monero/src/wallet/send/mod.rs @@ -33,7 +33,7 @@ use crate::{ RctBase, RctPrunable, RctSignatures, }, transaction::{Input, Output, Timelock, TransactionPrefix, Transaction}, - rpc::{Rpc, RpcError}, + rpc::{RpcError, RpcConnection, Rpc}, wallet::{ address::{Network, AddressSpec, MoneroAddress}, ViewPair, SpendableOutput, Decoys, PaymentId, ExtraField, Extra, key_image_sort, uniqueness, @@ -147,9 +147,9 @@ pub enum TransactionError { FrostError(FrostError), } -async fn prepare_inputs( +async fn prepare_inputs( rng: &mut R, - rpc: &Rpc, + rpc: &Rpc, ring_len: usize, inputs: &[SpendableOutput], spend: &Zeroizing, @@ -663,10 +663,10 @@ impl SignableTransaction { } /// Sign this transaction. - pub async fn sign( + pub async fn sign( mut self, rng: &mut R, - rpc: &Rpc, + rpc: &Rpc, spend: &Zeroizing, ) -> Result { let mut images = Vec::with_capacity(self.inputs.len()); diff --git a/coins/monero/src/wallet/send/multisig.rs b/coins/monero/src/wallet/send/multisig.rs index 5e3a8c6a..cfe499f4 100644 --- a/coins/monero/src/wallet/send/multisig.rs +++ b/coins/monero/src/wallet/send/multisig.rs @@ -30,7 +30,7 @@ use crate::{ RctPrunable, }, transaction::{Input, Transaction}, - rpc::Rpc, + rpc::{RpcConnection, Rpc}, wallet::{ TransactionError, InternalPayment, SignableTransaction, Decoys, key_image_sort, uniqueness, }, @@ -74,9 +74,9 @@ pub struct TransactionSignatureMachine { impl SignableTransaction { /// Create a FROST signing machine out of this signable transaction. /// The height is the Monero blockchain height to synchronize around. - pub async fn multisig( + pub async fn multisig( self, - rpc: &Rpc, + rpc: &Rpc, keys: ThresholdKeys, mut transcript: RecommendedTranscript, height: usize, diff --git a/coins/monero/tests/runner.rs b/coins/monero/tests/runner.rs index a94cd20f..ad4e65ce 100644 --- a/coins/monero/tests/runner.rs +++ b/coins/monero/tests/runner.rs @@ -12,7 +12,7 @@ use tokio::sync::Mutex; use monero_serai::{ random_scalar, - rpc::Rpc, + rpc::{HttpRpc, Rpc}, wallet::{ ViewPair, Scanner, address::{Network, AddressType, AddressSpec, AddressMeta, MoneroAddress}, @@ -38,7 +38,7 @@ pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) { // TODO: Support transactions already on-chain // TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions // TODO: mine as much as needed instead of default 10 blocks -pub async fn mine_until_unlocked(rpc: &Rpc, addr: &str, tx_hash: [u8; 32]) { +pub async fn mine_until_unlocked(rpc: &Rpc, addr: &str, tx_hash: [u8; 32]) { // mine until tx is in a block let mut height = rpc.get_height().await.unwrap(); let mut found = false; @@ -60,7 +60,7 @@ pub async fn mine_until_unlocked(rpc: &Rpc, addr: &str, tx_hash: [u8; 32]) { // Mines 60 blocks and returns an unlocked miner TX output. #[allow(dead_code)] -pub async fn get_miner_tx_output(rpc: &Rpc, view: &ViewPair) -> SpendableOutput { +pub async fn get_miner_tx_output(rpc: &Rpc, view: &ViewPair) -> SpendableOutput { let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); // Mine 60 blocks to unlock a miner TX @@ -74,8 +74,8 @@ pub async fn get_miner_tx_output(rpc: &Rpc, view: &ViewPair) -> SpendableOutput scanner.scan(rpc, &block).await.unwrap().swap_remove(0).ignore_timelock().swap_remove(0) } -pub async fn rpc() -> Rpc { - let rpc = Rpc::new("http://127.0.0.1:18081".to_string()).unwrap(); +pub async fn rpc() -> Rpc { + let rpc = HttpRpc::new("http://127.0.0.1:18081".to_string()).unwrap(); // Only run once if rpc.get_height().await.unwrap() != 1 { diff --git a/coins/monero/tests/send.rs b/coins/monero/tests/send.rs index 295b8027..c210c59c 100644 --- a/coins/monero/tests/send.rs +++ b/coins/monero/tests/send.rs @@ -69,7 +69,7 @@ test!( }, ), ( - |rpc: Rpc, _, _, mut outputs: Vec| async move { + |rpc: Rpc<_>, _, _, mut outputs: Vec| async move { let change_view = ViewPair::new( &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE, Zeroizing::new(random_scalar(&mut OsRng)), diff --git a/coins/monero/tests/wallet2_compatibility.rs b/coins/monero/tests/wallet2_compatibility.rs index db5f5d99..57c73b16 100644 --- a/coins/monero/tests/wallet2_compatibility.rs +++ b/coins/monero/tests/wallet2_compatibility.rs @@ -19,7 +19,7 @@ use monero_rpc::{ use monero_serai::{ transaction::Transaction, - rpc::Rpc, + rpc::{HttpRpc, Rpc}, wallet::{ address::{Network, AddressSpec, SubaddressIndex, MoneroAddress}, extra::{MAX_TX_EXTRA_NONCE_SIZE, Extra}, @@ -35,7 +35,7 @@ async fn make_integrated_address(payment_id: [u8; 8]) -> String { integrated_address: String, } - let rpc = Rpc::new("http://127.0.0.1:6061".to_string()).unwrap(); + let rpc = HttpRpc::new("http://127.0.0.1:6061".to_string()).unwrap(); let res = rpc .json_rpc_call::( "make_integrated_address", @@ -47,7 +47,7 @@ async fn make_integrated_address(payment_id: [u8; 8]) -> String { res.integrated_address } -async fn initialize_rpcs() -> (WalletClient, Rpc, monero_rpc::monero::Address) { +async fn initialize_rpcs() -> (WalletClient, Rpc, monero_rpc::monero::Address) { let wallet_rpc = monero_rpc::RpcClientBuilder::new().build("http://127.0.0.1:6061").unwrap().wallet(); let daemon_rpc = runner::rpc().await;