#![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc = include_str!("../README.md")] #![deny(missing_docs)] use std::{time::Duration, collections::HashMap, io}; use async_trait::async_trait; use zeroize::Zeroizing; use rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use transcript::{Transcript, RecommendedTranscript}; use ciphersuite::group::{ff::Field, Group}; use dalek_ff_group::{Scalar, EdwardsPoint}; use frost::{curve::Ed25519, ThresholdKeys}; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ ringct::RctType, transaction::Transaction, block::Block, rpc::{FeeRate, RpcError, Rpc}, address::{Network as MoneroNetwork, SubaddressIndex}, ViewPair, GuaranteedViewPair, WalletOutput, OutputWithDecoys, GuaranteedScanner, send::{ SendError, Change, SignableTransaction as MSignableTransaction, Eventuality, TransactionMachine, }, }; #[cfg(test)] use monero_wallet::Scanner; use tokio::time::sleep; pub use serai_client::{ primitives::{MAX_DATA_LEN, Coin, NetworkId, Amount, Balance}, networks::monero::Address, }; use crate::{ Payment, additional_key, networks::{ NetworkError, Block as BlockTrait, OutputType, Output as OutputTrait, Transaction as TransactionTrait, SignableTransaction as SignableTransactionTrait, Eventuality as EventualityTrait, EventualitiesTracker, Network, UtxoNetwork, }, multisigs::scheduler::utxo::Scheduler, }; #[derive(Clone, PartialEq, Eq, Debug)] pub struct Output(WalletOutput); const EXTERNAL_SUBADDRESS: Option = SubaddressIndex::new(0, 0); const BRANCH_SUBADDRESS: Option = SubaddressIndex::new(1, 0); const CHANGE_SUBADDRESS: Option = SubaddressIndex::new(2, 0); const FORWARD_SUBADDRESS: Option = SubaddressIndex::new(3, 0); impl OutputTrait for Output { // While we could use (tx, o), using the key ensures we won't be susceptible to the burning bug. // While we already are immune, thanks to using featured address, this doesn't hurt and is // technically more efficient. type Id = [u8; 32]; fn kind(&self) -> OutputType { match self.0.subaddress() { EXTERNAL_SUBADDRESS => OutputType::External, BRANCH_SUBADDRESS => OutputType::Branch, CHANGE_SUBADDRESS => OutputType::Change, FORWARD_SUBADDRESS => OutputType::Forwarded, _ => panic!("unrecognized address was scanned for"), } } fn id(&self) -> Self::Id { self.0.key().compress().to_bytes() } fn tx_id(&self) -> [u8; 32] { self.0.transaction() } fn key(&self) -> EdwardsPoint { EdwardsPoint(self.0.key() - (EdwardsPoint::generator().0 * self.0.key_offset())) } fn presumed_origin(&self) -> Option
{ None } fn balance(&self) -> Balance { Balance { coin: Coin::Monero, amount: Amount(self.0.commitment().amount) } } fn data(&self) -> &[u8] { let Some(data) = self.0.arbitrary_data().first() else { return &[] }; // If the data is too large, prune it // This should cause decoding the instruction to fail, and trigger a refund as appropriate if data.len() > usize::try_from(MAX_DATA_LEN).unwrap() { return &[]; } data } fn write(&self, writer: &mut W) -> io::Result<()> { self.0.write(writer)?; Ok(()) } fn read(reader: &mut R) -> io::Result { Ok(Output(WalletOutput::read(reader)?)) } } // TODO: Consider ([u8; 32], TransactionPruned) #[async_trait] impl TransactionTrait for Transaction { type Id = [u8; 32]; fn id(&self) -> Self::Id { self.hash() } #[cfg(test)] async fn fee(&self, _: &Monero) -> u64 { match self { Transaction::V1 { .. } => panic!("v1 TX in test-only function"), Transaction::V2 { ref proofs, .. } => proofs.as_ref().unwrap().base.fee, } } } impl EventualityTrait for Eventuality { type Claim = [u8; 32]; type Completion = Transaction; // Use the TX extra to look up potential matches // While anyone can forge this, a transaction with distinct outputs won't actually match // Extra includess the one time keys which are derived from the plan ID, so a collision here is a // hash collision fn lookup(&self) -> Vec { self.extra() } fn read(reader: &mut R) -> io::Result { Eventuality::read(reader) } fn serialize(&self) -> Vec { self.serialize() } fn claim(tx: &Transaction) -> [u8; 32] { tx.id() } fn serialize_completion(completion: &Transaction) -> Vec { completion.serialize() } fn read_completion(reader: &mut R) -> io::Result { Transaction::read(reader) } } #[derive(Clone, Debug)] pub struct SignableTransaction(MSignableTransaction); impl SignableTransactionTrait for SignableTransaction { fn fee(&self) -> u64 { self.0.necessary_fee() } } #[async_trait] impl BlockTrait for Block { type Id = [u8; 32]; fn id(&self) -> Self::Id { self.hash() } fn parent(&self) -> Self::Id { self.header.previous } async fn time(&self, rpc: &Monero) -> u64 { // Constant from Monero const BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW: usize = 60; // If Monero doesn't have enough blocks to build a window, it doesn't define a network time if (self.number().unwrap() + 1) < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { // Use the block number as the time return u64::try_from(self.number().unwrap()).unwrap(); } let mut timestamps = vec![self.header.timestamp]; let mut parent = self.parent(); while timestamps.len() < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { let mut parent_block; while { parent_block = rpc.rpc.get_block(parent).await; parent_block.is_err() } { log::error!("couldn't get parent block when trying to get block time: {parent_block:?}"); sleep(Duration::from_secs(5)).await; } let parent_block = parent_block.unwrap(); timestamps.push(parent_block.header.timestamp); parent = parent_block.parent(); if parent_block.number().unwrap() == 0 { break; } } timestamps.sort(); // Because 60 has two medians, Monero's epee picks the in-between value, calculated by the // following formula (from the "get_mid" function) let n = timestamps.len() / 2; let a = timestamps[n - 1]; let b = timestamps[n]; #[rustfmt::skip] // Enables Ctrl+F'ing for everything after the `= ` let res = (a/2) + (b/2) + ((a - 2*(a/2)) + (b - 2*(b/2)))/2; // Technically, res may be 1 if all prior blocks had a timestamp by 0, which would break // monotonicity with our above definition of height as time // Monero also solely requires the block's time not be less than the median, it doesn't ensure // it advances the median forward // Ensure monotonicity despite both these issues by adding the block number to the median time res + u64::try_from(self.number().unwrap()).unwrap() } } #[derive(Clone, Debug)] pub struct Monero { rpc: SimpleRequestRpc, } // Shim required for testing/debugging purposes due to generic arguments also necessitating trait // bounds impl PartialEq for Monero { fn eq(&self, _: &Self) -> bool { true } } impl Eq for Monero {} #[allow(clippy::needless_pass_by_value)] // Needed to satisfy API expectations fn map_rpc_err(err: RpcError) -> NetworkError { if let RpcError::InvalidNode(reason) = &err { log::error!("Monero RpcError::InvalidNode({reason})"); } else { log::debug!("Monero RpcError {err:?}"); } NetworkError::ConnectionError } enum MakeSignableTransactionResult { Fee(u64), SignableTransaction(MSignableTransaction), } impl Monero { pub async fn new(url: String) -> Monero { let mut res = SimpleRequestRpc::new(url.clone()).await; while let Err(e) = res { log::error!("couldn't connect to Monero node: {e:?}"); tokio::time::sleep(Duration::from_secs(5)).await; res = SimpleRequestRpc::new(url.clone()).await; } Monero { rpc: res.unwrap() } } fn view_pair(spend: EdwardsPoint) -> GuaranteedViewPair { GuaranteedViewPair::new(spend.0, Zeroizing::new(additional_key::(0).0)).unwrap() } fn address_internal(spend: EdwardsPoint, subaddress: Option) -> Address { Address::new(Self::view_pair(spend).address(MoneroNetwork::Mainnet, subaddress, None)).unwrap() } fn scanner(spend: EdwardsPoint) -> GuaranteedScanner { let mut scanner = GuaranteedScanner::new(Self::view_pair(spend)); debug_assert!(EXTERNAL_SUBADDRESS.is_none()); scanner.register_subaddress(BRANCH_SUBADDRESS.unwrap()); scanner.register_subaddress(CHANGE_SUBADDRESS.unwrap()); scanner.register_subaddress(FORWARD_SUBADDRESS.unwrap()); scanner } async fn median_fee(&self, block: &Block) -> Result { let mut fees = vec![]; for tx_hash in &block.transactions { let tx = self.rpc.get_transaction(*tx_hash).await.map_err(|_| NetworkError::ConnectionError)?; // Only consider fees from RCT transactions, else the fee property read wouldn't be accurate let fee = match &tx { Transaction::V2 { proofs: Some(proofs), .. } => proofs.base.fee, _ => continue, }; fees.push(fee / u64::try_from(tx.weight()).unwrap()); } fees.sort(); let fee = fees.get(fees.len() / 2).copied().unwrap_or(0); // TODO: Set a sane minimum fee const MINIMUM_FEE: u64 = 1_500_000; Ok(FeeRate::new(fee.max(MINIMUM_FEE), 10000).unwrap()) } async fn make_signable_transaction( &self, block_number: usize, plan_id: &[u8; 32], inputs: &[Output], payments: &[Payment], change: &Option
, calculating_fee: bool, ) -> Result, NetworkError> { for payment in payments { assert_eq!(payment.balance.coin, Coin::Monero); } // TODO2: Use an fee representative of several blocks, cached inside Self let block_for_fee = self.get_block(block_number).await?; let fee_rate = self.median_fee(&block_for_fee).await?; // Determine the RCT proofs to make based off the hard fork // TODO: Make a fn for this block which is duplicated with tests let rct_type = match block_for_fee.header.hardfork_version { 14 => RctType::ClsagBulletproof, 15 | 16 => RctType::ClsagBulletproofPlus, _ => panic!("Monero hard forked and the processor wasn't updated for it"), }; let mut transcript = RecommendedTranscript::new(b"Serai Processor Monero Transaction Transcript"); transcript.append_message(b"plan", plan_id); // All signers need to select the same decoys // All signers use the same height and a seeded RNG to make sure they do so. let mut inputs_actual = Vec::with_capacity(inputs.len()); for input in inputs { inputs_actual.push( OutputWithDecoys::fingerprintable_deterministic_new( &mut ChaCha20Rng::from_seed(transcript.rng_seed(b"decoys")), &self.rpc, // TODO: Have Decoys take RctType match rct_type { RctType::ClsagBulletproof => 11, RctType::ClsagBulletproofPlus => 16, _ => panic!("selecting decoys for an unsupported RctType"), }, block_number + 1, input.0.clone(), ) .await .map_err(map_rpc_err)?, ); } // Monero requires at least two outputs // If we only have one output planned, add a dummy payment let mut payments = payments.to_vec(); let outputs = payments.len() + usize::from(u8::from(change.is_some())); if outputs == 0 { return Ok(None); } else if outputs == 1 { payments.push(Payment { address: Address::new( ViewPair::new(EdwardsPoint::generator().0, Zeroizing::new(Scalar::ONE.0)) .unwrap() .legacy_address(MoneroNetwork::Mainnet), ) .unwrap(), balance: Balance { coin: Coin::Monero, amount: Amount(0) }, data: None, }); } let payments = payments .into_iter() .map(|payment| (payment.address.into(), payment.balance.amount.0)) .collect::>(); match MSignableTransaction::new( rct_type, // Use the plan ID as the outgoing view key Zeroizing::new(*plan_id), inputs_actual, payments, Change::fingerprintable(change.as_ref().map(|change| change.clone().into())), vec![], fee_rate, ) { Ok(signable) => Ok(Some({ if calculating_fee { MakeSignableTransactionResult::Fee(signable.necessary_fee()) } else { MakeSignableTransactionResult::SignableTransaction(signable) } })), Err(e) => match e { SendError::UnsupportedRctType => { panic!("trying to use an RctType unsupported by monero-wallet") } SendError::NoInputs | SendError::InvalidDecoyQuantity | SendError::NoOutputs | SendError::TooManyOutputs | SendError::NoChange | SendError::TooMuchArbitraryData | SendError::TooLargeTransaction | SendError::WrongPrivateKey => { panic!("created an invalid Monero transaction: {e}"); } SendError::MultiplePaymentIds => { panic!("multiple payment IDs despite not supporting integrated addresses"); } SendError::NotEnoughFunds { inputs, outputs, necessary_fee } => { log::debug!( "Monero NotEnoughFunds. inputs: {:?}, outputs: {:?}, necessary_fee: {necessary_fee:?}", inputs, outputs ); match necessary_fee { Some(necessary_fee) => { // If we're solely calculating the fee, return the fee this TX will cost if calculating_fee { Ok(Some(MakeSignableTransactionResult::Fee(necessary_fee))) } else { // If we're actually trying to make the TX, return None Ok(None) } } // We didn't have enough funds to even cover the outputs None => { // Ensure we're not misinterpreting this assert!(outputs > inputs); Ok(None) } } } SendError::MaliciousSerialization | SendError::ClsagError(_) | SendError::FrostError(_) => { panic!("supposedly unreachable (at this time) Monero error: {e}"); } }, } } #[cfg(test)] fn test_view_pair() -> ViewPair { ViewPair::new(*EdwardsPoint::generator(), Zeroizing::new(Scalar::ONE.0)).unwrap() } #[cfg(test)] fn test_scanner() -> Scanner { Scanner::new(Self::test_view_pair()) } #[cfg(test)] fn test_address() -> Address { Address::new(Self::test_view_pair().legacy_address(MoneroNetwork::Mainnet)).unwrap() } } #[async_trait] impl Network for Monero { type Curve = Ed25519; type Transaction = Transaction; type Block = Block; type Output = Output; type SignableTransaction = SignableTransaction; type Eventuality = Eventuality; type TransactionMachine = TransactionMachine; type Scheduler = Scheduler; type Address = Address; const NETWORK: NetworkId = NetworkId::Monero; const ID: &'static str = "Monero"; const ESTIMATED_BLOCK_TIME_IN_SECONDS: usize = 120; const CONFIRMATIONS: usize = 10; const MAX_OUTPUTS: usize = 16; // 0.01 XMR const DUST: u64 = 10000000000; // TODO const COST_TO_AGGREGATE: u64 = 0; // Monero doesn't require/benefit from tweaking fn tweak_keys(_: &mut ThresholdKeys) {} #[cfg(test)] async fn external_address(&self, key: EdwardsPoint) -> Address { Self::address_internal(key, EXTERNAL_SUBADDRESS) } fn branch_address(key: EdwardsPoint) -> Option
{ Some(Self::address_internal(key, BRANCH_SUBADDRESS)) } fn change_address(key: EdwardsPoint) -> Option
{ Some(Self::address_internal(key, CHANGE_SUBADDRESS)) } fn forward_address(key: EdwardsPoint) -> Option
{ Some(Self::address_internal(key, FORWARD_SUBADDRESS)) } async fn get_latest_block_number(&self) -> Result { // Monero defines height as chain length, so subtract 1 for block number Ok(self.rpc.get_height().await.map_err(map_rpc_err)? - 1) } async fn get_block(&self, number: usize) -> Result { Ok( self .rpc .get_block(self.rpc.get_block_hash(number).await.map_err(map_rpc_err)?) .await .map_err(map_rpc_err)?, ) } async fn get_outputs(&self, block: &Block, key: EdwardsPoint) -> Vec { let outputs = loop { match self .rpc .get_scannable_block(block.clone()) .await .map_err(|e| format!("{e:?}")) .and_then(|block| Self::scanner(key).scan(block).map_err(|e| format!("{e:?}"))) { Ok(outputs) => break outputs, Err(e) => { log::error!("couldn't scan block {}: {e:?}", hex::encode(block.id())); sleep(Duration::from_secs(60)).await; continue; } } }; // Miner transactions are required to explicitly state their timelock, so this does exclude // those (which have an extended timelock we don't want to deal with) let raw_outputs = outputs.not_additionally_locked(); let mut outputs = Vec::with_capacity(raw_outputs.len()); for output in raw_outputs { // This should be pointless as we shouldn't be able to scan for any other subaddress // This just helps ensures nothing invalid makes it through assert!([EXTERNAL_SUBADDRESS, BRANCH_SUBADDRESS, CHANGE_SUBADDRESS, FORWARD_SUBADDRESS] .contains(&output.subaddress())); outputs.push(Output(output)); } outputs } async fn get_eventuality_completions( &self, eventualities: &mut EventualitiesTracker, block: &Block, ) -> HashMap<[u8; 32], (usize, [u8; 32], Transaction)> { let mut res = HashMap::new(); if eventualities.map.is_empty() { return res; } async fn check_block( network: &Monero, eventualities: &mut EventualitiesTracker, block: &Block, res: &mut HashMap<[u8; 32], (usize, [u8; 32], Transaction)>, ) { for hash in &block.transactions { let tx = { let mut tx; while { tx = network.rpc.get_transaction(*hash).await; tx.is_err() } { log::error!("couldn't get transaction {}: {}", hex::encode(hash), tx.err().unwrap()); sleep(Duration::from_secs(60)).await; } tx.unwrap() }; if let Some((_, eventuality)) = eventualities.map.get(&tx.prefix().extra) { if eventuality.matches(&tx.clone().into()) { res.insert( eventualities.map.remove(&tx.prefix().extra).unwrap().0, (block.number().unwrap(), tx.id(), tx), ); } } } eventualities.block_number += 1; assert_eq!(eventualities.block_number, block.number().unwrap()); } for block_num in (eventualities.block_number + 1) .. block.number().unwrap() { let block = { let mut block; while { block = self.get_block(block_num).await; block.is_err() } { log::error!("couldn't get block {}: {}", block_num, block.err().unwrap()); sleep(Duration::from_secs(60)).await; } block.unwrap() }; check_block(self, eventualities, &block, &mut res).await; } // Also check the current block check_block(self, eventualities, block, &mut res).await; assert_eq!(eventualities.block_number, block.number().unwrap()); res } async fn needed_fee( &self, block_number: usize, inputs: &[Output], payments: &[Payment], change: &Option
, ) -> Result, NetworkError> { let res = self .make_signable_transaction(block_number, &[0; 32], inputs, payments, change, true) .await?; let Some(res) = res else { return Ok(None) }; let MakeSignableTransactionResult::Fee(fee) = res else { panic!("told make_signable_transaction calculating_fee and got transaction") }; Ok(Some(fee)) } async fn signable_transaction( &self, block_number: usize, plan_id: &[u8; 32], _key: EdwardsPoint, inputs: &[Output], payments: &[Payment], change: &Option
, (): &(), ) -> Result, NetworkError> { let res = self .make_signable_transaction(block_number, plan_id, inputs, payments, change, false) .await?; let Some(res) = res else { return Ok(None) }; let MakeSignableTransactionResult::SignableTransaction(signable) = res else { panic!("told make_signable_transaction not calculating_fee and got fee") }; let signable = SignableTransaction(signable); let eventuality = signable.0.clone().into(); Ok(Some((signable, eventuality))) } async fn attempt_sign( &self, keys: ThresholdKeys, transaction: SignableTransaction, ) -> Result { match transaction.0.clone().multisig(keys) { Ok(machine) => Ok(machine), Err(e) => panic!("failed to create a multisig machine for TX: {e}"), } } async fn publish_completion(&self, tx: &Transaction) -> Result<(), NetworkError> { match self.rpc.publish_transaction(tx).await { Ok(()) => Ok(()), Err(RpcError::ConnectionError(e)) => { log::debug!("Monero ConnectionError: {e}"); Err(NetworkError::ConnectionError)? } // TODO: Distinguish already in pool vs double spend (other signing attempt succeeded) vs // invalid transaction Err(e) => panic!("failed to publish TX {}: {e}", hex::encode(tx.hash())), } } async fn confirm_completion( &self, eventuality: &Eventuality, id: &[u8; 32], ) -> Result, NetworkError> { let tx = self.rpc.get_transaction(*id).await.map_err(map_rpc_err)?; if eventuality.matches(&tx.clone().into()) { Ok(Some(tx)) } else { Ok(None) } } #[cfg(test)] async fn get_block_number(&self, id: &[u8; 32]) -> usize { self.rpc.get_block(*id).await.unwrap().number().unwrap() } #[cfg(test)] async fn check_eventuality_by_claim( &self, eventuality: &Self::Eventuality, claim: &[u8; 32], ) -> bool { return eventuality.matches(&self.rpc.get_pruned_transaction(*claim).await.unwrap()); } #[cfg(test)] async fn get_transaction_by_eventuality( &self, block: usize, eventuality: &Eventuality, ) -> Transaction { let block = self.rpc.get_block_by_number(block).await.unwrap(); for tx in &block.transactions { let tx = self.rpc.get_transaction(*tx).await.unwrap(); if eventuality.matches(&tx.clone().into()) { return tx; } } panic!("block didn't have a transaction for this eventuality") } #[cfg(test)] async fn mine_block(&self) { // https://github.com/serai-dex/serai/issues/198 sleep(std::time::Duration::from_millis(100)).await; self.rpc.generate_blocks(&Self::test_address().into(), 1).await.unwrap(); } #[cfg(test)] async fn test_send(&self, address: Address) -> Block { use zeroize::Zeroizing; use rand_core::{RngCore, OsRng}; use monero_wallet::rpc::FeePriority; let new_block = self.get_latest_block_number().await.unwrap() + 1; for _ in 0 .. 80 { self.mine_block().await; } let new_block = self.rpc.get_block_by_number(new_block).await.unwrap(); let mut outputs = Self::test_scanner() .scan(self.rpc.get_scannable_block(new_block.clone()).await.unwrap()) .unwrap() .ignore_additional_timelock(); let output = outputs.swap_remove(0); let amount = output.commitment().amount; // The dust should always be sufficient for the fee let fee = Monero::DUST; let rct_type = match new_block.header.hardfork_version { 14 => RctType::ClsagBulletproof, 15 | 16 => RctType::ClsagBulletproofPlus, _ => panic!("Monero hard forked and the processor wasn't updated for it"), }; let output = OutputWithDecoys::fingerprintable_deterministic_new( &mut OsRng, &self.rpc, match rct_type { RctType::ClsagBulletproof => 11, RctType::ClsagBulletproofPlus => 16, _ => panic!("selecting decoys for an unsupported RctType"), }, self.rpc.get_height().await.unwrap(), output, ) .await .unwrap(); let mut outgoing_view_key = Zeroizing::new([0; 32]); OsRng.fill_bytes(outgoing_view_key.as_mut()); let tx = MSignableTransaction::new( rct_type, outgoing_view_key, vec![output], vec![(address.into(), amount - fee)], Change::fingerprintable(Some(Self::test_address().into())), vec![], self.rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), ) .unwrap() .sign(&mut OsRng, &Zeroizing::new(Scalar::ONE.0)) .unwrap(); let block = self.get_latest_block_number().await.unwrap() + 1; self.rpc.publish_transaction(&tx).await.unwrap(); for _ in 0 .. 10 { self.mine_block().await; } self.get_block(block).await.unwrap() } } impl UtxoNetwork for Monero { // wallet2 will not create a transaction larger than 100kb, and Monero won't relay a transaction // larger than 150kb. This fits within the 100kb mark // Technically, it can be ~124, yet a small bit of buffer is appreciated // TODO: Test creating a TX this big const MAX_INPUTS: usize = 120; }