diff --git a/consensus/Cargo.toml b/consensus/Cargo.toml index cf82c8a2..8cc0b2e7 100644 --- a/consensus/Cargo.toml +++ b/consensus/Cargo.toml @@ -28,10 +28,12 @@ tower = {version = "0.4", features = ["util"]} tracing = "0.1" futures = "0.3" +sha3 = "0.10" crypto-bigint = "0.5" +curve25519-dalek = "4" randomx-rs = "1" -monero-serai = {git="https://github.com/Cuprate/serai.git", rev = "df4af7b"} +monero-serai = {git="https://github.com/Cuprate/serai.git", rev = "39eafae"} cuprate-common = {path = "../common"} cryptonight-cuprate = {path = "../cryptonight"} @@ -45,6 +47,3 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = tr tracing-subscriber = {version = "0.3", optional = true} # here to help cargo to pick a version - remove me syn = "2.0.37" - -[profile.dev] -opt-level = 3 diff --git a/consensus/src/bin/scan_chain.rs b/consensus/src/bin/scan_chain.rs index f9d2591e..d3e3c153 100644 --- a/consensus/src/bin/scan_chain.rs +++ b/consensus/src/bin/scan_chain.rs @@ -39,7 +39,7 @@ impl Default for ScanningCache { ScanningCache { network: Default::default(), numb_outs: Default::default(), - height: 1, + height: 1_000_000, } } } @@ -106,65 +106,101 @@ where tracing::info!("Initialised verifier, begging scan"); let batch_size = rpc_config.read().unwrap().block_batch_size(); - let mut next_fut = tokio::spawn(database.clone().ready().await?.call( - DatabaseRequest::BlockBatchInRange( - cache.height..(cache.height + batch_size).min(chain_height), - ), - )); + let mut db = database.clone(); + let mut next_fut = tokio::spawn(async move { + let now = std::time::Instant::now(); + ( + db.ready() + .await + .unwrap() + .call(DatabaseRequest::BlockBatchInRange( + cache.height..(cache.height + batch_size).min(chain_height), + )) + .await + .unwrap(), + now.elapsed(), + ) + }); let mut current_height = cache.height; let mut next_batch_start_height = cache.height + batch_size; let mut time_to_verify_last_batch: u128 = 0; - let mut first = true; + let mut batches_till_check_batch_size: u64 = 2; while next_batch_start_height < chain_height { let next_batch_size = rpc_config.read().unwrap().block_batch_size(); - let time_to_retrieve_batch = std::time::Instant::now(); // Call the next batch while we handle this batch. + let mut db = database.clone(); let current_fut = std::mem::replace( &mut next_fut, - tokio::spawn( - database - .ready() - .await? - .call(DatabaseRequest::BlockBatchInRange( - next_batch_start_height - ..(next_batch_start_height + next_batch_size).min(chain_height), - )), - ), + tokio::spawn(async move { + let now = std::time::Instant::now(); + + ( + db.ready() + .await + .unwrap() + .call(DatabaseRequest::BlockBatchInRange( + next_batch_start_height + ..(next_batch_start_height + next_batch_size).min(chain_height), + )) + .await + .unwrap(), + now.elapsed(), + ) + }), ); - let DatabaseResponse::BlockBatchInRange(blocks) = current_fut.await?? else { + let (DatabaseResponse::BlockBatchInRange(blocks), time_to_retrieve_batch) = + current_fut.await? + else { panic!("Database sent incorrect response!"); }; let time_to_verify_batch = std::time::Instant::now(); - let time_to_retrieve_batch = time_to_retrieve_batch.elapsed().as_millis(); + let time_to_retrieve_batch = time_to_retrieve_batch.as_millis(); + + if time_to_retrieve_batch > time_to_verify_last_batch + 2000 + && batches_till_check_batch_size == 0 + { + batches_till_check_batch_size = 3; - if time_to_retrieve_batch > 2000 && !first { let mut conf = rpc_config.write().unwrap(); + tracing::info!( + "Decreasing batch size time to verify last batch: {}, time_to_retrieve_batch: {}", + time_to_verify_last_batch, + time_to_retrieve_batch + ); conf.max_blocks_per_node = (conf.max_blocks_per_node - * TryInto::::try_into( - time_to_verify_last_batch - / (time_to_verify_last_batch + time_to_retrieve_batch), - ) - .unwrap()) - .max(10_u64) - .min(MAX_BLOCKS_IN_RANGE); - tracing::info!("Decreasing batch size to: {}", conf.max_blocks_per_node); - } else if time_to_retrieve_batch < 100 { - let mut conf = rpc_config.write().unwrap(); - conf.max_blocks_per_node = (conf.max_blocks_per_node * 2) + * time_to_verify_last_batch as u64 + / (time_to_retrieve_batch as u64)) .max(10_u64) .min(MAX_BLOCKS_IN_RANGE); - tracing::info!("Increasing batch size to: {}", conf.max_blocks_per_node); - } + tracing::info!("Decreasing batch size to: {}", conf.max_blocks_per_node); + } else if time_to_retrieve_batch + 2000 < time_to_verify_last_batch + && batches_till_check_batch_size == 0 + { + batches_till_check_batch_size = 3; - first = false; + let mut conf = rpc_config.write().unwrap(); + tracing::info!( + "Increasing batch size time to verify last batch: {}, time_to_retrieve_batch: {}", + time_to_verify_last_batch, + time_to_retrieve_batch + ); + conf.max_blocks_per_node = (conf.max_blocks_per_node + * (time_to_verify_last_batch as u64) + / time_to_retrieve_batch.max(1) as u64) + .max(30_u64) + .min(MAX_BLOCKS_IN_RANGE); + tracing::info!("Increasing batch size to: {}", conf.max_blocks_per_node); + } else { + batches_till_check_batch_size = batches_till_check_batch_size.saturating_sub(1); + } tracing::info!( "Handling batch: {:?}, chain height: {}", @@ -227,7 +263,10 @@ async fn main() { "http://145.239.97.211:18089".to_string(), ]; - let rpc_config = RpcConfig::new(10, INITIAL_MAX_BLOCKS_HEADERS_IN_RANGE); + let rpc_config = RpcConfig::new( + INITIAL_MAX_BLOCKS_IN_RANGE, + INITIAL_MAX_BLOCKS_HEADERS_IN_RANGE, + ); let rpc_config = Arc::new(RwLock::new(rpc_config)); let rpc = init_rpc_load_balancer(urls, rpc_config.clone()); diff --git a/consensus/src/block.rs b/consensus/src/block.rs index 66f72509..441fa010 100644 --- a/consensus/src/block.rs +++ b/consensus/src/block.rs @@ -17,11 +17,36 @@ const BLOCK_FUTURE_TIME_LIMIT: u64 = 60 * 60 * 2; pub struct BlockVerificationData { hf: BlockHFInfo, pow: BlockPOWInfo, + current_difficulty: u128, weights: BlockWeightInfo, block_blob: Vec, block: Block, block_hash: [u8; 32], pow_hash: [u8; 32], + // txs: Vec, +} + +impl BlockVerificationData { + pub fn new( + block: Block, + difficulty_cache: &DifficultyCache, + weight_cache: &BlockWeightsCache, + ) -> Result { + let hf = BlockHFInfo::from_block_header(&block.header)?; + + let current_diff = difficulty_cache.next_difficulty(&hf.version); + let cum_diff = difficulty_cache.cumulative_difficulty() + current_diff; + + todo!() + /* + + Ok(BlockVerificationData { + hf: BlockHFInfo::from_block_header(&block.header)?, + pow: BlockPOWInfo::new(block.header.timestamp, cum_diff), + weights: + }) + */ + } } /// Sanity check on the block blob size. @@ -70,7 +95,7 @@ fn check_timestamp(block: &Block, median_timestamp: u64) -> Result<(), Consensus if block.header.timestamp < median_timestamp || block.header.timestamp > current_time() + BLOCK_FUTURE_TIME_LIMIT { - return Err(ConsensusError::BlockTimestampInvalid); + Err(ConsensusError::BlockTimestampInvalid) } else { Ok(()) } diff --git a/consensus/src/block/difficulty.rs b/consensus/src/block/difficulty.rs index e8fcf317..dd5630ee 100644 --- a/consensus/src/block/difficulty.rs +++ b/consensus/src/block/difficulty.rs @@ -96,6 +96,7 @@ impl DifficultyCache { let mut block_start = chain_height.saturating_sub(config.total_block_count()); + // skip the genesis block. if block_start == 0 { block_start = 1; } @@ -150,6 +151,7 @@ impl DifficultyCache { let mut block_start = (self.last_accounted_height + 1).saturating_sub(self.config.total_block_count()); + // skip the genesis block if block_start == 0 { block_start = 1; } @@ -216,6 +218,15 @@ impl DifficultyCache { .collect::>(), ) } + + /// Returns the cumulative difficulty of the chain. + pub fn cumulative_difficulty(&self) -> u128 { + self.cumulative_difficulty + } + + pub fn top_block_timestamp(&self) -> Option { + self.timestamps.back().copied() + } } fn get_window_start_and_end(window_len: usize, accounted_window: usize) -> (usize, usize) { diff --git a/consensus/src/block/pow.rs b/consensus/src/block/pow.rs index 6e98e452..350f44b6 100644 --- a/consensus/src/block/pow.rs +++ b/consensus/src/block/pow.rs @@ -1,5 +1,8 @@ use crypto_bigint::{CheckedMul, U256}; -use cryptonight_cuprate::{cryptonight_hash, Variant}; + +use cryptonight_cuprate::{ + cryptonight_hash_r, cryptonight_hash_v0, cryptonight_hash_v1, cryptonight_hash_v2, +}; use crate::{hardforks::HardFork, ConsensusError}; @@ -9,6 +12,15 @@ pub struct BlockPOWInfo { pub cumulative_difficulty: u128, } +impl BlockPOWInfo { + pub fn new(timestamp: u64, cumulative_difficulty: u128) -> BlockPOWInfo { + BlockPOWInfo { + timestamp, + cumulative_difficulty, + } + } +} + /// Returns if the blocks POW hash is valid for the current difficulty. /// /// See: https://cuprate.github.io/monero-book/consensus_rules/blocks/difficulty.html#checking-a-blocks-proof-of-work @@ -21,24 +33,29 @@ pub fn check_block_pow(hash: &[u8; 32], difficulty: u128) -> bool { } /// Calcualtes the POW hash of this block. -pub fn calculate_pow_hash(buf: &[u8], height: u64, hf: &HardFork) -> [u8; 32] { +pub fn calculate_pow_hash( + buf: &[u8], + height: u64, + hf: &HardFork, +) -> Result<[u8; 32], ConsensusError> { if height == 202612 { - return hex::decode("84f64766475d51837ac9efbef1926486e58563c95a19fef4aec3254f03000000") - .unwrap() - .try_into() - .unwrap(); + return Ok( + hex::decode("84f64766475d51837ac9efbef1926486e58563c95a19fef4aec3254f03000000") + .unwrap() + .try_into() + .unwrap(), + ); } - if hf.in_range(&HardFork::V1, &HardFork::V7) { - cryptonight_hash(buf, &Variant::V0) - //cryptonight_hash::cryptonight_hash(buf, &Variant::V0) + Ok(if hf.in_range(&HardFork::V1, &HardFork::V7) { + cryptonight_hash_v0(buf) } else if hf == &HardFork::V7 { - cryptonight_hash(buf, &Variant::V1) + cryptonight_hash_v1(buf).map_err(|_| ConsensusError::BlockPOWInvalid)? } else if hf.in_range(&HardFork::V8, &HardFork::V10) { - cryptonight_hash(buf, &Variant::V2) + cryptonight_hash_v2(buf) } else if hf.in_range(&HardFork::V10, &HardFork::V12) { - cryptonight_hash(buf, &Variant::R { height }) + cryptonight_hash_r(buf, height) } else { todo!("RandomX") - } + }) } diff --git a/consensus/src/hardforks.rs b/consensus/src/hardforks.rs index 15c385e8..f6994c3e 100644 --- a/consensus/src/hardforks.rs +++ b/consensus/src/hardforks.rs @@ -19,8 +19,8 @@ const NUMB_OF_HARD_FORKS: usize = 16; #[derive(Debug, Clone, Copy)] pub struct BlockHFInfo { - version: HardFork, - vote: HardFork, + pub version: HardFork, + pub vote: HardFork, } impl BlockHFInfo { diff --git a/consensus/src/helper.rs b/consensus/src/helper.rs index 0281ca1c..b5489cd3 100644 --- a/consensus/src/helper.rs +++ b/consensus/src/helper.rs @@ -4,6 +4,8 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use curve25519_dalek::edwards::CompressedEdwardsY; + /// Deserializes an object using the give `des` function, checking that all the bytes /// are consumed. pub(crate) fn size_check_decode( @@ -54,3 +56,16 @@ pub(crate) fn current_time() -> u64 { .unwrap() .as_secs() } + +/// Checks that a point is canonical. +/// +/// https://github.com/dalek-cryptography/curve25519-dalek/issues/380 +pub(crate) fn check_point(point: &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() +} diff --git a/consensus/src/lib.rs b/consensus/src/lib.rs index 50ed88dd..0924954d 100644 --- a/consensus/src/lib.rs +++ b/consensus/src/lib.rs @@ -1,3 +1,5 @@ +use std::collections::{HashMap, HashSet}; + pub mod block; pub mod genesis; pub mod hardforks; @@ -5,10 +7,27 @@ mod helper; pub mod miner_tx; #[cfg(feature = "binaries")] pub mod rpc; +pub mod transactions; pub mod verifier; #[derive(Debug, thiserror::Error)] pub enum ConsensusError { + #[error("Transaction sig invalid: {0}")] + TransactionSignatureInvalid(&'static str), + #[error("Transaction inputs overflow")] + TransactionInputsOverflow, + #[error("Transaction outputs overflow")] + TransactionOutputsOverflow, + #[error("Transaction has an invalid output: {0}")] + TransactionInvalidOutput(&'static str), + #[error("Transaction has an invalid version")] + TransactionVersionInvalid, + #[error("Transaction an invalid input: {0}")] + TransactionHasInvalidInput(&'static str), + #[error("Transaction has invalid ring: {0}")] + TransactionHasInvalidRing(&'static str), + #[error("Block has an invalid proof of work")] + BlockPOWInvalid, #[error("Block has a timestamp outside of the valid range")] BlockTimestampInvalid, #[error("Block is too large")] @@ -44,6 +63,9 @@ pub enum DatabaseRequest { ChainHeight, + Outputs(HashMap>), + NumberOutputsWithAmount(u64), + #[cfg(feature = "binaries")] BlockBatchInRange(std::ops::Range), } @@ -61,6 +83,9 @@ pub enum DatabaseResponse { ChainHeight(u64), + Outputs(HashMap>), + NumberOutputsWithAmount(usize), + #[cfg(feature = "binaries")] BlockBatchInRange( Vec<( diff --git a/consensus/src/rpc.rs b/consensus/src/rpc.rs index a62172ce..d25d5b0c 100644 --- a/consensus/src/rpc.rs +++ b/consensus/src/rpc.rs @@ -293,6 +293,7 @@ impl tower::Service f let err_slot = self.error_slot.clone(); match req { + _ => todo!(), DatabaseRequest::BlockHash(height) => async move { let res: Result<_, RpcError> = rpc .get_block_hash(height as usize) diff --git a/consensus/src/transactions.rs b/consensus/src/transactions.rs new file mode 100644 index 00000000..8066d45d --- /dev/null +++ b/consensus/src/transactions.rs @@ -0,0 +1,67 @@ +use monero_serai::transaction::Transaction; +use sha3::{Digest, Keccak256}; + +use crate::{hardforks::HardFork, ConsensusError, Database}; + +mod inputs; +mod outputs; +mod signatures; +mod time_lock; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum TxVersion { + RingSignatures, + RingCT, +} + +impl TxVersion { + pub fn from_raw(version: u64) -> Result { + match version { + 1 => Ok(TxVersion::RingSignatures), + 2 => Ok(TxVersion::RingCT), + _ => Err(ConsensusError::TransactionVersionInvalid), + } + } +} + +/// Data needed to verify a transaction. +/// +pub struct TransactionVerificationData { + tx: Transaction, + version: TxVersion, + tx_blob: Vec, + tx_weight: usize, + tx_hash: [u8; 32], + rings: signatures::Rings, +} + +impl TransactionVerificationData { + pub fn new( + tx: Transaction, + rings: signatures::Rings, + ) -> Result { + let tx_blob = tx.serialize(); + + Ok(TransactionVerificationData { + tx_hash: Keccak256::digest(&tx_blob).into(), + tx_blob, + tx_weight: tx.weight(), + rings, + version: TxVersion::from_raw(tx.prefix.version)?, + tx, + }) + } + + pub async fn batch_new( + txs: Vec, + hf: &HardFork, + database: D, + ) -> Result, ConsensusError> { + let rings = signatures::batch_get_rings(&txs, hf, database.clone()).await?; + + txs.into_iter() + .zip(rings.into_iter()) + .map(|(tx, ring)| TransactionVerificationData::new(tx, ring)) + .collect() + } +} diff --git a/consensus/src/transactions/inputs.rs b/consensus/src/transactions/inputs.rs new file mode 100644 index 00000000..7333fd5d --- /dev/null +++ b/consensus/src/transactions/inputs.rs @@ -0,0 +1,244 @@ +use curve25519_dalek::EdwardsPoint; +use std::cmp::{max, min}; +use std::collections::HashSet; + +use monero_serai::transaction::Input; +use tower::{Service, ServiceExt}; + +use crate::{hardforks::HardFork, ConsensusError, Database, DatabaseRequest, DatabaseResponse}; + +/// A struct holding information about the inputs and their decoys. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html +pub struct DecoyInfo { + /// The number of inputs that have enough outputs on the chain to mix with. + mixable: usize, + /// The number of inputs that don't have enough outputs on the chain to mix with. + not_mixable: usize, + /// The minimum amount of decoys used in the transaction. + min_decoys: usize, + /// The maximum amount of decoys used in the transaction. + max_decoys: usize, +} + +impl DecoyInfo { + /// Creates a new [`DecoyInfo`] struct relating to the passed in inputs. + /// + /// Do not rely on this function to do consensus checks! + /// + pub async fn new( + inputs: &[Input], + hf: &HardFork, + mut database: D, + ) -> 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 in inputs { + match inp { + Input::ToKey { + amount, + key_offsets, + .. + } => { + let DatabaseResponse::NumberOutputsWithAmount(numb_of_outs) = database + .ready() + .await? + .call(DatabaseRequest::NumberOutputsWithAmount( + amount.unwrap_or(0), + )) + .await? + else { + panic!("Database sent incorrect response!"); + }; + + // https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#mixable-and-unmixable-inputs + if numb_of_outs <= minimum_decoys { + not_mixable += 1; + } else { + mixable += 1; + } + + let numb_decoys = key_offsets + .len() + .checked_sub(1) + .ok_or(ConsensusError::TransactionHasInvalidRing("ring is empty"))?; + // 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(ConsensusError::TransactionHasInvalidInput( + "input not ToKey", + )) + } + } + } + + Ok(DecoyInfo { + mixable, + not_mixable, + min_decoys, + max_decoys, + }) + } + + /// 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 + pub fn check_decoy_info(&self, hf: &HardFork) -> Result<(), ConsensusError> { + if hf == &HardFork::V15 { + // Hard-fork 15 allows both v14 and v16 rules + return self + .check_decoy_info(&HardFork::V14) + .or_else(|_| self.check_decoy_info(&HardFork::V16)); + } + + let current_minimum_decoys = minimum_decoys(hf); + + if self.min_decoys < current_minimum_decoys { + if self.not_mixable == 0 { + return Err(ConsensusError::TransactionHasInvalidRing( + "input does not have enough decoys", + )); + } + if self.mixable > 1 { + return Err(ConsensusError::TransactionHasInvalidInput( + "more than one mixable input with unmixable inputs", + )); + } + } + + if hf >= &HardFork::V8 && self.min_decoys != current_minimum_decoys { + return Err(ConsensusError::TransactionHasInvalidRing( + "one ring does not have the minimum number of decoys", + )); + } + + if hf >= &HardFork::V12 && self.min_decoys != self.max_decoys { + return Err(ConsensusError::TransactionHasInvalidRing( + "rings do not have the same number of members", + )); + } + + Ok(()) + } + + /// Checks the version is in the allowed range. + /// + /// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#version + pub fn check_tx_version(&self, version: u64, hf: &HardFork) -> Result<(), ConsensusError> { + if version == 0 { + return Err(ConsensusError::TransactionVersionInvalid); + } + + let max = max_tx_version(hf); + if version > max { + return Err(ConsensusError::TransactionVersionInvalid); + } + + // TODO: Doc is wrong here + let min = min_tx_version(hf); + if version < min && self.not_mixable != 0 { + return Err(ConsensusError::TransactionVersionInvalid); + } + + Ok(()) + } +} + +fn max_tx_version(hf: &HardFork) -> u64 { + if hf <= &HardFork::V3 { + 1 + } else { + 2 + } +} + +fn min_tx_version(hf: &HardFork) -> u64 { + if hf >= &HardFork::V6 { + 2 + } else { + 1 + } +} + +/// Returns the 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::*; + match hf { + V1 => panic!("hard-fork 1 does not use these rules!"), + V2 | V3 | V4 | V5 => 2, + V6 => 4, + V7 => 6, + V8 | V9 | V10 | V11 | V12 | V13 | V14 => 10, + _ => 15, + } +} + +/// Sums the inputs checking for overflow. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#inputs-and-outputs-must-not-overflow +pub(crate) 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(ConsensusError::TransactionInputsOverflow)?; + } + _ => { + return Err(ConsensusError::TransactionHasInvalidInput( + "input not ToKey", + )) + } + } + } + + Ok(sum) +} + +/// 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( + inputs: &[Input], + spent_kis: &mut HashSet<[u8; 32]>, +) -> Result<(), ConsensusError> { + for inp in inputs { + match inp { + Input::ToKey { key_image, .. } => { + if !key_image.is_torsion_free() { + return Err(ConsensusError::TransactionHasInvalidInput( + "key image has torsion", + )); + } + if !spent_kis.insert(key_image.compress().to_bytes()) { + return Err(ConsensusError::TransactionHasInvalidInput( + "key image already spent", + )); + } + } + _ => { + return Err(ConsensusError::TransactionHasInvalidInput( + "Input not ToKey", + )) + } + } + } + + Ok(()) +} diff --git a/consensus/src/transactions/outputs.rs b/consensus/src/transactions/outputs.rs new file mode 100644 index 00000000..35eee345 --- /dev/null +++ b/consensus/src/transactions/outputs.rs @@ -0,0 +1,113 @@ +use std::sync::OnceLock; + +use monero_serai::transaction::Output; + +use crate::{hardforks::HardFork, helper::check_point, transactions::TxVersion, ConsensusError}; + +static DECOMPOSED_AMOUNTS: OnceLock<[u64; 172]> = OnceLock::new(); + +pub(crate) fn decomposed_amounts() -> &'static [u64; 172] { + DECOMPOSED_AMOUNTS.get_or_init(|| { + let mut amounts = [1; 172]; + for zeros in 0..19 { + for digit in 1..10 { + amounts[zeros * 9 + digit - 1] = + (digit * 10_usize.pow(zeros as u32)).try_into().unwrap(); + } + } + amounts[171] = 10000000000000000000; + amounts + }) +} + +/// Checks the output keys are canonical points. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-keys-canonical +pub(crate) fn check_output_keys(outputs: &[Output]) -> Result<(), ConsensusError> { + for out in outputs { + if !check_point(&out.key) { + return Err(ConsensusError::TransactionInvalidOutput( + "outputs one time key is not a valid point", + )); + } + } + + Ok(()) +} + +/// Checks the output types are allowed. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-type +pub(crate) fn check_output_types(outputs: &[Output], hf: &HardFork) -> Result<(), ConsensusError> { + 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(ConsensusError::TransactionInvalidOutput( + "v15 output must have one output type", + )); + } + } + return Ok(()); + } + + for out in outputs { + if hf <= &HardFork::V14 && out.view_tag.is_some() { + return Err(ConsensusError::TransactionInvalidOutput( + "tagged output used before allowed", + )); + } else if hf >= &HardFork::V16 && out.view_tag.is_none() { + return Err(ConsensusError::TransactionInvalidOutput( + "output does not have a view tag", + )); + } + } + Ok(()) +} + +/// Checks that an output amount is decomposed. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount +pub(crate) fn is_decomposed_amount(amount: u64) -> bool { + decomposed_amounts().binary_search(&amount).is_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<(), ConsensusError> { + if amount == 0 { + return Err(ConsensusError::TransactionInvalidOutput( + "zero amount output for v1 tx", + )); + } + + if hf >= &HardFork::V2 && !is_decomposed_amount(amount) { + return Err(ConsensusError::TransactionInvalidOutput( + "v1 tx does not have decomposed amount", + )); + } + + 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 +pub(crate) 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(ConsensusError::TransactionOutputsOverflow)?; + } + + Ok(sum) +} diff --git a/consensus/src/transactions/signatures.rs b/consensus/src/transactions/signatures.rs new file mode 100644 index 00000000..73755509 --- /dev/null +++ b/consensus/src/transactions/signatures.rs @@ -0,0 +1,183 @@ +use std::collections::{HashMap, HashSet}; + +use curve25519_dalek::EdwardsPoint; +use monero_serai::{ + ringct::{mlsag::RingMatrix, RctType}, + transaction::{Input, Transaction}, +}; +use tower::ServiceExt; + +use crate::{hardforks::HardFork, ConsensusError, Database, DatabaseRequest, DatabaseResponse}; + +mod ring_sigs; + +pub(crate) use ring_sigs::verify_inputs_signatures; + +/// Gets the absolute offsets from the relative offsets. +/// This function will return an error if the relative offsets are empty or if the hf version is 6 or higher and +/// not all the ring members are unique. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#inputs-must-have-decoys +/// TODO: change the URL on this link \/ +/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#unique-inputs +fn get_absolute_offsets( + relative_offsets: &[u64], + hf: &HardFork, +) -> Result, ConsensusError> { + if relative_offsets.is_empty() { + return Err(ConsensusError::TransactionHasInvalidRing( + "ring has no members", + )); + } + + let mut offsets = Vec::with_capacity(relative_offsets.len()); + offsets.push(relative_offsets[0]); + + for i in 1..relative_offsets.len() { + if relative_offsets[i] == 0 && hf >= &HardFork::V6 { + // all ring members must be unique after v6 + return Err(ConsensusError::TransactionHasInvalidRing( + "ring has duplicate member", + )); + } + + offsets.push(relative_offsets[i - 1] + relative_offsets[i]); + } + Ok(offsets) +} + +/// Returns the outputs that are needed to verify the transaction inputs. +/// +/// The returned value is a hashmap with: +/// keys = amount +/// values = hashset of amount idxs +/// +pub fn get_ring_member_ids( + tx: &Transaction, + hf: &HardFork, +) -> Result>, ConsensusError> { + let mut members = HashMap::with_capacity(tx.prefix.inputs.len()); + + for input in &tx.prefix.inputs { + match input { + Input::ToKey { + amount, + key_offsets, + .. + } => members + .entry(amount.unwrap_or(0)) + .or_insert_with(HashSet::new) + .extend(get_absolute_offsets(key_offsets, hf)?), + // https://cuprate.github.io/monero-book/consensus_rules/transactions.html#input-type + _ => { + return Err(ConsensusError::TransactionHasInvalidInput( + "input not ToKey", + )) + } + } + } + + // https://cuprate.github.io/monero-book/consensus_rules/transactions.html#no-empty-inputs + if members.is_empty() { + return Err(ConsensusError::TransactionHasInvalidInput( + "transaction has no inputs", + )); + } + + Ok(members) +} + +/// Represents the ring members of the inputs. +pub enum Rings { + /// Legacy, pre-ringCT, ring. + Legacy(Vec>), + /// TODO: + RingCT, +} + +impl Rings { + /// Builds the rings for the transaction inputs, from the outputs. + pub fn new( + outputs: &HashMap>, + inputs: &[Input], + rct_type: RctType, + hf: &HardFork, + ) -> Result { + match rct_type { + RctType::Null => { + let legacy_ring = inputs + .iter() + .map(|inp| match inp { + Input::ToKey { + amount, + key_offsets, + .. + } => { + let offsets = get_absolute_offsets(key_offsets, hf)?; + 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)) + // this is a legacy ring we only need the one time key. + .and_then(|out| Some(out[0])) + .ok_or(ConsensusError::TransactionHasInvalidRing( + "ring member not in database", + )) + }) + .collect::>()?) + } + _ => Err(ConsensusError::TransactionHasInvalidInput( + "input not ToKey", + )), + }) + .collect::>()?; + + Ok(Rings::Legacy(legacy_ring)) + } + _ => todo!("RingCT"), + } + } +} + +/// Get [`Rings`] aka the outputs a transaction references for each transaction. +pub async fn batch_get_rings( + txs: &[Transaction], + hf: &HardFork, + database: D, +) -> Result, ConsensusError> { + let mut output_ids = HashMap::new(); + + for tx in txs { + let mut tx_out_ids = get_ring_member_ids(tx, hf)?; + for (amount, idxs) in tx_out_ids.drain() { + output_ids + .entry(amount) + .or_insert_with(HashSet::new) + .extend(idxs); + } + } + + let DatabaseResponse::Outputs(outputs) = database + .oneshot(DatabaseRequest::Outputs(output_ids)) + .await? + else { + panic!("Database sent incorrect response!") + }; + + let mut rings = Vec::with_capacity(txs.len()); + + for tx in txs { + rings.push(Rings::new( + &outputs, + &tx.prefix.inputs, + tx.rct_signatures.rct_type(), + hf, + )?); + } + + Ok(rings) +} diff --git a/consensus/src/transactions/signatures/ring_sigs.rs b/consensus/src/transactions/signatures/ring_sigs.rs new file mode 100644 index 00000000..ad4ae160 --- /dev/null +++ b/consensus/src/transactions/signatures/ring_sigs.rs @@ -0,0 +1,48 @@ +//! 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}; + +use super::Rings; +use crate::ConsensusError; + +/// 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( + rings: &Rings, + inputs: &[Input], + signatures: &[RingSignature], + tx_sig_hash: &[u8; 32], +) -> Result<(), ConsensusError> { + 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(ConsensusError::TransactionSignatureInvalid( + "number of ring sigs != inputs", + )); + } + + for ((input, ring), sig) in inputs.iter().zip(rings).zip(signatures) { + let Input::ToKey { key_image, .. } = input else { + panic!("How did we build a ring with no decoys?"); + }; + + if !sig.verify_ring_signature(tx_sig_hash, ring, key_image) { + return Err(ConsensusError::TransactionSignatureInvalid( + "Invalid ring signature", + )); + } + } + } + _ => panic!("tried to verify v1 tx with a non v1 ring"), + } + Ok(()) +} diff --git a/consensus/src/transactions/time_lock.rs b/consensus/src/transactions/time_lock.rs new file mode 100644 index 00000000..a6dc979e --- /dev/null +++ b/consensus/src/transactions/time_lock.rs @@ -0,0 +1,72 @@ +use std::cmp::min; + +use monero_serai::transaction::Timelock; + +use crate::{block::DifficultyCache, hardforks::HardFork, helper::current_time}; + +const BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW: u64 = 60; + +/// Checks if an outputs unlock time has passed. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time +pub fn output_unlocked( + time_lock: &Timelock, + difficulty_cache: &DifficultyCache, + current_chain_height: 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, difficulty_cache, current_chain_height, 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 the timestamp the should be used when checking locked outputs. +/// +/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#getting-the-current-time +fn get_current_timestamp( + difficulty_cache: &DifficultyCache, + current_chain_height: u64, + hf: &HardFork, +) -> u64 { + if hf < &HardFork::V13 || current_chain_height < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { + current_time() + } else { + let median = difficulty_cache + .median_timestamp(BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW.try_into().unwrap()); + let adjusted_median = + median + (BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW + 1) * hf.block_time().as_secs() / 2; + + // This is safe as we just check we don't have less than 60 blocks in the chain. + let adjusted_top_block = + difficulty_cache.top_block_timestamp().unwrap() + hf.block_time().as_secs(); + + min(adjusted_median, adjusted_top_block) + } +} + +/// 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, + difficulty_cache: &DifficultyCache, + current_chain_height: u64, + hf: &HardFork, +) -> bool { + let timestamp = get_current_timestamp(difficulty_cache, current_chain_height, hf); + timestamp + hf.block_time().as_secs() >= unlock_timestamp +}