diff --git a/coins/monero/src/rpc.rs b/coins/monero/src/rpc.rs index 5085d6cd..851c3222 100644 --- a/coins/monero/src/rpc.rs +++ b/coins/monero/src/rpc.rs @@ -27,7 +27,7 @@ pub struct JsonRpcResponse { #[derive(Deserialize, Debug)] struct TransactionResponse { tx_hash: String, - block_height: usize, + block_height: Option, as_hex: String, pruned_as_hex: String, } @@ -274,7 +274,7 @@ impl Rpc { self.get_transactions(&[tx]).await.map(|mut txs| txs.swap_remove(0)) } - pub async fn get_transaction_block_number(&self, tx: &[u8]) -> Result { + pub async fn get_transaction_block_number(&self, tx: &[u8]) -> Result, RpcError> { let txs: TransactionsResponse = self.rpc_call("get_transactions", Some(json!({ "txs_hashes": [hex::encode(tx)] }))).await?; diff --git a/coins/monero/src/wallet/mod.rs b/coins/monero/src/wallet/mod.rs index 26d7919d..ae9f9934 100644 --- a/coins/monero/src/wallet/mod.rs +++ b/coins/monero/src/wallet/mod.rs @@ -18,7 +18,7 @@ pub mod address; use address::{Network, AddressType, AddressMeta, MoneroAddress}; mod scan; -pub use scan::SpendableOutput; +pub use scan::{ReceivedOutput, SpendableOutput}; pub(crate) mod decoys; pub(crate) use decoys::Decoys; diff --git a/coins/monero/src/wallet/send/mod.rs b/coins/monero/src/wallet/send/mod.rs index 85428240..691131c4 100644 --- a/coins/monero/src/wallet/send/mod.rs +++ b/coins/monero/src/wallet/send/mod.rs @@ -362,7 +362,7 @@ impl SignableTransaction { /// Sign this transaction. pub async fn sign( - &mut self, + mut self, rng: &mut R, rpc: &Rpc, spend: &Zeroizing, diff --git a/coins/monero/tests/rpc.rs b/coins/monero/tests/rpc.rs deleted file mode 100644 index e026f832..00000000 --- a/coins/monero/tests/rpc.rs +++ /dev/null @@ -1,49 +0,0 @@ -use rand_core::OsRng; - -use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; - -use serde_json::json; - -use monero_serai::{ - Protocol, random_scalar, - wallet::address::{Network, AddressType, AddressMeta, MoneroAddress}, - rpc::{EmptyResponse, RpcError, Rpc}, -}; - -pub async fn rpc() -> Rpc { - let rpc = Rpc::new("http://127.0.0.1:18081".to_string()).unwrap(); - - // Only run once - if rpc.get_height().await.unwrap() != 1 { - return rpc; - } - - let addr = MoneroAddress { - meta: AddressMeta::new(Network::Mainnet, AddressType::Standard), - spend: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE, - view: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE, - } - .to_string(); - - // Mine 20 blocks to ensure decoy availability - mine_block(&rpc, &addr).await.unwrap(); - mine_block(&rpc, &addr).await.unwrap(); - assert!(!matches!(rpc.get_protocol().await.unwrap(), Protocol::Unsupported(_))); - - rpc -} - -pub async fn mine_block(rpc: &Rpc, address: &str) -> Result { - rpc - .rpc_call( - "json_rpc", - Some(json!({ - "method": "generateblocks", - "params": { - "wallet_address": address, - "amount_of_blocks": 10 - }, - })), - ) - .await -} diff --git a/coins/monero/tests/runner.rs b/coins/monero/tests/runner.rs new file mode 100644 index 00000000..2a5d0863 --- /dev/null +++ b/coins/monero/tests/runner.rs @@ -0,0 +1,266 @@ +use std::sync::Mutex; + +use lazy_static::lazy_static; +use rand_core::OsRng; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; + +use serde_json::json; + +use monero_serai::{ + Protocol, random_scalar, + wallet::{ + ViewPair, + address::{Network, AddressType, AddressMeta, MoneroAddress}, + }, + rpc::{EmptyResponse, Rpc}, +}; + +pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) { + let spend = random_scalar(&mut OsRng); + let spend_pub = &spend * &ED25519_BASEPOINT_TABLE; + let view = random_scalar(&mut OsRng); + ( + spend, + ViewPair::new(spend_pub, view), + MoneroAddress { + meta: AddressMeta::new(Network::Mainnet, AddressType::Standard), + spend: spend_pub, + view: &view * &ED25519_BASEPOINT_TABLE, + }, + ) +} + +pub async fn mine_blocks(rpc: &Rpc, address: &str) { + rpc + .rpc_call::<_, EmptyResponse>( + "json_rpc", + Some(json!({ + "method": "generateblocks", + "params": { + "wallet_address": address, + "amount_of_blocks": 10 + }, + })), + ) + .await + .unwrap(); +} + +pub async fn rpc() -> Rpc { + let rpc = Rpc::new("http://127.0.0.1:18081".to_string()).unwrap(); + + // Only run once + if rpc.get_height().await.unwrap() != 1 { + return rpc; + } + + let addr = MoneroAddress { + meta: AddressMeta::new(Network::Mainnet, AddressType::Standard), + spend: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE, + view: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE, + } + .to_string(); + + // Mine 40 blocks to ensure decoy availability + for _ in 0 .. 4 { + mine_blocks(&rpc, &addr).await; + } + assert!(!matches!(rpc.get_protocol().await.unwrap(), Protocol::Unsupported(_))); + + rpc +} + +lazy_static! { + pub static ref SEQUENTIAL: Mutex<()> = Mutex::new(()); +} + +#[macro_export] +macro_rules! async_sequential { + ($(async fn $name: ident() $body: block)*) => { + $( + #[tokio::test] + async fn $name() { + let guard = runner::SEQUENTIAL.lock().unwrap(); + let local = tokio::task::LocalSet::new(); + local.run_until(async move { + if let Err(err) = tokio::task::spawn_local(async move { $body }).await { + drop(guard); + Err(err).unwrap() + } + }).await; + } + )* + } +} + +#[macro_export] +macro_rules! test { + ( + $name: ident, + ( + $first_tx: expr, + $first_checks: expr, + ), + $(( + $tx: expr, + $checks: expr, + )$(,)?),* + ) => { + async_sequential! { + async fn $name() { + use core::{ops::Deref, any::Any}; + use std::collections::HashSet; + #[cfg(feature = "multisig")] + use std::collections::HashMap; + + use zeroize::Zeroizing; + use rand_core::OsRng; + + use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; + + #[cfg(feature = "multisig")] + use transcript::{Transcript, RecommendedTranscript}; + #[cfg(feature = "multisig")] + use frost::{ + curve::Ed25519, + tests::{THRESHOLD, key_gen}, + }; + + use monero_serai::{ + random_scalar, + wallet::{ + address::Network, ViewPair, Scanner, SignableTransaction, + SignableTransactionBuilder, + }, + }; + + use runner::{random_address, rpc, mine_blocks}; + + type Builder = SignableTransactionBuilder; + + // Run each function as both a single signer and as a multisig + for multisig in [false, true] { + // Only run the multisig variant if multisig is enabled + if multisig { + #[cfg(not(feature = "multisig"))] + continue; + } + + let spend = Zeroizing::new(random_scalar(&mut OsRng)); + #[cfg(feature = "multisig")] + let keys = key_gen::<_, Ed25519>(&mut OsRng); + + let spend_pub = if !multisig { + spend.deref() * &ED25519_BASEPOINT_TABLE + } else { + #[cfg(not(feature = "multisig"))] + panic!("Multisig branch called without the multisig feature"); + #[cfg(feature = "multisig")] + keys[&1].group_key().0 + }; + + let view = ViewPair::new(spend_pub, random_scalar(&mut OsRng)); + + let rpc = rpc().await; + + let (addr, miner_tx) = { + let mut scanner = + Scanner::from_view(view.clone(), Network::Mainnet, Some(HashSet::new())); + let addr = scanner.address(); + + let start = rpc.get_height().await.unwrap(); + for _ in 0 .. 7 { + mine_blocks(&rpc, &addr.to_string()).await; + } + + let block = rpc.get_block(start).await.unwrap(); + ( + addr, + scanner.scan( + &rpc, + &block + ).await.unwrap().swap_remove(0).ignore_timelock().swap_remove(0) + ) + }; + + let builder = SignableTransactionBuilder::new( + rpc.get_protocol().await.unwrap(), + rpc.get_fee().await.unwrap(), + Some(random_address().2), + ); + + let sign = |tx: SignableTransaction| { + let rpc = rpc.clone(); + let spend = spend.clone(); + #[cfg(feature = "multisig")] + let keys = keys.clone(); + async move { + if !multisig { + tx.sign(&mut OsRng, &rpc, &spend).await.unwrap() + } else { + #[cfg(not(feature = "multisig"))] + panic!("Multisig branch called without the multisig feature"); + #[cfg(feature = "multisig")] + { + let mut machines = HashMap::new(); + for i in 1 ..= THRESHOLD { + machines.insert( + i, + tx + .clone() + .multisig( + &rpc, + keys[&i].clone(), + RecommendedTranscript::new(b"Monero Serai Test Transaction"), + rpc.get_height().await.unwrap() - 10, + (1 ..= THRESHOLD).collect::>(), + ) + .await + .unwrap(), + ); + } + + frost::tests::sign(&mut OsRng, machines, &vec![]) + } + } + } + }; + + // TODO: Generate a distinct wallet for each transaction to prevent overlap + let next_addr = addr; + + let temp = Box::new({ + let mut builder = builder.clone(); + builder.add_input(miner_tx); + let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await; + + let signed = sign(tx).await; + rpc.publish_transaction(&signed).await.unwrap(); + mine_blocks(&rpc, &random_address().2.to_string()).await; + ($first_checks)(rpc.clone(), signed.hash(), view.clone(), state).await + }); + #[allow(unused_variables, unused_mut, unused_assignments)] + let mut carried_state: Box = temp; + + $( + let (tx, state) = ($tx)( + rpc.clone(), + builder.clone(), + next_addr, + *carried_state.downcast().unwrap() + ).await; + + let signed = sign(tx).await; + rpc.publish_transaction(&signed).await.unwrap(); + #[allow(unused_assignments)] + { + carried_state = + Box::new(($checks)(rpc.clone(), signed.hash(), view.clone(), state).await); + } + )* + } + } + } + } +} diff --git a/coins/monero/tests/send.rs b/coins/monero/tests/send.rs index f3940107..38f7cefc 100644 --- a/coins/monero/tests/send.rs +++ b/coins/monero/tests/send.rs @@ -1,232 +1,57 @@ -use core::ops::Deref; -use std::{sync::Mutex, collections::HashSet}; -#[cfg(feature = "multisig")] -use std::collections::HashMap; - -use lazy_static::lazy_static; -use zeroize::Zeroizing; -use rand_core::OsRng; - -#[cfg(feature = "multisig")] -use blake2::{digest::Update, Digest, Blake2b512}; - -use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; - -#[cfg(feature = "multisig")] -use dalek_ff_group::Scalar; -#[cfg(feature = "multisig")] -use transcript::{Transcript, RecommendedTranscript}; -#[cfg(feature = "multisig")] -use frost::{ - curve::Ed25519, - tests::{THRESHOLD, key_gen, sign}, -}; - use monero_serai::{ - random_scalar, - wallet::{ - address::Network, ViewPair, Scanner, SpendableOutput, SignableTransaction, - SignableTransactionBuilder, - }, + rpc::Rpc, + wallet::{ReceivedOutput, SpendableOutput}, }; -mod rpc; -use crate::rpc::{rpc, mine_block}; +mod runner; -lazy_static! { - static ref SEQUENTIAL: Mutex<()> = Mutex::new(()); -} +test!( + spend_miner_output, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 5); + (builder.build().unwrap(), ()) + }, + |rpc: Rpc, hash, view, _| async move { + let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new())); + let tx = rpc.get_transaction(hash).await.unwrap(); + let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + assert_eq!(output.commitment().amount, 5); + }, + ), +); -macro_rules! async_sequential { - ($(async fn $name: ident() $body: block)*) => { - $( - #[tokio::test] - async fn $name() { - let guard = SEQUENTIAL.lock().unwrap(); - let local = tokio::task::LocalSet::new(); - local.run_until(async move { - if let Err(err) = tokio::task::spawn_local(async move { $body }).await { - drop(guard); - Err(err).unwrap() - } - }).await; +test!( + spend_multiple_outputs, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + builder.add_payment(addr, 2000000000000); + (builder.build().unwrap(), ()) + }, + |rpc: Rpc, hash, view, _| async move { + let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new())); + let tx = rpc.get_transaction(hash).await.unwrap(); + let mut outputs = scanner.scan_transaction(&tx).not_locked(); + outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + assert_eq!(outputs[1].commitment().amount, 2000000000000); + outputs + }, + ), + ( + |rpc, mut builder: Builder, addr, mut outputs: Vec| async move { + for output in outputs.drain(..) { + builder.add_input(SpendableOutput::from(&rpc, output).await.unwrap()); } - )* - }; -} - -async fn send_core(test: usize, multisig: bool) { - let rpc = rpc().await; - - // Generate an address - let spend = Zeroizing::new(random_scalar(&mut OsRng)); - #[allow(unused_mut)] - let mut view = random_scalar(&mut OsRng); - #[allow(unused_mut)] - let mut spend_pub = spend.deref() * &ED25519_BASEPOINT_TABLE; - - #[cfg(feature = "multisig")] - let keys = key_gen::<_, Ed25519>(&mut OsRng); - - if multisig { - #[cfg(not(feature = "multisig"))] - panic!("Running a multisig test without the multisig feature"); - #[cfg(feature = "multisig")] - { - view = Scalar::from_hash(Blake2b512::new().chain("Monero Serai Transaction Test")).0; - spend_pub = keys[&1].group_key().0; - } - } - - let view_pair = ViewPair::new(spend_pub, view); - let mut scanner = Scanner::from_view(view_pair, Network::Mainnet, Some(HashSet::new())); - let addr = scanner.address(); - - let fee = rpc.get_fee().await.unwrap(); - - let start = rpc.get_height().await.unwrap(); - for _ in 0 .. 7 { - mine_block(&rpc, &addr.to_string()).await.unwrap(); - } - - let mut tx = None; - // Allow tests to test variable transactions - for i in 0 .. [2, 1][test] { - let mut outputs = vec![]; - let mut amount = 0; - // Test spending both a miner output and a normal output - if test == 0 { - if i == 0 { - tx = Some(rpc.get_block_transactions(start).await.unwrap().swap_remove(0)); - } - - // Grab the largest output available - let output = { - let mut outputs = scanner.scan_transaction(tx.as_ref().unwrap()).ignore_timelock(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount).reverse()); - outputs.swap_remove(0) - }; - // Test creating a zero change output and a non-zero change output - amount = output.commitment().amount - u64::try_from(i).unwrap(); - outputs.push(SpendableOutput::from(&rpc, output).await.unwrap()); - - // Test spending multiple inputs - } else if test == 1 { - if i != 0 { - continue; - } - - // We actually need 120 decoys for this transaction, so mine until then - // 120 + 60 (miner TX maturity) + 10 (lock blocks) - // It is possible for this to be lower, by noting maturity is sufficient regardless of lock - // blocks, yet that's not currently implemented - // TODO, if we care - while rpc.get_height().await.unwrap() < 200 { - mine_block(&rpc, &addr.to_string()).await.unwrap(); - } - - for i in (start + 1) .. (start + 9) { - let mut txs = scanner.scan(&rpc, &rpc.get_block(i).await.unwrap()).await.unwrap(); - let output = txs.swap_remove(0).ignore_timelock().swap_remove(0); - amount += output.commitment().amount; - outputs.push(output); - } - } - - let mut signable = SignableTransaction::new( - rpc.get_protocol().await.unwrap(), - outputs, - vec![(addr, amount - 10000000000)], - Some(addr), - None, - fee, - ) - .unwrap(); - - if !multisig { - tx = Some(signable.sign(&mut OsRng, &rpc, &spend).await.unwrap()); - } else { - #[cfg(feature = "multisig")] - { - let mut machines = HashMap::new(); - for i in 1 ..= THRESHOLD { - machines.insert( - i, - signable - .clone() - .multisig( - &rpc, - keys[&i].clone(), - RecommendedTranscript::new(b"Monero Serai Test Transaction"), - rpc.get_height().await.unwrap() - 10, - (1 ..= THRESHOLD).collect::>(), - ) - .await - .unwrap(), - ); - } - - tx = Some(sign(&mut OsRng, machines, &vec![])); - } - } - - rpc.publish_transaction(tx.as_ref().unwrap()).await.unwrap(); - mine_block(&rpc, &addr.to_string()).await.unwrap(); - } -} - -async_sequential! { - async fn send_single_input() { - send_core(0, false).await; - } - - async fn send_multiple_inputs() { - send_core(1, false).await; - } -} - -#[cfg(feature = "multisig")] -async_sequential! { - async fn multisig_send_single_input() { - send_core(0, true).await; - } - - async fn multisig_send_multiple_inputs() { - send_core(1, true).await; - } -} - -async_sequential! { - async fn builder() { - let rpc = rpc().await; - - // Generate an address - let spend = Zeroizing::new(random_scalar(&mut OsRng)); - let view = random_scalar(&mut OsRng); - let spend_pub = spend.deref() * &ED25519_BASEPOINT_TABLE; - - let view_pair = ViewPair::new(spend_pub, view); - let mut scanner = Scanner::from_view(view_pair, Network::Mainnet, Some(HashSet::new())); - let addr = scanner.address(); - - let fee = rpc.get_fee().await.unwrap(); - - let start = rpc.get_height().await.unwrap(); - for _ in 0 .. 7 { - mine_block(&rpc, &addr.to_string()).await.unwrap(); - } - - let coinbase = rpc.get_block_transactions(start).await.unwrap().swap_remove(0); - let output = scanner.scan_transaction(&coinbase).ignore_timelock().swap_remove(0); - rpc.publish_transaction( - &SignableTransactionBuilder::new(rpc.get_protocol().await.unwrap(), fee, Some(addr)) - .add_input(SpendableOutput::from(&rpc, output).await.unwrap()) - .add_payment(addr, 0) - .build() - .unwrap() - .sign(&mut OsRng, &rpc, &spend) - .await - .unwrap() - ).await.unwrap(); - } -} + builder.add_payment(addr, 6); + (builder.build().unwrap(), ()) + }, + |rpc: Rpc, hash, view, _| async move { + let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new())); + let tx = rpc.get_transaction(hash).await.unwrap(); + let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + assert_eq!(output.commitment().amount, 6); + }, + ), +);