From 70c36ed06c4420e4966d898bd50071d489faac9f Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Thu, 27 Jun 2024 07:36:45 -0400 Subject: [PATCH] Rewrite monero-wallet's send code I have yet to redo the multisig code and the builder. This should be much cleaner, albeit slower due to redoing work. This compiles with clippy --all-features. I have to finish the multisig/builder for --all-targets to work (and start updating the rest of Serai). --- coins/monero/io/src/lib.rs | 12 +- coins/monero/ringct/clsag/src/lib.rs | 11 +- coins/monero/rpc/src/lib.rs | 2 + coins/monero/wallet/address/src/lib.rs | 1 + coins/monero/wallet/src/decoys.rs | 2 +- coins/monero/wallet/src/lib.rs | 418 ++---- .../wallet/src/{send => old_send}/builder.rs | 8 +- .../wallet/src/{send => old_send}/multisig.rs | 0 coins/monero/wallet/src/scan.rs | 118 +- coins/monero/wallet/src/send/eventuality.rs | 178 +++ coins/monero/wallet/src/send/mod.rs | 1225 ++++------------- coins/monero/wallet/src/send/scan.rs | 516 ------- coins/monero/wallet/src/send/tx.rs | 258 ++++ coins/monero/wallet/src/send/tx_keys.rs | 231 ++++ 14 files changed, 1189 insertions(+), 1791 deletions(-) rename coins/monero/wallet/src/{send => old_send}/builder.rs (92%) rename coins/monero/wallet/src/{send => old_send}/multisig.rs (100%) create mode 100644 coins/monero/wallet/src/send/eventuality.rs delete mode 100644 coins/monero/wallet/src/send/scan.rs create mode 100644 coins/monero/wallet/src/send/tx.rs create mode 100644 coins/monero/wallet/src/send/tx_keys.rs diff --git a/coins/monero/io/src/lib.rs b/coins/monero/io/src/lib.rs index 56f3aab7..68acbe80 100644 --- a/coins/monero/io/src/lib.rs +++ b/coins/monero/io/src/lib.rs @@ -54,7 +54,7 @@ pub fn write_byte(byte: &u8, w: &mut W) -> io::Result<()> { w.write_all(&[*byte]) } -/// Write a number, VarInt-encoded,. +/// Write a number, VarInt-encoded. /// /// This will panic if the VarInt exceeds u64::MAX. pub fn write_varint(varint: &U, w: &mut W) -> io::Result<()> { @@ -81,7 +81,7 @@ pub fn write_point(point: &EdwardsPoint, w: &mut W) -> io::Result<()> w.write_all(&point.compress().to_bytes()) } -/// Write a list of elements, without length-prefixing,. +/// Write a list of elements, without length-prefixing. pub fn write_raw_vec io::Result<()>>( f: F, values: &[T], @@ -93,7 +93,7 @@ pub fn write_raw_vec io::Result<()>>( Ok(()) } -/// Write a list of elements, with length-prefixing,. +/// Write a list of elements, with length-prefixing. pub fn write_vec io::Result<()>>( f: F, values: &[T], @@ -115,17 +115,17 @@ pub fn read_byte(r: &mut R) -> io::Result { Ok(read_bytes::<_, 1>(r)?[0]) } -/// Read a u16, little-endian encoded,. +/// Read a u16, little-endian encoded. pub fn read_u16(r: &mut R) -> io::Result { read_bytes(r).map(u16::from_le_bytes) } -/// Read a u32, little-endian encoded,. +/// Read a u32, little-endian encoded. pub fn read_u32(r: &mut R) -> io::Result { read_bytes(r).map(u32::from_le_bytes) } -/// Read a u64, little-endian encoded,. +/// Read a u64, little-endian encoded. pub fn read_u64(r: &mut R) -> io::Result { read_bytes(r).map(u64::from_le_bytes) } diff --git a/coins/monero/ringct/clsag/src/lib.rs b/coins/monero/ringct/clsag/src/lib.rs index fd6ae488..10ad7588 100644 --- a/coins/monero/ringct/clsag/src/lib.rs +++ b/coins/monero/ringct/clsag/src/lib.rs @@ -222,9 +222,12 @@ fn core( /// The CLSAG signature, as used in Monero. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Clsag { - D: EdwardsPoint, - s: Vec, - c1: Scalar, + /// The difference of the commitment randomnesses, scaling the key image generator. + pub D: EdwardsPoint, + /// The responses for each ring member. + pub s: Vec, + /// The first challenge in the ring. + pub c1: Scalar, } struct ClsagSignCore { @@ -383,7 +386,7 @@ impl Clsag { Ok(()) } - /// The weight a CLSAG will take within a Monero transaction. + /// The length a CLSAG will take once serialized. pub fn fee_weight(ring_len: usize) -> usize { (ring_len * 32) + 32 + 32 } diff --git a/coins/monero/rpc/src/lib.rs b/coins/monero/rpc/src/lib.rs index b237bb22..94fed5e4 100644 --- a/coins/monero/rpc/src/lib.rs +++ b/coins/monero/rpc/src/lib.rs @@ -745,6 +745,8 @@ pub trait Rpc: Sync + Clone + Debug { /// 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)] diff --git a/coins/monero/wallet/address/src/lib.rs b/coins/monero/wallet/address/src/lib.rs index 9455f3c1..42635ca8 100644 --- a/coins/monero/wallet/address/src/lib.rs +++ b/coins/monero/wallet/address/src/lib.rs @@ -74,6 +74,7 @@ impl AddressType { matches!(self, AddressType::Featured { subaddress: true, .. }) } + // TODO: wallet-core PaymentId? TX extra crate imported here? pub fn payment_id(&self) -> Option<[u8; 8]> { if let AddressType::Integrated(id) = self { Some(*id) diff --git a/coins/monero/wallet/src/decoys.rs b/coins/monero/wallet/src/decoys.rs index a0258621..98f1e2c5 100644 --- a/coins/monero/wallet/src/decoys.rs +++ b/coins/monero/wallet/src/decoys.rs @@ -11,7 +11,7 @@ use curve25519_dalek::edwards::EdwardsPoint; use monero_serai::{DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME}; use monero_rpc::{RpcError, Rpc}; -use crate::SpendableOutput; +use crate::scan::SpendableOutput; const RECENT_WINDOW: usize = 15; const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME; diff --git a/coins/monero/wallet/src/lib.rs b/coins/monero/wallet/src/lib.rs index 86354cbf..42bae1b8 100644 --- a/coins/monero/wallet/src/lib.rs +++ b/coins/monero/wallet/src/lib.rs @@ -4,23 +4,15 @@ #![cfg_attr(not(feature = "std"), no_std)] use core::ops::Deref; -use std_shims::{ - io as stdio, - collections::{HashSet, HashMap}, -}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use curve25519_dalek::{ - constants::ED25519_BASEPOINT_TABLE, - scalar::Scalar, - edwards::{EdwardsPoint, CompressedEdwardsY}, -}; +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; use monero_serai::{ - io::{read_byte, read_u16, write_varint}, + io::write_varint, primitives::{Commitment, keccak256, keccak256_to_scalar}, - ringct::{RctType, EncryptedAmount}, + ringct::EncryptedAmount, transaction::Input, }; @@ -29,13 +21,12 @@ pub use monero_serai::*; pub use monero_rpc as rpc; pub mod extra; -pub(crate) use extra::{PaymentId, ExtraField, Extra}; +pub(crate) use extra::{PaymentId, Extra}; pub use monero_address as address; use address::{Network, AddressType, SubaddressIndex, AddressSpec, AddressMeta, MoneroAddress}; -mod scan; -pub use scan::{ReceivedOutput, SpendableOutput, Timelocked}; +pub mod scan; #[cfg(feature = "std")] pub mod decoys; @@ -46,246 +37,12 @@ pub mod decoys { } pub use decoys::{DecoySelection, Decoys}; -mod send; -pub use send::{FeePriority, FeeRate, TransactionError, Change, SignableTransaction, Eventuality}; -#[cfg(feature = "std")] -pub use send::SignableTransactionBuilder; -#[cfg(feature = "multisig")] -pub(crate) use send::InternalPayment; -#[cfg(feature = "multisig")] -pub use send::TransactionMachine; +pub mod send; +/* TODO #[cfg(test)] mod tests; - -/// Monero protocol version. -/// -/// v15 is omitted as v15 was simply v14 and v16 being active at the same time, with regards to the -/// transactions supported. Accordingly, v16 should be used during v15. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -#[allow(non_camel_case_types)] -pub enum Protocol { - v14, - v16, - Custom { - ring_len: usize, - bp_plus: bool, - optimal_rct_type: RctType, - view_tags: bool, - v16_fee: bool, - }, -} - -impl TryFrom for Protocol { - type Error = (); - fn try_from(version: u8) -> Result { - Ok(match version { - 14 => Protocol::v14, // TODO: 13 | 14? - 15 | 16 => Protocol::v16, - _ => Err(())?, - }) - } -} - -impl Protocol { - /// Amount of ring members under this protocol version. - pub fn ring_len(&self) -> usize { - match self { - Protocol::v14 => 11, - Protocol::v16 => 16, - Protocol::Custom { ring_len, .. } => *ring_len, - } - } - - /// Whether or not the specified version uses Bulletproofs or Bulletproofs+. - /// - /// This method will likely be reworked when versions not using Bulletproofs at all are added. - pub fn bp_plus(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { bp_plus, .. } => *bp_plus, - } - } - - // TODO: Make this an Option when we support pre-RCT protocols - pub fn optimal_rct_type(&self) -> RctType { - match self { - Protocol::v14 => RctType::ClsagBulletproof, - Protocol::v16 => RctType::ClsagBulletproofPlus, - Protocol::Custom { optimal_rct_type, .. } => *optimal_rct_type, - } - } - - /// Whether or not the specified version uses view tags. - pub fn view_tags(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { view_tags, .. } => *view_tags, - } - } - - /// Whether or not the specified version uses the fee algorithm from Monero - /// hard fork version 16 (released in v18 binaries). - pub fn v16_fee(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { v16_fee, .. } => *v16_fee, - } - } - - pub fn write(&self, w: &mut W) -> stdio::Result<()> { - match self { - Protocol::v14 => w.write_all(&[0, 14]), - Protocol::v16 => w.write_all(&[0, 16]), - Protocol::Custom { ring_len, bp_plus, optimal_rct_type, view_tags, v16_fee } => { - // Custom, version 0 - w.write_all(&[1, 0])?; - w.write_all(&u16::try_from(*ring_len).unwrap().to_le_bytes())?; - w.write_all(&[u8::from(*bp_plus)])?; - w.write_all(&[u8::from(*optimal_rct_type)])?; - w.write_all(&[u8::from(*view_tags)])?; - w.write_all(&[u8::from(*v16_fee)]) - } - } - } - - pub fn read(r: &mut R) -> stdio::Result { - Ok(match read_byte(r)? { - // Monero protocol - 0 => match read_byte(r)? { - 14 => Protocol::v14, - 16 => Protocol::v16, - _ => Err(stdio::Error::other("unrecognized monero protocol"))?, - }, - // Custom - 1 => match read_byte(r)? { - 0 => Protocol::Custom { - ring_len: read_u16(r)?.into(), - bp_plus: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(stdio::Error::other("invalid bool serialization"))?, - }, - optimal_rct_type: RctType::try_from(read_byte(r)?) - .map_err(|()| stdio::Error::other("invalid RctType serialization"))?, - view_tags: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(stdio::Error::other("invalid bool serialization"))?, - }, - v16_fee: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(stdio::Error::other("invalid bool serialization"))?, - }, - }, - _ => Err(stdio::Error::other("unrecognized custom protocol serialization"))?, - }, - _ => Err(stdio::Error::other("unrecognized protocol serialization"))?, - }) - } -} - -fn key_image_sort(x: &EdwardsPoint, y: &EdwardsPoint) -> core::cmp::Ordering { - x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() -} - -// https://gist.github.com/kayabaNerve/8066c13f1fe1573286ba7a2fd79f6100 -pub(crate) fn uniqueness(inputs: &[Input]) -> [u8; 32] { - let mut u = b"uniqueness".to_vec(); - for input in inputs { - match input { - // If Gen, this should be the only input, making this loop somewhat pointless - // This works and even if there were somehow multiple inputs, it'd be a false negative - Input::Gen(height) => { - write_varint(height, &mut u).unwrap(); - } - Input::ToKey { key_image, .. } => u.extend(key_image.compress().to_bytes()), - } - } - keccak256(u) -} - -// Hs("view_tag" || 8Ra || o), Hs(8Ra || o), and H(8Ra || 0x8d) with uniqueness inclusion in the -// Scalar as an option -#[allow(non_snake_case)] -pub(crate) fn shared_key( - uniqueness: Option<[u8; 32]>, - ecdh: EdwardsPoint, - o: usize, -) -> (u8, Scalar, [u8; 8]) { - // 8Ra - let mut output_derivation = ecdh.mul_by_cofactor().compress().to_bytes().to_vec(); - - let mut payment_id_xor = [0; 8]; - payment_id_xor - .copy_from_slice(&keccak256([output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]); - - // || o - write_varint(&o, &mut output_derivation).unwrap(); - - let view_tag = keccak256([b"view_tag".as_ref(), &output_derivation].concat())[0]; - - // uniqueness || - let shared_key = if let Some(uniqueness) = uniqueness { - [uniqueness.as_ref(), &output_derivation].concat() - } else { - output_derivation - }; - - (view_tag, keccak256_to_scalar(shared_key), payment_id_xor) -} - -pub(crate) fn commitment_mask(shared_key: Scalar) -> Scalar { - let mut mask = b"commitment_mask".to_vec(); - mask.extend(shared_key.to_bytes()); - keccak256_to_scalar(mask) -} - -pub(crate) fn compact_amount_encryption(amount: u64, key: Scalar) -> [u8; 8] { - let mut amount_mask = b"amount".to_vec(); - amount_mask.extend(key.to_bytes()); - (amount ^ u64::from_le_bytes(keccak256(amount_mask)[.. 8].try_into().unwrap())).to_le_bytes() -} - -pub trait EncryptedAmountExt { - /// Decrypt an EncryptedAmount into the Commitment it encrypts. - /// - /// The caller must verify the decrypted Commitment matches with the actual Commitment used - /// within in the Monero protocol. - fn decrypt(&self, key: Scalar) -> Commitment; -} -impl EncryptedAmountExt for EncryptedAmount { - /// Decrypt an EncryptedAmount into the Commitment it encrypts. - /// - /// The caller must verify the decrypted Commitment matches with the actual Commitment used - /// within in the Monero protocol. - fn decrypt(&self, key: Scalar) -> Commitment { - match self { - // TODO: Add a test vector for this - EncryptedAmount::Original { mask, amount } => { - let mask_shared_sec = keccak256(key.as_bytes()); - let mask = - Scalar::from_bytes_mod_order(*mask) - Scalar::from_bytes_mod_order(mask_shared_sec); - - let amount_shared_sec = keccak256(mask_shared_sec); - let amount_scalar = - Scalar::from_bytes_mod_order(*amount) - Scalar::from_bytes_mod_order(amount_shared_sec); - // d2b from rctTypes.cpp - let amount = u64::from_le_bytes(amount_scalar.to_bytes()[0 .. 8].try_into().unwrap()); - - Commitment::new(mask, amount) - } - EncryptedAmount::Compact { amount } => Commitment::new( - commitment_mask(key), - u64::from_le_bytes(compact_amount_encryption(u64::from_le_bytes(*amount), key)), - ), - } - } -} +*/ /// The private view key and public spend key, enabling scanning transactions. #[derive(Clone, Zeroize, ZeroizeOnDrop)] @@ -356,67 +113,112 @@ impl ViewPair { } } -/// Transaction scanner. -/// This scanner is capable of generating subaddresses, additionally scanning for them once they've -/// been explicitly generated. If the burning bug is attempted, any secondary outputs will be -/// ignored. -#[derive(Clone)] -pub struct Scanner { - pair: ViewPair, - // Also contains the spend key as None - pub(crate) subaddresses: HashMap>, - pub(crate) burning_bug: Option>, +pub(crate) fn compact_amount_encryption(amount: u64, key: Scalar) -> [u8; 8] { + let mut amount_mask = b"amount".to_vec(); + amount_mask.extend(key.to_bytes()); + (amount ^ u64::from_le_bytes(keccak256(amount_mask)[.. 8].try_into().unwrap())).to_le_bytes() } -impl Zeroize for Scanner { - fn zeroize(&mut self) { - self.pair.zeroize(); +#[derive(Clone, PartialEq, Eq, Zeroize)] +struct SharedKeyDerivations { + // Hs("view_tag" || 8Ra || o) + view_tag: u8, + // Hs(uniqueness || 8Ra || o) where uniqueness may be empty + shared_key: Scalar, +} - // These may not be effective, unfortunately - for (mut key, mut value) in self.subaddresses.drain() { - key.zeroize(); - value.zeroize(); - } - if let Some(ref mut burning_bug) = self.burning_bug.take() { - for mut output in burning_bug.drain() { - output.zeroize(); +impl SharedKeyDerivations { + // https://gist.github.com/kayabaNerve/8066c13f1fe1573286ba7a2fd79f6100 + fn uniqueness(inputs: &[Input]) -> [u8; 32] { + let mut u = b"uniqueness".to_vec(); + for input in inputs { + match input { + // If Gen, this should be the only input, making this loop somewhat pointless + // This works and even if there were somehow multiple inputs, it'd be a false negative + Input::Gen(height) => { + write_varint(height, &mut u).unwrap(); + } + Input::ToKey { key_image, .. } => u.extend(key_image.compress().to_bytes()), } } - } -} - -impl Drop for Scanner { - fn drop(&mut self) { - self.zeroize(); - } -} - -impl ZeroizeOnDrop for Scanner {} - -impl Scanner { - /// Create a Scanner from a ViewPair. - /// - /// burning_bug is a HashSet of used keys, intended to prevent key reuse which would burn funds. - /// - /// When an output is successfully scanned, the output key MUST be saved to disk. - /// - /// When a new scanner is created, ALL saved output keys must be passed in to be secure. - /// - /// If None is passed, a modified shared key derivation is used which is immune to the burning - /// bug (specifically the Guaranteed feature from Featured Addresses). - pub fn from_view(pair: ViewPair, burning_bug: Option>) -> Scanner { - let mut subaddresses = HashMap::new(); - subaddresses.insert(pair.spend.compress(), None); - Scanner { pair, subaddresses, burning_bug } - } - - /// Register a subaddress. - // There used to be an address function here, yet it wasn't safe. It could generate addresses - // incompatible with the Scanner. While we could return None for that, then we have the issue - // of runtime failures to generate an address. - // Removing that API was the simplest option. - pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) { - let (spend, _) = self.pair.subaddress_keys(subaddress); - self.subaddresses.insert(spend.compress(), Some(subaddress)); + keccak256(u) + } + + #[allow(clippy::needless_pass_by_value)] + fn output_derivations( + uniqueness: Option<[u8; 32]>, + ecdh: Zeroizing, + o: usize, + ) -> Zeroizing { + // 8Ra + let mut output_derivation = Zeroizing::new( + Zeroizing::new(Zeroizing::new(ecdh.mul_by_cofactor()).compress().to_bytes()).to_vec(), + ); + + // || o + { + let output_derivation: &mut Vec = output_derivation.as_mut(); + write_varint(&o, output_derivation).unwrap(); + } + + let view_tag = keccak256([b"view_tag".as_ref(), &output_derivation].concat())[0]; + + // uniqueness || + let output_derivation = if let Some(uniqueness) = uniqueness { + Zeroizing::new([uniqueness.as_ref(), &output_derivation].concat()) + } else { + output_derivation + }; + + Zeroizing::new(SharedKeyDerivations { + view_tag, + shared_key: keccak256_to_scalar(&output_derivation), + }) + } + + // H(8Ra || 0x8d) + // TODO: Make this itself a PaymentId + #[allow(clippy::needless_pass_by_value)] + fn payment_id_xor(ecdh: Zeroizing) -> [u8; 8] { + // 8Ra + let output_derivation = Zeroizing::new( + Zeroizing::new(Zeroizing::new(ecdh.mul_by_cofactor()).compress().to_bytes()).to_vec(), + ); + + let mut payment_id_xor = [0; 8]; + payment_id_xor + .copy_from_slice(&keccak256([output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]); + payment_id_xor + } + + fn commitment_mask(&self) -> Scalar { + let mut mask = b"commitment_mask".to_vec(); + mask.extend(self.shared_key.as_bytes()); + let res = keccak256_to_scalar(&mask); + mask.zeroize(); + res + } + + fn decrypt(&self, enc_amount: &EncryptedAmount) -> Commitment { + match enc_amount { + // TODO: Add a test vector for this + EncryptedAmount::Original { mask, amount } => { + let mask_shared_sec = keccak256(self.shared_key.as_bytes()); + let mask = + Scalar::from_bytes_mod_order(*mask) - Scalar::from_bytes_mod_order(mask_shared_sec); + + let amount_shared_sec = keccak256(mask_shared_sec); + let amount_scalar = + Scalar::from_bytes_mod_order(*amount) - Scalar::from_bytes_mod_order(amount_shared_sec); + // d2b from rctTypes.cpp + let amount = u64::from_le_bytes(amount_scalar.to_bytes()[0 .. 8].try_into().unwrap()); + + Commitment::new(mask, amount) + } + EncryptedAmount::Compact { amount } => Commitment::new( + self.commitment_mask(), + u64::from_le_bytes(compact_amount_encryption(u64::from_le_bytes(*amount), self.shared_key)), + ), + } } } diff --git a/coins/monero/wallet/src/send/builder.rs b/coins/monero/wallet/src/old_send/builder.rs similarity index 92% rename from coins/monero/wallet/src/send/builder.rs rename to coins/monero/wallet/src/old_send/builder.rs index 9567ed83..053b0588 100644 --- a/coins/monero/wallet/src/send/builder.rs +++ b/coins/monero/wallet/src/old_send/builder.rs @@ -3,13 +3,13 @@ use std::sync::{Arc, RwLock}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use crate::{ - Protocol, address::MoneroAddress, FeeRate, SpendableOutput, Change, Decoys, SignableTransaction, + WalletProtocol, address::MoneroAddress, FeeRate, SpendableOutput, Change, Decoys, SignableTransaction, TransactionError, extra::MAX_ARBITRARY_DATA_SIZE, }; #[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] struct SignableTransactionBuilderInternal { - protocol: Protocol, + protocol: WalletProtocol, fee_rate: FeeRate, r_seed: Option>, @@ -22,7 +22,7 @@ struct SignableTransactionBuilderInternal { impl SignableTransactionBuilderInternal { // Takes in the change address so users don't miss that they have to manually set one // If they don't, all leftover funds will become part of the fee - fn new(protocol: Protocol, fee_rate: FeeRate, change_address: Change) -> Self { + fn new(protocol: WalletProtocol, fee_rate: FeeRate, change_address: Change) -> Self { Self { protocol, fee_rate, @@ -87,7 +87,7 @@ impl SignableTransactionBuilder { Self(self.0.clone()) } - pub fn new(protocol: Protocol, fee_rate: FeeRate, change_address: Change) -> Self { + pub fn new(protocol: WalletProtocol, fee_rate: FeeRate, change_address: Change) -> Self { Self(Arc::new(RwLock::new(SignableTransactionBuilderInternal::new( protocol, fee_rate, diff --git a/coins/monero/wallet/src/send/multisig.rs b/coins/monero/wallet/src/old_send/multisig.rs similarity index 100% rename from coins/monero/wallet/src/send/multisig.rs rename to coins/monero/wallet/src/old_send/multisig.rs diff --git a/coins/monero/wallet/src/scan.rs b/coins/monero/wallet/src/scan.rs index a49790a3..710907ae 100644 --- a/coins/monero/wallet/src/scan.rs +++ b/coins/monero/wallet/src/scan.rs @@ -1,13 +1,18 @@ use core::ops::Deref; use std_shims::{ + io::{self, Read, Write}, vec::Vec, string::ToString, - io::{self, Read, Write}, + collections::{HashSet, HashMap}, }; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar, edwards::EdwardsPoint}; +use curve25519_dalek::{ + constants::ED25519_BASEPOINT_TABLE, + Scalar, + edwards::{EdwardsPoint, CompressedEdwardsY}, +}; use monero_rpc::{RpcError, Rpc}; use monero_serai::{ @@ -16,15 +21,13 @@ use monero_serai::{ transaction::{Input, Timelock, Transaction}, block::Block, }; -use crate::{ - PaymentId, Extra, address::SubaddressIndex, Scanner, EncryptedAmountExt, uniqueness, shared_key, -}; +use crate::{address::SubaddressIndex, ViewPair, PaymentId, Extra, SharedKeyDerivations}; /// An absolute output ID, defined as its transaction hash and output index. #[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] pub struct AbsoluteId { pub tx: [u8; 32], - pub o: u8, + pub o: u32, } impl core::fmt::Debug for AbsoluteId { @@ -36,17 +39,17 @@ impl core::fmt::Debug for AbsoluteId { impl AbsoluteId { pub fn write(&self, w: &mut W) -> io::Result<()> { w.write_all(&self.tx)?; - w.write_all(&[self.o]) + w.write_all(&self.o.to_le_bytes()) } pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(32 + 1); + let mut serialized = Vec::with_capacity(32 + 4); self.write(&mut serialized).unwrap(); serialized } pub fn read(r: &mut R) -> io::Result { - Ok(AbsoluteId { tx: read_bytes(r)?, o: read_byte(r)? }) + Ok(AbsoluteId { tx: read_bytes(r)?, o: read_u32(r)? }) } } @@ -244,7 +247,10 @@ impl SpendableOutput { self.global_index = *rpc .get_o_indexes(self.output.absolute.tx) .await? - .get(usize::from(self.output.absolute.o)) + .get( + usize::try_from(self.output.absolute.o) + .map_err(|_| RpcError::InternalError("output's index didn't fit within a usize"))?, + ) .ok_or(RpcError::InvalidNode( "node returned output indexes didn't include an index for this output".to_string(), ))?; @@ -330,6 +336,72 @@ impl Timelocked { } } +/// Transaction scanner. +/// +/// This scanner is capable of generating subaddresses, additionally scanning for them once they've +/// been explicitly generated. If the burning bug is attempted, any secondary outputs will be +/// ignored. +#[derive(Clone)] +pub struct Scanner { + pair: ViewPair, + // Also contains the spend key as None + pub(crate) subaddresses: HashMap>, + pub(crate) burning_bug: Option>, +} + +impl Zeroize for Scanner { + fn zeroize(&mut self) { + self.pair.zeroize(); + + // These may not be effective, unfortunately + for (mut key, mut value) in self.subaddresses.drain() { + key.zeroize(); + value.zeroize(); + } + if let Some(ref mut burning_bug) = self.burning_bug.take() { + for mut output in burning_bug.drain() { + output.zeroize(); + } + } + } +} + +impl Drop for Scanner { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl ZeroizeOnDrop for Scanner {} + +impl Scanner { + /// Create a Scanner from a ViewPair. + /// + /// burning_bug is a HashSet of used keys, intended to prevent key reuse which would burn funds. + /// + /// When an output is successfully scanned, the output key MUST be saved to disk. + /// + /// When a new scanner is created, ALL saved output keys must be passed in to be secure. + /// + /// If None is passed, a modified shared key derivation is used which is immune to the burning + /// bug (specifically the Guaranteed feature from Featured Addresses). + pub fn from_view(pair: ViewPair, burning_bug: Option>) -> Scanner { + let mut subaddresses = HashMap::new(); + subaddresses.insert(pair.spend.compress(), None); + Scanner { pair, subaddresses, burning_bug } + } + + /// Register a subaddress. + // There used to be an address function here, yet it wasn't safe. It could generate addresses + // incompatible with the Scanner. While we could return None for that, then we have the issue + // of runtime failures to generate an address. + // Removing that API was the simplest option. + pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) { + let (spend, _) = self.pair.subaddress_keys(subaddress); + self.subaddresses.insert(spend.compress(), Some(subaddress)); + } +} + impl Scanner { /// Scan a transaction to discover the received outputs. pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked { @@ -379,23 +451,29 @@ impl Scanner { break; } }; - let (view_tag, shared_key, payment_id_xor) = shared_key( - if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix().inputs)) } else { None }, - self.pair.view.deref() * key, + let ecdh = Zeroizing::new(self.pair.view.deref() * key); + let output_derivations = SharedKeyDerivations::output_derivations( + if self.burning_bug.is_none() { + Some(SharedKeyDerivations::uniqueness(&tx.prefix().inputs)) + } else { + None + }, + ecdh.clone(), o, ); - let payment_id = payment_id.map(|id| id ^ payment_id_xor); + let payment_id = payment_id.map(|id| id ^ SharedKeyDerivations::payment_id_xor(ecdh)); if let Some(actual_view_tag) = output.view_tag { - if actual_view_tag != view_tag { + if actual_view_tag != output_derivations.view_tag { continue; } } // P - shared == spend - let subaddress = - self.subaddresses.get(&(output_key - (&shared_key * ED25519_BASEPOINT_TABLE)).compress()); + let subaddress = self.subaddresses.get( + &(output_key - (&output_derivations.shared_key * ED25519_BASEPOINT_TABLE)).compress(), + ); if subaddress.is_none() { continue; } @@ -407,7 +485,7 @@ impl Scanner { // If we did though, it'd enable bypassing the included burning bug protection assert!(output_key.is_torsion_free()); - let mut key_offset = shared_key; + let mut key_offset = output_derivations.shared_key; if let Some(subaddress) = subaddress { key_offset += self.pair.subaddress_derivation(subaddress); } @@ -424,7 +502,7 @@ impl Scanner { }; commitment = match proofs.base.encrypted_amounts.get(o) { - Some(amount) => amount.decrypt(shared_key), + Some(amount) => output_derivations.decrypt(amount), // This should never happen, yet it may be possible with miner transactions? // Using get just decreases the possibility of a panic and lets us move on in that case None => break, diff --git a/coins/monero/wallet/src/send/eventuality.rs b/coins/monero/wallet/src/send/eventuality.rs new file mode 100644 index 00000000..ec408815 --- /dev/null +++ b/coins/monero/wallet/src/send/eventuality.rs @@ -0,0 +1,178 @@ +use zeroize::Zeroize; + +use crate::{ + ringct::RctProofs, + transaction::{Input, Timelock, Transaction}, + send::SignableTransaction, +}; + +/// The eventual output of a SignableTransaction. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct Eventuality(SignableTransaction); + +impl From for Eventuality { + fn from(tx: SignableTransaction) -> Eventuality { + Eventuality(tx) + } +} + +impl Eventuality { + /// Return the extra any TX following this intent would use. + /// + /// This enables building a HashMap of Extra -> Eventuality for efficiently checking if an + /// on-chain transaction may match one of several eventuality. + /// + /// This extra is cryptographically bound to the set of outputs intended to be spent as inputs. + /// This means two SignableTransactions for the same set of payments will have distinct extras. + /// This does not guarantee the matched transaction actually spent the intended outputs. + pub fn extra(&self) -> Vec { + self.0.extra() + } + + /// Return if this TX matches the SignableTransaction this was created from. + /// + /// Matching the SignableTransaction means this transaction created the expected outputs, they're + /// scannable, they're not locked, and this transaction claims to use the intended inputs (though + /// this is not guaranteed). This 'claim' is evaluated by this transaction using the transaction + /// keys derived from the intended inputs. This ensures two SignableTransactions with the same + /// intended payments don't match for each other's `Eventuality`s (as they'll have distinct + /// inputs intended). + #[must_use] + pub fn matches(&self, tx: &Transaction) -> bool { + // Verify extra + if self.0.extra() != tx.prefix().extra { + return false; + } + + // Also ensure no timelock was set + if tx.prefix().timelock != Timelock::None { + return false; + } + + // Check the amount of inputs aligns + if tx.prefix().inputs.len() != self.0.inputs.len() { + return false; + } + // Collect the key images used by this transaction + let Ok(key_images) = tx + .prefix() + .inputs + .iter() + .map(|input| match input { + Input::Gen(_) => Err(()), + Input::ToKey { key_image, .. } => Ok(*key_image), + }) + .collect::, _>>() + else { + return false; + }; + + // Check the outputs + if self.0.outputs(&key_images) != tx.prefix().outputs { + return false; + } + + // Check the encrypted amounts and commitments + let commitments_and_encrypted_amounts = self.0.commitments_and_encrypted_amounts(&key_images); + let Transaction::V2 { proofs: Some(RctProofs { ref base, .. }), .. } = tx else { + return false; + }; + if base.commitments != + commitments_and_encrypted_amounts + .iter() + .map(|(commitment, _)| commitment.calculate()) + .collect::>() + { + return false; + } + if base.encrypted_amounts != + commitments_and_encrypted_amounts.into_iter().map(|(_, amount)| amount).collect::>() + { + return false; + } + + true + } + + /* + pub fn write(&self, w: &mut W) -> io::Result<()> { + self.protocol.write(w)?; + write_raw_vec(write_byte, self.r_seed.as_ref(), w)?; + write_vec(write_point, &self.inputs, w)?; + + fn write_payment(payment: &InternalPayment, w: &mut W) -> io::Result<()> { + match payment { + InternalPayment::Payment(payment, need_dummy_payment_id) => { + w.write_all(&[0])?; + write_vec(write_byte, payment.0.to_string().as_bytes(), w)?; + w.write_all(&payment.1.to_le_bytes())?; + if *need_dummy_payment_id { + w.write_all(&[1]) + } else { + w.write_all(&[0]) + } + } + InternalPayment::Change(change, change_view) => { + w.write_all(&[1])?; + write_vec(write_byte, change.0.to_string().as_bytes(), w)?; + w.write_all(&change.1.to_le_bytes())?; + if let Some(view) = change_view.as_ref() { + w.write_all(&[1])?; + write_scalar(view, w) + } else { + w.write_all(&[0]) + } + } + } + } + write_vec(write_payment, &self.payments, w)?; + + write_vec(write_byte, &self.extra, w) + } + + pub fn serialize(&self) -> Vec { + let mut buf = Vec::with_capacity(128); + self.write(&mut buf).unwrap(); + buf + } + + pub fn read(r: &mut R) -> io::Result { + fn read_address(r: &mut R) -> io::Result { + String::from_utf8(read_vec(read_byte, r)?) + .ok() + .and_then(|str| MoneroAddress::from_str_raw(&str).ok()) + .ok_or_else(|| io::Error::other("invalid address")) + } + + fn read_payment(r: &mut R) -> io::Result { + Ok(match read_byte(r)? { + 0 => InternalPayment::Payment( + (read_address(r)?, read_u64(r)?), + match read_byte(r)? { + 0 => false, + 1 => true, + _ => Err(io::Error::other("invalid need additional"))?, + }, + ), + 1 => InternalPayment::Change( + (read_address(r)?, read_u64(r)?), + match read_byte(r)? { + 0 => None, + 1 => Some(Zeroizing::new(read_scalar(r)?)), + _ => Err(io::Error::other("invalid change view"))?, + }, + ), + _ => Err(io::Error::other("invalid payment"))?, + }) + } + + Ok(Eventuality { + protocol: RctType::read(r)?, + r_seed: Zeroizing::new(read_bytes::<_, 32>(r)?), + inputs: read_vec(read_point, r)?, + payments: read_vec(read_payment, r)?, + extra: read_vec(read_byte, r)?, + }) + } + */ +} diff --git a/coins/monero/wallet/src/send/mod.rs b/coins/monero/wallet/src/send/mod.rs index 658186fd..21dab804 100644 --- a/coins/monero/wallet/src/send/mod.rs +++ b/coins/monero/wallet/src/send/mod.rs @@ -1,420 +1,221 @@ use core::{ops::Deref, fmt}; -use std_shims::{ - vec::Vec, - io, - string::{String, ToString}, -}; -use rand_core::{RngCore, CryptoRng, SeedableRng}; -use rand_chacha::ChaCha20Rng; +use zeroize::{Zeroize, Zeroizing}; + +use rand_core::{RngCore, CryptoRng}; use rand::seq::SliceRandom; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; - -use group::Group; -use curve25519_dalek::{ - constants::{ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_TABLE}, - scalar::Scalar, - edwards::EdwardsPoint, -}; - +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; #[cfg(feature = "multisig")] use frost::FrostError; -use monero_rpc::RpcError; -pub use monero_rpc::{FeePriority, FeeRate}; -use monero_serai::{ - io::*, - generators::hash_to_point, - primitives::{Commitment, keccak256}, +use crate::{ + generators::{MAX_COMMITMENTS, hash_to_point}, + primitives::Decoys, ringct::{ clsag::{ClsagError, ClsagContext, Clsag}, - bulletproofs::{MAX_COMMITMENTS, Bulletproof}, - RctBase, RctPrunable, RctProofs, + RctType, RctPrunable, RctProofs, }, - transaction::{Input, Output, Timelock, TransactionPrefix, Transaction}, -}; -use crate::{ - Protocol, + transaction::Transaction, + extra::MAX_ARBITRARY_DATA_SIZE, address::{Network, AddressSpec, MoneroAddress}, - ViewPair, SpendableOutput, Decoys, PaymentId, ExtraField, Extra, key_image_sort, uniqueness, - shared_key, commitment_mask, compact_amount_encryption, - extra::{ARBITRARY_DATA_MARKER, MAX_ARBITRARY_DATA_SIZE}, + rpc::FeeRate, + ViewPair, + scan::SpendableOutput, }; -#[cfg(feature = "std")] -mod builder; -#[cfg(feature = "std")] -pub use builder::SignableTransactionBuilder; +mod tx_keys; +mod tx; +mod eventuality; +pub use eventuality::Eventuality; -#[cfg(feature = "multisig")] -mod multisig; -#[cfg(feature = "multisig")] -pub use multisig::TransactionMachine; -use monero_serai::ringct::EncryptedAmount; - -/// Generate a key image for a given key. Defined as `x * hash_to_point(xG)`. -pub fn generate_key_image(secret: &Zeroizing) -> EdwardsPoint { - hash_to_point((ED25519_BASEPOINT_TABLE * secret.deref()).compress().to_bytes()) * secret.deref() +#[derive(Clone, PartialEq, Eq, Zeroize)] +enum ChangeEnum { + None, + AddressOnly(MoneroAddress), + AddressWithView(MoneroAddress, Zeroizing), } -#[allow(non_snake_case)] -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -struct SendOutput { - R: EdwardsPoint, - view_tag: u8, - dest: EdwardsPoint, - commitment: Commitment, - amount: [u8; 8], -} - -impl SendOutput { - #[allow(non_snake_case)] - fn internal( - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ecdh: EdwardsPoint, - R: EdwardsPoint, - ) -> (SendOutput, Option<[u8; 8]>) { - let o = output.0; - let need_dummy_payment_id = output.2; - let output = output.1; - - let (view_tag, shared_key, payment_id_xor) = - shared_key(Some(unique).filter(|_| output.0.is_guaranteed()), ecdh, o); - - let payment_id = output - .0 - .payment_id() - .or(if need_dummy_payment_id { Some([0u8; 8]) } else { None }) - .map(|id| (u64::from_le_bytes(id) ^ u64::from_le_bytes(payment_id_xor)).to_le_bytes()); - - ( - SendOutput { - R, - view_tag, - dest: ((&shared_key * ED25519_BASEPOINT_TABLE) + output.0.spend), - commitment: Commitment::new(commitment_mask(shared_key), output.1), - amount: compact_amount_encryption(output.1, shared_key), - }, - payment_id, - ) - } - - fn new( - r: &Zeroizing, - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ) -> (SendOutput, Option<[u8; 8]>) { - let address = output.1 .0; - SendOutput::internal( - unique, - output, - r.deref() * address.view, - if !address.is_subaddress() { - r.deref() * ED25519_BASEPOINT_TABLE - } else { - r.deref() * address.spend - }, - ) - } - - fn change( - ecdh: EdwardsPoint, - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ) -> (SendOutput, Option<[u8; 8]>) { - SendOutput::internal(unique, output, ecdh, ED25519_BASEPOINT_POINT) - } -} - -#[derive(Clone, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "std", derive(thiserror::Error))] -pub enum TransactionError { - #[cfg_attr(feature = "std", error("multiple addresses with payment IDs"))] - MultiplePaymentIds, - #[cfg_attr(feature = "std", error("no inputs"))] - NoInputs, - #[cfg_attr(feature = "std", error("no outputs"))] - NoOutputs, - #[cfg_attr(feature = "std", error("invalid number of decoys"))] - InvalidDecoyQuantity, - #[cfg_attr(feature = "std", error("only one output and no change address"))] - NoChange, - #[cfg_attr(feature = "std", error("too many outputs"))] - TooManyOutputs, - #[cfg_attr(feature = "std", error("too much data"))] - TooMuchData, - #[cfg_attr(feature = "std", error("too many inputs/too much arbitrary data"))] - TooLargeTransaction, - #[cfg_attr( - feature = "std", - error("not enough funds (inputs {inputs}, outputs {outputs}, fee {fee})") - )] - NotEnoughFunds { inputs: u64, outputs: u64, fee: u64 }, - #[cfg_attr(feature = "std", error("wrong spend private key"))] - WrongPrivateKey, - #[cfg_attr(feature = "std", error("rpc error ({0})"))] - RpcError(RpcError), - #[cfg_attr(feature = "std", error("clsag error ({0})"))] - ClsagError(ClsagError), - #[cfg_attr(feature = "std", error("invalid transaction ({0})"))] - InvalidTransaction(RpcError), - #[cfg(feature = "multisig")] - #[cfg_attr(feature = "std", error("frost error {0}"))] - FrostError(FrostError), -} - -fn prepare_inputs( - inputs: &[(SpendableOutput, Decoys)], - spend: &Zeroizing, - tx: &mut Transaction, -) -> Result, ClsagContext)>, TransactionError> { - let mut signable = Vec::with_capacity(inputs.len()); - - for (input, decoys) in inputs { - let input_spend = Zeroizing::new(input.key_offset() + spend.deref()); - let image = generate_key_image(&input_spend); - signable.push(( - input_spend, - ClsagContext::new(decoys.clone(), input.commitment().clone()) - .map_err(TransactionError::ClsagError)?, - )); - - tx.prefix_mut().inputs.push(Input::ToKey { - amount: None, - key_offsets: decoys.offsets().to_vec(), - key_image: image, - }); - } - - // We now need to sort the inputs by their key image - // We take the transaction's inputs, temporarily - let mut tx_inputs = Vec::with_capacity(inputs.len()); - core::mem::swap(&mut tx_inputs, &mut tx.prefix_mut().inputs); - - // Then we join them with their signable contexts - let mut joint = tx_inputs.into_iter().zip(signable).collect::>(); - // Perform the actual sort - joint.sort_by(|(x, _), (y, _)| { - if let (Input::ToKey { key_image: x, .. }, Input::ToKey { key_image: y, .. }) = (x, y) { - x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() - } else { - panic!("Input wasn't ToKey") +impl fmt::Debug for ChangeEnum { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ChangeEnum::None => f.debug_struct("ChangeEnum::None").finish_non_exhaustive(), + ChangeEnum::AddressOnly(addr) => { + f.debug_struct("ChangeEnum::AddressOnly").field("addr", &addr).finish() + } + ChangeEnum::AddressWithView(addr, _) => { + f.debug_struct("ChangeEnum::AddressWithView").field("addr", &addr).finish_non_exhaustive() + } } - }); - - // We now re-create the consumed signable (tx.prefix().inputs already having an empty vector) and - // split the joint iterator back into two Vecs - let mut signable = Vec::with_capacity(inputs.len()); - for (input, signable_i) in joint { - tx.prefix_mut().inputs.push(input); - signable.push(signable_i); } - - Ok(signable) -} - -// Deterministically calculate what the TX weight and fee will be. -fn calculate_weight_and_fee( - protocol: Protocol, - decoy_weights: &[usize], - n_outputs: usize, - extra: usize, - fee_rate: FeeRate, -) -> (usize, u64) { - // Starting the fee at 0 here is different than core Monero's wallet2.cpp, which starts its fee - // calculation with an estimate. - // - // This difference is okay in practice because wallet2 still ends up using a fee calculated from - // a TX's weight, as calculated later in this function. - // - // See this PR highlighting wallet2's behavior: - // https://github.com/monero-project/monero/pull/8882 - // - // Even with that PR, if the estimated fee's VarInt byte length is larger than the calculated - // fee's, the wallet can theoretically use a fee not based on the actual TX weight. This does not - // occur in practice as it's nearly impossible for wallet2 to estimate a fee that is larger - // than the calculated fee today, and on top of that, even more unlikely for that estimate's - // VarInt to be larger in byte length than the calculated fee's. - let mut weight = 0usize; - let mut fee = 0u64; - - let mut done = false; - let mut iters = 0; - let max_iters = 5; - while !done { - weight = Transaction::fee_weight( - protocol.view_tags(), - protocol.bp_plus(), - protocol.ring_len(), - decoy_weights, - n_outputs, - extra, - fee, - ); - - let fee_calculated_from_weight = fee_rate.calculate_fee_from_weight(weight); - - // Continue trying to use the fee calculated from the tx's weight - done = fee_calculated_from_weight == fee; - - fee = fee_calculated_from_weight; - - #[cfg(test)] - debug_assert!(iters < max_iters, "Reached max fee calculation attempts"); - // Should never happen because the fee VarInt byte length shouldn't change *every* single iter. - // `iters` reaching `max_iters` is unexpected. - if iters >= max_iters { - // Fail-safe break to ensure funds are still spendable - break; - } - iters += 1; - } - - (weight, fee) -} - -#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] -pub(crate) enum InternalPayment { - Payment((MoneroAddress, u64), bool), - Change((MoneroAddress, u64), Option>), -} - -/// The eventual output of a SignableTransaction. -/// -/// If the SignableTransaction has a Change with a view key, this will also have the view key. -/// Accordingly, it must be treated securely. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] -pub struct Eventuality { - protocol: Protocol, - r_seed: Zeroizing<[u8; 32]>, - inputs: Vec, - payments: Vec, - extra: Vec, -} - -/// A signable transaction, either in a single-signer or multisig context. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct SignableTransaction { - protocol: Protocol, - r_seed: Option>, - inputs: Vec<(SpendableOutput, Decoys)>, - has_change: bool, - payments: Vec, - data: Vec>, - fee: u64, - fee_rate: FeeRate, } /// Specification for a change output. -#[derive(Clone, PartialEq, Eq, Zeroize)] -pub struct Change { - address: Option, - view: Option>, -} - -impl fmt::Debug for Change { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Change").field("address", &self.address).finish_non_exhaustive() - } -} +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct Change(ChangeEnum); impl Change { - /// Create a change output specification from a ViewPair, as needed to maintain privacy. + /// Create a change output specification. + /// + /// This take the view key as Monero assumes it has the view key for change outputs. It optimizes + /// its wallet protocol accordingly. + // TODO: Accept AddressSpec, not `guaranteed: bool` pub fn new(view: &ViewPair, guaranteed: bool) -> Change { - Change { - address: Some(view.address( + Change(ChangeEnum::AddressWithView( + view.address( + // Which network doesn't matter as the derivations will all be the same Network::Mainnet, if !guaranteed { AddressSpec::Standard } else { AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: true } }, - )), - view: Some(view.view.clone()), + ), + view.view.clone(), + )) + } + + /// Create a fingerprintable change output specification. + /// + /// You MUST assume this will harm your privacy. Only use this if you know what you're doing. + /// + /// If the change address is Some, this will be unable to optimize the transaction as the + /// Monero wallet protocol expects it can (due to presumably having the view key for the change + /// output). If a transaction should be optimized, and isn'tm it will be fingerprintable. + /// + /// If the change address is None, there are two fingerprints: + /// + /// 1) The change in the TX is shunted to the fee (making it fingerprintable). + /// + /// 2) If there are two outputs in the TX, Monero would create a payment ID for the non-change + /// output so an observer can't tell apart TXs with a payment ID from TXs without a payment + /// ID. monero-wallet will simply not create a payment ID in this case, revealing it's a + /// monero-wallet TX without change. + pub fn fingerprintable(address: Option) -> Change { + if let Some(address) = address { + Change(ChangeEnum::AddressOnly(address)) + } else { + Change(ChangeEnum::None) } } +} - /// Create a fingerprintable change output specification which will harm privacy. Only use this - /// if you know what you're doing. - /// - /// If the change address is None, there are 2 potential fingerprints: - /// - /// 1) The change in the tx is shunted to the fee (fingerprintable fee). - /// - /// 2) If there are 2 outputs in the tx, there would be no payment ID as is the case when the - /// reference wallet creates 2 output txs, since monero-serai doesn't know which output - /// to tie the dummy payment ID to. - pub fn fingerprintable(address: Option) -> Change { - Change { address, view: None } +#[derive(Clone, PartialEq, Eq, Zeroize)] +enum InternalPayment { + Payment(MoneroAddress, u64), + Change(MoneroAddress, Option>), +} + +impl InternalPayment { + fn address(&self) -> &MoneroAddress { + match self { + InternalPayment::Payment(addr, _) | InternalPayment::Change(addr, _) => addr, + } } } -fn need_additional(payments: &[InternalPayment]) -> (bool, bool) { - let mut has_change_view = false; - let subaddresses = payments - .iter() - .filter(|payment| match *payment { - InternalPayment::Payment(payment, _) => payment.0.is_subaddress(), - InternalPayment::Change(change, change_view) => { - if change_view.is_some() { - has_change_view = true; - // It should not be possible to construct a change specification to a subaddress with a - // view key - debug_assert!(!change.0.is_subaddress()); - } - change.0.is_subaddress() +impl fmt::Debug for InternalPayment { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InternalPayment::Payment(addr, amount) => f + .debug_struct("InternalPayment::Payment") + .field("addr", &addr) + .field("amount", &amount) + .finish(), + InternalPayment::Change(addr, _) => { + f.debug_struct("InternalPayment::Change").field("addr", &addr).finish_non_exhaustive() } - }) - .count() != - 0; - - // We need additional keys if we have any subaddresses - let mut additional = subaddresses; - // Unless the above change view key path is taken - if (payments.len() == 2) && has_change_view { - additional = false; + } } - - (subaddresses, additional) } -fn sanity_check_change_payment_quantity(payments: &[InternalPayment], has_change_address: bool) { - debug_assert_eq!( - payments - .iter() - .filter(|payment| match *payment { - InternalPayment::Payment(_, _) => false, - InternalPayment::Change(_, _) => true, - }) - .count(), - if has_change_address { 1 } else { 0 }, - "Unexpected number of change outputs" - ); +#[derive(Clone, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum SendError { + #[cfg_attr(feature = "std", error("this library doesn't yet support that RctType"))] + UnsupportedRctType, + #[cfg_attr(feature = "std", error("no inputs"))] + NoInputs, + #[cfg_attr(feature = "std", error("invalid number of decoys"))] + InvalidDecoyQuantity, + #[cfg_attr(feature = "std", error("no outputs"))] + NoOutputs, + #[cfg_attr(feature = "std", error("too many outputs"))] + TooManyOutputs, + #[cfg_attr(feature = "std", error("only one output and no change address"))] + NoChange, + #[cfg_attr(feature = "std", error("multiple addresses with payment IDs"))] + MultiplePaymentIds, + #[cfg_attr(feature = "std", error("too much data"))] + TooMuchData, + #[cfg_attr(feature = "std", error("too many inputs/too much arbitrary data"))] + TooLargeTransaction, + #[cfg_attr( + feature = "std", + error("not enough funds (inputs {inputs}, outputs {outputs}, fee {fee:?})") + )] + NotEnoughFunds { inputs: u64, outputs: u64, fee: Option }, + #[cfg_attr(feature = "std", error("invalid amount of key images specified"))] + InvalidAmountOfKeyImages, + #[cfg_attr(feature = "std", error("wrong spend private key"))] + WrongPrivateKey, // TODO + #[cfg_attr(feature = "std", error("clsag error ({0})"))] + ClsagError(ClsagError), + #[cfg(feature = "multisig")] + #[cfg_attr(feature = "std", error("frost error {0}"))] + FrostError(FrostError), +} + +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct SignableTransaction { + rct_type: RctType, + sender_view_key: Zeroizing, + inputs: Vec<(SpendableOutput, Decoys)>, + payments: Vec, + data: Vec>, + fee_rate: FeeRate, } impl SignableTransaction { - /// Create a signable transaction. - /// - /// `r_seed` refers to a seed used to derive the transaction's ephemeral keys (colloquially - /// called Rs). If None is provided, one will be automatically generated. - /// - /// Up to 16 outputs may be present, including the change output. If the change address is - /// specified, leftover funds will be sent to it. - /// - /// Each chunk of data must not exceed MAX_ARBITRARY_DATA_SIZE and will be embedded in TX extra. pub fn new( - protocol: Protocol, - r_seed: Option>, + rct_type: RctType, + sender_view_key: Zeroizing, inputs: Vec<(SpendableOutput, Decoys)>, payments: Vec<(MoneroAddress, u64)>, - change: &Change, + change: Change, data: Vec>, fee_rate: FeeRate, - ) -> Result { - // Make sure there's only one payment ID - let mut has_payment_id = { + ) -> Result { + match rct_type { + RctType::ClsagBulletproof | RctType::ClsagBulletproofPlus => {} + _ => Err(SendError::UnsupportedRctType)?, + }; + + if inputs.is_empty() { + Err(SendError::NoInputs)?; + } + for (_, decoys) in &inputs { + if decoys.len() != + match rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => panic!("unsupported RctType"), + } + { + Err(SendError::InvalidDecoyQuantity)?; + } + } + + if payments.is_empty() { + Err(SendError::NoOutputs)?; + } + // If we don't have at least two outputs, as required by Monero, error + if (payments.len() == 1) && matches!(change, Change(ChangeEnum::None)) { + Err(SendError::NoChange)?; + } + + // Make sure there's at most one payment ID + { let mut payment_ids = 0; let mut count = |addr: MoneroAddress| { if addr.payment_id().is_some() { @@ -424,614 +225,174 @@ impl SignableTransaction { for payment in &payments { count(payment.0); } - if let Some(change_address) = change.address.as_ref() { - count(*change_address); + match &change.0 { + ChangeEnum::None => (), + ChangeEnum::AddressOnly(addr) | ChangeEnum::AddressWithView(addr, _) => count(*addr), } if payment_ids > 1 { - Err(TransactionError::MultiplePaymentIds)?; - } - payment_ids == 1 - }; - - if inputs.is_empty() { - Err(TransactionError::NoInputs)?; - } - if payments.is_empty() { - Err(TransactionError::NoOutputs)?; - } - - for (_, decoys) in &inputs { - if decoys.len() != protocol.ring_len() { - Err(TransactionError::InvalidDecoyQuantity)?; + Err(SendError::MultiplePaymentIds)?; } } - for part in &data { - if part.len() > MAX_ARBITRARY_DATA_SIZE { - Err(TransactionError::TooMuchData)?; - } - } - - // If we don't have two outputs, as required by Monero, error - if (payments.len() == 1) && change.address.is_none() { - Err(TransactionError::NoChange)?; - } - - // All 2 output txs created by the reference wallet have payment IDs to avoid - // fingerprinting integrated addresses. Note: we won't create a dummy payment - // ID if we create a 0-change 2-output tx since we don't know which output should - // receive the payment ID and such a tx is fingerprintable to monero-serai anyway - let need_dummy_payment_id = !has_payment_id && payments.len() == 1; - has_payment_id |= need_dummy_payment_id; - - // Get the outgoing amount ignoring fees - let out_amount = payments.iter().map(|payment| payment.1).sum::(); - - let outputs = payments.len() + usize::from(change.address.is_some()); - if outputs > MAX_COMMITMENTS { - Err(TransactionError::TooManyOutputs)?; - } - - // Collect payments in a container that includes a change output if a change address is provided + // Re-format the payments and change into a consolidated payments list + let payments_amount = payments.iter().map(|(_, amount)| amount).sum::(); let mut payments = payments .into_iter() - .map(|payment| InternalPayment::Payment(payment, need_dummy_payment_id)) + .map(|(addr, amount)| InternalPayment::Payment(addr, amount)) .collect::>(); - debug_assert!(!need_dummy_payment_id || (payments.len() == 1 && change.address.is_some())); - - if let Some(change_address) = change.address.as_ref() { - // Push a 0 amount change output that we'll use to do fee calculations. - // We'll modify the change amount after calculating the fee - payments.push(InternalPayment::Change((*change_address, 0), change.view.clone())); + match change.0 { + ChangeEnum::None => {} + ChangeEnum::AddressOnly(addr) => payments.push(InternalPayment::Change(addr, None)), + ChangeEnum::AddressWithView(addr, view) => { + payments.push(InternalPayment::Change(addr, Some(view))) + } + } + if payments.len() > MAX_COMMITMENTS { + Err(SendError::TooManyOutputs)?; } - // Determine if we'll need additional pub keys in tx extra - let (_, additional) = need_additional(&payments); + // Check the length of each arbitrary data + for part in &data { + if part.len() > MAX_ARBITRARY_DATA_SIZE { + Err(SendError::TooMuchData)?; + } + } - // Calculate the extra length - let extra = Extra::fee_weight(outputs, additional, has_payment_id, data.as_ref()); + let res = SignableTransaction { rct_type, sender_view_key, inputs, payments, data, fee_rate }; + // Check the length of TX extra // https://github.com/monero-project/monero/pull/8733 const MAX_EXTRA_SIZE: usize = 1060; - if extra > MAX_EXTRA_SIZE { - Err(TransactionError::TooMuchData)?; + if res.extra().len() > MAX_EXTRA_SIZE { + Err(SendError::TooMuchData)?; } - // Caclculate weight of decoys - let decoy_weights = inputs - .iter() - .map(|(_, decoys)| { - let offsets = decoys.offsets(); - varint_len(offsets.len()) + offsets.iter().map(|offset| varint_len(*offset)).sum::() - }) - .collect::>(); - - // Deterministically calculate tx weight and fee - let (weight, fee) = - calculate_weight_and_fee(protocol, &decoy_weights, outputs, extra, fee_rate); + // Make sure we have enough funds + let in_amount = res.inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); + // Necessary so weight_and_fee doesn't underflow + if in_amount < payments_amount { + Err(SendError::NotEnoughFunds { inputs: in_amount, outputs: payments_amount, fee: None })?; + } + let (weight, fee) = res.weight_and_fee(); + if in_amount < (payments_amount + fee) { + Err(SendError::NotEnoughFunds { + inputs: in_amount, + outputs: payments_amount, + fee: Some(fee), + })?; + } // The actual limit is half the block size, and for the minimum block size of 300k, that'd be // 150k // wallet2 will only create transactions up to 100k bytes however const MAX_TX_SIZE: usize = 100_000; if weight >= MAX_TX_SIZE { - Err(TransactionError::TooLargeTransaction)?; + Err(SendError::TooLargeTransaction)?; } - // Make sure we have enough funds - let in_amount = inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); - if in_amount < (out_amount + fee) { - Err(TransactionError::NotEnoughFunds { inputs: in_amount, outputs: out_amount, fee })?; - } - - // Sanity check we have the expected number of change outputs - sanity_check_change_payment_quantity(&payments, change.address.is_some()); - - // Modify the amount of the change output - if let Some(change_address) = change.address.as_ref() { - let change_payment = payments.last_mut().unwrap(); - debug_assert!(matches!(change_payment, InternalPayment::Change(_, _))); - *change_payment = InternalPayment::Change( - (*change_address, in_amount - out_amount - fee), - change.view.clone(), - ); - } - - // Sanity check the change again after modifying - sanity_check_change_payment_quantity(&payments, change.address.is_some()); - - // Sanity check outgoing amount + fee == incoming amount - if change.address.is_some() { - debug_assert_eq!( - payments - .iter() - .map(|payment| match *payment { - InternalPayment::Payment(payment, _) => payment.1, - InternalPayment::Change(change, _) => change.1, - }) - .sum::() + - fee, - in_amount, - "Outgoing amount + fee != incoming amount" - ); - } - - Ok(SignableTransaction { - protocol, - r_seed, - inputs, - payments, - has_change: change.address.is_some(), - data, - fee, - fee_rate, - }) + Ok(res) } - pub fn fee(&self) -> u64 { - self.fee - } + fn with_key_images(mut self, key_images: Vec) -> SignableTransactionWithKeyImages { + debug_assert_eq!(self.inputs.len(), key_images.len()); - pub fn fee_rate(&self) -> FeeRate { - self.fee_rate - } + // Sort the inputs by their key images + fn key_image_sort(x: &EdwardsPoint, y: &EdwardsPoint) -> core::cmp::Ordering { + x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() + } + let mut sorted_inputs = self.inputs.into_iter().zip(key_images).collect::>(); + sorted_inputs + .sort_by(|(_, key_image_a), (_, key_image_b)| key_image_sort(key_image_a, key_image_b)); - #[allow(clippy::type_complexity)] - fn prepare_payments( - seed: &Zeroizing<[u8; 32]>, - inputs: &[EdwardsPoint], - payments: &mut Vec, - uniqueness: [u8; 32], - ) -> (EdwardsPoint, Vec>, Vec, Option<[u8; 8]>) { - let mut rng = { - // Hash the inputs into the seed so we don't re-use Rs - // Doesn't re-use uniqueness as that's based on key images, which requires interactivity - // to generate. The output keys do not - // This remains private so long as the seed is private - let mut r_uniqueness = vec![]; - for input in inputs { - r_uniqueness.extend(input.compress().to_bytes()); - } - ChaCha20Rng::from_seed(keccak256( - [b"monero-serai_outputs".as_ref(), seed.as_ref(), &r_uniqueness].concat(), - )) - }; + self.inputs = Vec::with_capacity(sorted_inputs.len()); + let mut key_images = Vec::with_capacity(sorted_inputs.len()); + for (input, key_image) in sorted_inputs { + self.inputs.push(input); + key_images.push(key_image); + } // Shuffle the payments - payments.shuffle(&mut rng); - - // Used for all non-subaddress outputs, or if there's only one subaddress output and a change - let tx_key = Zeroizing::new(Scalar::random(&mut rng)); - let mut tx_public_key = tx_key.deref() * ED25519_BASEPOINT_TABLE; - - // If any of these outputs are to a subaddress, we need keys distinct to them - // The only time this *does not* force having additional keys is when the only other output - // is a change output we have the view key for, enabling rewriting rA to aR - let (subaddresses, additional) = need_additional(payments); - let modified_change_ecdh = subaddresses && (!additional); - - // If we're using the aR rewrite, update tx_public_key from rG to rB - if modified_change_ecdh { - for payment in &*payments { - match payment { - InternalPayment::Payment(payment, _) => { - // This should be the only payment and it should be a subaddress - debug_assert!(payment.0.is_subaddress()); - tx_public_key = tx_key.deref() * payment.0.spend; - } - InternalPayment::Change(_, _) => {} - } - } - debug_assert!(tx_public_key != (tx_key.deref() * ED25519_BASEPOINT_TABLE)); + { + let mut rng = self.seeded_rng(b"shuffle_payments"); + self.payments.shuffle(&mut rng); } - // Actually create the outputs - let mut additional_keys = vec![]; - let mut outputs = Vec::with_capacity(payments.len()); - let mut id = None; - for (o, mut payment) in payments.drain(..).enumerate() { - // Downcast the change output to a payment output if it doesn't require special handling - // regarding it's view key - payment = if !modified_change_ecdh { - if let InternalPayment::Change(change, _) = &payment { - InternalPayment::Payment(*change, false) - } else { - payment - } - } else { - payment - }; - - let (output, payment_id) = match payment { - InternalPayment::Payment(payment, need_dummy_payment_id) => { - // If this is a subaddress, generate a dedicated r. Else, reuse the TX key - let dedicated = Zeroizing::new(Scalar::random(&mut rng)); - let use_dedicated = additional && payment.0.is_subaddress(); - let r = if use_dedicated { &dedicated } else { &tx_key }; - - let (mut output, payment_id) = - SendOutput::new(r, uniqueness, (o, payment, need_dummy_payment_id)); - if modified_change_ecdh { - debug_assert_eq!(tx_public_key, output.R); - } - - if use_dedicated { - additional_keys.push(dedicated); - } else { - // If this used tx_key, randomize its R - // This is so when extra is created, there's a distinct R for it to use - output.R = EdwardsPoint::random(&mut rng); - } - (output, payment_id) - } - InternalPayment::Change(change, change_view) => { - // Instead of rA, use Ra, where R is r * subaddress_spend_key - // change.view must be Some as if it's None, this payment would've been downcast - let ecdh = tx_public_key * change_view.unwrap().deref(); - SendOutput::change(ecdh, uniqueness, (o, change, false)) - } - }; - - outputs.push(output); - id = id.or(payment_id); - } - - (tx_public_key, additional_keys, outputs, id) + SignableTransactionWithKeyImages { intent: self, key_images } } - #[allow(non_snake_case)] - fn extra( - tx_key: EdwardsPoint, - additional: bool, - Rs: Vec, - id: Option<[u8; 8]>, - data: &mut Vec>, - ) -> Vec { - #[allow(non_snake_case)] - let Rs_len = Rs.len(); - let mut extra = Extra::new(tx_key, if additional { Rs } else { vec![] }); - - if let Some(id) = id { - let mut id_vec = Vec::with_capacity(1 + 8); - PaymentId::Encrypted(id).write(&mut id_vec).unwrap(); - extra.push(ExtraField::Nonce(id_vec)); - } - - // Include data if present - let extra_len = Extra::fee_weight(Rs_len, additional, id.is_some(), data.as_ref()); - for part in data.drain(..) { - let mut arb = vec![ARBITRARY_DATA_MARKER]; - arb.extend(part); - extra.push(ExtraField::Nonce(arb)); - } - - let mut serialized = Vec::with_capacity(extra_len); - extra.write(&mut serialized).unwrap(); - debug_assert_eq!(extra_len, serialized.len()); - serialized - } - - /// Returns the eventuality of this transaction. - /// - /// The eventuality is defined as the TX extra/outputs this transaction will create, if signed - /// with the specified seed. This eventuality can be compared to on-chain transactions to see - /// if the transaction has already been signed and published. - pub fn eventuality(&self) -> Option { - let inputs = self.inputs.iter().map(|(input, _)| input.key()).collect::>(); - let (tx_key, additional, outputs, id) = Self::prepare_payments( - self.r_seed.as_ref()?, - &inputs, - &mut self.payments.clone(), - // Lie about the uniqueness, used when determining output keys/commitments yet not the - // ephemeral keys, which is want we want here - // While we do still grab the outputs variable, it's so we can get its Rs - [0; 32], - ); - #[allow(non_snake_case)] - let Rs = outputs.iter().map(|output| output.R).collect(); - drop(outputs); - - let additional = !additional.is_empty(); - let extra = Self::extra(tx_key, additional, Rs, id, &mut self.data.clone()); - - Some(Eventuality { - protocol: self.protocol, - r_seed: self.r_seed.clone()?, - inputs, - payments: self.payments.clone(), - extra, - }) - } - - fn prepare_transaction( - &mut self, - rng: &mut R, - uniqueness: [u8; 32], - ) -> (Transaction, Scalar) { - // If no seed for the ephemeral keys was provided, make one - let r_seed = self.r_seed.clone().unwrap_or_else(|| { - let mut res = Zeroizing::new([0; 32]); - rng.fill_bytes(res.as_mut()); - res - }); - - let (tx_key, additional, outputs, id) = Self::prepare_payments( - &r_seed, - &self.inputs.iter().map(|(input, _)| input.key()).collect::>(), - &mut self.payments, - uniqueness, - ); - // This function only cares if additional keys were necessary, not what they were - let additional = !additional.is_empty(); - - let commitments = outputs.iter().map(|output| output.commitment.clone()).collect::>(); - let sum = commitments.iter().map(|commitment| commitment.mask).sum(); - - // Safe due to the constructor checking MAX_COMMITMENTS - let bp = if self.protocol.bp_plus() { - Bulletproof::prove_plus(rng, commitments.clone()).unwrap() - } else { - Bulletproof::prove(rng, &commitments).unwrap() - }; - - // Create the TX extra - let extra = Self::extra( - tx_key, - additional, - outputs.iter().map(|output| output.R).collect(), - id, - &mut self.data, - ); - - let mut fee = self.inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); - let mut tx_outputs = Vec::with_capacity(outputs.len()); - let mut encrypted_amounts = Vec::with_capacity(outputs.len()); - for output in &outputs { - fee -= output.commitment.amount; - tx_outputs.push(Output { - amount: None, - key: output.dest.compress(), - view_tag: Some(output.view_tag).filter(|_| self.protocol.view_tags()), - }); - encrypted_amounts.push(EncryptedAmount::Compact { amount: output.amount }); - } - if self.has_change { - debug_assert_eq!(self.fee, fee, "transaction will use an unexpected fee"); - } - - ( - Transaction::V2 { - prefix: TransactionPrefix { - timelock: Timelock::None, - inputs: vec![], - outputs: tx_outputs, - extra, - }, - proofs: Some(RctProofs { - base: RctBase { - fee, - encrypted_amounts, - pseudo_outs: vec![], - commitments: commitments.iter().map(Commitment::calculate).collect(), - }, - prunable: RctPrunable::Clsag { bulletproof: bp, clsags: vec![], pseudo_outs: vec![] }, - }), - }, - sum, - ) - } - - /// Sign this transaction. - pub fn sign( - mut self, - rng: &mut R, - spend: &Zeroizing, - ) -> Result { - let mut images = Vec::with_capacity(self.inputs.len()); + pub fn sign( + self, + rng: &mut (impl RngCore + CryptoRng), + sender_spend_key: &Zeroizing, + ) -> Result { + // Calculate the key images + let mut key_images = vec![]; for (input, _) in &self.inputs { - let mut offset = Zeroizing::new(spend.deref() + input.key_offset()); - if (offset.deref() * ED25519_BASEPOINT_TABLE) != input.key() { - Err(TransactionError::WrongPrivateKey)?; + let input_key = Zeroizing::new(sender_spend_key.deref() + input.key_offset()); + if (input_key.deref() * ED25519_BASEPOINT_TABLE) != input.key() { + Err(SendError::WrongPrivateKey)?; } - - images.push(generate_key_image(&offset)); - offset.zeroize(); + let key_image = input_key.deref() * hash_to_point(input.key().compress().to_bytes()); + key_images.push(key_image); } - images.sort_by(key_image_sort); - let (mut tx, mask_sum) = self.prepare_transaction( - rng, - uniqueness( - &images - .iter() - .map(|image| Input::ToKey { amount: None, key_offsets: vec![], key_image: *image }) - .collect::>(), - ), - ); + // Convert to a SignableTransactionWithKeyImages + let tx = self.with_key_images(key_images); - let signable = prepare_inputs(&self.inputs, spend, &mut tx)?; + // Prepare the CLSAG signatures + let mut clsag_signs = Vec::with_capacity(tx.intent.inputs.len()); + for (input, decoys) in &tx.intent.inputs { + // Re-derive the input key as this will be in a different order + let input_key = Zeroizing::new(sender_spend_key.deref() + input.key_offset()); + clsag_signs.push(( + input_key, + ClsagContext::new(decoys.clone(), input.commitment().clone()) + .map_err(SendError::ClsagError)?, + )); + } - let clsag_pairs = Clsag::sign(rng, signable, mask_sum, tx.signature_hash().unwrap()) - .map_err(|_| TransactionError::WrongPrivateKey)?; - let fee = match tx { - Transaction::V2 { - proofs: - Some(RctProofs { - ref base, - prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. }, - }), - .. - } => { - clsags.append(&mut clsag_pairs.iter().map(|clsag| clsag.0.clone()).collect::>()); - pseudo_outs.append(&mut clsag_pairs.iter().map(|clsag| clsag.1).collect::>()); - base.fee - } - _ => unreachable!("attempted to sign a TX which wasn't CLSAG"), + // Get the output commitments' mask sum + let mask_sum = tx + .intent + .commitments_and_encrypted_amounts(&tx.key_images) + .into_iter() + .map(|(commitment, _)| commitment.mask) + .sum::(); + + // Get the actual TX, just needing the CLSAGs + let mut tx = tx.transaction_without_clsags_and_pseudo_outs(); + + // Sign the CLSAGs + let clsags_and_pseudo_outs = + Clsag::sign(rng, clsag_signs, mask_sum, tx.signature_hash().unwrap()) + .map_err(SendError::ClsagError)?; + + // Fill in the CLSAGs/pseudo-outs + let inputs_len = tx.prefix().inputs.len(); + let Transaction::V2 { + proofs: + Some(RctProofs { + prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. }, + .. + }), + .. + } = tx + else { + panic!("not signing clsag?") }; - - if self.has_change { - debug_assert_eq!( - self.fee_rate.calculate_fee_from_weight(tx.weight()), - fee, - "transaction used unexpected fee", - ); + *clsags = Vec::with_capacity(inputs_len); + *pseudo_outs = Vec::with_capacity(inputs_len); + for (clsag, pseudo_out) in clsags_and_pseudo_outs { + clsags.push(clsag); + pseudo_outs.push(pseudo_out); } + // Return the signed TX Ok(tx) } } -impl Eventuality { - /// Enables building a HashMap of Extra -> Eventuality for efficiently checking if an on-chain - /// transaction may match this eventuality. - /// - /// This extra is cryptographically bound to: - /// 1) A specific set of inputs (via their output key) - /// 2) A specific seed for the ephemeral keys - /// - /// This extra may be used in a transaction with a distinct set of inputs, yet no honest - /// transaction which doesn't satisfy this Eventuality will contain it. - pub fn extra(&self) -> &[u8] { - &self.extra - } - - #[must_use] - pub fn matches(&self, tx: &Transaction) -> bool { - if self.payments.len() != tx.prefix().outputs.len() { - return false; - } - - // Verify extra. - // Even if all the outputs were correct, a malicious extra could still cause a recipient to - // fail to receive their funds. - // This is the cheapest check available to perform as it does not require TX-specific ECC ops. - if self.extra != tx.prefix().extra { - return false; - } - - // Also ensure no timelock was set. - if tx.prefix().timelock != Timelock::None { - return false; - } - - // Generate the outputs. This is TX-specific due to uniqueness. - let (_, _, outputs, _) = SignableTransaction::prepare_payments( - &self.r_seed, - &self.inputs, - &mut self.payments.clone(), - uniqueness(&tx.prefix().inputs), - ); - - let Transaction::V2 { proofs: Some(ref proofs), .. } = &tx else { - return false; - }; - - let rct_type = proofs.rct_type(); - if rct_type != self.protocol.optimal_rct_type() { - return false; - } - - // TODO: Remove this if/when the following for loop is updated to support older TXs - assert!( - rct_type.compact_encrypted_amounts(), - "created an Eventuality for a very old RctType we don't support proving for" - ); - - for (o, (expected, actual)) in outputs.iter().zip(tx.prefix().outputs.iter()).enumerate() { - // Verify the output, commitment, and encrypted amount. - if (&Output { - amount: None, - key: expected.dest.compress(), - view_tag: Some(expected.view_tag).filter(|_| self.protocol.view_tags()), - } != actual) || - (Some(&expected.commitment.calculate()) != proofs.base.commitments.get(o)) || - (Some(&EncryptedAmount::Compact { amount: expected.amount }) != - proofs.base.encrypted_amounts.get(o)) - { - return false; - } - } - - true - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.protocol.write(w)?; - write_raw_vec(write_byte, self.r_seed.as_ref(), w)?; - write_vec(write_point, &self.inputs, w)?; - - fn write_payment(payment: &InternalPayment, w: &mut W) -> io::Result<()> { - match payment { - InternalPayment::Payment(payment, need_dummy_payment_id) => { - w.write_all(&[0])?; - write_vec(write_byte, payment.0.to_string().as_bytes(), w)?; - w.write_all(&payment.1.to_le_bytes())?; - if *need_dummy_payment_id { - w.write_all(&[1]) - } else { - w.write_all(&[0]) - } - } - InternalPayment::Change(change, change_view) => { - w.write_all(&[1])?; - write_vec(write_byte, change.0.to_string().as_bytes(), w)?; - w.write_all(&change.1.to_le_bytes())?; - if let Some(view) = change_view.as_ref() { - w.write_all(&[1])?; - write_scalar(view, w) - } else { - w.write_all(&[0]) - } - } - } - } - write_vec(write_payment, &self.payments, w)?; - - write_vec(write_byte, &self.extra, w) - } - - pub fn serialize(&self) -> Vec { - let mut buf = Vec::with_capacity(128); - self.write(&mut buf).unwrap(); - buf - } - - pub fn read(r: &mut R) -> io::Result { - fn read_address(r: &mut R) -> io::Result { - String::from_utf8(read_vec(read_byte, r)?) - .ok() - .and_then(|str| MoneroAddress::from_str_raw(&str).ok()) - .ok_or_else(|| io::Error::other("invalid address")) - } - - fn read_payment(r: &mut R) -> io::Result { - Ok(match read_byte(r)? { - 0 => InternalPayment::Payment( - (read_address(r)?, read_u64(r)?), - match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(io::Error::other("invalid need additional"))?, - }, - ), - 1 => InternalPayment::Change( - (read_address(r)?, read_u64(r)?), - match read_byte(r)? { - 0 => None, - 1 => Some(Zeroizing::new(read_scalar(r)?)), - _ => Err(io::Error::other("invalid change view"))?, - }, - ), - _ => Err(io::Error::other("invalid payment"))?, - }) - } - - Ok(Eventuality { - protocol: Protocol::read(r)?, - r_seed: Zeroizing::new(read_bytes::<_, 32>(r)?), - inputs: read_vec(read_point, r)?, - payments: read_vec(read_payment, r)?, - extra: read_vec(read_byte, r)?, - }) - } +struct SignableTransactionWithKeyImages { + intent: SignableTransaction, + key_images: Vec, } diff --git a/coins/monero/wallet/src/send/scan.rs b/coins/monero/wallet/src/send/scan.rs deleted file mode 100644 index b33a42d9..00000000 --- a/coins/monero/wallet/src/send/scan.rs +++ /dev/null @@ -1,516 +0,0 @@ -use core::ops::Deref; -use std_shims::{ - vec::Vec, - string::ToString, - io::{self, Read, Write}, -}; - -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar, edwards::EdwardsPoint}; - -use monero_rpc::{RpcError, Rpc}; -use monero_serai::{ - io::*, - primitives::Commitment, - transaction::{Input, Timelock, Transaction}, - block::Block, -}; -use crate::{ - PaymentId, Extra, address::SubaddressIndex, Scanner, EncryptedAmountExt, uniqueness, shared_key, -}; - -/// An absolute output ID, defined as its transaction hash and output index. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct AbsoluteId { - pub tx: [u8; 32], - pub o: u8, -} - -impl core::fmt::Debug for AbsoluteId { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt.debug_struct("AbsoluteId").field("tx", &hex::encode(self.tx)).field("o", &self.o).finish() - } -} - -impl AbsoluteId { - pub fn write(&self, w: &mut W) -> io::Result<()> { - w.write_all(&self.tx)?; - w.write_all(&[self.o]) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(32 + 1); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(AbsoluteId { tx: read_bytes(r)?, o: read_byte(r)? }) - } -} - -/// The data contained with an output. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct OutputData { - pub key: EdwardsPoint, - /// Absolute difference between the spend key and the key in this output - pub key_offset: Scalar, - pub commitment: Commitment, -} - -impl core::fmt::Debug for OutputData { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt - .debug_struct("OutputData") - .field("key", &hex::encode(self.key.compress().0)) - .field("key_offset", &hex::encode(self.key_offset.to_bytes())) - .field("commitment", &self.commitment) - .finish() - } -} - -impl OutputData { - pub fn write(&self, w: &mut W) -> io::Result<()> { - w.write_all(&self.key.compress().to_bytes())?; - w.write_all(&self.key_offset.to_bytes())?; - w.write_all(&self.commitment.mask.to_bytes())?; - w.write_all(&self.commitment.amount.to_le_bytes()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(32 + 32 + 32 + 8); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(OutputData { - key: read_point(r)?, - key_offset: read_scalar(r)?, - commitment: Commitment::new(read_scalar(r)?, read_u64(r)?), - }) - } -} - -/// The metadata for an output. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct Metadata { - /// The subaddress this output was sent to. - pub subaddress: Option, - /// The payment ID included with this output. - /// There are 2 circumstances in which the reference wallet2 ignores the payment ID - /// but the payment ID will be returned here anyway: - /// - /// 1) If the payment ID is tied to an output received by a subaddress account - /// that spent Monero in the transaction (the received output is considered - /// "change" and is not considered a "payment" in this case). If there are multiple - /// spending subaddress accounts in a transaction, the highest index spent key image - /// is used to determine the spending subaddress account. - /// - /// 2) If the payment ID is the unencrypted variant and the block's hf version is - /// v12 or higher (https://github.com/serai-dex/serai/issues/512) - pub payment_id: Option, - /// Arbitrary data encoded in TX extra. - pub arbitrary_data: Vec>, -} - -impl core::fmt::Debug for Metadata { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt - .debug_struct("Metadata") - .field("subaddress", &self.subaddress) - .field("payment_id", &self.payment_id) - .field("arbitrary_data", &self.arbitrary_data.iter().map(hex::encode).collect::>()) - .finish() - } -} - -impl Metadata { - pub fn write(&self, w: &mut W) -> io::Result<()> { - if let Some(subaddress) = self.subaddress { - w.write_all(&[1])?; - w.write_all(&subaddress.account().to_le_bytes())?; - w.write_all(&subaddress.address().to_le_bytes())?; - } else { - w.write_all(&[0])?; - } - - if let Some(payment_id) = self.payment_id { - w.write_all(&[1])?; - payment_id.write(w)?; - } else { - w.write_all(&[0])?; - } - - w.write_all(&u32::try_from(self.arbitrary_data.len()).unwrap().to_le_bytes())?; - for part in &self.arbitrary_data { - w.write_all(&[u8::try_from(part.len()).unwrap()])?; - w.write_all(part)?; - } - Ok(()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(1 + 8 + 1); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - let subaddress = if read_byte(r)? == 1 { - Some( - SubaddressIndex::new(read_u32(r)?, read_u32(r)?) - .ok_or_else(|| io::Error::other("invalid subaddress in metadata"))?, - ) - } else { - None - }; - - Ok(Metadata { - subaddress, - payment_id: if read_byte(r)? == 1 { PaymentId::read(r).ok() } else { None }, - arbitrary_data: { - let mut data = vec![]; - for _ in 0 .. read_u32(r)? { - let len = read_byte(r)?; - data.push(read_raw_vec(read_byte, usize::from(len), r)?); - } - data - }, - }) - } -} - -/// A received output, defined as its absolute ID, data, and metadara. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct ReceivedOutput { - pub absolute: AbsoluteId, - pub data: OutputData, - pub metadata: Metadata, -} - -impl ReceivedOutput { - pub fn key(&self) -> EdwardsPoint { - self.data.key - } - - pub fn key_offset(&self) -> Scalar { - self.data.key_offset - } - - pub fn commitment(&self) -> Commitment { - self.data.commitment.clone() - } - - pub fn arbitrary_data(&self) -> &[Vec] { - &self.metadata.arbitrary_data - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.absolute.write(w)?; - self.data.write(w)?; - self.metadata.write(w) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(ReceivedOutput { - absolute: AbsoluteId::read(r)?, - data: OutputData::read(r)?, - metadata: Metadata::read(r)?, - }) - } -} - -/// A spendable output, defined as a received output and its index on the Monero blockchain. -/// This index is dependent on the Monero blockchain and will only be known once the output is -/// included within a block. This may change if there's a reorganization. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct SpendableOutput { - pub output: ReceivedOutput, - pub global_index: u64, -} - -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: &impl Rpc) -> Result<(), RpcError> { - self.global_index = *rpc - .get_o_indexes(self.output.absolute.tx) - .await? - .get(usize::from(self.output.absolute.o)) - .ok_or(RpcError::InvalidNode( - "node returned output indexes didn't include an index for this output".to_string(), - ))?; - Ok(()) - } - - pub async fn from(rpc: &impl Rpc, output: ReceivedOutput) -> Result { - let mut output = SpendableOutput { output, global_index: 0 }; - output.refresh_global_index(rpc).await?; - Ok(output) - } - - pub fn key(&self) -> EdwardsPoint { - self.output.key() - } - - pub fn key_offset(&self) -> Scalar { - self.output.key_offset() - } - - pub fn commitment(&self) -> Commitment { - self.output.commitment() - } - - pub fn arbitrary_data(&self) -> &[Vec] { - self.output.arbitrary_data() - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.output.write(w)?; - w.write_all(&self.global_index.to_le_bytes()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(SpendableOutput { output: ReceivedOutput::read(r)?, global_index: read_u64(r)? }) - } -} - -/// A collection of timelocked outputs, either received or spendable. -#[derive(Zeroize)] -pub struct Timelocked(Timelock, Vec); -impl Drop for Timelocked { - fn drop(&mut self) { - self.zeroize(); - } -} -impl ZeroizeOnDrop for Timelocked {} - -impl Timelocked { - pub fn timelock(&self) -> Timelock { - self.0 - } - - /// Return the outputs if they're not timelocked, or an empty vector if they are. - #[must_use] - pub fn not_locked(&self) -> Vec { - if self.0 == Timelock::None { - return self.1.clone(); - } - vec![] - } - - /// Returns None if the Timelocks aren't comparable. Returns Some(vec![]) if none are unlocked. - #[must_use] - pub fn unlocked(&self, timelock: Timelock) -> Option> { - // If the Timelocks are comparable, return the outputs if they're now unlocked - if self.0 <= timelock { - Some(self.1.clone()) - } else { - None - } - } - - #[must_use] - pub fn ignore_timelock(&self) -> Vec { - self.1.clone() - } -} - -impl Scanner { - /// Scan a transaction to discover the received outputs. - pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked { - // Only scan RCT TXs since we can only spend RCT outputs - if tx.version() != 2 { - return Timelocked(tx.prefix().timelock, vec![]); - } - - let Ok(extra) = Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) else { - return Timelocked(tx.prefix().timelock, vec![]); - }; - - let Some((tx_keys, additional)) = extra.keys() else { - return Timelocked(tx.prefix().timelock, vec![]); - }; - - let payment_id = extra.payment_id(); - - let mut res = vec![]; - for (o, output) in tx.prefix().outputs.iter().enumerate() { - // https://github.com/serai-dex/serai/issues/106 - if let Some(burning_bug) = self.burning_bug.as_ref() { - if burning_bug.contains(&output.key) { - continue; - } - } - - let output_key = decompress_point(output.key.to_bytes()); - if output_key.is_none() { - continue; - } - let output_key = output_key.unwrap(); - - let additional = additional.as_ref().map(|additional| additional.get(o)); - - for key in tx_keys.iter().map(|key| Some(Some(key))).chain(core::iter::once(additional)) { - let key = match key { - Some(Some(key)) => key, - Some(None) => { - // This is non-standard. There were additional keys, yet not one for this output - // https://github.com/monero-project/monero/ - // blob/04a1e2875d6e35e27bb21497988a6c822d319c28/ - // src/cryptonote_basic/cryptonote_format_utils.cpp#L1062 - continue; - } - None => { - break; - } - }; - let (view_tag, shared_key, payment_id_xor) = shared_key( - if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix().inputs)) } else { None }, - self.pair.view.deref() * key, - o, - ); - - let payment_id = payment_id.map(|id| id ^ payment_id_xor); - - if let Some(actual_view_tag) = output.view_tag { - if actual_view_tag != view_tag { - continue; - } - } - - // P - shared == spend - let subaddress = - self.subaddresses.get(&(output_key - (&shared_key * ED25519_BASEPOINT_TABLE)).compress()); - if subaddress.is_none() { - continue; - } - let subaddress = *subaddress.unwrap(); - - // If it has torsion, it'll subtract the non-torsioned shared key to a torsioned key - // We will not have a torsioned key in our HashMap of keys, so we wouldn't identify it as - // ours - // If we did though, it'd enable bypassing the included burning bug protection - assert!(output_key.is_torsion_free()); - - let mut key_offset = shared_key; - if let Some(subaddress) = subaddress { - key_offset += self.pair.subaddress_derivation(subaddress); - } - // Since we've found an output to us, get its amount - let mut commitment = Commitment::zero(); - - // Miner transaction - if let Some(amount) = output.amount { - commitment.amount = amount; - // Regular transaction - } else { - let proofs = match &tx { - Transaction::V2 { proofs: Some(proofs), .. } => &proofs, - _ => return Timelocked(tx.prefix().timelock, vec![]), - }; - - commitment = match proofs.base.encrypted_amounts.get(o) { - Some(amount) => amount.decrypt(shared_key), - // This should never happen, yet it may be possible with miner transactions? - // Using get just decreases the possibility of a panic and lets us move on in that case - None => break, - }; - - // If this is a malicious commitment, move to the next output - // Any other R value will calculate to a different spend key and are therefore ignorable - if Some(&commitment.calculate()) != proofs.base.commitments.get(o) { - break; - } - } - - if commitment.amount != 0 { - res.push(ReceivedOutput { - absolute: AbsoluteId { tx: tx.hash(), o: o.try_into().unwrap() }, - - data: OutputData { key: output_key, key_offset, commitment }, - - metadata: Metadata { subaddress, payment_id, arbitrary_data: extra.data() }, - }); - - if let Some(burning_bug) = self.burning_bug.as_mut() { - burning_bug.insert(output.key); - } - } - // Break to prevent public keys from being included multiple times, triggering multiple - // inclusions of the same output - break; - } - } - - Timelocked(tx.prefix().timelock, res) - } - - /// Scan a block to obtain its spendable outputs. Its the presence in a block giving these - /// transactions their global index, and this must be batched as asking for the index of specific - /// 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( - &mut self, - rpc: &impl Rpc, - block: &Block, - ) -> Result>, RpcError> { - let mut index = rpc.get_o_indexes(block.miner_tx.hash()).await?[0]; - let mut txs = vec![block.miner_tx.clone()]; - txs.extend(rpc.get_transactions(&block.txs).await?); - - let map = |mut timelock: Timelocked, index| { - if timelock.1.is_empty() { - None - } else { - Some(Timelocked( - timelock.0, - timelock - .1 - .drain(..) - .map(|output| SpendableOutput { - global_index: index + u64::from(output.absolute.o), - output, - }) - .collect(), - )) - } - }; - - let mut res = vec![]; - for tx in txs { - if let Some(timelock) = map(self.scan_transaction(&tx), index) { - res.push(timelock); - } - index += u64::try_from( - tx.prefix() - .outputs - .iter() - // Filter to v2 miner TX outputs/RCT outputs since we're tracking the RCT output index - .filter(|output| { - let is_v2_miner_tx = - (tx.version() == 2) && matches!(tx.prefix().inputs.first(), Some(Input::Gen(..))); - is_v2_miner_tx || output.amount.is_none() - }) - .count(), - ) - .unwrap() - } - Ok(res) - } -} diff --git a/coins/monero/wallet/src/send/tx.rs b/coins/monero/wallet/src/send/tx.rs new file mode 100644 index 00000000..8bc0cb59 --- /dev/null +++ b/coins/monero/wallet/src/send/tx.rs @@ -0,0 +1,258 @@ +use rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; + +use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_TABLE}, + Scalar, EdwardsPoint, +}; + +use crate::{ + io::varint_len, + primitives::Commitment, + ringct::{ + clsag::Clsag, bulletproofs::Bulletproof, EncryptedAmount, RctType, RctBase, RctPrunable, + RctProofs, + }, + transaction::{Input, Output, Timelock, TransactionPrefix, Transaction}, + extra::{ARBITRARY_DATA_MARKER, PaymentId, ExtraField, Extra}, + send::{InternalPayment, SignableTransaction, SignableTransactionWithKeyImages}, +}; + +impl SignableTransaction { + // Output the inputs for this transaction. + pub(crate) fn inputs(&self, key_images: &[EdwardsPoint]) -> Vec { + debug_assert_eq!(self.inputs.len(), key_images.len()); + + let mut res = Vec::with_capacity(self.inputs.len()); + for ((_, decoys), key_image) in self.inputs.iter().zip(key_images) { + res.push(Input::ToKey { + amount: None, + key_offsets: decoys.offsets().to_vec(), + key_image: *key_image, + }); + } + res + } + + // Output the outputs for this transaction. + pub(crate) fn outputs(&self, key_images: &[EdwardsPoint]) -> Vec { + let shared_key_derivations = self.shared_key_derivations(key_images); + debug_assert_eq!(self.payments.len(), shared_key_derivations.len()); + + let mut res = Vec::with_capacity(self.payments.len()); + for (payment, shared_key_derivations) in self.payments.iter().zip(&shared_key_derivations) { + let key = + (&shared_key_derivations.shared_key * ED25519_BASEPOINT_TABLE) + payment.address().spend; + res.push(Output { + key: key.compress(), + amount: None, + view_tag: (match self.rct_type { + RctType::ClsagBulletproof => false, + RctType::ClsagBulletproofPlus => true, + _ => panic!("unsupported RctType"), + }) + .then_some(shared_key_derivations.view_tag), + }); + } + res + } + + // Calculate the TX extra for this transaction. + pub(crate) fn extra(&self) -> Vec { + let (tx_key, additional_keys) = self.transaction_keys_pub(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + let payment_id_xors = self.payment_id_xors(); + debug_assert_eq!(self.payments.len(), payment_id_xors.len()); + + let amount_of_keys = 1 + additional_keys.len(); + let mut extra = Extra::new(tx_key, additional_keys); + + if let Some((id, id_xor)) = + self.payments.iter().zip(&payment_id_xors).find_map(|(payment, payment_id_xor)| { + payment.address().payment_id().map(|id| (id, payment_id_xor)) + }) + { + let id = (u64::from_le_bytes(id) ^ u64::from_le_bytes(*id_xor)).to_le_bytes(); + let mut id_vec = Vec::with_capacity(1 + 8); + PaymentId::Encrypted(id).write(&mut id_vec).unwrap(); + extra.push(ExtraField::Nonce(id_vec)); + } else { + // If there's no payment ID, we push a dummy (as wallet2 does) if there's only one payment + if (self.payments.len() == 2) && + self.payments.iter().any(|payment| matches!(payment, InternalPayment::Change(_, _))) + { + let (_, payment_id_xor) = self + .payments + .iter() + .zip(&payment_id_xors) + .find(|(payment, _)| matches!(payment, InternalPayment::Payment(_, _))) + .expect("multiple change outputs?"); + let mut id_vec = Vec::with_capacity(1 + 8); + // The dummy payment ID is [0; 8], which when xor'd with the mask, is just the mask + PaymentId::Encrypted(*payment_id_xor).write(&mut id_vec).unwrap(); + extra.push(ExtraField::Nonce(id_vec)); + } + } + + // Include data if present + for part in &self.data { + let mut arb = vec![ARBITRARY_DATA_MARKER]; + arb.extend(part); + extra.push(ExtraField::Nonce(arb)); + } + + let mut serialized = Vec::with_capacity(32 * amount_of_keys); + extra.write(&mut serialized).unwrap(); + serialized + } + + pub(crate) fn weight_and_fee(&self) -> (usize, u64) { + /* + This transaction is variable length to: + - The decoy offsets (fixed) + - The TX extra (variable to key images, requiring an interactive protocol) + + Thankfully, the TX extra *length* is fixed. Accordingly, we can calculate the inevitable TX's + weight at this time with a shimmed transaction. + */ + let base_weight = { + let mut key_images = Vec::with_capacity(self.inputs.len()); + let mut clsags = Vec::with_capacity(self.inputs.len()); + let mut pseudo_outs = Vec::with_capacity(self.inputs.len()); + for _ in &self.inputs { + key_images.push(ED25519_BASEPOINT_POINT); + clsags.push(Clsag { + D: ED25519_BASEPOINT_POINT, + s: vec![Scalar::ZERO; 16], + c1: Scalar::ZERO, + }); + pseudo_outs.push(ED25519_BASEPOINT_POINT); + } + let mut encrypted_amounts = Vec::with_capacity(self.payments.len()); + let mut bp_commitments = Vec::with_capacity(self.payments.len()); + let mut commitments = Vec::with_capacity(self.payments.len()); + for _ in &self.payments { + encrypted_amounts.push(EncryptedAmount::Compact { amount: [0; 8] }); + bp_commitments.push(Commitment::zero()); + commitments.push(ED25519_BASEPOINT_POINT); + } + // TODO: Remove this. Deserialize an empty BP? + let bulletproof = (match self.rct_type { + RctType::ClsagBulletproof => { + Bulletproof::prove(&mut ChaCha20Rng::from_seed([0; 32]), &bp_commitments) + } + RctType::ClsagBulletproofPlus => { + Bulletproof::prove_plus(&mut ChaCha20Rng::from_seed([0; 32]), bp_commitments) + } + _ => panic!("unsupported RctType"), + }) + .expect("couldn't prove BP(+)s for this many payments despite checking in constructor?"); + + // `- 1` to remove the one byte for the 0 fee + Transaction::V2 { + prefix: TransactionPrefix { + timelock: Timelock::None, + inputs: self.inputs(&key_images), + outputs: self.outputs(&key_images), + extra: self.extra(), + }, + proofs: Some(RctProofs { + base: RctBase { fee: 0, encrypted_amounts, pseudo_outs: vec![], commitments }, + prunable: RctPrunable::Clsag { bulletproof, clsags, pseudo_outs }, + }), + } + .weight() - + 1 + }; + + // If we don't have a change output, the difference is the fee + if !self.payments.iter().any(|payment| matches!(payment, InternalPayment::Change(_, _))) { + let inputs = self.inputs.iter().map(|input| input.0.commitment().amount).sum::(); + let payments = self + .payments + .iter() + .filter_map(|payment| match payment { + InternalPayment::Payment(_, amount) => Some(amount), + InternalPayment::Change(_, _) => None, + }) + .sum::(); + // Safe since the constructor checks inputs > payments before any calls to weight_and_fee + let fee = inputs - payments; + return (base_weight + varint_len(fee), fee); + } + + // We now have the base weight, without the fee encoded + // The fee itself will impact the weight as its encoding is [1, 9] bytes long + let mut possible_weights = Vec::with_capacity(9); + for i in 1 ..= 9 { + possible_weights.push(base_weight + i); + } + debug_assert_eq!(possible_weights.len(), 9); + + // We now calculate the fee which would be used for each weight + let mut possible_fees = Vec::with_capacity(9); + for weight in possible_weights { + possible_fees.push(self.fee_rate.calculate_fee_from_weight(weight)); + } + + // We now look for the fee whose length matches the length used to derive it + let mut weight_and_fee = None; + for (len, possible_fee) in possible_fees.into_iter().enumerate() { + let len = 1 + len; + debug_assert!(1 <= len); + debug_assert!(len <= 9); + + // We use the first fee whose encoded length is not larger than the length used within this + // weight + // This should be because the lengths are equal, yet means if somehow none are equal, this + // will still terminate successfully + if varint_len(possible_fee) <= len { + weight_and_fee = Some((base_weight + len, possible_fee)); + break; + } + } + weight_and_fee.unwrap() + } +} + +impl SignableTransactionWithKeyImages { + pub(crate) fn transaction_without_clsags_and_pseudo_outs(&self) -> Transaction { + let commitments_and_encrypted_amounts = + self.intent.commitments_and_encrypted_amounts(&self.key_images); + let mut commitments = Vec::with_capacity(self.intent.payments.len()); + let mut bp_commitments = Vec::with_capacity(self.intent.payments.len()); + let mut encrypted_amounts = Vec::with_capacity(self.intent.payments.len()); + for (commitment, encrypted_amount) in commitments_and_encrypted_amounts { + commitments.push(commitment.calculate()); + bp_commitments.push(commitment); + encrypted_amounts.push(encrypted_amount); + } + let bulletproof = { + let mut bp_rng = self.intent.seeded_rng(b"bulletproof"); + (match self.intent.rct_type { + RctType::ClsagBulletproof => Bulletproof::prove(&mut bp_rng, &bp_commitments), + RctType::ClsagBulletproofPlus => Bulletproof::prove_plus(&mut bp_rng, bp_commitments), + _ => panic!("unsupported RctType"), + }) + .expect("couldn't prove BP(+)s for this many payments despite checking in constructor?") + }; + + Transaction::V2 { + prefix: TransactionPrefix { + timelock: Timelock::None, + inputs: self.intent.inputs(&self.key_images), + outputs: self.intent.outputs(&self.key_images), + extra: self.intent.extra(), + }, + proofs: Some(RctProofs { + base: RctBase { + fee: self.intent.weight_and_fee().1, + encrypted_amounts, + pseudo_outs: vec![], + commitments, + }, + prunable: RctPrunable::Clsag { bulletproof, clsags: vec![], pseudo_outs: vec![] }, + }), + } + } +} diff --git a/coins/monero/wallet/src/send/tx_keys.rs b/coins/monero/wallet/src/send/tx_keys.rs new file mode 100644 index 00000000..4409b50d --- /dev/null +++ b/coins/monero/wallet/src/send/tx_keys.rs @@ -0,0 +1,231 @@ +use core::ops::Deref; + +use zeroize::Zeroizing; + +use rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; + +use crate::{ + primitives::{keccak256, Commitment}, + ringct::EncryptedAmount, + SharedKeyDerivations, compact_amount_encryption, + send::{InternalPayment, SignableTransaction}, +}; + +fn seeded_rng( + dst: &'static [u8], + view_key: &Zeroizing, + output_keys: impl Iterator, +) -> ChaCha20Rng { + // Apply the DST + let mut transcript = Zeroizing::new(vec![u8::try_from(dst.len()).unwrap()]); + transcript.extend(dst); + // Bind to the private view key to prevent foreign entities from rebuilding the transcript + transcript.extend(view_key.to_bytes()); + // Ensure uniqueness across transactions by binding to a use-once object + // The output key is also binding to the output's key image, making this use-once + for key in output_keys { + transcript.extend(key.compress().to_bytes()); + } + ChaCha20Rng::from_seed(keccak256(&transcript)) +} + +impl SignableTransaction { + pub(crate) fn seeded_rng(&self, dst: &'static [u8]) -> ChaCha20Rng { + seeded_rng(dst, &self.sender_view_key, self.inputs.iter().map(|(input, _)| input.output.key())) + } + + fn has_payments_to_subaddresses(&self) -> bool { + self.payments.iter().any(|payment| match payment { + InternalPayment::Payment(addr, _) => addr.is_subaddress(), + InternalPayment::Change(addr, view) => { + if view.is_some() { + // It should not be possible to construct a change specification to a subaddress with a + // view key + // TODO + debug_assert!(!addr.is_subaddress()); + } + addr.is_subaddress() + } + }) + } + + fn should_use_additional_keys(&self) -> bool { + let has_payments_to_subaddresses = self.has_payments_to_subaddresses(); + if !has_payments_to_subaddresses { + return false; + } + + let has_change_view = self.payments.iter().any(|payment| match payment { + InternalPayment::Payment(_, _) => false, + InternalPayment::Change(_, view) => view.is_some(), + }); + + /* + If sending to a subaddress, the shared key is not `rG` yet `rB`. Because of this, a + per-subaddress shared key is necessary, causing the usage of additional keys. + + The one exception is if we're sending to a subaddress in a 2-output transaction. The second + output, the change output, will attempt scanning the singular key `rB` with `v rB`. While we + cannot calculate `r vB` with just `r` (as that'd require `vB` when we presumably only have + `vG` when sending), since we do in fact have `v` (due to it being our own view key for our + change output), we can still calculate the shared secret. + */ + has_payments_to_subaddresses && !((self.payments.len() == 2) && has_change_view) + } + + // Calculate the transaction keys used as randomness. + fn transaction_keys(&self) -> (Zeroizing, Vec>) { + let mut rng = self.seeded_rng(b"transaction_keys"); + + let tx_key = Zeroizing::new(Scalar::random(&mut rng)); + + let mut additional_keys = vec![]; + if self.should_use_additional_keys() { + for _ in 0 .. self.payments.len() { + additional_keys.push(Zeroizing::new(Scalar::random(&mut rng))); + } + } + (tx_key, additional_keys) + } + + fn ecdhs(&self) -> Vec> { + let (tx_key, additional_keys) = self.transaction_keys(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + let (tx_key_pub, additional_keys_pub) = self.transaction_keys_pub(); + debug_assert_eq!(additional_keys_pub.len(), additional_keys.len()); + + let mut res = Vec::with_capacity(self.payments.len()); + for (i, payment) in self.payments.iter().enumerate() { + let addr = payment.address(); + let key_to_use = + if addr.is_subaddress() { additional_keys.get(i).unwrap_or(&tx_key) } else { &tx_key }; + + let ecdh = match payment { + // If we don't have the view key, use the key dedicated for this address (r A) + InternalPayment::Payment(_, _) | InternalPayment::Change(_, None) => { + Zeroizing::new(key_to_use.deref() * addr.view) + } + // If we do have the view key, use the commitment to the key (a R) + InternalPayment::Change(_, Some(view)) => Zeroizing::new(view.deref() * tx_key_pub), + }; + + res.push(ecdh); + } + res + } + + // Calculate the shared keys and the necessary derivations. + pub(crate) fn shared_key_derivations( + &self, + key_images: &[EdwardsPoint], + ) -> Vec> { + let ecdhs = self.ecdhs(); + + let uniqueness = SharedKeyDerivations::uniqueness(&self.inputs(key_images)); + + let mut res = Vec::with_capacity(self.payments.len()); + for (i, (payment, ecdh)) in self.payments.iter().zip(ecdhs).enumerate() { + let addr = payment.address(); + res.push(SharedKeyDerivations::output_derivations( + addr.is_guaranteed().then_some(uniqueness), + ecdh, + i, + )); + } + res + } + + // Calculate the payment ID XOR masks. + pub(crate) fn payment_id_xors(&self) -> Vec<[u8; 8]> { + let mut res = Vec::with_capacity(self.payments.len()); + for ecdh in self.ecdhs() { + res.push(SharedKeyDerivations::payment_id_xor(ecdh)); + } + res + } + + // Calculate the transaction_keys' commitments. + // + // These depend on the payments. Commitments for payments to subaddresses use the spend key for + // the generator. + pub(crate) fn transaction_keys_pub(&self) -> (EdwardsPoint, Vec) { + let (tx_key, additional_keys) = self.transaction_keys(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + + // The single transaction key uses the subaddress's spend key as its generator + let has_payments_to_subaddresses = self.has_payments_to_subaddresses(); + let should_use_additional_keys = self.should_use_additional_keys(); + if has_payments_to_subaddresses && (!should_use_additional_keys) { + debug_assert_eq!(additional_keys.len(), 0); + + let InternalPayment::Payment(addr, _) = self + .payments + .iter() + .find(|payment| matches!(payment, InternalPayment::Payment(_, _))) + .expect("payment to subaddress yet no payment") + else { + panic!("filtered payment wasn't a payment") + }; + + // TODO: Support subaddresses as change? + debug_assert!(addr.is_subaddress()); + + return (tx_key.deref() * addr.spend, vec![]); + } + + if should_use_additional_keys { + let mut additional_keys_pub = vec![]; + for (additional_key, payment) in additional_keys.into_iter().zip(&self.payments) { + let addr = payment.address(); + // TODO: Double check this against wallet2 + if addr.is_subaddress() { + additional_keys_pub.push(additional_key.deref() * addr.spend); + } else { + additional_keys_pub.push(additional_key.deref() * ED25519_BASEPOINT_TABLE) + } + } + return (tx_key.deref() * ED25519_BASEPOINT_TABLE, additional_keys_pub); + } + + debug_assert!(!has_payments_to_subaddresses); + debug_assert!(!should_use_additional_keys); + (tx_key.deref() * ED25519_BASEPOINT_TABLE, vec![]) + } + + pub(crate) fn commitments_and_encrypted_amounts( + &self, + key_images: &[EdwardsPoint], + ) -> Vec<(Commitment, EncryptedAmount)> { + let shared_key_derivations = self.shared_key_derivations(key_images); + + let mut res = Vec::with_capacity(self.payments.len()); + for (payment, shared_key_derivations) in self.payments.iter().zip(shared_key_derivations) { + let amount = match payment { + InternalPayment::Payment(_, amount) => *amount, + InternalPayment::Change(_, _) => { + let inputs = self.inputs.iter().map(|input| input.0.commitment().amount).sum::(); + let payments = self + .payments + .iter() + .filter_map(|payment| match payment { + InternalPayment::Payment(_, amount) => Some(amount), + InternalPayment::Change(_, _) => None, + }) + .sum::(); + let fee = self.weight_and_fee().1; + // Safe since the constructor checked this + inputs - (payments + fee) + } + }; + let commitment = Commitment::new(shared_key_derivations.commitment_mask(), amount); + let encrypted_amount = EncryptedAmount::Compact { + amount: compact_amount_encryption(amount, shared_key_derivations.shared_key), + }; + res.push((commitment, encrypted_amount)); + } + res + } +}