diff --git a/coins/monero/rpc/simple-request/tests/tests.rs b/coins/monero/rpc/simple-request/tests/tests.rs index c787387d..8f8d6de9 100644 --- a/coins/monero/rpc/simple-request/tests/tests.rs +++ b/coins/monero/rpc/simple-request/tests/tests.rs @@ -3,7 +3,6 @@ use monero_address::{Network, MoneroAddress}; // monero-rpc doesn't include a transport // We can't include the simple-request crate there as then we'd have a cyclical dependency // Accordingly, we test monero-rpc here (implicitly testing the simple-request transport) -use monero_rpc::*; use monero_simple_request_rpc::*; const ADDRESS: &str = @@ -11,6 +10,8 @@ const ADDRESS: &str = #[tokio::test] async fn test_rpc() { + use monero_rpc::Rpc; + let rpc = SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap(); @@ -52,17 +53,35 @@ async fn test_rpc() { } assert_eq!(blocks, actual_blocks); } +} + +#[tokio::test] +async fn test_decoy_rpc() { + use monero_rpc::{Rpc, DecoyRpc}; + + let rpc = + SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap(); // Test get_output_distribution // It's documented to take two inclusive block numbers { - let height = rpc.get_height().await.unwrap(); + let distribution_len = rpc.get_output_distribution_len().await.unwrap(); + assert_eq!(distribution_len, rpc.get_height().await.unwrap()); - rpc.get_output_distribution(0 ..= height).await.unwrap_err(); - assert_eq!(rpc.get_output_distribution(0 .. height).await.unwrap().len(), height); + rpc.get_output_distribution(0 ..= distribution_len).await.unwrap_err(); + assert_eq!( + rpc.get_output_distribution(0 .. distribution_len).await.unwrap().len(), + distribution_len + ); - assert_eq!(rpc.get_output_distribution(0 .. (height - 1)).await.unwrap().len(), height - 1); - assert_eq!(rpc.get_output_distribution(1 .. height).await.unwrap().len(), height - 1); + assert_eq!( + rpc.get_output_distribution(0 .. (distribution_len - 1)).await.unwrap().len(), + distribution_len - 1 + ); + assert_eq!( + rpc.get_output_distribution(1 .. distribution_len).await.unwrap().len(), + distribution_len - 1 + ); assert_eq!(rpc.get_output_distribution(0 ..= 0).await.unwrap().len(), 1); assert_eq!(rpc.get_output_distribution(0 ..= 1).await.unwrap().len(), 2); diff --git a/coins/monero/rpc/src/lib.rs b/coins/monero/rpc/src/lib.rs index 8e0eb831..b37bcace 100644 --- a/coins/monero/rpc/src/lib.rs +++ b/coins/monero/rpc/src/lib.rs @@ -492,6 +492,129 @@ pub trait Rpc: Sync + Clone + Debug { Ok(res) } + /// Get the currently estimated fee rate from the node. + /// + /// This may be manipulated to unsafe levels and MUST be sanity checked. + /// + /// This MUST NOT be expected to be deterministic in any way. + // TODO: Take a sanity check argument + async fn get_fee_rate(&self, priority: FeePriority) -> Result { + #[derive(Deserialize, Debug)] + struct FeeResponse { + status: String, + fees: Option>, + fee: u64, + quantization_mask: u64, + } + + let res: FeeResponse = self + .json_rpc_call( + "get_fee_estimate", + Some(json!({ "grace_blocks": GRACE_BLOCKS_FOR_FEE_ESTIMATE })), + ) + .await?; + + if res.status != "OK" { + Err(RpcError::InvalidFee)?; + } + + if let Some(fees) = res.fees { + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/wallet/wallet2.cpp#L7615-L7620 + let priority_idx = usize::try_from(if priority.fee_priority() >= 4 { + 3 + } else { + priority.fee_priority().saturating_sub(1) + }) + .map_err(|_| RpcError::InvalidPriority)?; + + if priority_idx >= fees.len() { + Err(RpcError::InvalidPriority) + } else { + FeeRate::new(fees[priority_idx], res.quantization_mask) + } + } else { + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/wallet/wallet2.cpp#L7569-L7584 + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/wallet/wallet2.cpp#L7660-L7661 + let priority_idx = + usize::try_from(if priority.fee_priority() == 0 { 1 } else { priority.fee_priority() - 1 }) + .map_err(|_| RpcError::InvalidPriority)?; + let multipliers = [1, 5, 25, 1000]; + if priority_idx >= multipliers.len() { + // though not an RPC error, it seems sensible to treat as such + Err(RpcError::InvalidPriority)?; + } + let fee_multiplier = multipliers[priority_idx]; + + FeeRate::new(res.fee * fee_multiplier, res.quantization_mask) + } + } + + /// Publish a transaction. + async fn publish_transaction(&self, tx: &Transaction) -> Result<(), RpcError> { + #[allow(dead_code)] + #[derive(Deserialize, Debug)] + struct SendRawResponse { + status: String, + double_spend: bool, + fee_too_low: bool, + invalid_input: bool, + invalid_output: bool, + low_mixin: bool, + not_relayed: bool, + overspend: bool, + too_big: bool, + too_few_outputs: bool, + reason: String, + } + + let res: SendRawResponse = self + .rpc_call( + "send_raw_transaction", + Some(json!({ "tx_as_hex": hex::encode(tx.serialize()), "do_sanity_checks": false })), + ) + .await?; + + if res.status != "OK" { + Err(RpcError::InvalidTransaction(tx.hash()))?; + } + + Ok(()) + } + + /// Generate blocks, with the specified address receiving the block reward. + /// + /// Returns the hashes of the generated blocks and the last block's number. + async fn generate_blocks( + &self, + address: &Address, + block_count: usize, + ) -> Result<(Vec<[u8; 32]>, usize), RpcError> { + #[derive(Debug, Deserialize)] + struct BlocksResponse { + blocks: Vec, + height: usize, + } + + let res = self + .json_rpc_call::( + "generateblocks", + Some(json!({ + "wallet_address": address.to_string(), + "amount_of_blocks": block_count + })), + ) + .await?; + + let mut blocks = Vec::with_capacity(res.blocks.len()); + for block in res.blocks { + blocks.push(hash_hex(&block)?); + } + Ok((blocks, res.height)) + } + /// Get the output indexes of the specified transaction. async fn get_o_indexes(&self, hash: [u8; 32]) -> Result, RpcError> { // Given the immaturity of Rust epee libraries, this is a homegrown one which is only validated @@ -673,10 +796,57 @@ pub trait Rpc: Sync + Clone + Debug { })() .map_err(|e| RpcError::InvalidNode(format!("invalid binary response: {e:?}"))) } +} + +/// A trait for any object which can be used to select decoys. +/// +/// An implementation is provided for any satisfier of `Rpc`. The benefit of this trait is the +/// ability to select decoys off of a locally stored output distribution, preventing potential +/// attacks a remote node can perform. +#[async_trait] +pub trait DecoyRpc: Sync + Clone + Debug { + /// Get the length of the output distribution. + /// + /// This is equivalent to the hight of the blockchain it's for. This is intended to be cheaper + /// than fetching the entire output distribution. + async fn get_output_distribution_len(&self) -> Result; /// Get the output distribution. /// /// `range` is in terms of block numbers. + async fn get_output_distribution( + &self, + range: impl Send + RangeBounds, + ) -> Result, RpcError>; + + /// Get the specified outputs from the RingCT (zero-amount) pool. + async fn get_outs(&self, indexes: &[u64]) -> Result, RpcError>; + + /// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their + /// timelock has been satisfied. + /// + /// The timelock being satisfied is distinct from being free of the 10-block lock applied to all + /// Monero transactions. + /// + /// The node is trusted for if the output is unlocked unless `fingerprintable_canonical` is set + /// to true. If `fingerprintable_canonical` is set to true, the node's local view isn't used, yet + /// the transaction's timelock is checked to be unlocked at the specified `height`. This offers a + /// canonical decoy selection, yet is fingerprintable as time-based timelocks aren't evaluated + /// (and considered locked, preventing their selection). + async fn get_unlocked_outputs( + &self, + indexes: &[u64], + height: usize, + fingerprintable_canonical: bool, + ) -> Result>, RpcError>; +} + +#[async_trait] +impl DecoyRpc for R { + async fn get_output_distribution_len(&self) -> Result { + ::get_height(self).await + } + async fn get_output_distribution( &self, range: impl Send + RangeBounds, @@ -743,7 +913,6 @@ pub trait Rpc: Sync + Clone + Debug { Ok(distribution) } - /// Get the specified outputs from the RingCT (zero-amount) pool. async fn get_outs(&self, indexes: &[u64]) -> Result, RpcError> { #[derive(Deserialize, Debug)] struct OutsResponse { @@ -771,17 +940,6 @@ pub trait Rpc: Sync + Clone + Debug { Ok(res.outs) } - /// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their - /// timelock has been satisfied. - /// - /// The timelock being satisfied is distinct from being free of the 10-block lock applied to all - /// Monero transactions. - /// - /// The node is trusted for if the output is unlocked unless `fingerprintable_canonical` is set - /// to true. If `fingerprintable_canonical` is set to true, the node's local view isn't used, yet - /// the transaction's timelock is checked to be unlocked at the specified `height`. This offers a - /// canonical decoy selection, yet is fingerprintable as time-based timelocks aren't evaluated - /// (and considered locked, preventing their selection). async fn get_unlocked_outputs( &self, indexes: &[u64], @@ -830,127 +988,4 @@ pub trait Rpc: Sync + Clone + Debug { }) .collect() } - - /// Get the currently estimated fee rate from the node. - /// - /// This may be manipulated to unsafe levels and MUST be sanity checked. - /// - /// This MUST NOT be expected to be deterministic in any way. - // TODO: Take a sanity check argument - async fn get_fee_rate(&self, priority: FeePriority) -> Result { - #[derive(Deserialize, Debug)] - struct FeeResponse { - status: String, - fees: Option>, - fee: u64, - quantization_mask: u64, - } - - let res: FeeResponse = self - .json_rpc_call( - "get_fee_estimate", - Some(json!({ "grace_blocks": GRACE_BLOCKS_FOR_FEE_ESTIMATE })), - ) - .await?; - - if res.status != "OK" { - Err(RpcError::InvalidFee)?; - } - - if let Some(fees) = res.fees { - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/wallet/wallet2.cpp#L7615-L7620 - let priority_idx = usize::try_from(if priority.fee_priority() >= 4 { - 3 - } else { - priority.fee_priority().saturating_sub(1) - }) - .map_err(|_| RpcError::InvalidPriority)?; - - if priority_idx >= fees.len() { - Err(RpcError::InvalidPriority) - } else { - FeeRate::new(fees[priority_idx], res.quantization_mask) - } - } else { - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/wallet/wallet2.cpp#L7569-L7584 - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/wallet/wallet2.cpp#L7660-L7661 - let priority_idx = - usize::try_from(if priority.fee_priority() == 0 { 1 } else { priority.fee_priority() - 1 }) - .map_err(|_| RpcError::InvalidPriority)?; - let multipliers = [1, 5, 25, 1000]; - if priority_idx >= multipliers.len() { - // though not an RPC error, it seems sensible to treat as such - Err(RpcError::InvalidPriority)?; - } - let fee_multiplier = multipliers[priority_idx]; - - FeeRate::new(res.fee * fee_multiplier, res.quantization_mask) - } - } - - /// Publish a transaction. - async fn publish_transaction(&self, tx: &Transaction) -> Result<(), RpcError> { - #[allow(dead_code)] - #[derive(Deserialize, Debug)] - struct SendRawResponse { - status: String, - double_spend: bool, - fee_too_low: bool, - invalid_input: bool, - invalid_output: bool, - low_mixin: bool, - not_relayed: bool, - overspend: bool, - too_big: bool, - too_few_outputs: bool, - reason: String, - } - - let res: SendRawResponse = self - .rpc_call( - "send_raw_transaction", - Some(json!({ "tx_as_hex": hex::encode(tx.serialize()), "do_sanity_checks": false })), - ) - .await?; - - if res.status != "OK" { - Err(RpcError::InvalidTransaction(tx.hash()))?; - } - - Ok(()) - } - - /// Generate blocks, with the specified address receiving the block reward. - /// - /// Returns the hashes of the generated blocks and the last block's number. - async fn generate_blocks( - &self, - address: &Address, - block_count: usize, - ) -> Result<(Vec<[u8; 32]>, usize), RpcError> { - #[derive(Debug, Deserialize)] - struct BlocksResponse { - blocks: Vec, - height: usize, - } - - let res = self - .json_rpc_call::( - "generateblocks", - Some(json!({ - "wallet_address": address.to_string(), - "amount_of_blocks": block_count - })), - ) - .await?; - - let mut blocks = Vec::with_capacity(res.blocks.len()); - for block in res.blocks { - blocks.push(hash_hex(&block)?); - } - Ok((blocks, res.height)) - } } diff --git a/coins/monero/wallet/src/decoys.rs b/coins/monero/wallet/src/decoys.rs index 6e043481..503a7d44 100644 --- a/coins/monero/wallet/src/decoys.rs +++ b/coins/monero/wallet/src/decoys.rs @@ -12,7 +12,7 @@ use curve25519_dalek::{Scalar, EdwardsPoint}; use crate::{ DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME, primitives::{Commitment, Decoys}, - rpc::{RpcError, Rpc}, + rpc::{RpcError, DecoyRpc}, output::OutputData, WalletOutput, }; @@ -24,7 +24,7 @@ const TIP_APPLICATION: f64 = (DEFAULT_LOCK_WINDOW * BLOCK_TIME) as f64; async fn select_n( rng: &mut (impl RngCore + CryptoRng), - rpc: &impl Rpc, + rpc: &impl DecoyRpc, height: usize, real_output: u64, ring_len: usize, @@ -33,7 +33,7 @@ async fn select_n( if height < DEFAULT_LOCK_WINDOW { Err(RpcError::InternalError("not enough blocks to select decoys".to_string()))?; } - if height > rpc.get_height().await? { + if height > rpc.get_output_distribution_len().await? { Err(RpcError::InternalError( "decoys being requested from blocks this node doesn't have".to_string(), ))?; @@ -164,7 +164,7 @@ async fn select_n( async fn select_decoys( rng: &mut R, - rpc: &impl Rpc, + rpc: &impl DecoyRpc, ring_len: usize, height: usize, input: &WalletOutput, @@ -230,7 +230,7 @@ impl OutputWithDecoys { /// Select decoys for this output. pub async fn new( rng: &mut (impl Send + Sync + RngCore + CryptoRng), - rpc: &impl Rpc, + rpc: &impl DecoyRpc, ring_len: usize, height: usize, output: WalletOutput, @@ -249,7 +249,7 @@ impl OutputWithDecoys { /// methodology. pub async fn fingerprintable_deterministic_new( rng: &mut (impl Send + Sync + RngCore + CryptoRng), - rpc: &impl Rpc, + rpc: &impl DecoyRpc, ring_len: usize, height: usize, output: WalletOutput, diff --git a/coins/monero/wallet/tests/decoys.rs b/coins/monero/wallet/tests/decoys.rs index d2fe0e88..2a955bd7 100644 --- a/coins/monero/wallet/tests/decoys.rs +++ b/coins/monero/wallet/tests/decoys.rs @@ -2,7 +2,7 @@ use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ DEFAULT_LOCK_WINDOW, transaction::Transaction, - rpc::{OutputResponse, Rpc}, + rpc::{OutputResponse, Rpc, DecoyRpc}, WalletOutput, };