diff --git a/consensus/rules/src/signatures.rs b/consensus/rules/src/signatures.rs index 075d00b..ed54e55 100644 --- a/consensus/rules/src/signatures.rs +++ b/consensus/rules/src/signatures.rs @@ -1,6 +1,7 @@ -use curve25519_dalek::EdwardsPoint; use monero_serai::transaction::Transaction; +use crate::transactions::Rings; + mod ring_signatures; #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -11,15 +12,6 @@ pub enum SignatureError { IncorrectSignature, } -/// Represents the ring members of all the inputs. -#[derive(Debug)] -pub enum Rings { - /// Legacy, pre-ringCT, rings. - Legacy(Vec>), - // RingCT rings, (outkey, amount commitment). - RingCT(Vec>), -} - pub fn verify_contextual_signatures(tx: &Transaction, rings: &Rings) -> Result<(), SignatureError> { match rings { Rings::Legacy(_) => ring_signatures::verify_inputs_signatures( diff --git a/consensus/rules/src/transactions.rs b/consensus/rules/src/transactions.rs index 56d8ed3..d8ab32d 100644 --- a/consensus/rules/src/transactions.rs +++ b/consensus/rules/src/transactions.rs @@ -4,6 +4,9 @@ use monero_serai::transaction::{Input, Output, Timelock}; use crate::{check_point, is_decomposed_amount, HardFork, TxVersion}; +mod contextual_data; +pub use contextual_data::*; + #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum TransactionError { //-------------------------------------------------------- OUTPUTS @@ -38,6 +41,8 @@ pub enum TransactionError { InputsOverflow, #[error("The transaction has no inputs.")] NoInputs, + #[error("Ring member not in database")] + RingMemberNotFound, } //----------------------------------------------------------------------------------------------------------- OUTPUTS @@ -136,44 +141,75 @@ pub fn check_outputs( } } -//----------------------------------------------------------------------------------------------------------- INPUTS +//----------------------------------------------------------------------------------------------------------- TIME LOCKS -/// A struct holding information about the inputs and their decoys. This data can vary by block so -/// this data needs to be retrieved after every change in the blockchain. +/// Checks all the time locks are unlocked. /// -/// This data *does not* need to be refreshed if one of these are true: -/// - The input amounts are *ALL* 0 (RCT) -/// - The top block hash is the same as when this data was retrieved (the blockchain state is unchanged). +/// `current_time_lock_timestamp` must be: https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#getting-the-current-time /// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html -#[derive(Debug)] -pub struct DecoyInfo { - /// The number of inputs that have enough outputs on the chain to mix with. - pub mixable: usize, - /// The number of inputs that don't have enough outputs on the chain to mix with. - pub not_mixable: usize, - /// The minimum amount of decoys used in the transaction. - pub min_decoys: usize, - /// The maximum amount of decoys used in the transaction. - pub max_decoys: usize, +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time +pub fn check_all_time_locks( + time_locks: &[Timelock], + current_chain_height: u64, + current_time_lock_timestamp: u64, + hf: &HardFork, +) -> Result<(), TransactionError> { + time_locks.iter().try_for_each(|time_lock| { + if !output_unlocked( + time_lock, + current_chain_height, + current_time_lock_timestamp, + hf, + ) { + Err(TransactionError::OneOrMoreDecoysLocked) + } else { + Ok(()) + } + }) } -/// Returns the default minimum amount of decoys for a hard-fork. -/// **There are exceptions to this always being the minimum decoys** +/// Checks if an outputs unlock time has passed. /// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#minimum-amount-of-decoys -fn minimum_decoys(hf: &HardFork) -> usize { - use HardFork as HF; - match hf { - HF::V1 => panic!("hard-fork 1 does not use these rules!"), - HF::V2 | HF::V3 | HF::V4 | HF::V5 => 2, - HF::V6 => 4, - HF::V7 => 6, - HF::V8 | HF::V9 | HF::V10 | HF::V11 | HF::V12 | HF::V13 | HF::V14 => 10, - HF::V15 | HF::V16 => 15, +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time +fn output_unlocked( + time_lock: &Timelock, + current_chain_height: u64, + current_time_lock_timestamp: u64, + hf: &HardFork, +) -> bool { + match *time_lock { + Timelock::None => true, + Timelock::Block(unlock_height) => { + check_block_time_lock(unlock_height.try_into().unwrap(), current_chain_height) + } + Timelock::Time(unlock_time) => { + check_timestamp_time_lock(unlock_time, current_time_lock_timestamp, hf) + } } } +/// Returns if a locked output, which uses a block height, can be spend. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#block-height +fn check_block_time_lock(unlock_height: u64, current_chain_height: u64) -> bool { + // current_chain_height = 1 + top height + unlock_height <= current_chain_height +} + +/// /// +/// Returns if a locked output, which uses a block height, can be spend. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#timestamp +fn check_timestamp_time_lock( + unlock_timestamp: u64, + current_time_lock_timestamp: u64, + hf: &HardFork, +) -> bool { + current_time_lock_timestamp + hf.block_time().as_secs() >= unlock_timestamp +} + +//----------------------------------------------------------------------------------------------------------- INPUTS + /// Checks the decoys are allowed. /// /// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#minimum-decoys @@ -348,9 +384,8 @@ fn sum_inputs_v1(inputs: &[Input]) -> Result { /// pub fn check_inputs( inputs: &[Input], - youngest_used_out_height: u64, + tx_ring_members_info: &TxRingMembersInfo, current_chain_height: u64, - decoys_info: Option<&DecoyInfo>, hf: &HardFork, tx_version: &TxVersion, spent_kis: Arc>>, @@ -359,9 +394,13 @@ pub fn check_inputs( return Err(TransactionError::NoInputs); } - check_10_block_lock(youngest_used_out_height, current_chain_height, hf)?; + check_10_block_lock( + tx_ring_members_info.youngest_used_out_height, + current_chain_height, + hf, + )?; - if let Some(decoys_info) = decoys_info { + if let Some(decoys_info) = &tx_ring_members_info.decoy_info { check_decoy_info(decoys_info, hf)?; } else { assert_eq!(hf, &HardFork::V1); @@ -387,70 +426,3 @@ pub fn check_inputs( _ => panic!("TODO: RCT"), } } - -//----------------------------------------------------------------------------------------------------------- TIME LOCKS - -/// Checks all the time locks are unlocked. -/// -/// `current_time_lock_timestamp` must be: https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#getting-the-current-time -/// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time -pub fn check_all_time_locks( - time_locks: &[Timelock], - current_chain_height: u64, - current_time_lock_timestamp: u64, - hf: &HardFork, -) -> Result<(), TransactionError> { - time_locks.iter().try_for_each(|time_lock| { - if !output_unlocked( - time_lock, - current_chain_height, - current_time_lock_timestamp, - hf, - ) { - Err(TransactionError::OneOrMoreDecoysLocked) - } else { - Ok(()) - } - }) -} - -/// Checks if an outputs unlock time has passed. -/// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time -fn output_unlocked( - time_lock: &Timelock, - current_chain_height: u64, - current_time_lock_timestamp: u64, - hf: &HardFork, -) -> bool { - match *time_lock { - Timelock::None => true, - Timelock::Block(unlock_height) => { - check_block_time_lock(unlock_height.try_into().unwrap(), current_chain_height) - } - Timelock::Time(unlock_time) => { - check_timestamp_time_lock(unlock_time, current_time_lock_timestamp, hf) - } - } -} - -/// Returns if a locked output, which uses a block height, can be spend. -/// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#block-height -fn check_block_time_lock(unlock_height: u64, current_chain_height: u64) -> bool { - // current_chain_height = 1 + top height - unlock_height <= current_chain_height -} - -/// /// -/// Returns if a locked output, which uses a block height, can be spend. -/// -/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#timestamp -fn check_timestamp_time_lock( - unlock_timestamp: u64, - current_time_lock_timestamp: u64, - hf: &HardFork, -) -> bool { - current_time_lock_timestamp + hf.block_time().as_secs() >= unlock_timestamp -} diff --git a/consensus/rules/src/transactions/contextual_data.rs b/consensus/rules/src/transactions/contextual_data.rs new file mode 100644 index 0000000..07cf5a2 --- /dev/null +++ b/consensus/rules/src/transactions/contextual_data.rs @@ -0,0 +1,280 @@ +use std::{ + cmp::{max, min}, + collections::{HashMap, HashSet}, +}; + +use curve25519_dalek::EdwardsPoint; +use monero_serai::transaction::{Input, Timelock}; + +use crate::{transactions::TransactionError, HardFork, TxVersion}; + +/// An already approved previous transaction output. +#[derive(Debug)] +pub struct OutputOnChain { + height: u64, + time_lock: Timelock, + key: EdwardsPoint, + mask: EdwardsPoint, +} + +/// Gets the absolute offsets from the relative offsets. +/// +/// This function will return an error if the relative offsets are empty. +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#inputs-must-have-decoys +fn get_absolute_offsets(relative_offsets: &[u64]) -> Result, TransactionError> { + if relative_offsets.is_empty() { + return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys); + } + + let mut offsets = Vec::with_capacity(relative_offsets.len()); + offsets.push(relative_offsets[0]); + + for i in 1..relative_offsets.len() { + offsets.push(offsets[i - 1] + relative_offsets[i]); + } + Ok(offsets) +} + +/// Inserts the output IDs that are needed to verify the transaction inputs into the provided HashMap. +/// +/// This will error if the inputs are empty +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#no-empty-inputs +/// +pub fn insert_ring_member_ids( + inputs: &[Input], + output_ids: &mut HashMap>, +) -> Result<(), TransactionError> { + if inputs.is_empty() { + return Err(TransactionError::NoInputs); + } + + for input in inputs { + match input { + Input::ToKey { + amount, + key_offsets, + .. + } => output_ids + .entry(amount.unwrap_or(0)) + .or_default() + .extend(get_absolute_offsets(key_offsets)?), + _ => return Err(TransactionError::IncorrectInputType), + } + } + Ok(()) +} + +/// Get the ring members for the inputs from the outputs on the chain. +/// +/// Will error if `outputs` does not contain the outputs needed. +fn get_ring_members_for_inputs<'a>( + outputs: &'a HashMap>, + inputs: &[Input], +) -> Result>, TransactionError> { + inputs + .iter() + .map(|inp| match inp { + Input::ToKey { + amount, + key_offsets, + .. + } => { + let offsets = get_absolute_offsets(key_offsets)?; + Ok(offsets + .iter() + .map(|offset| { + // get the hashmap for this amount. + outputs + .get(&amount.unwrap_or(0)) + // get output at the index from the amount hashmap. + .and_then(|amount_map| amount_map.get(offset)) + .ok_or(TransactionError::RingMemberNotFound) + }) + .collect::>()?) + } + _ => Err(TransactionError::IncorrectInputType), + }) + .collect::>() +} + +/// Represents the ring members of all the inputs. +#[derive(Debug)] +pub enum Rings { + /// Legacy, pre-ringCT, rings. + Legacy(Vec>), + /// RingCT rings, (outkey, amount commitment). + RingCT(Vec>), +} + +impl Rings { + /// Builds the rings for the transaction inputs, from the given outputs. + fn new(outputs: Vec>, tx_version: TxVersion) -> Rings { + match tx_version { + TxVersion::RingSignatures => Rings::Legacy( + outputs + .into_iter() + .map(|inp_outs| inp_outs.into_iter().map(|out| out.key).collect()) + .collect(), + ), + TxVersion::RingCT => Rings::RingCT( + outputs + .into_iter() + .map(|inp_outs| { + inp_outs + .into_iter() + .map(|out| [out.key, out.mask]) + .collect() + }) + .collect(), + ), + } + } +} + +/// Information on the outputs the transaction is is referencing for inputs (ring members). +#[derive(Debug)] +pub struct TxRingMembersInfo { + pub rings: Rings, + /// Information on the structure of the decoys, will be [`None`] for txs before [`HardFork::V1`] + pub decoy_info: Option, + pub youngest_used_out_height: u64, + pub time_locked_outs: Vec, +} + +impl TxRingMembersInfo { + /// Construct a [`TxRingMembersInfo`] struct. + /// + /// The used outs must be all the ring members used in the transactions inputs. + fn new( + used_outs: Vec>, + decoy_info: Option, + tx_version: TxVersion, + ) -> TxRingMembersInfo { + TxRingMembersInfo { + youngest_used_out_height: used_outs + .iter() + .map(|inp_outs| { + inp_outs + .iter() + // the output with the highest height is the youngest + .map(|out| out.height) + .max() + .expect("Input must have ring members") + }) + .max() + .expect("Tx must have inputs"), + time_locked_outs: used_outs + .iter() + .flat_map(|inp_outs| { + inp_outs + .iter() + .filter_map(|out| match out.time_lock { + Timelock::None => None, + lock => Some(lock), + }) + .collect::>() + }) + .collect(), + rings: Rings::new(used_outs, tx_version), + decoy_info, + } + } +} + +/// A struct holding information about the inputs and their decoys. This data can vary by block so +/// this data needs to be retrieved after every change in the blockchain. +/// +/// This data *does not* need to be refreshed if one of these are true: +/// - The input amounts are *ALL* 0 (RCT) +/// - The top block hash is the same as when this data was retrieved (the blockchain state is unchanged). +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html +#[derive(Debug)] +pub struct DecoyInfo { + /// The number of inputs that have enough outputs on the chain to mix with. + pub mixable: usize, + /// The number of inputs that don't have enough outputs on the chain to mix with. + pub not_mixable: usize, + /// The minimum amount of decoys used in the transaction. + pub min_decoys: usize, + /// The maximum amount of decoys used in the transaction. + pub max_decoys: usize, +} + +impl DecoyInfo { + /// Creates a new [`DecoyInfo`] struct relating to the passed in inputs, This is only needed from + /// hf 2 onwards. + /// + /// `outputs_with_amount` is a list of the amount of outputs currently on the chain with the same amount + /// as the `inputs` amount at the same index. For RCT inputs it instead should be [`None`]. + /// + /// So: + /// + /// amount_outs_on_chain(inputs[X]) == outputs_with_amount[X] + /// + /// Do not rely on this function to do consensus checks! + /// + pub fn new( + inputs: &[Input], + outputs_with_amount: &[Option], + hf: &HardFork, + ) -> Result { + let mut min_decoys = usize::MAX; + let mut max_decoys = usize::MIN; + let mut mixable = 0; + let mut not_mixable = 0; + + let minimum_decoys = minimum_decoys(hf); + + for (inp, outs_with_amt) in inputs.iter().zip(outputs_with_amount) { + match inp { + Input::ToKey { key_offsets, .. } => { + if let Some(outs_with_amt) = *outs_with_amt { + // https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#mixable-and-unmixable-inputs + if outs_with_amt <= minimum_decoys { + not_mixable += 1; + } else { + mixable += 1; + } + } else { + // ringCT amounts are always mixable. + mixable += 1; + } + + let numb_decoys = key_offsets + .len() + .checked_sub(1) + .ok_or(TransactionError::InputDoesNotHaveExpectedNumbDecoys)?; + + // https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#minimum-and-maximum-decoys-used + min_decoys = min(min_decoys, numb_decoys); + max_decoys = max(max_decoys, numb_decoys); + } + _ => return Err(TransactionError::IncorrectInputType), + } + } + + Ok(DecoyInfo { + mixable, + not_mixable, + min_decoys, + max_decoys, + }) + } +} + +/// Returns the default minimum amount of decoys for a hard-fork. +/// **There are exceptions to this always being the minimum decoys** +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#minimum-amount-of-decoys +pub fn minimum_decoys(hf: &HardFork) -> usize { + use HardFork as HF; + match hf { + HF::V1 => panic!("hard-fork 1 does not use these rules!"), + HF::V2 | HF::V3 | HF::V4 | HF::V5 => 2, + HF::V6 => 4, + HF::V7 => 6, + HF::V8 | HF::V9 | HF::V10 | HF::V11 | HF::V12 | HF::V13 | HF::V14 => 10, + HF::V15 | HF::V16 => 15, + } +}