From 3eea0b73bd9d5985945551084feb7d2d0b22cc91 Mon Sep 17 00:00:00 2001 From: Boog900 <54e72d8a-345f-4599-bd90-c6b9bc7d0ec5@aleeas.com> Date: Thu, 14 Dec 2023 15:39:16 +0000 Subject: [PATCH] Split consensus rules into separate crate. --- Cargo.toml | 14 +- consensus/Cargo.toml | 2 +- consensus/README.md | 10 + consensus/rules/Cargo.toml | 24 + consensus/rules/src/decomposed_amount.rs | 63 +++ consensus/rules/src/hard_forks.rs | 270 +++++++++++ consensus/rules/src/lib.rs | 53 ++ consensus/rules/src/miner_tx.rs | 189 ++++++++ consensus/rules/src/signatures.rs | 33 ++ .../rules/src/signatures/ring_signatures.rs | 51 ++ consensus/rules/src/time_locks.rs | 0 consensus/rules/src/transactions.rs | 456 ++++++++++++++++++ 12 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 consensus/README.md create mode 100644 consensus/rules/Cargo.toml create mode 100644 consensus/rules/src/decomposed_amount.rs create mode 100644 consensus/rules/src/hard_forks.rs create mode 100644 consensus/rules/src/lib.rs create mode 100644 consensus/rules/src/miner_tx.rs create mode 100644 consensus/rules/src/signatures.rs create mode 100644 consensus/rules/src/signatures/ring_signatures.rs create mode 100644 consensus/rules/src/time_locks.rs create mode 100644 consensus/rules/src/transactions.rs diff --git a/Cargo.toml b/Cargo.toml index 4f4e99a..52db035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "common", "consensus", + "consensus/rules", "cryptonight", # "cuprate", # "database", @@ -39,14 +40,14 @@ bytes = { version = "1.5.0" } clap = { version = "4.4.7" } chrono = { version = "0.4.31" } crypto-bigint = { version = "0.5.3" } -curve25519-dalek = { version = "4.11" } -dalek-ff-group = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" } +curve25519-dalek = { version = "4.1.1" } +dalek-ff-group = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" } dirs = { version = "5.0.1" } futures = { version = "0.3.29" } hex = { version = "0.4.3" } monero-epee-bin-serde = { git = "https://github.com/monero-rs/monero-epee-bin-serde.git", rev = "e4a585a" } -monero-serai = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" } -multiexp = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" } +monero-serai = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" } +multiexp = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" } randomx-rs = { version = "1.2.1" } rand = { version = "0.8.5" } rayon = { version = "1.8.0" } @@ -60,6 +61,11 @@ tower = { version = "0.4.13", features = ["util", "steer"] } tracing-subscriber = { version = "0.3.17" } tracing = { version = "0.1.40" } +## workspace.dev-dependencies +proptest = { version = "1" } +proptest-derive = { version = "0.4.0" } + + ## TODO: ## Potential dependencies. # arc-swap = { version = "1.6.0" } # Atomically swappable Arc | https://github.com/vorner/arc-swap diff --git a/consensus/Cargo.toml b/consensus/Cargo.toml index 13d3043..c02057b 100644 --- a/consensus/Cargo.toml +++ b/consensus/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "monero-consensus" +name = "cuprate-consensus" version = "0.1.0" edition = "2021" description = "A crate implimenting all Moneros consensus rules." diff --git a/consensus/README.md b/consensus/README.md new file mode 100644 index 0000000..55cb225 --- /dev/null +++ b/consensus/README.md @@ -0,0 +1,10 @@ +# Consensus Rules + +This folder contains 2 crates: `monero-consensus` (rules) and `cuprate-consensus`. `monero-consensus` contains the raw-rules +and isb built to be a more flexible library which requires the user to give the correct data and do minimal calculations, `cuprate-consensus` +on the other hand contains multiple tower::Services that handle tx/ block verification as a whole with a `context` service that +keeps track of blockchain state. `cuprate-consensus` uses `monero-consensus` internally. + +If you are looking to use monero consensus rules it's recommended you try to integrate `cuprate-consensus` and fall back to +`monero-consensus` if you need more flexibility. + diff --git a/consensus/rules/Cargo.toml b/consensus/rules/Cargo.toml new file mode 100644 index 0000000..1068177 --- /dev/null +++ b/consensus/rules/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "monero-consensus" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +proptest = ["dep:proptest", "dep:proptest-derive"] +rayon = ["dep:rayon"] + +[dependencies] +monero-serai = { workspace = true } +curve25519-dalek = { workspace = true } + +tracing = { workspace = true } + +thiserror = { workspace = true } + +rayon = { workspace = true, optional = true } + +# Testing +proptest = {workspace = true, optional = true} +proptest-derive = {workspace = true, optional = true} + diff --git a/consensus/rules/src/decomposed_amount.rs b/consensus/rules/src/decomposed_amount.rs new file mode 100644 index 0000000..860a594 --- /dev/null +++ b/consensus/rules/src/decomposed_amount.rs @@ -0,0 +1,63 @@ +use std::sync::OnceLock; + +/// Decomposed amount table. +/// +static DECOMPOSED_AMOUNTS: OnceLock<[u64; 172]> = OnceLock::new(); + +#[rustfmt::skip] +pub fn decomposed_amounts() -> &'static [u64; 172] { + DECOMPOSED_AMOUNTS.get_or_init(|| { + [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 20, 30, 40, 50, 60, 70, 80, 90, + 100, 200, 300, 400, 500, 600, 700, 800, 900, + 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, + 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, + 100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000, + 1000000, 2000000, 3000000, 4000000, 5000000, 6000000, 7000000, 8000000, 9000000, + 10000000, 20000000, 30000000, 40000000, 50000000, 60000000, 70000000, 80000000, 90000000, + 100000000, 200000000, 300000000, 400000000, 500000000, 600000000, 700000000, 800000000, 900000000, + 1000000000, 2000000000, 3000000000, 4000000000, 5000000000, 6000000000, 7000000000, 8000000000, 9000000000, + 10000000000, 20000000000, 30000000000, 40000000000, 50000000000, 60000000000, 70000000000, 80000000000, 90000000000, + 100000000000, 200000000000, 300000000000, 400000000000, 500000000000, 600000000000, 700000000000, 800000000000, 900000000000, + 1000000000000, 2000000000000, 3000000000000, 4000000000000, 5000000000000, 6000000000000, 7000000000000, 8000000000000, 9000000000000, + 10000000000000, 20000000000000, 30000000000000, 40000000000000, 50000000000000, 60000000000000, 70000000000000, 80000000000000, 90000000000000, + 100000000000000, 200000000000000, 300000000000000, 400000000000000, 500000000000000, 600000000000000, 700000000000000, 800000000000000, 900000000000000, + 1000000000000000, 2000000000000000, 3000000000000000, 4000000000000000, 5000000000000000, 6000000000000000, 7000000000000000, 8000000000000000, 9000000000000000, + 10000000000000000, 20000000000000000, 30000000000000000, 40000000000000000, 50000000000000000, 60000000000000000, 70000000000000000, 80000000000000000, 90000000000000000, + 100000000000000000, 200000000000000000, 300000000000000000, 400000000000000000, 500000000000000000, 600000000000000000, 700000000000000000, 800000000000000000, 900000000000000000, + 1000000000000000000, 2000000000000000000, 3000000000000000000, 4000000000000000000, 5000000000000000000, 6000000000000000000, 7000000000000000000, 8000000000000000000, 9000000000000000000, + 10000000000000000000] + + }) +} + +/// Checks that an output amount is decomposed. +/// +/// This is also used during miner tx verification. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-amounts +#[inline] +pub fn is_decomposed_amount(amount: &u64) -> bool { + decomposed_amounts().binary_search(amount).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decomposed_amounts_return_decomposed() { + for amount in decomposed_amounts() { + assert!(is_decomposed_amount(amount)) + } + } + + #[test] + fn decomposed_amounts_return_not_decomposed() { + assert!(!is_decomposed_amount(&21)); + assert!(!is_decomposed_amount(&345431)); + assert!(!is_decomposed_amount(&20000001)); + } +} diff --git a/consensus/rules/src/hard_forks.rs b/consensus/rules/src/hard_forks.rs new file mode 100644 index 0000000..81bb65e --- /dev/null +++ b/consensus/rules/src/hard_forks.rs @@ -0,0 +1,270 @@ +//! # Hard-Forks +//! +//! Monero use hard-forks to update it's protocol, this module contains a [`HardFork`] enum which is +//! an identifier for every current hard-fork. +//! +//! This module also contains a [`HFVotes`] struct which keeps track of current blockchain voting, and +//! has a method [`HFVotes::check_next_hard_fork`] to check if the next hard-fork should be activated. +//! +use std::{ + collections::VecDeque, + fmt::{Display, Formatter}, + time::Duration, +}; + +/// Target block time for hf 1. +const BLOCK_TIME_V1: Duration = Duration::from_secs(60); +/// Target block time from v2. +const BLOCK_TIME_V2: Duration = Duration::from_secs(120); + +const NUMB_OF_HARD_FORKS: usize = 16; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] +pub enum HardForkError { + #[error("The hard-fork is unknown")] + HardForkUnknown, + #[error("The block is on an incorrect hard-fork")] + VersionIncorrect, + #[error("The block's vote is for a previous hard-fork")] + VoteTooLow, +} + +/// Information about a given hard-fork. +#[derive(Debug, Clone, Copy)] +pub struct HFInfo { + height: u64, + threshold: u64, +} +impl HFInfo { + pub const fn new(height: u64, threshold: u64) -> HFInfo { + HFInfo { height, threshold } + } +} + +/// Information about every hard-fork Monero has had. +#[derive(Debug, Clone, Copy)] +pub struct HFsInfo([HFInfo; NUMB_OF_HARD_FORKS]); + +impl HFsInfo { + pub fn info_for_hf(&self, hf: &HardFork) -> HFInfo { + self.0[*hf as usize - 1] + } + + /// Returns the main-net hard-fork information. + /// + /// https://cuprate.github.io/monero-book/consensus_rules/hardforks.html#Mainnet-Hard-Forks + pub const fn main_net() -> HFsInfo { + Self([ + HFInfo::new(0, 0), + HFInfo::new(1009827, 0), + HFInfo::new(1141317, 0), + HFInfo::new(1220516, 0), + HFInfo::new(1288616, 0), + HFInfo::new(1400000, 0), + HFInfo::new(1546000, 0), + HFInfo::new(1685555, 0), + HFInfo::new(1686275, 0), + HFInfo::new(1788000, 0), + HFInfo::new(1788720, 0), + HFInfo::new(1978433, 0), + HFInfo::new(2210000, 0), + HFInfo::new(2210720, 0), + HFInfo::new(2688888, 0), + HFInfo::new(2689608, 0), + ]) + } +} + +/// An identifier for every hard-fork Monero has had. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] +#[cfg_attr(proptest, derive(proptest_derive::Arbitrary))] +#[repr(u8)] +pub enum HardFork { + V1 = 1, + V2, + V3, + V4, + V5, + V6, + V7, + V8, + V9, + V10, + V11, + V12, + V13, + V14, + V15, + // remember to update from_vote! + V16, +} + +impl HardFork { + /// Returns the hard-fork for a blocks `major_version` field. + /// + /// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#blocks-version-and-vote + pub fn from_version(version: &u8) -> Result { + Ok(match version { + 1 => HardFork::V1, + 2 => HardFork::V2, + 3 => HardFork::V3, + 4 => HardFork::V4, + 5 => HardFork::V5, + 6 => HardFork::V6, + 7 => HardFork::V7, + 8 => HardFork::V8, + 9 => HardFork::V9, + 10 => HardFork::V10, + 11 => HardFork::V11, + 12 => HardFork::V12, + 13 => HardFork::V13, + 14 => HardFork::V14, + 15 => HardFork::V15, + 16 => HardFork::V16, + _ => return Err(HardForkError::HardForkUnknown), + }) + } + + /// Returns the hard-fork for a blocks `minor_version` (vote) field. + /// + /// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#blocks-version-and-vote + pub fn from_vote(vote: &u8) -> HardFork { + if *vote == 0 { + // A vote of 0 is interpreted as 1 as that's what Monero used to default to. + return HardFork::V1; + } + // This must default to the latest hard-fork! + Self::from_version(vote).unwrap_or(HardFork::V16) + } + + /// Returns the next hard-fork. + pub fn next_fork(&self) -> Option { + HardFork::from_version(&(*self as u8 + 1)).ok() + } + + /// Returns the target block time for this hardfork. + pub fn block_time(&self) -> Duration { + match self { + HardFork::V1 => BLOCK_TIME_V1, + _ => BLOCK_TIME_V2, + } + } + + /// Checks a blocks version and vote, assuming that `self` is the current hard-fork. + /// + /// https://cuprate.github.io/monero-book/consensus_rules/blocks.html#version-and-vote + pub fn check_block_version_vote( + current_hf: &Self, + version: &HardFork, + vote: &HardFork, + ) -> Result<(), HardForkError> { + if current_hf != version { + Err(HardForkError::VersionIncorrect)?; + } + if current_hf < vote { + Err(HardForkError::VoteTooLow)?; + } + + Ok(()) + } +} + +/// A struct holding the current voting state of the blockchain. +#[derive(Debug, Clone)] +pub struct HFVotes { + votes: [u64; NUMB_OF_HARD_FORKS], + vote_list: VecDeque, + window_size: usize, +} + +impl Display for HFVotes { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HFVotes") + .field("total", &self.total_votes()) + .field("V1", &self.votes_for_hf(&HardFork::V1)) + .field("V2", &self.votes_for_hf(&HardFork::V2)) + .field("V3", &self.votes_for_hf(&HardFork::V3)) + .field("V4", &self.votes_for_hf(&HardFork::V4)) + .field("V5", &self.votes_for_hf(&HardFork::V5)) + .field("V6", &self.votes_for_hf(&HardFork::V6)) + .field("V7", &self.votes_for_hf(&HardFork::V7)) + .field("V8", &self.votes_for_hf(&HardFork::V8)) + .field("V9", &self.votes_for_hf(&HardFork::V9)) + .field("V10", &self.votes_for_hf(&HardFork::V10)) + .field("V11", &self.votes_for_hf(&HardFork::V11)) + .field("V12", &self.votes_for_hf(&HardFork::V12)) + .field("V13", &self.votes_for_hf(&HardFork::V13)) + .field("V14", &self.votes_for_hf(&HardFork::V14)) + .field("V15", &self.votes_for_hf(&HardFork::V15)) + .field("V16", &self.votes_for_hf(&HardFork::V16)) + .finish() + } +} + +impl HFVotes { + pub fn new(window_size: usize) -> HFVotes { + HFVotes { + votes: [0; NUMB_OF_HARD_FORKS], + vote_list: VecDeque::with_capacity(window_size), + window_size, + } + } + + /// Add a vote for a hard-fork, this function removes votes outside of the window. + pub fn add_vote_for_hf(&mut self, hf: &HardFork) { + self.vote_list.push_back(*hf); + self.votes[*hf as usize - 1] += 1; + if self.vote_list.len() > self.window_size { + let hf = self.vote_list.pop_front().unwrap(); + self.votes[hf as usize - 1] -= 1; + } + } + + /// Returns the total votes for a hard-fork. + /// + /// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#accepting-a-fork + pub fn votes_for_hf(&self, hf: &HardFork) -> u64 { + self.votes[*hf as usize - 1..].iter().sum() + } + + /// Returns the total amount of votes being tracked + pub fn total_votes(&self) -> u64 { + self.votes.iter().sum() + } + + /// Checks if a future hard fork should be activated, returning the next hard-fork that should be + /// activated. + /// + /// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#accepting-a-fork + pub fn check_next_hard_fork( + &self, + current_hf: &HardFork, + current_height: u64, + window: u64, + hfs_info: &HFsInfo, + ) -> Option { + let mut approved_next_hf = None; + while let Some(next_hf) = current_hf.next_fork() { + let hf_info = hfs_info.info_for_hf(&next_hf); + if current_height >= hf_info.height + && self.votes_for_hf(&next_hf) >= votes_needed(hf_info.threshold, window) + { + approved_next_hf = Some(next_hf); + } else { + // if we don't have enough votes for this fork any future fork won't have enough votes + // as votes are cumulative. + // TODO: If a future fork has a lower threshold that could not be true, but as all current forks + // have threshold 0 it is ok for now. + return approved_next_hf; + } + } + approved_next_hf + } +} + +/// Returns the votes needed for a hard-fork. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/hardforks.html#accepting-a-fork +pub fn votes_needed(threshold: u64, window: u64) -> u64 { + (threshold * window).div_ceil(100) +} diff --git a/consensus/rules/src/lib.rs b/consensus/rules/src/lib.rs new file mode 100644 index 0000000..1f50dd8 --- /dev/null +++ b/consensus/rules/src/lib.rs @@ -0,0 +1,53 @@ +mod decomposed_amount; +mod hard_forks; +mod miner_tx; +mod signatures; +mod transactions; + +pub use decomposed_amount::is_decomposed_amount; +pub use hard_forks::{HFVotes, HFsInfo, HardFork}; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum TxVersion { + RingSignatures, + RingCT, +} + +impl TxVersion { + pub fn from_raw(version: u64) -> Option { + Some(match version { + 1 => TxVersion::RingSignatures, + 2 => TxVersion::RingCT, + _ => return None, + }) + } +} + +/// Checks that a point is canonical. +/// +/// https://github.com/dalek-cryptography/curve25519-dalek/issues/380 +fn check_point(point: &curve25519_dalek::edwards::CompressedEdwardsY) -> bool { + let bytes = point.as_bytes(); + + point + .decompress() + // Ban points which are either unreduced or -0 + .filter(|point| point.compress().as_bytes() == bytes) + .is_some() +} + +#[cfg(feature = "rayon")] +fn try_par_iter(t: T) -> T::Iter +where + T: rayon::iter::IntoParallelIterator, +{ + t.into_par_iter() +} + +#[cfg(not(feature = "rayon"))] +fn try_par_iter(t: T) -> impl std::iter::Iterator +where + T: std::iter::IntoIterator, +{ + t.into_iter() +} diff --git a/consensus/rules/src/miner_tx.rs b/consensus/rules/src/miner_tx.rs new file mode 100644 index 0000000..82d20a6 --- /dev/null +++ b/consensus/rules/src/miner_tx.rs @@ -0,0 +1,189 @@ +use monero_serai::{ + ringct::RctType, + transaction::{Input, Output, Timelock, Transaction}, +}; + +use crate::{is_decomposed_amount, transactions::check_output_types, HardFork, TxVersion}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum MinerTxError { + #[error("The miners transaction version is invalid.")] + VersionInvalid, + #[error("The miner transaction does not have exactly one input.")] + IncorrectNumbOfInputs, + #[error("The miner transactions input has the wrong block height.")] + InputsHeightIncorrect, + #[error("The input is not of type `gen`.")] + InputNotOfTypeGen, + #[error("The transaction has an incorrect lock time.")] + InvalidLockTime, + #[error("The transaction has an output which is not decomposed.")] + OutputNotDecomposed, + #[error("The transaction outputs overflow when summed.")] + OutputsOverflow, + #[error("The miner transaction outputs the wrong amount.")] + OutputAmountIncorrect, + #[error("The miner transactions RCT type is not NULL.")] + RCTTypeNotNULL, + #[error("The miner transactions has an invalid output type.")] + InvalidOutputType, +} + +/// A constant called "money supply", not actually a cap, it is used during +/// block reward calculations. +const MONEY_SUPPLY: u64 = u64::MAX; +/// The minimum block reward per minute, "tail-emission" +const MINIMUM_REWARD_PER_MIN: u64 = 3 * 10_u64.pow(11); +/// The value which `lock_time` should be for a coinbase output. +const MINER_TX_TIME_LOCKED_BLOCKS: u64 = 60; + +/// Calculates the base block reward without taking away the penalty for expanding +/// the block. +fn calculate_base_reward(already_generated_coins: u64, hf: &HardFork) -> u64 { + let target_mins = hf.block_time().as_secs() / 60; + let emission_speed_factor = 20 - (target_mins - 1); + ((MONEY_SUPPLY - already_generated_coins) >> emission_speed_factor) + .max(MINIMUM_REWARD_PER_MIN * target_mins) +} + +/// Calculates the miner reward for this block. +pub fn calculate_block_reward( + block_weight: usize, + median_bw: usize, + already_generated_coins: u64, + hf: &HardFork, +) -> u64 { + let base_reward = calculate_base_reward(already_generated_coins, hf); + + if block_weight <= median_bw { + return base_reward; + } + + let multiplicand: u128 = ((2 * median_bw - block_weight) * block_weight) + .try_into() + .unwrap(); + let effective_median_bw: u128 = median_bw.try_into().unwrap(); + + (((base_reward as u128 * multiplicand) / effective_median_bw) / effective_median_bw) + .try_into() + .unwrap() +} + +/// Checks the miner transactions version. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#version +fn check_miner_tx_version(tx_version: &TxVersion, hf: &HardFork) -> Result<(), MinerTxError> { + // The TxVersion enum checks if the version is not 1 or 2 + if hf >= &HardFork::V12 && tx_version != &TxVersion::RingCT { + Err(MinerTxError::VersionInvalid) + } else { + Ok(()) + } +} + +/// Checks the miner transactions inputs. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#input +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#height +fn check_inputs(inputs: &[Input], chain_height: u64) -> Result<(), MinerTxError> { + if inputs.len() != 1 { + return Err(MinerTxError::IncorrectNumbOfInputs); + } + + match &inputs[0] { + Input::Gen(height) => { + if height != &chain_height { + Err(MinerTxError::InputsHeightIncorrect) + } else { + Ok(()) + } + } + _ => Err(MinerTxError::InputNotOfTypeGen), + } +} + +/// Checks the miner transaction has a correct time lock. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#unlock-time +fn check_time_lock(time_lock: &Timelock, chain_height: u64) -> Result<(), MinerTxError> { + match time_lock { + Timelock::Block(till_height) => { + // Lock times above this amount are timestamps not blocks. + // This is just for safety though and shouldn't actually be hit. + if till_height >= &500_000_000 { + Err(MinerTxError::InvalidLockTime)?; + } + if u64::try_from(*till_height).unwrap() != chain_height + MINER_TX_TIME_LOCKED_BLOCKS { + Err(MinerTxError::InvalidLockTime) + } else { + Ok(()) + } + } + _ => Err(MinerTxError::InvalidLockTime), + } +} + +/// Sums the outputs checking for overflow. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-amounts +fn sum_outputs(outputs: &[Output], hf: &HardFork) -> Result { + let mut sum: u64 = 0; + for out in outputs { + let amt = out.amount.unwrap_or(0); + if hf == &HardFork::V3 && !is_decomposed_amount(&amt) { + return Err(MinerTxError::OutputNotDecomposed); + } + sum = sum.checked_add(amt).ok_or(MinerTxError::OutputsOverflow)?; + } + Ok(sum) +} + +/// Checks the total outputs amount is correct returning the amount of coins collected by the miner. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#total-outputs +fn check_total_output_amt( + total_output: u64, + reward: u64, + fees: u64, + hf: &HardFork, +) -> Result { + if hf == &HardFork::V1 || hf >= &HardFork::V12 { + if total_output != reward + fees { + return Err(MinerTxError::OutputAmountIncorrect); + } + Ok(reward) + } else { + if total_output - fees > reward || total_output > reward + fees { + return Err(MinerTxError::OutputAmountIncorrect); + } + Ok(total_output - fees) + } +} + +pub fn check_miner_tx( + tx: &Transaction, + total_fees: u64, + chain_height: u64, + block_weight: usize, + median_bw: usize, + already_generated_coins: u64, + hf: &HardFork, +) -> Result { + let tx_version = TxVersion::from_raw(tx.prefix.version).ok_or(MinerTxError::VersionInvalid)?; + check_miner_tx_version(&tx_version, hf)?; + + if hf >= &HardFork::V12 && tx.rct_signatures.rct_type() != RctType::Null { + return Err(MinerTxError::RCTTypeNotNULL); + } + + check_time_lock(&tx.prefix.timelock, chain_height)?; + + check_inputs(&tx.prefix.inputs, chain_height)?; + + check_output_types(&tx.prefix.outputs, hf).map_err(|_| MinerTxError::InvalidOutputType)?; + + let reward = calculate_block_reward(block_weight, median_bw, already_generated_coins, hf); + let total_outs = sum_outputs(&tx.prefix.outputs, hf)?; + + check_total_output_amt(total_outs, reward, total_fees, hf) +} diff --git a/consensus/rules/src/signatures.rs b/consensus/rules/src/signatures.rs new file mode 100644 index 0000000..075d00b --- /dev/null +++ b/consensus/rules/src/signatures.rs @@ -0,0 +1,33 @@ +use curve25519_dalek::EdwardsPoint; +use monero_serai::transaction::Transaction; + +mod ring_signatures; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SignatureError { + #[error("Number of signatures is different to the amount required.")] + MismatchSignatureSize, + #[error("The signature is incorrect.")] + 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( + &tx.prefix.inputs, + &tx.signatures, + rings, + &tx.signature_hash(), + ), + _ => panic!("TODO: RCT"), + } +} diff --git a/consensus/rules/src/signatures/ring_signatures.rs b/consensus/rules/src/signatures/ring_signatures.rs new file mode 100644 index 0000000..4ef296e --- /dev/null +++ b/consensus/rules/src/signatures/ring_signatures.rs @@ -0,0 +1,51 @@ +//! Version 1 ring signature verification. +//! +//! Some checks have to be done at deserialization or with data we don't have so we can't do them here, those checks are: +//! https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#signatures-must-be-canonical +//! this happens at deserialization in monero-serai. +//! https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#amount-of-signatures-in-a-ring +//! and this happens during ring signature verification in monero-serai. +//! +use monero_serai::{ring_signatures::RingSignature, transaction::Input}; + +#[cfg(feature = "rayon")] +use rayon::prelude::*; + +use super::{Rings, SignatureError}; +use crate::par_iter; + +/// Verifies the ring signature. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#the-ring-signature-must-be-valid +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#amount-of-ring-signatures +pub fn verify_inputs_signatures( + inputs: &[Input], + signatures: &[RingSignature], + rings: &Rings, + tx_sig_hash: &[u8; 32], +) -> Result<(), SignatureError> { + match rings { + Rings::Legacy(rings) => { + // rings.len() != inputs.len() can't happen but check any way. + if signatures.len() != inputs.len() || rings.len() != inputs.len() { + return Err(SignatureError::MismatchSignatureSize); + } + + par_iter(inputs) + .zip(rings) + .zip(signatures) + .try_for_each(|((input, ring), sig)| { + let Input::ToKey { key_image, .. } = input else { + panic!("How did we build a ring with no decoys?"); + }; + + if !sig.verify(tx_sig_hash, ring, key_image) { + return Err(SignatureError::IncorrectSignature); + } + Ok(()) + })?; + } + _ => panic!("tried to verify v1 tx with a non v1 ring"), + } + Ok(()) +} diff --git a/consensus/rules/src/time_locks.rs b/consensus/rules/src/time_locks.rs new file mode 100644 index 0000000..e69de29 diff --git a/consensus/rules/src/transactions.rs b/consensus/rules/src/transactions.rs new file mode 100644 index 0000000..56d8ed3 --- /dev/null +++ b/consensus/rules/src/transactions.rs @@ -0,0 +1,456 @@ +use std::{cmp::Ordering, collections::HashSet, sync::Arc}; + +use monero_serai::transaction::{Input, Output, Timelock}; + +use crate::{check_point, is_decomposed_amount, HardFork, TxVersion}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum TransactionError { + //-------------------------------------------------------- OUTPUTS + #[error("Output is not a valid point.")] + OutputNotValidPoint, + #[error("The transaction has an invalid output type.")] + OutputTypeInvalid, + #[error("The transaction is v1 with a 0 amount output.")] + ZeroOutputForV1, + #[error("The transaction has an output which is not decomposed.")] + AmountNotDecomposed, + #[error("The transactions outputs overflow.")] + OutputsOverflow, + //-------------------------------------------------------- INPUTS + #[error("One or more inputs don't have the expected number of decoys.")] + InputDoesNotHaveExpectedNumbDecoys, + #[error("The transaction has more than one mixable input with unmixable inputs.")] + MoreThanOneMixableInputWithUnmixable, + #[error("The key-image is not in the prime sub-group.")] + KeyImageIsNotInPrimeSubGroup, + #[error("Key-image is already spent.")] + KeyImageSpent, + #[error("The input is not the expected type.")] + IncorrectInputType, + #[error("The transaction has a duplicate ring member.")] + DuplicateRingMember, + #[error("The transaction inputs are not ordered.")] + InputsAreNotOrdered, + #[error("The transaction spends a decoy which is too young.")] + OneOrMoreDecoysLocked, + #[error("The transaction inputs overflow.")] + InputsOverflow, + #[error("The transaction has no inputs.")] + NoInputs, +} + +//----------------------------------------------------------------------------------------------------------- OUTPUTS + +/// Checks the output keys are canonical points. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-keys-canonical +fn check_output_keys(outputs: &[Output]) -> Result<(), TransactionError> { + for out in outputs { + if !check_point(&out.key) { + return Err(TransactionError::OutputNotValidPoint); + } + } + + Ok(()) +} + +/// Checks the output types are allowed. +/// +/// This is also used during miner-tx verification. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-type +/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-type +pub(crate) fn check_output_types( + outputs: &[Output], + hf: &HardFork, +) -> Result<(), TransactionError> { + if hf == &HardFork::V15 { + for outs in outputs.windows(2) { + if outs[0].view_tag.is_some() != outs[0].view_tag.is_some() { + return Err(TransactionError::OutputTypeInvalid); + } + } + return Ok(()); + } + + for out in outputs { + if hf <= &HardFork::V14 && out.view_tag.is_some() + || hf >= &HardFork::V16 && out.view_tag.is_none() + { + return Err(TransactionError::OutputTypeInvalid); + } + } + Ok(()) +} + +/// Checks the outputs amount for version 1 txs. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount +fn check_output_amount_v1(amount: u64, hf: &HardFork) -> Result<(), TransactionError> { + if amount == 0 { + return Err(TransactionError::ZeroOutputForV1); + } + + if hf >= &HardFork::V2 && !is_decomposed_amount(&amount) { + return Err(TransactionError::AmountNotDecomposed); + } + + Ok(()) +} + +/// Sums the outputs, checking for overflow and other consensus rules. +/// +/// Should only be used on v1 transactions. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#inputs-and-outputs-must-not-overflow +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount +fn sum_outputs_v1(outputs: &[Output], hf: &HardFork) -> Result { + let mut sum: u64 = 0; + + for out in outputs { + let raw_amount = out.amount.unwrap_or(0); + + check_output_amount_v1(raw_amount, hf)?; + + sum = sum + .checked_add(raw_amount) + .ok_or(TransactionError::OutputsOverflow)?; + } + + Ok(sum) +} + +/// Checks the outputs against all output consensus rules, returning the sum of the output amounts. +pub fn check_outputs( + outputs: &[Output], + hf: &HardFork, + tx_version: &TxVersion, +) -> Result { + check_output_types(outputs, hf)?; + check_output_keys(outputs)?; + + match tx_version { + TxVersion::RingSignatures => sum_outputs_v1(outputs, hf), + _ => todo!("RingCT"), + } +} + +//----------------------------------------------------------------------------------------------------------- INPUTS + +/// 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, +} + +/// 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 +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, + } +} + +/// Checks the decoys are allowed. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#minimum-decoys +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#equal-number-of-decoys +fn check_decoy_info(decoy_info: &DecoyInfo, hf: &HardFork) -> Result<(), TransactionError> { + if hf == &HardFork::V15 { + // Hard-fork 15 allows both v14 and v16 rules + return check_decoy_info(decoy_info, &HardFork::V14) + .or_else(|_| check_decoy_info(decoy_info, &HardFork::V16)); + } + + let current_minimum_decoys = minimum_decoys(hf); + + if decoy_info.min_decoys < current_minimum_decoys { + // Only allow rings without enough decoys if there aren't enough decoys to mix with. + if decoy_info.not_mixable == 0 { + return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys); + } + // Only allow upto 1 mixable input with unmixable inputs. + if decoy_info.mixable > 1 { + return Err(TransactionError::MoreThanOneMixableInputWithUnmixable); + } + } else if hf >= &HardFork::V8 && decoy_info.min_decoys != current_minimum_decoys { + // From V8 enforce the minimum used number of rings is the default minimum. + return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys); + } + + // From v12 all inputs must have the same number of decoys. + if hf >= &HardFork::V12 && decoy_info.min_decoys != decoy_info.max_decoys { + return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys); + } + + Ok(()) +} + +/// Checks the inputs key images for torsion and for duplicates in the transaction. +/// +/// The `spent_kis` parameter is not meant to be a complete list of key images, just a list of related transactions +/// key images, for example transactions in a block. The chain will be checked for duplicates later. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#unique-key-image +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#torsion-free-key-image +pub(crate) fn check_key_images( + input: &Input, + spent_kis: &mut HashSet<[u8; 32]>, +) -> Result<(), TransactionError> { + match input { + Input::ToKey { key_image, .. } => { + // this happens in monero-serai but we may as well duplicate the check. + if !key_image.is_torsion_free() { + return Err(TransactionError::KeyImageIsNotInPrimeSubGroup); + } + if !spent_kis.insert(key_image.compress().to_bytes()) { + return Err(TransactionError::KeyImageSpent); + } + } + _ => Err(TransactionError::IncorrectInputType)?, + } + + Ok(()) +} + +/// Checks that the input is of type [`Input::ToKey`] aka txin_to_key. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#input-type +fn check_input_type(input: &Input) -> Result<(), TransactionError> { + match input { + Input::ToKey { .. } => Ok(()), + _ => Err(TransactionError::IncorrectInputType)?, + } +} + +/// Checks that the input has decoys. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#inputs-must-have-decoys +fn check_input_has_decoys(input: &Input) -> Result<(), TransactionError> { + match input { + Input::ToKey { key_offsets, .. } => { + if key_offsets.is_empty() { + Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys) + } else { + Ok(()) + } + } + _ => Err(TransactionError::IncorrectInputType)?, + } +} + +/// Checks that the ring members for the input are unique after hard-fork 6. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#unique-ring-members +fn check_ring_members_unique(input: &Input, hf: &HardFork) -> Result<(), TransactionError> { + if hf >= &HardFork::V6 { + match input { + Input::ToKey { key_offsets, .. } => key_offsets.iter().skip(1).try_for_each(|offset| { + if *offset == 0 { + Err(TransactionError::DuplicateRingMember) + } else { + Ok(()) + } + }), + _ => Err(TransactionError::IncorrectInputType)?, + } + } else { + Ok(()) + } +} + +/// Checks that from hf 7 the inputs are sorted by key image. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#sorted-inputs +fn check_inputs_sorted(inputs: &[Input], hf: &HardFork) -> Result<(), TransactionError> { + let get_ki = |inp: &Input| match inp { + Input::ToKey { key_image, .. } => Ok(key_image.compress().to_bytes()), + _ => Err(TransactionError::IncorrectInputType), + }; + + if hf >= &HardFork::V7 { + for inps in inputs.windows(2) { + match get_ki(&inps[0])?.cmp(&get_ki(&inps[1])?) { + Ordering::Less => (), + _ => return Err(TransactionError::InputsAreNotOrdered), + } + } + Ok(()) + } else { + Ok(()) + } +} + +/// Checks the youngest output is at least 10 blocks old. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#10-block-lock +fn check_10_block_lock( + youngest_used_out_height: u64, + current_chain_height: u64, + hf: &HardFork, +) -> Result<(), TransactionError> { + if hf >= &HardFork::V12 { + if youngest_used_out_height + 10 > current_chain_height { + Err(TransactionError::OneOrMoreDecoysLocked) + } else { + Ok(()) + } + } else { + Ok(()) + } +} + +/// Sums the inputs checking for overflow. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#inputs-and-outputs-must-not-overflow +fn sum_inputs_v1(inputs: &[Input]) -> Result { + let mut sum: u64 = 0; + for inp in inputs { + match inp { + Input::ToKey { amount, .. } => { + sum = sum + .checked_add(amount.unwrap_or(0)) + .ok_or(TransactionError::InputsOverflow)?; + } + _ => Err(TransactionError::IncorrectInputType)?, + } + } + + Ok(sum) +} + +/// Checks all input consensus rules. +/// +/// TODO: list rules. +/// +pub fn check_inputs( + inputs: &[Input], + youngest_used_out_height: u64, + current_chain_height: u64, + decoys_info: Option<&DecoyInfo>, + hf: &HardFork, + tx_version: &TxVersion, + spent_kis: Arc>>, +) -> Result { + if inputs.is_empty() { + return Err(TransactionError::NoInputs); + } + + check_10_block_lock(youngest_used_out_height, current_chain_height, hf)?; + + if let Some(decoys_info) = decoys_info { + check_decoy_info(decoys_info, hf)?; + } else { + assert_eq!(hf, &HardFork::V1); + } + + for input in inputs { + check_input_type(input)?; + check_input_has_decoys(input)?; + + check_ring_members_unique(input, hf)?; + + let mut spent_kis_lock = spent_kis.lock().unwrap(); + check_key_images(input, &mut spent_kis_lock)?; + // Adding this here for clarity so we don't add more work here while the mutex guard is still + // in scope. + drop(spent_kis_lock); + } + + check_inputs_sorted(inputs, hf)?; + + match tx_version { + TxVersion::RingSignatures => sum_inputs_v1(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 +}