From 5fbf27ad765f63cb01cf49aa3c04778fd6f3eb10 Mon Sep 17 00:00:00 2001 From: bay Date: Sun, 9 Aug 2026 10:05:23 -0700 Subject: [PATCH 1/7] Not found reporting mawwlr related issues, transaciton processing optimization --- api/src/foreign.rs | 16 +- api/src/foreign_rpc.rs | 17 +- api/src/handlers/pool_api.rs | 53 +++--- api/src/rest.rs | 3 +- pool/fuzz/fuzz_targets/common.rs | 5 +- pool/fuzz/fuzz_targets/transaction_pool.rs | 29 +-- pool/src/pool.rs | 6 +- pool/src/transaction_pool.rs | 115 ++++++++---- pool/tests/block_building.rs | 45 ++++- pool/tests/block_max_weight.rs | 3 +- pool/tests/block_reconciliation.rs | 3 +- pool/tests/coinbase_maturity.rs | 7 +- pool/tests/common.rs | 19 ++ pool/tests/nrd_kernel_relative_height.rs | 38 ++-- pool/tests/nrd_kernels_disabled.rs | 4 +- pool/tests/nrd_kernels_enabled.rs | 6 +- pool/tests/transaction_pool.rs | 209 +++++++++++++-------- servers/src/common/adapters.rs | 10 +- servers/src/mwc/dandelion_monitor.rs | 66 ++++--- 19 files changed, 412 insertions(+), 242 deletions(-) diff --git a/api/src/foreign.rs b/api/src/foreign.rs index 37e1866a1c..ed957efdb5 100644 --- a/api/src/foreign.rs +++ b/api/src/foreign.rs @@ -477,13 +477,15 @@ where let pool_handler = PoolHandler { tx_pool: self.tx_pool.clone(), }; - pool_handler.push_transaction(tx, fluff, secp).map_err(|e| { - warn!( - "Unable to push transaction {} into the pool, {}", - tx_hash, e - ); - e - }) + pool_handler + .push_transaction(tx, tx_hash, fluff, secp) + .map_err(|e| { + warn!( + "Unable to push transaction {} into the pool, {}", + tx_hash, e + ); + e + }) } pub fn get_running_time(&self) -> u64 { diff --git a/api/src/foreign_rpc.rs b/api/src/foreign_rpc.rs index bf22cf6e79..3be8bbf4b3 100644 --- a/api/src/foreign_rpc.rs +++ b/api/src/foreign_rpc.rs @@ -28,6 +28,7 @@ use mwc_core::core::transaction::Transaction; use mwc_core::ser::{self, ProtocolVersion}; use mwc_crates::easy_jsonrpc_mwc; use mwc_crates::easy_jsonrpc_mwc::{Handler, InvalidArgs, Params, Value}; +use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_crates::serde::de::DeserializeOwned; use mwc_p2p::types::{PeerInfoDisplayLegacy, ProcessStatus}; use mwc_pool::{BlockChain, PoolAdapter}; @@ -1172,9 +1173,10 @@ where })? .get_context_id(); let (tx, fluff) = parse_push_transaction_args(params, context_id)?; - let result = secp_static::with_commit_mut(Error::from, |secp| { - Foreign::push_transaction(self.inner, tx, fluff, secp) - }); + // Relay may re-enter the thread-local secp context while serializing the tx. + let result = Secp256k1::with_caps(ContextFlag::Commit) + .map_err(Error::from) + .and_then(|mut secp| Foreign::push_transaction(self.inner, tx, fluff, &mut secp)); easy_jsonrpc_mwc::try_serialize(&result.into_rpc_result()) } } @@ -1971,10 +1973,11 @@ where Foreign::get_unconfirmed_transactions(self).into_rpc_result() } fn push_transaction(&self, tx: Transaction, fluff: Option) -> RpcResult<()> { - secp_static::with_commit_mut(Error::from, |secp| { - Foreign::push_transaction(self, tx, fluff, secp) - }) - .into_rpc_result() + // Relay may re-enter the thread-local secp context while serializing the tx. + Secp256k1::with_caps(ContextFlag::Commit) + .map_err(Error::from) + .and_then(|mut secp| Foreign::push_transaction(self, tx, fluff, &mut secp)) + .into_rpc_result() } } diff --git a/api/src/handlers/pool_api.rs b/api/src/handlers/pool_api.rs index 2ff65392e9..c18cbe0e34 100644 --- a/api/src/handlers/pool_api.rs +++ b/api/src/handlers/pool_api.rs @@ -18,7 +18,7 @@ use crate::rest::*; use crate::router::{Handler, ResponseFuture}; use crate::types::*; use crate::web::*; -use mwc_core::core::hash::Hashed; +use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::Transaction; use mwc_core::ser::{self, ProtocolVersion}; use mwc_crates::bytes::Bytes; @@ -28,7 +28,7 @@ use mwc_crates::parking_lot::RwLock; use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_crates::serde::{self, Deserialize, Serialize}; use mwc_pool::{self, BlockChain, PoolAdapter}; -use std::sync::Weak; +use std::sync::{Arc, Weak}; pub const MAX_UNCONFIRMED_TRANSACTIONS: usize = 1_000; @@ -89,13 +89,12 @@ where pub fn push_transaction( &self, tx: Transaction, + tx_hash: Hash, fluff: Option, secp: &mut Secp256k1, ) -> Result<(), Error> { let pool_arc = w(&self.tx_pool)?; - let context_id = pool_arc.read_recursive().get_context_id(); let source = mwc_pool::TxSource::PushApi; - let tx_hash = tx.hash(context_id)?; info!( "Pushing transaction {} to pool (inputs: {}, outputs: {}, kernels: {}, fluff: {:?})", tx_hash, @@ -105,15 +104,22 @@ where fluff, ); - // Push to tx pool. - let mut tx_pool = pool_arc.write(); - let header = tx_pool - .blockchain + let blockchain = { + let tx_pool = pool_arc.read_recursive(); + tx_pool.blockchain.clone() + }; + let header = blockchain .chain_head() .map_err(|e| Error::Internal(format!("Failed to get chain head, {}", e)))?; - tx_pool - .add_to_pool(source, tx, !fluff.unwrap_or(false), &header, secp) - .map_err(pool_error_to_api_error)?; + mwc_pool::TransactionPool::submit_to_pool( + pool_arc.as_ref(), + source, + tx, + !fluff.unwrap_or(false), + &header, + secp, + ) + .map_err(pool_error_to_api_error)?; info!("transaction {} was added to the pool", tx_hash); @@ -207,27 +213,12 @@ where )) })?; - let source = mwc_pool::TxSource::PushApi; - info!( - "Pushing transaction {} to pool (inputs: {}, outputs: {}, kernels: {})", - tx.hash(context_id)?, - tx.inputs().len(), - tx.outputs().len(), - tx.kernels().len(), - ); - + let tx_hash = tx.hash(context_id)?; let mut secp = Secp256k1::with_caps(ContextFlag::Commit)?; - - // Push to tx pool. - let mut tx_pool = pool.write(); - let header = tx_pool - .blockchain - .chain_head() - .map_err(|e| Error::Internal(format!("Failed to get chain head: {}", e)))?; - tx_pool - .add_to_pool(source, tx, !fluff, &header, &mut secp) - .map_err(pool_error_to_api_error)?; - Ok(()) + let pool_handler = PoolHandler { + tx_pool: Arc::downgrade(&pool), + }; + pool_handler.push_transaction(tx, tx_hash, Some(fluff), &mut secp) } impl Handler for PoolPushHandler diff --git a/api/src/rest.rs b/api/src/rest.rs index 87e4963f8c..a6e65a04cf 100644 --- a/api/src/rest.rs +++ b/api/src/rest.rs @@ -75,7 +75,8 @@ pub enum Error { Internal(String), #[error("API Bad arguments: {0}")] Argument(String), - #[error("API Not found: {0}")] + // Note, keep 'NotFound' because wallet using it to classify the error types + #[error("API NotFound: {0}")] NotFound(String), #[error("API Request error: {0}")] RequestError(String), diff --git a/pool/fuzz/fuzz_targets/common.rs b/pool/fuzz/fuzz_targets/common.rs index 7a9ebdd702..0ec46386c4 100644 --- a/pool/fuzz/fuzz_targets/common.rs +++ b/pool/fuzz/fuzz_targets/common.rs @@ -43,6 +43,7 @@ use mwc_core::global; use mwc_core::libtx::{build, reward, ProofBuilder}; use mwc_core::pow; use mwc_crates::chrono::Duration; +use mwc_crates::parking_lot::RwLock; use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::{ContextFlag, Secp256k1, SecretKey}; use mwc_keychain::{ExtKeychain, ExtKeychainPath, Keychain}; @@ -222,7 +223,7 @@ pub struct PoolFuzzer { pub chain: Arc, pub secp: Secp256k1, pub keychain: ExtKeychain, - pub pool: TransactionPool, + pub pool: RwLock>, } impl PoolFuzzer { @@ -246,7 +247,7 @@ impl PoolFuzzer { chain, secp, keychain, - pool, + pool: RwLock::new(pool), }; ret.add_some_blocks(3); diff --git a/pool/fuzz/fuzz_targets/transaction_pool.rs b/pool/fuzz/fuzz_targets/transaction_pool.rs index 4c38dd7fce..6395266277 100644 --- a/pool/fuzz/fuzz_targets/transaction_pool.rs +++ b/pool/fuzz/fuzz_targets/transaction_pool.rs @@ -123,13 +123,11 @@ fuzz_target!(|data: &[u8]| { global::set_local_accept_fee_base(global::DEFAULT_ACCEPT_FEE_BASE) .expect("valid accept fee base"); - // check for corpus generation arguments - // only generate corpus once, skipping on every other run - if let Ok(gen_corpus) = std::env::var("MWC_POOL_GEN_CORPUS") { - if gen_corpus == "0" { - gen_tx_corpus().unwrap(); - std::env::set_var("MWC_POOL_GEN_CORPUS", "1"); - } + // Check for corpus generation arguments and generate at most once per + // process. Avoid mutating the process environment from the fuzz callback. + static GENERATE_CORPUS: std::sync::Once = std::sync::Once::new(); + if std::env::var("MWC_POOL_GEN_CORPUS").as_deref() == Ok("0") { + GENERATE_CORPUS.call_once(|| gen_tx_corpus().unwrap()); } let mut fuzzer = PoolFuzzer::new("fuzz/target/.transaction_pool"); @@ -147,14 +145,19 @@ fuzz_target!(|data: &[u8]| { // attempt to add fuzzed tx to the transaction pool // fuzz tx source on random first byte of fuzzer input // add to tx pool, then stem pool - match fuzzer - .pool - .add_to_pool(tx_source, tx.unwrap(), i, &header, &mut fuzzer.secp) - { + match mwc_pool::TransactionPool::submit_to_pool( + &fuzzer.pool, + tx_source, + tx.unwrap(), + i, + &header, + &mut fuzzer.secp, + ) { Ok(_) if i => { - assert!(fuzzer.pool.stempool.size() >= 1 || fuzzer.pool.total_size() >= 1) + let pool = fuzzer.pool.read_recursive(); + assert!(pool.stempool.size() >= 1 || pool.total_size() >= 1) } - Ok(_) => assert!(fuzzer.pool.total_size() >= 1), + Ok(_) => assert!(fuzzer.pool.read_recursive().total_size() >= 1), Err(_) => continue, } } diff --git a/pool/src/pool.rs b/pool/src/pool.rs index 39c8056da2..bada0ba1fa 100644 --- a/pool/src/pool.rs +++ b/pool/src/pool.rs @@ -241,7 +241,7 @@ where // Aggregate this new tx with all existing txs in the pool. // If we can validate the aggregated tx against the current chain state // then we can safely add the tx to the pool. - pub fn add_to_pool( + pub(crate) fn add_entry( &mut self, entry: PoolEntry, extra_tx: Option, @@ -286,7 +286,7 @@ where fn log_pool_add(&self, entry: &PoolEntry, header: &BlockHeader) { debug!( - "add_to_pool [{}]: {} ({:?}) [in/out/kern: {}/{}/{}] pool: {} (at block {})", + "pool add_entry [{}]: {} ({:?}) [in/out/kern: {}/{}/{}] pool: {} (at block {})", self.name, entry.tx.hash(self.context_id).unwrap_or(Hash::default()), entry.src, @@ -477,7 +477,7 @@ where self.entries.clear(); for x in existing_entries { let tx_hash = x.tx.hash(self.context_id).unwrap_or(Hash::default()); - if let Err(e) = self.add_to_pool(x, extra_tx.clone(), header, secp) { + if let Err(e) = self.add_entry(x, extra_tx.clone(), header, secp) { warn!( "reconcile [{}]: evicting tx {} at block {} due to error: {}", self.name, tx_hash, header_hash, e, diff --git a/pool/src/transaction_pool.rs b/pool/src/transaction_pool.rs index d1dbf75d66..17dd7f4be4 100644 --- a/pool/src/transaction_pool.rs +++ b/pool/src/transaction_pool.rs @@ -36,6 +36,15 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::Duration; +enum PoolAddOutcome { + /// The transaction was accepted into the stempool and relayed through the + /// Dandelion stem path. + Stemmed, + /// The transaction was accepted into the public txpool and should be + /// broadcast on a best-effort basis. + Fluff(PoolEntry), +} + /// Transaction pool implementation. pub struct TransactionPool where @@ -88,7 +97,7 @@ where secp: &mut Secp256k1, ) -> Result<(), PoolError> { self.stempool - .add_to_pool(entry.clone(), extra_tx, header, secp) + .add_entry(entry.clone(), extra_tx, header, secp) } fn add_to_reorg_cache(&mut self, entry: &PoolEntry) { @@ -104,16 +113,20 @@ where } // Deaggregate this tx against the txpool. - // Returns the new deaggregated tx or the original tx if no deaggregation. - fn deaggregate_tx(&self, entry: PoolEntry, secp: &Secp256k1) -> Result { + // Returns the resulting entry and whether deaggregation changed the tx. + fn deaggregate_tx( + &self, + entry: PoolEntry, + secp: &Secp256k1, + ) -> Result<(PoolEntry, bool), PoolError> { if entry.tx.kernels().len() > 1 { let txs = self.txpool.find_matching_transactions(entry.tx.kernels())?; if !txs.is_empty() { let tx = transaction::deaggregate(self.context_id, entry.tx, &txs, secp)?; - return Ok(PoolEntry::new(tx, TxSource::Deaggregate)); + return Ok((PoolEntry::new(tx, TxSource::Deaggregate), true)); } } - Ok(entry) + Ok((entry, false)) } fn add_to_txpool( @@ -122,7 +135,7 @@ where header: &BlockHeader, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - self.txpool.add_to_pool(entry.clone(), None, header, secp)?; + self.txpool.add_entry(entry.clone(), None, header, secp)?; // We now need to reconcile the stempool based on the new state of the txpool. // Some stempool txs may no longer be valid and we need to evict them. @@ -150,29 +163,69 @@ where Ok(()) } - /// Add the given tx to the pool, directing it to either the stempool or - /// txpool based on stem flag provided. - pub fn add_to_pool( - &mut self, + /// Validate and submit a transaction through the shared transaction pool. + /// + /// This is the public transaction-admission boundary. State-independent + /// cryptographic validation happens before the pool write lock is acquired, + /// pool- and chain-dependent admission happens under the lock, and public + /// fluff relay happens after the lock is released. + pub fn submit_to_pool( + tx_pool: &RwLock, src: TxSource, tx: Transaction, stem: bool, header: &BlockHeader, secp: &mut Secp256k1, ) -> Result<(), PoolError> { + let (context_id, adapter) = { + let tx_pool = tx_pool.read_recursive(); + (tx_pool.context_id, tx_pool.adapter.clone()) + }; + + tx.validate(context_id, Weighting::AsTransaction, secp)?; + + let outcome = { + let mut tx_pool = tx_pool.write(); + if tx_pool.context_id != context_id { + return Err(PoolError::Other(format!( + "transaction context {} does not match pool context {}", + context_id, tx_pool.context_id + ))); + } + tx_pool.admit_prevalidated(src, tx, stem, header, secp)? + }; + + if let PoolAddOutcome::Fluff(entry) = outcome { + if let Err(e) = adapter.tx_accepted(&entry) { + // Local acceptance is the contract here; network relay is best-effort. + warn!("txpool adapter failed after accepting tx: {}", e); + } + } + + Ok(()) + } + + fn admit_prevalidated( + &mut self, + src: TxSource, + tx: Transaction, + stem: bool, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { // Quick check for duplicate txs. // Our stempool is private and we do not want to reveal anything about the txs contained. // If this is a stem tx and is already present in stempool then fluff by adding to txpool. // Otherwise if already present in txpool return a "duplicate tx" error. if stem && self.stempool.contains_tx(&tx)? { - return self.add_to_pool(src, tx, false, header, secp); + return self.admit_prevalidated(src, tx, false, header, secp); } else if self.txpool.contains_tx(&tx)? { return Err(PoolError::DuplicateTx); } // Attempt to deaggregate the tx if not stem tx. - let entry = if stem { - PoolEntry::new(tx, src) + let (entry, deaggregated) = if stem { + (PoolEntry::new(tx, src), false) } else { self.deaggregate_tx(PoolEntry::new(tx, src), secp)? }; @@ -187,14 +240,16 @@ where let mut evict = false; if !stem && matches!(acceptability.as_ref().err(), Some(PoolError::OverCapacity)) { evict = true; - } else if acceptability.is_err() { - return acceptability; + } else { + acceptability?; } - // Make sure the transaction is valid before anything else. - // Validate tx accounting for max tx weight. - tx.validate(self.context_id, Weighting::AsTransaction, secp) - .map_err(PoolError::InvalidTx)?; + // A deaggregated transaction differs from the transaction validated before + // taking the write lock and must be checked again. + if deaggregated { + tx.validate(self.context_id, Weighting::AsTransaction, secp) + .map_err(PoolError::InvalidTx)?; + } // Check the tx lock_time is valid based on current chain state. self.blockchain.verify_tx_lock_height(tx)?; @@ -226,31 +281,27 @@ where self.blockchain.verify_coinbase_maturity(&coinbase_inputs)?; // Convert the tx to "v2" compatibility with "features and commit" inputs. - let ref entry = self.convert_tx_v2(entry, &spent_pool, &spent_utxo, secp)?; + let entry = self.convert_tx_v2(entry, &spent_pool, &spent_utxo, secp)?; // If this is a stem tx then attempt to add it to stempool. // If the adapter fails to accept the new stem tx then fallback to fluff via txpool. if stem { - self.add_to_stempool(entry, header, extra_tx, secp)?; - if self.adapter.stem_tx_accepted(entry).is_ok() { - return Ok(()); + self.add_to_stempool(&entry, header, extra_tx, secp)?; + if self.adapter.stem_tx_accepted(&entry).is_ok() { + return Ok(PoolAddOutcome::Stemmed); } } // Add tx to txpool. - self.add_to_txpool(entry, header, secp)?; - self.add_to_reorg_cache(entry); - if let Err(e) = self.adapter.tx_accepted(entry) { - // Local acceptance is the contract here; network relay is best-effort. - warn!("txpool adapter failed after accepting tx: {}", e); - } + self.add_to_txpool(&entry, header, secp)?; + self.add_to_reorg_cache(&entry); // Transaction passed all the checks but we have to make space for it if evict { self.evict_from_txpool(secp)?; } - Ok(()) + Ok(PoolAddOutcome::Fluff(entry)) } /// Convert a transaction for v2 compatibility. @@ -353,8 +404,8 @@ where // retained cache entries because accept_fee_base is initialized once per // node context and max transaction weight is derived from fixed chain // constants. The context-sensitive checks that can change across blocks - // or reorgs are still re-run by Pool::add_to_pool(). - match self.txpool.add_to_pool(entry.clone(), None, header, secp) { + // or reorgs are still re-run by Pool::add_entry(). + match self.txpool.add_entry(entry.clone(), None, header, secp) { Ok(()) => { added = true; replayed += 1; diff --git a/pool/tests/block_building.rs b/pool/tests/block_building.rs index 1bf7f5f4f3..a25a877669 100644 --- a/pool/tests/block_building.rs +++ b/pool/tests/block_building.rs @@ -75,13 +75,48 @@ fn test_transaction_pool_block_building() -> Result<(), PoolError> { { // Add the three root txs to the pool. - pool.add_to_pool(test_source(), root_tx_1.clone(), false, &header, &mut secp)?; - pool.add_to_pool(test_source(), root_tx_2.clone(), false, &header, &mut secp)?; - pool.add_to_pool(test_source(), root_tx_3.clone(), false, &header, &mut secp)?; + submit_to_pool!( + pool, + test_source(), + root_tx_1.clone(), + false, + &header, + &mut secp + )?; + submit_to_pool!( + pool, + test_source(), + root_tx_2.clone(), + false, + &header, + &mut secp + )?; + submit_to_pool!( + pool, + test_source(), + root_tx_3.clone(), + false, + &header, + &mut secp + )?; // Now add the two child txs to the pool. - pool.add_to_pool(test_source(), child_tx_1.clone(), false, &header, &mut secp)?; - pool.add_to_pool(test_source(), child_tx_2.clone(), false, &header, &mut secp)?; + submit_to_pool!( + pool, + test_source(), + child_tx_1.clone(), + false, + &header, + &mut secp + )?; + submit_to_pool!( + pool, + test_source(), + child_tx_2.clone(), + false, + &header, + &mut secp + )?; assert_eq!(pool.total_size(), 5); } diff --git a/pool/tests/block_max_weight.rs b/pool/tests/block_max_weight.rs index bb688d4db5..8e210aebf5 100644 --- a/pool/tests/block_max_weight.rs +++ b/pool/tests/block_max_weight.rs @@ -110,8 +110,7 @@ fn test_block_building_max_weight() { // Populate our txpool with the txs. for tx in txs { - pool.add_to_pool(test_source(), tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); } // Check we added them all to the txpool successfully. diff --git a/pool/tests/block_reconciliation.rs b/pool/tests/block_reconciliation.rs index e8df5aa84b..a6140b44e8 100644 --- a/pool/tests/block_reconciliation.rs +++ b/pool/tests/block_reconciliation.rs @@ -120,8 +120,7 @@ fn test_transaction_pool_block_reconciliation() { assert_eq!(pool.total_size(), 0); for tx in &txs_to_add { - pool.add_to_pool(test_source(), tx.clone(), false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx.clone(), false, &header, &mut secp).unwrap(); } assert_eq!(pool.total_size(), txs_to_add.len()); diff --git a/pool/tests/coinbase_maturity.rs b/pool/tests/coinbase_maturity.rs index 119dbb3c03..35ef2848a6 100644 --- a/pool/tests/coinbase_maturity.rs +++ b/pool/tests/coinbase_maturity.rs @@ -55,16 +55,15 @@ fn test_coinbase_maturity() { // Coinbase is not yet matured and cannot be spent. let header = chain.head_header().unwrap(); assert!(matches!( - pool.add_to_pool(test_source(), tx.clone(), true, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx.clone(), true, &header, &mut secp), Err(PoolError::ImmatureCoinbase) )); // Add 2 more blocks. Original coinbase output is now matured and can be spent. add_some_blocks(&mut secp, &chain, 2, &keychain); let header = chain.head_header().unwrap(); - assert!(pool - .add_to_pool(test_source(), tx.clone(), true, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx.clone(), true, &header, &mut secp).is_ok()); + assert_eq!(pool.stempool.size(), 1); clean_output_dir(db_root.into()); } diff --git a/pool/tests/common.rs b/pool/tests/common.rs index d3723afb33..b296e7d805 100644 --- a/pool/tests/common.rs +++ b/pool/tests/common.rs @@ -36,6 +36,25 @@ use std::convert::TryInto; use std::fs; use std::sync::Arc; +#[allow(unused_macros)] +macro_rules! submit_to_pool { + ($pool:expr, $src:expr, $tx:expr, $stem:expr, $header:expr, $secp:expr) => {{ + let tx_pool_lock = mwc_crates::parking_lot::RwLock::new($pool); + let result = mwc_pool::TransactionPool::submit_to_pool( + &tx_pool_lock, + $src, + $tx, + $stem, + $header, + $secp, + ); + $pool = tx_pool_lock.into_inner(); + result + }}; +} +#[allow(unused_imports)] +pub(crate) use submit_to_pool; + // Keep test targets compilable without exposing the production builder. Any // affected test reaches this shim and fails with an actionable runtime error. #[allow(dead_code, unused_imports)] diff --git a/pool/tests/nrd_kernel_relative_height.rs b/pool/tests/nrd_kernel_relative_height.rs index 8bb414ab76..06a080799e 100644 --- a/pool/tests/nrd_kernel_relative_height.rs +++ b/pool/tests/nrd_kernel_relative_height.rs @@ -138,34 +138,30 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { }; // Confirm we can successfully add tx1 with NRD kernel to stempool. - assert!(pool - .add_to_pool(test_source(), tx1.clone(), true, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx1.clone(), true, &header, &mut secp).is_ok()); assert_eq!(pool.stempool.size(), 1); // Confirm we cannot add tx2 to stempool while tx1 is in there (duplicate NRD kernels). assert!(matches!( - pool.add_to_pool(test_source(), tx2.clone(), true, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx2.clone(), true, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); // Confirm we can successfully add tx1 with NRD kernel to txpool, // removing existing instance of tx1 from stempool in the process. - assert!(pool - .add_to_pool(test_source(), tx1.clone(), false, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx1.clone(), false, &header, &mut secp).is_ok()); assert_eq!(pool.txpool.size(), 1); assert_eq!(pool.stempool.size(), 0); // Confirm we cannot add tx2 to stempool while tx1 is in txpool (duplicate NRD kernels). assert!(matches!( - pool.add_to_pool(test_source(), tx2.clone(), true, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx2.clone(), true, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); // Confirm we cannot add tx2 to txpool while tx1 is in there (duplicate NRD kernels). assert!(matches!( - pool.add_to_pool(test_source(), tx2.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx2.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); @@ -191,13 +187,13 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { // Confirm we cannot add tx2 to stempool with tx1 in previous block (NRD relative_height=2) assert!(matches!( - pool.add_to_pool(test_source(), tx2.clone(), true, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx2.clone(), true, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); // Confirm we cannot add tx2 to txpool with tx1 in previous block (NRD relative_height=2) assert!(matches!( - pool.add_to_pool(test_source(), tx2.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx2.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); @@ -206,27 +202,23 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { let header = chain.head_header().unwrap(); // Confirm we can now add tx2 to stempool with NRD relative_height rule met. - assert!(pool - .add_to_pool(test_source(), tx2.clone(), true, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx2.clone(), true, &header, &mut secp).is_ok()); assert_eq!(pool.total_size(), 0); assert_eq!(pool.txpool.size(), 0); assert_eq!(pool.stempool.size(), 1); // Confirm we cannot yet add tx3 to stempool (NRD relative_height=1) assert!(matches!( - pool.add_to_pool(test_source(), tx3.clone(), true, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx3.clone(), true, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); // Confirm we can now add tx2 to txpool with NRD relative_height rule met. - assert!(pool - .add_to_pool(test_source(), tx2.clone(), false, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx2.clone(), false, &header, &mut secp).is_ok()); // Confirm we cannot yet add tx3 to txpool (NRD relative_height=1) assert!(matches!( - pool.add_to_pool(test_source(), tx3.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx3.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelRelativeHeight) )); @@ -250,18 +242,14 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { assert_eq!(pool.stempool.size(), 0); // Confirm we can now add tx3 to stempool with tx2 in immediate previous block (NRD relative_height=1) - assert!(pool - .add_to_pool(test_source(), tx3.clone(), true, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx3.clone(), true, &header, &mut secp).is_ok()); assert_eq!(pool.total_size(), 0); assert_eq!(pool.txpool.size(), 0); assert_eq!(pool.stempool.size(), 1); // Confirm we can now add tx3 to txpool with tx2 in immediate previous block (NRD relative_height=1) - assert!(pool - .add_to_pool(test_source(), tx3.clone(), false, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx3.clone(), false, &header, &mut secp).is_ok()); assert_eq!(pool.total_size(), 1); assert_eq!(pool.txpool.size(), 1); diff --git a/pool/tests/nrd_kernels_disabled.rs b/pool/tests/nrd_kernels_disabled.rs index a2785fa197..449d2b24e5 100644 --- a/pool/tests/nrd_kernels_disabled.rs +++ b/pool/tests/nrd_kernels_disabled.rs @@ -76,7 +76,7 @@ fn test_nrd_kernels_disabled() { assert!(header.version < HeaderVersion(4)); assert!(matches!( - pool.add_to_pool(test_source(), tx_1.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx_1.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelNotEnabled) )); @@ -88,7 +88,7 @@ fn test_nrd_kernels_disabled() { // NRD kernel support not enabled via feature flag, so not valid. assert!(matches!( - pool.add_to_pool(test_source(), tx_1.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx_1.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelNotEnabled) )); diff --git a/pool/tests/nrd_kernels_enabled.rs b/pool/tests/nrd_kernels_enabled.rs index 0123956272..6b4e915adf 100644 --- a/pool/tests/nrd_kernels_enabled.rs +++ b/pool/tests/nrd_kernels_enabled.rs @@ -78,7 +78,7 @@ fn test_nrd_kernels_enabled() { assert!(header.version < HeaderVersion(3)); // in MWC activating NRD from V3 assert!(matches!( - pool.add_to_pool(test_source(), tx_1.clone(), false, &header, &mut secp), + submit_to_pool!(pool, test_source(), tx_1.clone(), false, &header, &mut secp), Err(PoolError::NRDKernelPreHF3) )); @@ -89,9 +89,7 @@ fn test_nrd_kernels_enabled() { assert_eq!(header.version, HeaderVersion(4)); // NRD kernel support enabled via feature flag, so valid. - assert!(pool - .add_to_pool(test_source(), tx_1.clone(), false, &header, &mut secp) - .is_ok()); + assert!(submit_to_pool!(pool, test_source(), tx_1.clone(), false, &header, &mut secp).is_ok()); assert_eq!(pool.total_size(), 1); let txs = pool.prepare_mineable_transactions(&mut secp).unwrap(); diff --git a/pool/tests/transaction_pool.rs b/pool/tests/transaction_pool.rs index 56709b5f25..64ef873016 100644 --- a/pool/tests/transaction_pool.rs +++ b/pool/tests/transaction_pool.rs @@ -20,12 +20,90 @@ use mwc_core::core::hash::Hashed; use mwc_core::core::{transaction, Weighting}; use mwc_core::global; use mwc_core::ser; +use mwc_crates::parking_lot::{Mutex, RwLock}; use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::{ContextFlag, Secp256k1, SecretKey}; use mwc_keychain::{ExtKeychain, Keychain}; -use mwc_pool::{PoolError, TxSource}; +use mwc_pool::{PoolAdapter, PoolConfig, PoolEntry, PoolError, TransactionPool, TxSource}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +#[derive(Default)] +struct RelayLockCheckingAdapter { + pool_is_unlocked: Mutex bool + Send + Sync>>>, + relay_observed_unlocked_pool: AtomicBool, +} + +impl PoolAdapter for RelayLockCheckingAdapter { + fn tx_accepted(&self, _entry: &PoolEntry) -> Result<(), PoolError> { + let pool_is_unlocked = self + .pool_is_unlocked + .lock() + .as_ref() + .map(|check| check()) + .unwrap_or(false); + self.relay_observed_unlocked_pool + .store(pool_is_unlocked, Ordering::SeqCst); + Ok(()) + } + + fn stem_tx_accepted(&self, _entry: &PoolEntry) -> Result<(), PoolError> { + Ok(()) + } +} + +#[test] +fn fluff_relay_runs_after_pool_write_lock_is_released() { + mwc_util::init_test_logger().unwrap(); + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_accept_fee_base(1).unwrap(); + global::set_local_nrd_enabled(false); + + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain: ExtKeychain = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let db_root = "target/.transaction_pool_relay_unlock"; + clean_output_dir(db_root.into()); + + let genesis = genesis_block(&keychain); + let chain = Arc::new(init_chain(&secp, db_root, genesis)); + add_some_blocks(&mut secp, &chain, 4 * 3, &keychain); + let header = chain.head_header().unwrap(); + let header_1 = chain.get_header_by_height(1).unwrap(); + let tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500]); + + let adapter = Arc::new(RelayLockCheckingAdapter::default()); + let pool = Arc::new(RwLock::new(TransactionPool::new( + 0, + PoolConfig { + tx_fee_base: mwc_pool::types::default_tx_fee_base(), + reorg_cache_timeout: 1_440, + max_pool_size: 50, + max_stempool_size: 50, + mineable_max_weight: 10_000, + }, + Arc::new(ChainAdapter { + chain: chain.clone(), + }), + adapter.clone(), + ))); + let weak_pool = Arc::downgrade(&pool); + *adapter.pool_is_unlocked.lock() = Some(Box::new(move || { + weak_pool + .upgrade() + .map(|pool| pool.try_write().is_some()) + .unwrap_or(false) + })); + + TransactionPool::submit_to_pool(pool.as_ref(), test_source(), tx, false, &header, &mut secp) + .unwrap(); + + assert!(adapter.relay_observed_unlocked_pool.load(Ordering::SeqCst)); + assert_eq!(pool.read_recursive().total_size(), 1); + clean_output_dir(db_root.into()); +} + /// Test we can add some txs to the pool (both stempool and txpool). #[test] fn test_the_transaction_pool() { @@ -63,8 +141,7 @@ fn test_the_transaction_pool() { // Add this tx to the pool (stem=false, direct to txpool). { - pool.add_to_pool(test_source(), initial_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 1); } @@ -72,9 +149,7 @@ fn test_the_transaction_pool() { // already in the txpool. In this case we attempt to spend the original coinbase twice. { let tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header, vec![501]); - assert!(pool - .add_to_pool(test_source(), tx, false, &header, &mut secp) - .is_err()); + assert!(submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).is_err()); } // tx1 spends some outputs from the initial test tx. @@ -87,8 +162,7 @@ fn test_the_transaction_pool() { assert_eq!(pool.total_size(), 1); // First, add a simple tx directly to the txpool (stem = false). - pool.add_to_pool(test_source(), tx1.clone(), false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx1.clone(), false, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 2); let tx1_kernel_hash = tx1 .kernels() @@ -108,8 +182,7 @@ fn test_the_transaction_pool() { .unwrap()); // Add another tx spending outputs from the previous tx. - pool.add_to_pool(test_source(), tx2.clone(), false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx2.clone(), false, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 3); } @@ -117,26 +190,22 @@ fn test_the_transaction_pool() { // This will fail for stem=false during tx aggregation due to duplicate // outputs and duplicate kernels. { - assert!(pool - .add_to_pool(test_source(), tx1.clone(), false, &header, &mut secp) - .is_err()); + assert!( + submit_to_pool!(pool, test_source(), tx1.clone(), false, &header, &mut secp).is_err() + ); } // Test adding a duplicate tx with the same input and outputs. // Note: not the *same* tx, just same underlying inputs/outputs. { let tx1a = test_transaction(&mut secp, &keychain, vec![500, 600], vec![469, 569]); - assert!(pool - .add_to_pool(test_source(), tx1a, false, &header, &mut secp) - .is_err()); + assert!(submit_to_pool!(pool, test_source(), tx1a, false, &header, &mut secp).is_err()); } // Test adding a tx attempting to spend a non-existent output. { let bad_tx = test_transaction(&mut secp, &keychain, vec![10_001], vec![9_900]); - assert!(pool - .add_to_pool(test_source(), bad_tx, false, &header, &mut secp) - .is_err()); + assert!(submit_to_pool!(pool, test_source(), bad_tx, false, &header, &mut secp).is_err()); } // Test adding a tx that would result in a duplicate output (conflicts with @@ -145,28 +214,22 @@ fn test_the_transaction_pool() { // to be immediately stolen via a "replay" tx. { let tx = test_transaction(&mut secp, &keychain, vec![900], vec![498]); - assert!(pool - .add_to_pool(test_source(), tx, false, &header, &mut secp) - .is_err()); + assert!(submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).is_err()); } // Confirm the tx pool correctly identifies an invalid tx (already spent). { let tx3 = test_transaction(&mut secp, &keychain, vec![500], vec![467]); - assert!(pool - .add_to_pool(test_source(), tx3, false, &header, &mut secp) - .is_err()); + assert!(submit_to_pool!(pool, test_source(), tx3, false, &header, &mut secp).is_err()); assert_eq!(pool.total_size(), 3); } // Now add a couple of txs to the stempool (stem = true). { let tx = test_transaction(&mut secp, &keychain, vec![569], vec![538]); - pool.add_to_pool(test_source(), tx, true, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, true, &header, &mut secp).unwrap(); let tx2 = test_transaction(&mut secp, &keychain, vec![538], vec![507]); - pool.add_to_pool(test_source(), tx2, true, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx2, true, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 3); assert_eq!(pool.stempool.size(), 2); } @@ -180,8 +243,7 @@ fn test_the_transaction_pool() { .unwrap() .unwrap(); assert_eq!(agg_tx.kernels().len(), 2); - pool.add_to_pool(test_source(), agg_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), agg_tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 4); assert!(pool.stempool.is_empty()); } @@ -190,15 +252,13 @@ fn test_the_transaction_pool() { // This handles the case of the stem path having a cycle in it. { let tx = test_transaction(&mut secp, &keychain, vec![507], vec![476]); - pool.add_to_pool(test_source(), tx.clone(), true, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx.clone(), true, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 4); assert_eq!(pool.txpool.size(), 4); assert_eq!(pool.stempool.size(), 1); // Duplicate stem tx so fluff, adding it to txpool and removing it from stempool. - pool.add_to_pool(test_source(), tx.clone(), true, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx.clone(), true, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 5); assert_eq!(pool.txpool.size(), 5); assert!(pool.stempool.is_empty()); @@ -224,8 +284,7 @@ fn test_the_transaction_pool() { .validate(0, Weighting::AsTransaction, &mut secp) .unwrap(); - pool.add_to_pool(test_source(), agg_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), agg_tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.total_size(), 6); let entries = pool.txpool.all_entries(); let entry = entries.last().unwrap(); @@ -240,26 +299,27 @@ fn test_the_transaction_pool() { test_transaction_spending_coinbase(&mut secp, &keychain, &header, vec![1000]); // check we cannot add a double spend to the stempool - assert!(pool - .add_to_pool( - test_source(), - double_spend_tx.clone(), - true, - &header, - &mut secp - ) - .is_err()); + assert!(submit_to_pool!( + pool, + test_source(), + double_spend_tx.clone(), + true, + &header, + &mut secp + ) + .is_err()); // check we cannot add a double spend to the txpool - assert!(pool - .add_to_pool( - test_source(), - double_spend_tx.clone(), - false, - &header, - &mut secp - ) - .is_err()); + assert!(submit_to_pool!( + pool, + test_source(), + double_spend_tx.clone(), + false, + &header, + &mut secp + ) + .is_err()); + assert_eq!(pool.total_size(), 6); } // Cleanup db directory @@ -291,12 +351,18 @@ fn test_stempool_remove_tx_by_transaction() { })); let initial_tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500, 600]); - pool.add_to_pool(test_source(), initial_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); let stem_tx = test_transaction(&mut secp, &keychain, vec![500], vec![469]); - pool.add_to_pool(test_source(), stem_tx.clone(), true, &header, &mut secp) - .unwrap(); + submit_to_pool!( + pool, + test_source(), + stem_tx.clone(), + true, + &header, + &mut secp + ) + .unwrap(); assert_eq!(pool.stempool.size(), 1); assert!(pool.stempool.contains_tx(&stem_tx).unwrap()); @@ -332,8 +398,7 @@ fn test_reconcile_reorg_cache_retains_valid_entries() { chain: chain.clone(), })); let tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500, 600]); - pool.add_to_pool(test_source(), tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.txpool.size(), 1); assert_eq!(pool.reorg_cache.read().len(), 1); @@ -384,20 +449,17 @@ fn test_transaction_pool_capacity_limits() { let initial_tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500, 600]); - pool.add_to_pool(test_source(), initial_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.txpool.size(), 1); let low_fee_tx = test_transaction(&mut secp, &keychain, vec![600], vec![599]); - let err = pool - .add_to_pool(test_source(), low_fee_tx, false, &header, &mut secp) + let err = submit_to_pool!(pool, test_source(), low_fee_tx, false, &header, &mut secp) .unwrap_err(); assert!(matches!(err, PoolError::LowFeeTransaction(1))); assert_eq!(pool.txpool.size(), 1); let tx = test_transaction(&mut secp, &keychain, vec![500], vec![469]); - pool.add_to_pool(test_source(), tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.txpool.size(), 1); } @@ -408,8 +470,7 @@ fn test_transaction_pool_capacity_limits() { pool.config.max_pool_size = 0; let tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_2, vec![700]); - pool.add_to_pool(test_source(), tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); assert_eq!(pool.txpool.size(), 0); } @@ -421,18 +482,14 @@ fn test_transaction_pool_capacity_limits() { let initial_tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_3, vec![800, 900]); - pool.add_to_pool(test_source(), initial_tx, false, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); let tx = test_transaction(&mut secp, &keychain, vec![800], vec![769]); - pool.add_to_pool(test_source(), tx, true, &header, &mut secp) - .unwrap(); + submit_to_pool!(pool, test_source(), tx, true, &header, &mut secp).unwrap(); assert_eq!(pool.stempool.size(), 1); let tx = test_transaction(&mut secp, &keychain, vec![900], vec![869]); - let err = pool - .add_to_pool(test_source(), tx, true, &header, &mut secp) - .unwrap_err(); + let err = submit_to_pool!(pool, test_source(), tx, true, &header, &mut secp).unwrap_err(); assert!(matches!(err, PoolError::OverCapacity)); assert_eq!(pool.stempool.size(), 1); } diff --git a/servers/src/common/adapters.rs b/servers/src/common/adapters.rs index 150bc235bd..bba9e4fea6 100644 --- a/servers/src/common/adapters.rs +++ b/servers/src/common/adapters.rs @@ -449,8 +449,14 @@ where hook.on_transaction_received(self.context_id, &tx); } - let mut tx_pool = self.tx_pool.write(); - match tx_pool.add_to_pool(source, tx, stem, &header, secp) { + match mwc_pool::TransactionPool::submit_to_pool( + self.tx_pool.as_ref(), + source, + tx, + stem, + &header, + secp, + ) { Ok(_) => { self.processed_transactions.contains(&tx_hash, true); Ok(true) diff --git a/servers/src/mwc/dandelion_monitor.rs b/servers/src/mwc/dandelion_monitor.rs index 5fbfd4728d..898503a4f3 100644 --- a/servers/src/mwc/dandelion_monitor.rs +++ b/servers/src/mwc/dandelion_monitor.rs @@ -21,7 +21,9 @@ use mwc_core::global; use mwc_crates::log::{debug, error, info, warn}; use mwc_crates::rand::{rng, RngExt}; use mwc_crates::secp::{ContextFlag, Secp256k1}; -use mwc_pool::{BlockChain, DandelionConfig, Pool, PoolEntry, PoolError, TxSource}; +use mwc_pool::{ + BlockChain, DandelionConfig, Pool, PoolEntry, PoolError, TransactionPool, TxSource, +}; use mwc_util::StopState; use std::collections::HashSet; use std::sync::Arc; @@ -126,16 +128,15 @@ fn process_fluff_phase( adapter: &Arc, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - // Take a write lock on the txpool for the duration of this processing. - let mut tx_pool = tx_pool.write(); + let mut pool = tx_pool.write(); - let all_entries = tx_pool.stempool.all_entries(); + let all_entries = pool.stempool.all_entries(); if all_entries.is_empty() { return Ok(()); } let cutoff_secs = dandelion_config.aggregation_secs as u32; - let cutoff_entries = select_txs_cutoff(&tx_pool.stempool, cutoff_secs); + let cutoff_entries = select_txs_cutoff(&pool.stempool, cutoff_secs); // If epoch is expired, fluff *all* outstanding entries in stempool. // If *any* entry older than aggregation_secs (30s) then fluff *all* entries. @@ -144,13 +145,13 @@ fn process_fluff_phase( return Ok(()); } - let header = tx_pool.chain_head()?; - let context_id = tx_pool.get_context_id(); + let header = pool.chain_head()?; + let context_id = pool.get_context_id(); let fluffable_txs = { - let txpool_tx = tx_pool.txpool.all_transactions_aggregate(None, secp)?; + let txpool_tx = pool.txpool.all_transactions_aggregate(None, secp)?; let txs: Vec<_> = all_entries.iter().map(|x| x.tx.clone()).collect(); - tx_pool.stempool.validate_raw_txs( + pool.stempool.validate_raw_txs( &txs, txpool_tx, &header, @@ -166,7 +167,7 @@ fn process_fluff_phase( for entry in &all_entries { let tx_hash = entry.tx.hash(context_id)?; if !fluffable_hashes.contains(&tx_hash) { - if tx_pool.stempool.remove_tx(&entry.tx)?.is_some() { + if pool.stempool.remove_tx(&entry.tx)?.is_some() { skipped += 1; debug!( "dand_mon: removed skipped stempool tx {} after failed aggregate validation", @@ -186,6 +187,7 @@ fn process_fluff_phase( return Ok(()); } + drop(pool); let fluff_txs = aggregate_fluffable_txs(context_id, &fluffable_txs, secp)?; debug!( "dand_mon: fluffing {} stempool txs as {} transaction batches", @@ -194,7 +196,22 @@ fn process_fluff_phase( ); for tx in fluff_txs { - tx_pool.add_to_pool(TxSource::Fluff, tx, false, &header, secp)?; + match TransactionPool::submit_to_pool( + tx_pool.as_ref(), + TxSource::Fluff, + tx, + false, + &header, + secp, + ) { + Ok(()) => {} + Err(PoolError::DuplicateTx) => { + // Admission is deliberately done after releasing the stempool lock. + // Another inbound path may have fluffed this same batch meanwhile. + debug!("dand_mon: fluff batch was already present in txpool"); + } + Err(e) => return Err(e), + } } Ok(()) } @@ -240,11 +257,7 @@ fn aggregate_as_transaction( txs: &[transaction::Transaction], secp: &mut Secp256k1, ) -> Result { - let agg_tx = transaction::aggregate(context_id, txs, secp)?; - agg_tx - .validate(context_id, transaction::Weighting::AsTransaction, secp) - .map_err(PoolError::InvalidTx)?; - Ok(agg_tx) + Ok(transaction::aggregate(context_id, txs, secp)?) } fn checked_add_weight(current_weight: u64, tx_weight: u64) -> Result { @@ -261,11 +274,10 @@ fn process_expired_entries( tx_pool: &ServerTxPool, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - // Take a write lock on the txpool for the duration of this processing. - let mut tx_pool = tx_pool.write(); + let pool = tx_pool.write(); let embargo_secs = dandelion_config.embargo_secs as u32 + rng().random_range(0..31); - let expired_entries = select_txs_cutoff(&tx_pool.stempool, embargo_secs); + let expired_entries = select_txs_cutoff(&pool.stempool, embargo_secs); if expired_entries.is_empty() { return Ok(()); @@ -273,28 +285,34 @@ fn process_expired_entries( debug!("dand_mon: Found {} expired txs.", expired_entries.len()); - let header = tx_pool.chain_head()?; - let context_id = tx_pool.get_context_id(); + let header = pool.chain_head()?; + let context_id = pool.get_context_id(); + drop(pool); for entry in expired_entries { let txhash = entry.tx.hash(context_id)?; - match tx_pool.add_to_pool( + match TransactionPool::submit_to_pool( + tx_pool.as_ref(), TxSource::EmbargoExpired, entry.tx.clone(), false, &header, secp, ) { - Ok(_) => info!( + Ok(()) => info!( "dand_mon: embargo expired for {}, fluffed successfully.", txhash ), + Err(PoolError::DuplicateTx) => debug!( + "dand_mon: embargo-expired tx {} was already present in txpool", + txhash + ), Err(e) => { warn!( "dand_mon: failed to fluff expired tx {}, evicting from stempool: {:?}", txhash, e ); - tx_pool.stempool.remove_tx(&entry.tx)?; + tx_pool.write().stempool.remove_tx(&entry.tx)?; } }; } From b78fea76920dafd582ff844d1d867f536a4964b5 Mon Sep 17 00:00:00 2001 From: bay Date: Tue, 18 Aug 2026 21:45:10 -0700 Subject: [PATCH 2/7] - Invalidate full blockchain data at the node start, add flag `--skip_start_blockchain_validation` to control this step. - Address bunch of audit comments about data consistency. - Range proof private nonce calculation is changes to address possible weakness. Because of the same reason removed not used extra data (to address possible future issue) - Eliminate possible race conditions during Arti (Tor) stop/restart. - Limit data volume at get_blocks REST API. - Limit data for output API if proofs are requested. - Address block v2 and v3 request and migraiton issues. Address problem that inputs can be sorted differently. - Address block v2 and v3 compare problems (needed to track malicious node). - Make reading from multiple DBs transactional (one of data consistency problem). - Address possible PIBD retry race conditions (ignoring late responses). - Address bunch of crash in the middle of blockchain issues. Many if such errors should be recoverable now. - Handle long rewinds with duplicated commitments (realistic case for coinbase outputs). Update spent commitments index to support that functionality. - Update Secp context creations to address performance issues. For frequent secp operations we can hold only one fast secp instance per thread. The next instances come with performance impact. - Address possible race conditions for Desegmenter and Segmenter. - Update how node difficulty cache can handle reorgs. - Limit rewinds for archive node, so the behaviour will be the same as non archive nodes have. - Handle better failures during save operations, do the best not to leak any data on failure. - Address possible problems because of very slow peers. Timeouts now applied with more fair policy. - Update log files rotation policy to save some HDD space. - Address comment regarding logging, conventing logs from different libraries. - Update peers eviction policy. Switch back to pure random. - Rewind changes to make it more tolerant to posiible data corruption. - Limit amount of calculations needed to update the transactions in the pool. - Optimize Tx pool operations. Add indexes so we can deny already accepted or conflicting transactions fast. - Data size checking for signature serialization. - Tighten block and headers validation. Checking that all data across all DB matching each other. - Update getting compact block REST API. Nonce was missing there. - Make p2p protocol more DDoS proof and stale requests proof. - Fixed secondary transaction submission problem. Second transaction submit can fluff, even first copy still at dandellion phase. - Fixed bunch of orphan blocks caching problems. - Optimize transaction submit workflow, so transaction pool will be more DDoS attack proof. - Optimize dandelion pool and transaction pool to be able to handle large number of transactions efficiently. - Update IP based peer tracking, switch to pure IP address without port to track peers. - Fixed PIBD Segmenter size estimation issue for segments that are partly pruned. - Handle server create/release race conditions. - Limit PIBD root hash cache. - Connect PIDB related requests with active Desegmenter. That allow to ignore late responses from the peers. --- Cargo.lock | 835 +- Cargo.toml | 20 +- api/Cargo.toml | 16 +- api/src/foreign.rs | 36 +- api/src/foreign_rpc.rs | 248 +- api/src/handlers/blocks_api.rs | 392 +- api/src/handlers/chain_api.rs | 583 +- api/src/handlers/peers_api.rs | 24 +- api/src/handlers/transactions_api.rs | 227 +- api/src/handlers/utils.rs | 46 - api/src/json_rpc.rs | 3 + api/src/owner.rs | 3 +- api/src/stratum.rs | 50 +- api/src/types.rs | 184 +- chain/Cargo.toml | 12 +- chain/src/chain.rs | 9525 +++++++++++++++-- chain/src/error.rs | 124 + chain/src/lib.rs | 248 +- chain/src/pipe.rs | 749 +- chain/src/store.rs | 743 +- chain/src/tests/chain_test_helper.rs | 6 +- chain/src/tests/mine_simple_chain.rs | 1266 ++- chain/src/tests/mod.rs | 2 +- chain/src/tests/process_block_cut_through.rs | 61 +- chain/src/tests/store_indices.rs | 8 +- chain/src/tests/test_block_known.rs | 118 +- chain/src/tests/test_pibd_copy.rs | 7 +- chain/src/txhashset/desegmenter.rs | 184 +- chain/src/txhashset/headers_desegmenter.rs | 140 +- chain/src/txhashset/segmenter.rs | 78 +- chain/src/txhashset/txhashset.rs | 4632 ++++++-- chain/src/txhashset/utxo_view.rs | 19 +- chain/src/types.rs | 61 + chain/tests/data_file_integrity.rs | 6 +- chain/tests/nrd_validation_rules.rs | 38 + chain/tests/test_coinbase_maturity.rs | 1 + chain/tests/test_get_kernel_height.rs | 3 +- chain/tests/test_header_perf.rs | 2 + chain/tests/test_pibd_validation.rs | 1 + chain/tests/validate_blockchain.rs | 4 +- config/Cargo.toml | 12 +- core/Cargo.toml | 8 +- core/src/core/block.rs | 55 +- core/src/core/pmmr/backend.rs | 5 +- core/src/core/pmmr/pmmr.rs | 5 +- core/src/core/pmmr/segment.rs | 14 +- core/src/core/transaction.rs | 187 +- core/src/difficulty_cache.rs | 65 +- core/src/libtx/aggsig.rs | 4 +- core/src/libtx/build.rs | 1 - core/src/libtx/proof.rs | 143 +- core/src/libtx/reward.rs | 2 +- core/src/libtx/secp_ser.rs | 147 +- core/src/pow/types.rs | 21 + core/src/ser.rs | 4 + core/src/stratum/connections.rs | 36 +- core/tests/segment.rs | 47 +- core/tests/transaction.rs | 25 +- doc/coinbase_maturity.md | 11 + doc/releases/release-7.0.0.md | 4 +- doc/releases/release-7.1.0.md | 41 + doc/replay_attack_mitigation.md | 16 + doc/translations/coinbase_maturity_KR.md | 11 +- doc/translations/coinbase_maturity_ZH-CN.md | 7 + keychain/Cargo.toml | 6 +- keychain/src/extkey_bip32.rs | 6 +- keychain/src/zeroizing_hmac_sha512.rs | 15 +- mwc_crates/Cargo.toml | 24 +- mwc_node_lib/Cargo.toml | 14 +- mwc_node_lib/src/mwc_node_calls.rs | 3 + node_workflow/Cargo.toml | 16 +- node_workflow/src/server.rs | 574 +- p2p/Cargo.toml | 12 +- p2p/src/conn.rs | 2 + p2p/src/handshake.rs | 207 +- p2p/src/peer.rs | 379 +- p2p/src/peers.rs | 93 +- p2p/src/store.rs | 5 + p2p/src/tor/arti.rs | 179 +- p2p/src/tor/onion_service.rs | 123 +- p2p/src/tor/tcp_data_stream.rs | 89 +- p2p/src/types.rs | 7 +- pool/Cargo.toml | 12 +- pool/fuzz/fuzz_targets/common.rs | 10 +- pool/src/pool.rs | 1955 +++- pool/src/transaction_pool.rs | 430 +- pool/src/types.rs | 9 +- pool/tests/block_building.rs | 6 +- pool/tests/block_max_weight.rs | 5 +- pool/tests/block_reconciliation.rs | 6 +- pool/tests/common.rs | 11 +- pool/tests/nrd_kernel_relative_height.rs | 9 +- pool/tests/transaction_pool.rs | 131 +- servers/Cargo.toml | 20 +- servers/src/common/adapters.rs | 87 +- servers/src/mwc/dandelion_monitor.rs | 43 +- servers/src/mwc/server.rs | 27 +- .../mwc/sync/block_headers_request_cache.rs | 1 + servers/src/mwc/sync/body_sync.rs | 92 +- servers/src/mwc/sync/header_sync.rs | 63 +- servers/src/mwc/sync/orphans_sync.rs | 499 +- servers/src/mwc/sync/state_sync.rs | 311 +- servers/src/mwc/sync/sync_manager.rs | 8 +- servers/src/mwc/sync/sync_utils.rs | 212 +- src/bin/cmd/server.rs | 38 +- src/bin/mwc.rs | 28 +- src/bin/mwc.yml | 4 + store/Cargo.toml | 8 +- store/src/lib.rs | 38 +- store/src/lmdb.rs | 29 +- store/src/pmmr.rs | 140 +- store/src/prune_list.rs | 8 + store/tests/lmdb.rs | 24 + store/tests/pmmr.rs | 116 + util/Cargo.toml | 4 +- util/src/file.rs | 118 +- util/src/logger.rs | 480 +- util/tests/file.rs | 27 + 118 files changed, 23954 insertions(+), 4385 deletions(-) create mode 100644 doc/releases/release-7.1.0.md diff --git a/Cargo.lock b/Cargo.lock index 692454c2d1..8f6deb971a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,13 +29,13 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", "zeroize", ] @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -244,9 +244,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc89b08e6a475a83d70e52f0ede139a433eb46e986a6cee6eeaa1b73297bf1" +checksum = "18e7e5f663c4a5db15ba9377b98b3fff2190f64c02e608beb1292dd67b30f199" dependencies = [ "async-trait", "cfg-if", @@ -266,7 +266,7 @@ dependencies = [ "safelog", "serde", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -315,7 +315,7 @@ dependencies = [ "nom", "num-traits 0.2.19", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -372,9 +372,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -496,9 +496,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -579,9 +579,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -589,9 +589,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -633,6 +633,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -806,9 +812,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -856,9 +862,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "castaway" @@ -871,9 +877,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -941,7 +947,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", - "zeroize", ] [[package]] @@ -953,6 +958,7 @@ dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", + "zeroize", ] [[package]] @@ -1132,6 +1138,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1303,11 +1315,11 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.4.4", + "cipher 0.5.2", ] [[package]] @@ -1523,9 +1535,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "defmt" @@ -1555,7 +1567,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1610,7 +1622,7 @@ checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", "indexmap 2.14.0", - "itertools 0.15.0", + "itertools", "proc-macro-crate", "proc-macro2", "quote", @@ -1802,13 +1814,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2057,9 +2069,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" @@ -2107,6 +2119,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "extension-traits" version = "1.0.1" @@ -2192,9 +2215,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -2242,16 +2265,17 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" dependencies = [ "derive_builder_fork_arti", "dirs 6.0.0", + "extend", "libc", "pwd-grp", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "void", "walkdir", ] @@ -2274,12 +2298,12 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fslock-guard" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e77b8a36ee17f9079b53db7281de9b16a8fb2bc97a7cd1e41a2998f3a837af7a" +checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" dependencies = [ "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -2291,9 +2315,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2306,9 +2330,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2316,15 +2340,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2333,9 +2357,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2352,13 +2376,13 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2374,21 +2398,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2542,9 +2566,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -2671,9 +2695,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2691,9 +2715,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2741,11 +2765,12 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", + "zeroize", ] [[package]] @@ -2840,9 +2865,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2854,9 +2879,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2867,9 +2892,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2881,16 +2906,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2901,15 +2927,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -2982,9 +3008,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -3029,9 +3055,18 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "iprange" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] [[package]] name = "is-terminal" @@ -3050,15 +3085,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.15.0" @@ -3139,7 +3165,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3188,9 +3214,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -3233,9 +3259,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -3243,9 +3269,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -3315,9 +3341,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -3364,9 +3390,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lmdb-zero" @@ -3428,7 +3454,7 @@ dependencies = [ "serde-value", "serde_json", "serde_yaml", - "thiserror 2.0.19", + "thiserror 2.0.20", "thread-id", "typemap-ors", "unicode-segmentation", @@ -3560,7 +3586,7 @@ dependencies = [ [[package]] name = "mwc" -version = "7.0.0" +version = "7.1.0" dependencies = [ "built", "mwc_api", @@ -3572,12 +3598,12 @@ dependencies = [ "mwc_p2p", "mwc_servers", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_api" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_chain", "mwc_core", @@ -3586,46 +3612,46 @@ dependencies = [ "mwc_p2p", "mwc_pool", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_chain" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_core", "mwc_crates", "mwc_keychain", "mwc_store", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_config" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_core", "mwc_crates", "mwc_p2p", "mwc_servers", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_core" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_crates", "mwc_keychain", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_crates" -version = "7.0.0" +version = "7.1.0" dependencies = [ "anyhow", "argon2", @@ -3716,7 +3742,7 @@ dependencies = [ "tokio-rustls", "tokio-socks", "tokio-util", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tor-cell", "tor-config", "tor-error", @@ -3738,17 +3764,17 @@ dependencies = [ [[package]] name = "mwc_keychain" -version = "7.0.0" +version = "7.1.0" dependencies = [ "blake2", "mwc_crates", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_node_lib" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_core", "mwc_crates", @@ -3757,12 +3783,12 @@ dependencies = [ "mwc_servers", "mwc_util", "safer-ffi", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_node_workflow" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_api", "mwc_chain", @@ -3771,31 +3797,31 @@ dependencies = [ "mwc_p2p", "mwc_servers", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_p2p" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_chain", "mwc_core", "mwc_crates", "mwc_store", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_pool" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_chain", "mwc_core", "mwc_crates", "mwc_keychain", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3814,7 +3840,7 @@ dependencies = [ [[package]] name = "mwc_servers" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_api", "mwc_chain", @@ -3825,25 +3851,25 @@ dependencies = [ "mwc_pool", "mwc_store", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_store" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_core", "mwc_crates", "mwc_util", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "mwc_util" -version = "7.0.0" +version = "7.1.0" dependencies = [ "mwc_crates", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4042,9 +4068,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits 0.2.19", ] @@ -4178,9 +4204,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oneshot-fused-workaround" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" dependencies = [ "futures", ] @@ -4358,12 +4384,12 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] @@ -4389,22 +4415,22 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -4421,9 +4447,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -4494,9 +4520,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polling" @@ -4524,9 +4550,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -4554,9 +4580,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4647,7 +4673,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4670,7 +4696,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -4678,9 +4704,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -4693,7 +4719,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4856,6 +4882,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "rayon" version = "1.12.0" @@ -4878,11 +4910,12 @@ dependencies = [ [[package]] name = "rdrand" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" dependencies = [ - "rand_core 0.6.4", + "libc", + "rand_core 0.10.1", ] [[package]] @@ -4930,23 +4963,23 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", @@ -4967,9 +5000,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5047,9 +5080,9 @@ dependencies = [ [[package]] name = "retry-error" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" dependencies = [ "humantime", "web-time", @@ -5127,7 +5160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5213,9 +5246,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -5287,9 +5320,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -5333,15 +5366,15 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safelog" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5396,9 +5429,9 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" [[package]] name = "schannel" @@ -5587,9 +5620,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -5597,6 +5630,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -5607,9 +5641,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -5711,7 +5745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", "sponge-cursor", ] @@ -5823,14 +5857,14 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.19", + "thiserror 2.0.20", "void", ] @@ -6193,11 +6227,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -6213,9 +6247,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -6243,9 +6277,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -6274,9 +6308,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -6317,13 +6351,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -6377,9 +6411,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -6457,30 +6491,42 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27fc83e7539ac5f717b722456df991ca8819782d04f7ae2ee28eefacca030eef" +checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" dependencies = [ + "cfg-if", "derive-deftly", "educe", + "extend", "futures", "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.19", + "sync_wrapper", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-basic-utils" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd01b9947f57a99193547f3a40a0bd179e9a332ead855c07e2fb702a1517f74" +checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" dependencies = [ + "derive-deftly", "derive_more", + "educe", + "extend", "getrandom 0.4.3", "hex", - "itertools 0.14.0", + "itertools", "libc", "paste", "rand 0.10.2", @@ -6488,16 +6534,17 @@ dependencies = [ "serde", "slab", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", + "tracing", "weak-table", "web-time-compat", ] [[package]] name = "tor-bytes" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6a7af55881d599cd48c841916520318d5711df17514001aafd5f796f61e337" +checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" dependencies = [ "bytes", "derive-deftly", @@ -6505,7 +6552,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "zeroize", @@ -6513,9 +6560,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad292658d194b145aaec268ffb91566b7d96c98971eea4980c1bf951d7d082b" +checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" dependencies = [ "amplify", "bitflags 2.13.1", @@ -6524,11 +6571,11 @@ dependencies = [ "derive-deftly", "derive_more", "educe", - "itertools 0.14.0", + "itertools", "paste", "rand 0.10.2", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -6544,15 +6591,16 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "474e732207b1be44f9ae4ddf295fba3644cd3c09494d131846ff9c9a624ab263" +checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "thiserror 2.0.19", + "saturating-time", + "thiserror 2.0.20", "tor-bytes", "tor-checkable", "tor-error", @@ -6562,9 +6610,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bb088804f54de320ee97dd2a48ae319f0df9d9d0e37eef050d65488194a768" +checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" dependencies = [ "async-trait", "base64ct", @@ -6582,7 +6630,7 @@ dependencies = [ "safelog", "serde", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6605,22 +6653,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9505791bac25b31b8de88c0e2b02e1690e10ff1308583c6f61c320bab2677959" +checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" dependencies = [ "humantime", + "itertools", "signature", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e986c7c4e1faae8a412b575ce66ff087050525a371db1034f0b24d70b459a2e" +checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" dependencies = [ "amplify", "async-trait", @@ -6633,7 +6682,7 @@ dependencies = [ "educe", "futures", "humantime-serde", - "itertools 0.14.0", + "itertools", "once_cell", "oneshot-fused-workaround", "pin-project", @@ -6641,7 +6690,7 @@ dependencies = [ "retry-error", "safelog", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6668,9 +6717,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224f85aa02b514c384ea5ec32f1b924233bea6b553195fd83da7f1e50a2e0d1b" +checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" dependencies = [ "amplify", "cfg-if", @@ -6678,11 +6727,12 @@ dependencies = [ "derive_builder_fork_arti", "educe", "either", + "extend", "figment", "fs-mistrust", "futures", "humantime-serde", - "itertools 0.14.0", + "itertools", "notify", "paste", "postage", @@ -6691,8 +6741,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -6702,30 +6752,30 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4232a56a8ce40a47ad6b42c9c7d9e6f4485e6e6a43b3149304c35bc295034107" +checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fcd0c631a66b45dca4421b8a06309dd02ae5d6aea5daa55399f68638b2abf3" +checksum = "243ef0da7444be7f8f15ba17e3eb7c9e4359e9f93ddb68abd33984874e39cf6e" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -6733,9 +6783,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf637d6a5d8c886f23ca2df50edfaa56443f23294428f037206fb3a0d07ba57e" +checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" dependencies = [ "async-compression", "base64ct", @@ -6745,9 +6795,9 @@ dependencies = [ "http", "httparse", "httpdate", - "itertools 0.14.0", + "itertools", "memchr", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -6762,9 +6812,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e412167d54c3618c3261dc77f1a31fe20c518f59aca5abfdec5f600b6a9b1f" +checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" dependencies = [ "base64ct", "derive-deftly", @@ -6783,9 +6833,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b984d2b0f0a2764a1b24d7c4c203ef83600b071c0c5af36e20d4f63e80ce562" +checksum = "2b5523974c841cf2a9f85cfae6a1345572ac6e9deda1a43c2acf5301bddf8f83" dependencies = [ "async-trait", "base64ct", @@ -6800,7 +6850,7 @@ dependencies = [ "hex", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools", "memmap2", "oneshot-fused-workaround", "paste", @@ -6814,7 +6864,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -6840,9 +6890,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1409b1a9085502c7797405a01fe3f3292a9827c9ed6a3f7c34fbb222572db5ac" +checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" dependencies = [ "derive_more", "futures", @@ -6850,7 +6900,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "void", "web-time-compat", @@ -6858,20 +6908,20 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f17e219140e587e169f71f53e528ca394c39e83593c9fbe9e9366cce7655e1" +checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" dependencies = [ "derive_more", - "thiserror 2.0.19", + "thiserror 2.0.20", "void", ] [[package]] name = "tor-guardmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56c43d7afa526449c6e6460d373a02624a24c78637ddd7c68e2ac1879ae587c" +checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" dependencies = [ "amplify", "base64ct", @@ -6883,7 +6933,7 @@ dependencies = [ "futures", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools", "num_enum", "oneshot-fused-workaround", "pin-project", @@ -6892,7 +6942,7 @@ dependencies = [ "safelog", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -6914,17 +6964,18 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff8ba0afebc5f76b42240f76b2bfb92aa5862c19ff184492c59c654750ee275" +checksum = "00a766e2a933e69f627ab112232634d1c5d92f8a24f694484aaf8b47f98e664e" dependencies = [ "async-trait", + "cfg-if", "derive-deftly", "derive_more", "educe", "either", "futures", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "postage", "rand 0.10.2", @@ -6932,7 +6983,7 @@ dependencies = [ "safelog", "slotmap-careful", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -6959,25 +7010,25 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3761c1828e74818cba4eb661d1eb79d4f663b05e8e7d3bc6e35d754968282fc0" +checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" dependencies = [ - "cipher 0.4.4", + "cipher 0.5.2", "data-encoding", "derive-deftly", "derive_more", "digest 0.10.7", "hex", "humantime", - "itertools 0.14.0", + "itertools", "paste", "rand 0.10.2", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-error", @@ -6992,9 +7043,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa87ed8d1ccfe8447b4e65bf0e2dfcab6a8ac9d2eadf7b3f37c102e1c18e9bb" +checksum = "3291bbf7641b457a16ef803db4d585334e669fa72fe194a276e4c6fc631f858d" dependencies = [ "amplify", "async-trait", @@ -7010,7 +7061,7 @@ dependencies = [ "growable-bloom-filter", "hex", "humantime", - "itertools 0.14.0", + "itertools", "k12", "once_cell", "oneshot-fused-workaround", @@ -7022,7 +7073,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -7051,9 +7102,9 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86c877b95e0679f151743bc2b706320a29e14bd18d9bcb92918df34948e2e9d2" +checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" dependencies = [ "derive-deftly", "derive_more", @@ -7063,7 +7114,7 @@ dependencies = [ "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-bytes", "tor-cert", "tor-checkable", @@ -7073,9 +7124,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f59fbec28e31d688822b4c00a6f7fd94539886baccd3ce1b35dc26699c37873" +checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" dependencies = [ "amplify", "arrayvec 0.7.8", @@ -7089,13 +7140,13 @@ dependencies = [ "glob-match", "humantime", "inventory", - "itertools 0.14.0", + "itertools", "rand 0.10.2", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -7114,9 +7165,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1cc8d5469c324d1e0761066d868363d3fdccb723b14cdc983a5aef9b67981b" +checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" dependencies = [ "base64ct", "by_address", @@ -7125,12 +7176,12 @@ dependencies = [ "derive_builder_fork_arti", "derive_more", "hex", - "itertools 0.14.0", + "itertools", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -7141,9 +7192,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0015d521f5e30a2f74879fe4c615fc2c3ace822d7cc88c1dfed0ae816b25f15" +checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" dependencies = [ "aes", "base64ct", @@ -7174,7 +7225,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-memquota-cost", "visibility", @@ -7184,13 +7235,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d16152b21f2e7b309dfbdcbe587c5adba9547d3119f2933b453d6edbce7e8e0" +checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" dependencies = [ "futures", "humantime", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-rtcompat", "tracing", @@ -7200,24 +7251,25 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ad2950d88e968572b0de6bb5702f5ed42f36555f362fb741c572c62441476e" +checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" dependencies = [ "cfg-if", "derive-deftly", "derive_more", "dyn-clone", "educe", + "extend", "futures", - "itertools 0.14.0", + "itertools", "paste", "pin-project", "serde", "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -7231,21 +7283,21 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993ad5bfd39fb629990da40c2b2086284b1481b7d4843652a959db1d9e5ac463" +checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" dependencies = [ "derive-deftly", - "itertools 0.14.0", + "itertools", "paste", "void", ] [[package]] name = "tor-netdir" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6700c5e590818d9873a13e06b49b95cf3a39fa8fb3968bd67ec3295cd584b97c" +checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" dependencies = [ "async-trait", "bitflags 2.13.1", @@ -7254,12 +7306,12 @@ dependencies = [ "futures", "hex", "humantime", - "itertools 0.14.0", + "itertools", "num_enum", "rand 0.10.2", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tor-basic-utils", "tor-error", @@ -7276,27 +7328,31 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a497ce4c36f59e4c67c352e3b80cd4642f6c5e660bb003de1922be4c7ed03081" +checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" dependencies = [ "amplify", "base64ct", - "cipher 0.4.4", + "cipher 0.5.2", "derive-deftly", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", "educe", "enumset", + "extend", "hex", "hostname-validator", "humantime", - "itertools 0.14.0", + "ipnet", + "iprange", + "itertools", "memchr", "paste", - "phf 0.13.1", + "phf 0.14.0", "rand 0.10.2", + "rangemap", "saturating-time", "serde", "serde_with", @@ -7304,7 +7360,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tinystr", "tor-basic-utils", @@ -7325,24 +7381,25 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6da1a96a1f6418faac1cd1892e029fd265cf50e67e941a4ba2580d92dd32d47" +checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" dependencies = [ "amplify", "derive-deftly", "derive_more", + "extend", "filetime", "fs-mistrust", "fslock-guard", "futures", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "paste", "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -7354,9 +7411,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a5fa0e1a365fc089a7f140f4142379dd74c91c15103c2f9d11ea711e16029e" +checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" dependencies = [ "amplify", "async-trait", @@ -7365,7 +7422,7 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher 0.4.4", + "cipher 0.5.2", "coarsetime", "derive-deftly", "derive_builder_fork_arti", @@ -7377,7 +7434,7 @@ dependencies = [ "futures-util", "hkdf", "hmac 0.12.1", - "itertools 0.14.0", + "itertools", "nonany", "oneshot-fused-workaround", "pin-project", @@ -7390,8 +7447,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "sync_wrapper", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-async-utils", @@ -7421,23 +7477,24 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f7f4ee15c9bfa0b700ae06e85fa52abddf17a35531cd3d3833412900e8da5c" +checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" dependencies = [ "caret", + "derive-deftly", "paste", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-ptmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4cc37ac22faff9e0233fa287b92819d8819f31cae85a50ff99c51dd549b31c0" +checksum = "d052da1eadd9f65cc46aa367adb8b573d397acf3d0635d39351670bbe40a82f7" dependencies = [ "async-trait", "cfg-if", @@ -7445,10 +7502,10 @@ dependencies = [ "derive_builder_fork_arti", "fs-mistrust", "futures", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-chanmgr", @@ -7465,9 +7522,9 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b565b6dec2e3edb7b033203a3662edb7b586e486d53e02af4295e05e7562870" +checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" dependencies = [ "rand 0.10.2", "serde", @@ -7479,9 +7536,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd7a4ff4898044b3ec693c0bf098251accf13c58741c44ed9364ce5faa89287" +checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" dependencies = [ "amplify", "async-trait", @@ -7503,7 +7560,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-error", @@ -7516,9 +7573,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8331e7d57bf7cf157cf3610ff6dbb5a591de5c3b22fa65ea6be18b08ff81477a" +checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" dependencies = [ "amplify", "assert_matches", @@ -7528,13 +7585,13 @@ dependencies = [ "educe", "futures", "humantime", - "itertools 0.14.0", + "itertools", "oneshot-fused-workaround", "pin-project", "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -7546,9 +7603,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f432eda94343bdadd73995f0b3082d96637dadb0e20c3f9a1bf2349f623d3c" +checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" dependencies = [ "amplify", "caret", @@ -7556,21 +7613,21 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf033ee8b22b11abd8272272e19ccbbf58c7b3152f8d224aa8d21f358c43d0d" +checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-memquota", ] @@ -7840,11 +7897,11 @@ checksum = "0976c77def3f1f75c4ef892a292c31c0bbe9e3d0702c63044d7c76db298171a3" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -7857,11 +7914,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http", "httparse", "log", @@ -7899,9 +7956,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7917,9 +7974,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" [[package]] name = "vcpkg" @@ -8007,9 +8064,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -8020,9 +8077,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -8030,9 +8087,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8040,9 +8097,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -8053,9 +8110,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -8068,9 +8125,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -8088,9 +8145,9 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" dependencies = [ "web-time", ] @@ -8485,9 +8542,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -8553,18 +8610,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -8615,9 +8672,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8626,9 +8683,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "serde", "yoke", @@ -8638,13 +8695,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -8663,9 +8720,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index ac6f39f38f..8b933577cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -24,15 +24,15 @@ name = "mwc" path = "src/bin/mwc.rs" [dependencies] -mwc_crates = { path = "./mwc_crates", version = "7.0.0" } -mwc_api = { path = "./api", version = "7.0.0" } -mwc_config = { path = "./config", version = "7.0.0" } -mwc_chain = { path = "./chain", version = "7.0.0" } -mwc_core = { path = "./core", version = "7.0.0" } -mwc_p2p = { path = "./p2p", version = "7.0.0" } -mwc_servers = { path = "./servers", version = "7.0.0" } -mwc_util = { path = "./util", version = "7.0.0" } -mwc_node_workflow = { path = "./node_workflow", version = "7.0.0" } +mwc_crates = { path = "./mwc_crates", version = "7.1.0" } +mwc_api = { path = "./api", version = "7.1.0" } +mwc_config = { path = "./config", version = "7.1.0" } +mwc_chain = { path = "./chain", version = "7.1.0" } +mwc_core = { path = "./core", version = "7.1.0" } +mwc_p2p = { path = "./p2p", version = "7.1.0" } +mwc_servers = { path = "./servers", version = "7.1.0" } +mwc_util = { path = "./util", version = "7.1.0" } +mwc_node_workflow = { path = "./node_workflow", version = "7.1.0" } thiserror.workspace = true [build-dependencies] diff --git a/api/Cargo.toml b/api/Cargo.toml index ac8e6d854c..77a114ca05 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_api" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "APIs for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,16 +10,16 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_chain = { path = "../chain", version = "7.0.0" } -mwc_p2p = { path = "../p2p", version = "7.0.0" } -mwc_pool = { path = "../pool", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_chain = { path = "../chain", version = "7.1.0" } +mwc_p2p = { path = "../p2p", version = "7.1.0" } +mwc_pool = { path = "../pool", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true [dev-dependencies] -mwc_keychain = { path = "../keychain", version = "7.0.0" } +mwc_keychain = { path = "../keychain", version = "7.1.0" } [features] default = [] diff --git a/api/src/foreign.rs b/api/src/foreign.rs index ed957efdb5..a3e70769e2 100644 --- a/api/src/foreign.rs +++ b/api/src/foreign.rs @@ -42,6 +42,11 @@ use std::time::{Duration, Instant}; const PROCESS_STATUS_CACHE_MAX_AGE: Duration = Duration::from_secs(5); +/// A host-utilization snapshot shared by foreign API requests. +/// +/// The three metrics are sampled together and reused for at most +/// `PROCESS_STATUS_CACHE_MAX_AGE`. They are monitoring data and are not used +/// for authorization, consensus, or resource-admission decisions. #[derive(Clone, Copy)] pub struct ProcessHostMetrics { pub host_cpu_usage: f64, @@ -75,6 +80,11 @@ impl ProcessStatusCache { } } + /// Returns the current snapshot, refreshing it once its bounded age expires. + /// + /// Keeping the `System` sampler between calls is required for meaningful CPU + /// deltas and avoids making every public status request sleep for sysinfo's + /// minimum CPU update interval. pub fn get(&mut self) -> ProcessHostMetrics { if self.updated_at.map_or(true, |updated_at| { updated_at.elapsed() >= PROCESS_STATUS_CACHE_MAX_AGE @@ -182,7 +192,6 @@ where pub fn get_header( &self, - secp: &Secp256k1, height: Option, hash: Option, commit: Option, @@ -190,7 +199,7 @@ where let header_handler = HeaderHandler { chain: self.chain.clone(), }; - let hash = header_handler.parse_inputs(secp, height, hash, commit)?; + let hash = header_handler.parse_inputs(height, hash, commit)?; header_handler.get_header_v2(&hash) } @@ -202,7 +211,9 @@ where /// * `hash` - block hash. /// * `commit` - output commitment. /// * `include_proof` - include range proofs for outputs. Default: false - /// * `include_merkle_proof` - include merkle proofs (for unspent coinbase outputs). Default: false + /// * `include_merkle_proof` - include current-output-PMMR Merkle proofs for + /// unspent coinbase outputs. These proofs are not tied to the returned + /// block's header, even when an older block is requested. Default: false. /// /// # Returns /// * Result Containing: @@ -212,7 +223,6 @@ where pub fn get_block( &self, - secp: &Secp256k1, height: Option, hash: Option, commit: Option, @@ -222,9 +232,8 @@ where let block_handler = BlockHandler { chain: self.chain.clone(), }; - let hash = block_handler.parse_inputs(secp, height, hash, commit)?; + let hash = block_handler.parse_inputs(height, hash, commit)?; block_handler.get_block( - secp, &hash, include_proof.unwrap_or(true), include_merkle_proof.unwrap_or(false), @@ -251,7 +260,6 @@ where pub fn get_blocks( &self, - secp: &Secp256k1, start_height: u64, end_height: u64, max: u64, @@ -260,7 +268,7 @@ where let block_handler = BlockHandler { chain: self.chain.clone(), }; - block_handler.get_blocks(secp, start_height, end_height, max, include_proof) + block_handler.get_blocks(start_height, end_height, max, include_proof) } /// Returns the node version and block header version (used by mwc-wallet). @@ -328,7 +336,12 @@ where /// # Arguments /// * `commits` - a vector of unspent output commitments. /// * `include_proof` - whether or not to include the range proof in the response. - /// * `include_merkle_proof` - whether or not to include the merkle proof in the response. + /// * `include_merkle_proof` - whether to include a current-output-PMMR Merkle + /// proof for each unspent coinbase output. It is not an origin-header proof; + /// use the proof's `mmr_size` to select the matching output root. + /// Requests enabling this option accept at most 100 commitment entries. + /// Duplicate commitments retain legacy response behavior and count separately + /// toward that limit. /// /// # Returns /// * Result Containing: @@ -338,7 +351,6 @@ where pub fn get_outputs( &self, - secp: &Secp256k1, commits: Vec, include_proof: Option, include_merkle_proof: Option, @@ -347,7 +359,6 @@ where chain: self.chain.clone(), }; output_handler.get_outputs_v2( - secp, Some(commits), None, None, @@ -372,7 +383,6 @@ where pub fn get_unspent_outputs( &self, - secp: &Secp256k1, start_index: u64, end_index: Option, max: u64, @@ -381,7 +391,7 @@ where let output_handler = OutputHandler { chain: self.chain.clone(), }; - output_handler.get_unspent_outputs(secp, start_index, end_index, max, include_proof) + output_handler.get_unspent_outputs(start_index, end_index, max, include_proof) } /// Retrieves the PMMR indices based on the provided block height(s). diff --git a/api/src/foreign_rpc.rs b/api/src/foreign_rpc.rs index 3be8bbf4b3..dac0e94ec4 100644 --- a/api/src/foreign_rpc.rs +++ b/api/src/foreign_rpc.rs @@ -32,8 +32,7 @@ use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_crates::serde::de::DeserializeOwned; use mwc_p2p::types::{PeerInfoDisplayLegacy, ProcessStatus}; use mwc_pool::{BlockChain, PoolAdapter}; -use mwc_util::{self, secp_static}; -use std::time::Instant; +use mwc_util; /// Public definition used to generate Node jsonrpc api. /// * When running `mwc` with defaults, the V2 api is available at @@ -1145,19 +1144,16 @@ where match normalize_get_outputs_params(params)? { GetOutputsParams::Current(params) => self.generated().handle("get_outputs", params), GetOutputsParams::Legacy(args) => { - let result = secp_static::with_verify_only(Error::from, |secp| { - let output_handler = OutputHandler { - chain: self.inner.chain.clone(), - }; - output_handler.get_outputs_v2( - secp, - args.commits, - args.start_height, - args.end_height, - args.include_proof, - args.include_merkle_proof, - ) - }); + let output_handler = OutputHandler { + chain: self.inner.chain.clone(), + }; + let result = output_handler.get_outputs_v2( + args.commits, + args.start_height, + args.end_height, + args.include_proof, + args.include_merkle_proof, + ); easy_jsonrpc_mwc::try_serialize(&result.into_rpc_result()) } } @@ -1325,16 +1321,15 @@ fn parse_transaction_arg( return Ok(tx); } - let current = easy_jsonrpc_mwc::serde_json::from_value::(value.clone()).ok(); - - normalize_legacy_transaction_value(&mut value); - let normalized = easy_jsonrpc_mwc::serde_json::from_value::(value).ok(); + if let Ok(tx) = easy_jsonrpc_mwc::serde_json::from_value::(value.clone()) { + return Ok(tx); + } - current - .or(normalized) - .ok_or_else(|| -> easy_jsonrpc_mwc::Error { - InvalidArgs::invalid_arg_structure(name, index, "parsing error".to_string()).into() - }) + let parsing_error = || -> easy_jsonrpc_mwc::Error { + InvalidArgs::invalid_arg_structure(name, index, "parsing error".to_string()).into() + }; + normalize_legacy_transaction_value(&mut value).map_err(|_| parsing_error())?; + easy_jsonrpc_mwc::serde_json::from_value::(value).map_err(|_| parsing_error()) } fn parse_transaction_hex_arg( @@ -1361,38 +1356,49 @@ fn parse_transaction_hex_arg( Ok(Some(tx)) } -fn normalize_legacy_transaction_value(value: &mut Value) { +#[derive(Debug)] +struct LegacyNormalizationError; + +fn normalize_legacy_transaction_value(value: &mut Value) -> Result<(), LegacyNormalizationError> { let Some(kernels) = value .get_mut("body") .and_then(|body| body.get_mut("kernels")) .and_then(Value::as_array_mut) else { - return; + return Ok(()); }; for kernel in kernels { - normalize_legacy_kernel_value(kernel); + normalize_legacy_kernel_value(kernel)?; } + Ok(()) } -fn normalize_legacy_kernel_value(kernel: &mut Value) { +fn normalize_legacy_kernel_value(kernel: &mut Value) -> Result<(), LegacyNormalizationError> { let Some(kernel) = kernel.as_object_mut() else { - return; + return Ok(()); }; if kernel.get("features").is_some_and(Value::is_object) { normalize_nested_kernel_features(kernel); - return; + return Ok(()); } let Some(feature_name) = legacy_kernel_feature_name(kernel.get("features")) else { - return; + return Ok(()); }; match feature_name { "Plain" => { - let Some(fee) = kernel.remove("fee").and_then(normalize_u64_json_value) else { - return; - }; + let fee = kernel + .get("fee") + .cloned() + .and_then(normalize_u64_json_value) + .ok_or(LegacyNormalizationError)?; + if normalized_u64_field(kernel.get("lock_height")) != Some(0) { + return Err(LegacyNormalizationError); + } + + kernel.remove("fee"); kernel.remove("lock_height"); kernel.insert( "features".to_string(), @@ -1400,6 +1406,12 @@ fn normalize_legacy_kernel_value(kernel: &mut Value) { ); } "Coinbase" => { + if normalized_u64_field(kernel.get("fee")) != Some(0) + || normalized_u64_field(kernel.get("lock_height")) != Some(0) + { + return Err(LegacyNormalizationError); + } + kernel.remove("fee"); kernel.remove("lock_height"); kernel.insert( @@ -1408,15 +1420,19 @@ fn normalize_legacy_kernel_value(kernel: &mut Value) { ); } "HeightLocked" => { - let Some(fee) = kernel.remove("fee").and_then(normalize_u64_json_value) else { - return; - }; - let Some(lock_height) = kernel - .remove("lock_height") + let fee = kernel + .get("fee") + .cloned() .and_then(normalize_u64_json_value) - else { - return; - }; + .ok_or(LegacyNormalizationError)?; + let lock_height = kernel + .get("lock_height") + .cloned() + .and_then(normalize_u64_json_value) + .ok_or(LegacyNormalizationError)?; + + kernel.remove("fee"); + kernel.remove("lock_height"); kernel.insert( "features".to_string(), easy_jsonrpc_mwc::serde_json::json!({ @@ -1429,6 +1445,7 @@ fn normalize_legacy_kernel_value(kernel: &mut Value) { } _ => {} } + Ok(()) } fn normalize_nested_kernel_features(kernel: &mut easy_jsonrpc_mwc::serde_json::Map) { @@ -1476,6 +1493,13 @@ fn normalize_u64_json_value(value: Value) -> Option { } } +fn normalized_u64_field(value: Option<&Value>) -> Option { + value + .cloned() + .and_then(normalize_u64_json_value) + .and_then(|value| value.as_u64()) +} + struct LegacyGetOutputsArgs { commits: Option>, start_height: Option, @@ -1501,8 +1525,9 @@ fn normalize_get_outputs_params( fn normalize_get_outputs_positional( args: Vec, ) -> Result { - if args.len() > 5 { - return Err(wrong_number_of_args("get_outputs", 1, 5, args.len())); + let actual = args.len(); + if !(1..=5).contains(&actual) { + return Err(wrong_number_of_args("get_outputs", 1, 5, actual)); } if uses_current_get_outputs_layout(&args) { return normalize_trailing_optional_params(Params::Positional(args), 1, 3, "get_outputs") @@ -1714,6 +1739,76 @@ mod tests { assert!(normalize_trailing_optional_params(params, 0, 5, "get_block").is_err()); } + #[test] + fn normalize_legacy_plain_kernel_accepts_zero_lock_height() { + for lock_height in [json!(0), json!("0")] { + let mut kernel = json!({ + "features": "Plain", + "fee": "42", + "lock_height": lock_height, + }); + + normalize_legacy_kernel_value(&mut kernel).unwrap(); + + assert_eq!(kernel["features"], json!({ "Plain": { "fee": 42 } })); + assert!(kernel.get("fee").is_none()); + assert!(kernel.get("lock_height").is_none()); + } + } + + #[test] + fn normalize_legacy_plain_kernel_rejects_invalid_lock_height_without_mutating() { + let lock_heights = [ + None, + Some(json!(1)), + Some(json!("1")), + Some(json!("invalid")), + Some(json!(-1)), + Some(json!(0.0)), + Some(Value::Null), + Some(json!({})), + ]; + + for lock_height in lock_heights { + let mut kernel = json!({ + "features": "Plain", + "fee": "42", + }); + if let Some(lock_height) = lock_height { + kernel["lock_height"] = lock_height; + } + let original = kernel.clone(); + + assert!(normalize_legacy_kernel_value(&mut kernel).is_err()); + assert_eq!(kernel, original); + } + } + + #[test] + fn normalize_legacy_coinbase_kernel_validates_discarded_fields() { + let mut valid = json!({ + "features": "Coinbase", + "fee": "0", + "lock_height": "0", + }); + normalize_legacy_kernel_value(&mut valid).unwrap(); + assert_eq!(valid["features"], json!("Coinbase")); + assert!(valid.get("fee").is_none()); + assert!(valid.get("lock_height").is_none()); + + for (fee, lock_height) in [(json!(1), json!(0)), (json!(0), json!(1))] { + let mut kernel = json!({ + "features": "Coinbase", + "fee": fee, + "lock_height": lock_height, + }); + let original = kernel.clone(); + + assert!(normalize_legacy_kernel_value(&mut kernel).is_err()); + assert_eq!(kernel, original); + } + } + #[cfg(not(feature = "test-support"))] #[test] fn parse_push_transaction_accepts_legacy_flat_plain_kernel() { @@ -1740,6 +1835,24 @@ mod tests { } } + #[cfg(feature = "test-support")] + #[test] + fn parse_push_transaction_rejects_invalid_legacy_plain_lock_height() { + let tx = transaction_with_kernel(KernelFeatures::Plain { + fee: FeeFields::new(42).unwrap(), + }); + + for lock_height in [json!(1), json!("invalid")] { + let mut value = serde_json::to_value(&tx).unwrap(); + let kernel = value["body"]["kernels"][0].as_object_mut().unwrap(); + kernel.insert("features".to_string(), json!("Plain")); + kernel.insert("fee".to_string(), json!("42")); + kernel.insert("lock_height".to_string(), lock_height); + + assert!(parse_transaction_arg(value, "tx", 0, 0).is_err()); + } + } + #[cfg(not(feature = "test-support"))] #[test] fn parse_push_transaction_accepts_string_nested_fee() { @@ -1835,10 +1948,7 @@ where .map_err(|e| Error::Argument(format!("invalid block hash: {}", e)))?, ); } - secp_static::with_verify_only(Error::from, |secp| { - Foreign::get_header(self, secp, height, parsed_hash, commit) - }) - .into_rpc_result() + Foreign::get_header(self, height, parsed_hash, commit).into_rpc_result() } fn get_block( @@ -1866,17 +1976,14 @@ where ); } - secp_static::with_verify_only(Error::from, |secp| { - Foreign::get_block( - self, - secp, - height, - parsed_hash, - commit, - include_proof, - include_merkle_proof, - ) - }) + Foreign::get_block( + self, + height, + parsed_hash, + commit, + include_proof, + include_merkle_proof, + ) .into_rpc_result() } @@ -1887,10 +1994,7 @@ where max: u64, include_proof: Option, ) -> RpcResult { - secp_static::with_verify_only(Error::from, |secp| { - Foreign::get_blocks(self, secp, start_height, end_height, max, include_proof) - }) - .into_rpc_result() + Foreign::get_blocks(self, start_height, end_height, max, include_proof).into_rpc_result() } fn get_version(&self) -> RpcResult { @@ -1916,10 +2020,7 @@ where include_proof: Option, include_merkle_proof: Option, ) -> RpcResult> { - secp_static::with_verify_only(Error::from, |secp| { - Foreign::get_outputs(self, secp, commits, include_proof, include_merkle_proof) - }) - .into_rpc_result() + Foreign::get_outputs(self, commits, include_proof, include_merkle_proof).into_rpc_result() } fn get_unspent_outputs( @@ -1929,10 +2030,8 @@ where max: u64, include_proof: Option, ) -> RpcResult { - secp_static::with_verify_only(Error::from, |secp| { - Foreign::get_unspent_outputs(self, secp, start_index, end_index, max, include_proof) - }) - .into_rpc_result() + Foreign::get_unspent_outputs(self, start_index, end_index, max, include_proof) + .into_rpc_result() } fn get_pmmr_indices( @@ -1948,11 +2047,8 @@ where } fn get_process_status(&self) -> RpcResult { - let now = Instant::now(); - let tor_online_time = match mwc_p2p::tor::arti::get_arti_restart_time() { - Some(start) => now.duration_since(start).as_secs(), - None => 0, - }; + let tor_online_time = + mwc_p2p::tor::arti::get_arti_online_duration().map_or(0, |duration| duration.as_secs()); let host_metrics = self.process_status_cache.lock().get(); diff --git a/api/src/handlers/blocks_api.rs b/api/src/handlers/blocks_api.rs index d688dfac2b..2b874037f8 100644 --- a/api/src/handlers/blocks_api.rs +++ b/api/src/handlers/blocks_api.rs @@ -12,20 +12,24 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use super::utils::{get_output, get_output_v2, w}; +use super::utils::{get_output, w}; use crate::rest::*; use crate::router::{Handler, ResponseFuture}; use crate::types::*; use crate::web::*; use mwc_core::core::hash::Hash; use mwc_core::core::hash::Hashed; +use mwc_core::core::BlockHeader; use mwc_crates::bytes::Bytes; use mwc_crates::hyper::{Request, StatusCode}; -use mwc_crates::secp::Secp256k1; -use mwc_util::secp_static; use std::sync::Weak; -pub const BLOCK_TRANSFER_LIMIT: u64 = 1000; +/// Maximum number of full blocks returned by a single get_blocks request. +/// Kept small because every returned block is fully materialized in memory, +/// performs per-output UTXO/proof lookups, and is hex-expanded when proofs +/// are requested, so the response cost scales with both block count and block +/// contents. Callers can paginate via `last_retrieved_height`. +pub const BLOCK_TRANSFER_LIMIT: u64 = 10; const MAX_U64_DECIMAL_LEN: usize = 20; const INVALID_INPUT_PREVIEW_CHARS: usize = 80; @@ -92,7 +96,7 @@ fn parse_block_query(params: Option<&str>) -> Result { return Err(Error::RequestError(format!( "unsupported query parameter: {}", param - ))) + ))); } } } @@ -130,10 +134,19 @@ impl HeaderHandler { } } let h = Hash::from_hex(&input).map_err(|e| invalid_hash_or_height(&input, e))?; - let header = w(&self.chain)?.get_block_header(&h).map_err(|e| { + let chain = w(&self.chain)?; + let header = chain.get_block_header(&h).map_err(|e| { let msg = format!("Block header for hash {}, {}", h, e); Error::chain_read_error(e, msg) })?; + let actual_hash = header.hash(chain.get_context_id())?; + if actual_hash != h { + return Err(mwc_chain::Error::InvalidPersistedChainState(format!( + "block header record key/hash mismatch: requested {}, loaded {}", + h, actual_hash + )) + .into()); + } Ok(BlockHeaderPrintable::from_header(&header)?) } @@ -160,13 +173,20 @@ impl HeaderHandler { let msg = format!("Block header for hash {}, {}", h, e); Error::chain_read_error(e, msg) })?; + let actual_hash = header.hash(chain.get_context_id())?; + if actual_hash != *h { + return Err(mwc_chain::Error::InvalidPersistedChainState(format!( + "block header record key/hash mismatch: requested {}, loaded {}", + h, actual_hash + )) + .into()); + } Ok(BlockHeaderPrintable::from_header(&header)?) } // Try to get hash from height, hash or output commit pub fn parse_inputs( &self, - secp: &Secp256k1, height: Option, hash: Option, commit: Option, @@ -184,7 +204,7 @@ impl HeaderHandler { return Ok(hash); } if let Some(commit) = commit { - let oid = match get_output_v2(secp, &self.chain, &commit, false, false)? { + let oid = match get_output(&self.chain, &commit)? { Some((_, o)) => o, None => return Err(Error::NotFound("Output not found".to_string())), }; @@ -220,6 +240,10 @@ impl Handler for HeaderHandler { /// /// Optionally turn off the Merkle proof extraction by passing "?no_merkle_proof" query /// param GET /v1/blocks/?no_merkle_proof +/// +/// Included proofs describe the node's current output PMMR state. For a +/// historical block response they do not verify against the block header in +/// that response; consumers must use a root matching each proof's `mmr_size`. pub struct BlockHandler { pub chain: Weak, } @@ -234,17 +258,33 @@ impl BlockHandler { pub fn get_block( &self, - secp: &Secp256k1, h: &Hash, include_proof: bool, include_merkle_proof: bool, ) -> Result { let chain = w(&self.chain)?; - let block = chain.get_block(h).map_err(|e| { + chain.with_output_read_snapshot(|snapshot| { + let header = snapshot.get_block_header(h).map_err(|e| { + let msg = format!("Block header for hash {}, {}", h, e); + Error::chain_read_error(e, msg) + })?; + self.get_block_for_header(snapshot, &header, include_proof, include_merkle_proof) + }) + } + + fn get_block_for_header( + &self, + snapshot: &mwc_chain::OutputReadSnapshot<'_>, + header: &BlockHeader, + include_proof: bool, + include_merkle_proof: bool, + ) -> Result { + let h = header.hash(snapshot.get_context_id())?; + let block = snapshot.get_block_for_header(header).map_err(|e| { let msg = format!("Block for hash {}, {}", h, e); Error::chain_read_error(e, msg) })?; - BlockPrintable::from_block(secp, &block, &chain, include_proof, include_merkle_proof) + BlockPrintable::from_block_snapshot(&block, snapshot, include_proof, include_merkle_proof) .map_err(|e| { Error::Internal(format!("chain error, broken block for hash {}. {}", h, e)) }) @@ -252,8 +292,7 @@ impl BlockHandler { pub fn get_blocks( &self, - secp: &Secp256k1, - mut start_height: u64, + start_height: u64, end_height: u64, mut max: u64, include_proof: Option, @@ -265,90 +304,101 @@ impl BlockHandler { if max > BLOCK_TRANSFER_LIMIT { max = BLOCK_TRANSFER_LIMIT; } - let tail_height = self.get_tail_height()?; - let orig_start_height = start_height; - - if start_height < tail_height { - start_height = tail_height; - } + let chain = w(&self.chain)?; + chain.with_output_read_snapshot(|snapshot| { + let mut start_height = start_height; + let tail_height = snapshot + .get_tail() + .map_err(|e| Error::chain_read_error(e, "Tail not found".to_string()))? + .height; + let orig_start_height = start_height; + if start_height < tail_height { + start_height = tail_height; + } - // In full archive node, tail will be set to 1, so include genesis block as well - // for consistency - if start_height == 1 && orig_start_height == 0 { - start_height = 0; - } + // In full archive node, tail will be set to 1, so include genesis block as well + // for consistency + if start_height == 1 && orig_start_height == 0 { + start_height = 0; + } - let mut result_set = BlockListing { - last_retrieved_height: 0, - blocks: vec![], - }; - let mut block_count = 0; - for h in start_height..=end_height { - let hash = match self.parse_inputs(secp, Some(h), None, None) { - Err(e) => { - if Self::is_unavailable_block_error(&e) { - break; - } else { - return Err(e); - } - } - Ok(h) => h, + let mut result_set = BlockListing { + last_retrieved_height: 0, + blocks: vec![], }; + let mut block_count = 0; + for h in start_height..=end_height { + let header = match snapshot.get_header_by_height(h).map_err(|e| { + let msg = format!("Header for height {}, {}", h, e); + Error::chain_read_error(e, msg) + }) { + Err(e) => { + if Self::is_unavailable_block_error(&e) { + break; + } else { + return Err(e); + } + } + Ok(header) => header, + }; - let block_res = self.get_block(secp, &hash, include_proof == Some(true), false); + let block_res = self.get_block_for_header( + snapshot, + &header, + include_proof == Some(true), + false, + ); - match block_res { - Err(e) => { - if Self::is_unavailable_block_error(&e) { - break; - } else { - return Err(e); + match block_res { + Err(e) => { + if Self::is_unavailable_block_error(&e) { + break; + } else { + return Err(e); + } + } + Ok(b) => { + block_count += 1; + result_set.blocks.push(b); + result_set.last_retrieved_height = h; } } - Ok(b) => { - block_count += 1; - result_set.blocks.push(b); - result_set.last_retrieved_height = h; + if block_count >= max { + break; } } - if block_count >= max { - break; - } - } - Ok(result_set) - } - - pub fn get_tail_height(&self) -> Result { - let chain = w(&self.chain)?; - Ok(chain - .get_tail() - .map_err(|e| Error::chain_read_error(e, "Tail not found".to_string()))? - .height) + Ok(result_set) + }) } fn get_compact_block( &self, - secp: &Secp256k1, h: &Hash, include_merkle_proof: bool, ) -> Result { let chain = w(&self.chain)?; - let block = chain.get_block(h).map_err(|e| { - let msg = format!("Block for hash {}, {}", h, e); - Error::chain_read_error(e, msg) - })?; - CompactBlockPrintable::from_compact_block( - secp, - &mwc_core::core::CompactBlock::from(block) - .map_err(|e| Error::Internal(format!("Unable to build a CompactBlock, {}", e)))?, - &chain, - include_merkle_proof, - ) - .map_err(|e| { - Error::Internal(format!( - "chain error, broken compact block for hash {}, {}", - h, e - )) + chain.with_output_read_snapshot(|snapshot| { + let header = snapshot.get_block_header(h).map_err(|e| { + let msg = format!("Block header for hash {}, {}", h, e); + Error::chain_read_error(e, msg) + })?; + let block = snapshot.get_block_for_header(&header).map_err(|e| { + let msg = format!("Block for hash {}, {}", h, e); + Error::chain_read_error(e, msg) + })?; + CompactBlockPrintable::from_compact_block_snapshot( + &mwc_core::core::CompactBlock::from(block).map_err(|e| { + Error::Internal(format!("Unable to build a CompactBlock, {}", e)) + })?, + snapshot, + include_merkle_proof, + ) + .map_err(|e| { + Error::Internal(format!( + "chain error, broken compact block for hash {}, {}", + h, e + )) + }) }) } @@ -369,7 +419,6 @@ impl BlockHandler { // Try to get hash from height, hash or output commit pub fn parse_inputs( &self, - secp: &Secp256k1, height: Option, hash: Option, commit: Option, @@ -387,7 +436,7 @@ impl BlockHandler { return Ok(hash); } if let Some(commit) = commit { - let oid = match get_output_v2(secp, &self.chain, &commit, false, false)? { + let oid = match get_output(&self.chain, &commit)? { Some((_, o)) => o, None => return Err(Error::NotFound("Output not found".to_string())), }; @@ -426,30 +475,17 @@ impl Handler for BlockHandler { }; if options.compact { - return result_to_response(secp_static::with_verify_only( - |e| Error::Internal(format!("failed to create secp instance: {}", e)), - |secp| self.get_compact_block(secp, &h, options.include_merkle_proof), - )); + return result_to_response(self.get_compact_block(&h, options.include_merkle_proof)); } - result_to_response(secp_static::with_verify_only( - |e| Error::Internal(format!("failed to create secp instance: {}", e)), - |secp| { - self.get_block( - secp, - &h, - options.include_proof, - options.include_merkle_proof, - ) - }, - )) + result_to_response(self.get_block(&h, options.include_proof, options.include_merkle_proof)) } } #[cfg(test)] mod tests { use super::*; - use mwc_crates::secp::ContextFlag; + use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_util::ToHex; use std::fs; use std::sync::Arc; @@ -471,6 +507,90 @@ mod tests { .into_owned() } + #[test] + fn full_and_compact_blocks_build_output_metadata_from_snapshot() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("block_response_snapshot"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let output = *genesis.outputs().first().expect("genesis output"); + let block_hash = genesis.hash(0).unwrap(); + let output_root = genesis.header.output_root; + let output_mmr_size = genesis.header.output_mmr_size; + let source_compact = + mwc_core::core::CompactBlock::from(genesis.clone()).expect("compact genesis block"); + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let handler = BlockHandler { + chain: Arc::downgrade(&chain), + }; + + let printable = handler.get_block(&block_hash, true, true).unwrap(); + let printable_output = printable + .outputs + .iter() + .find(|printable| printable.commit == output.commitment()) + .expect("genesis output in printable block"); + let proof = printable_output + .merkle_proof + .as_ref() + .expect("coinbase merkle proof"); + let pos0 = printable_output + .mmr_index + .checked_sub(1) + .expect("one-based output position"); + assert_eq!(proof.mmr_size, output_mmr_size); + proof + .verify(0, output_root, &output.identifier(), pos0) + .unwrap(); + + let compact = handler.get_compact_block(&block_hash, true).unwrap(); + let converted_compact = + CompactBlockPrintable::from_compact_block(&source_compact, &chain, true) + .expect("printable compact genesis block"); + assert_eq!(converted_compact.nonce, source_compact.nonce); + let serialized_compact = + mwc_crates::serde_json::to_value(&converted_compact).expect("serialize compact block"); + assert_eq!( + serialized_compact + .get("nonce") + .and_then(|nonce| nonce.as_u64()), + Some(source_compact.nonce) + ); + let compact_output = compact + .out_full + .iter() + .find(|printable| printable.commit == output.commitment()) + .expect("genesis output in printable compact block"); + assert_eq!(compact_output.context_id, printable_output.context_id); + assert_eq!( + compact_output + .merkle_proof + .as_ref() + .map(|proof| proof.mmr_size), + Some(output_mmr_size) + ); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn unavailable_block_errors_include_heights_above_header_pmmr() { assert!(BlockHandler::is_unavailable_block_error(&Error::NotFound( @@ -592,6 +712,81 @@ mod tests { } } + #[test] + fn get_header_v2_rejects_misindexed_record() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("misindexed_header_record"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain = mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + mwc_core::genesis::genesis_floo(&secp, 0), + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(); + let header = chain.genesis(); + let actual_hash = header.hash(chain.get_context_id()).unwrap(); + + let mut wrong_hash_bytes = actual_hash.to_vec(); + wrong_hash_bytes[0] ^= 1; + let wrong_hash = Hash::from_vec(&wrong_hash_bytes); + drop(chain); + mwc_chain::pipe::release_context_data(0); + + let store = mwc_chain::ChainStore::new(0, &chain_dir).unwrap(); + let batch = store.batch_write().unwrap(); + let mut wrong_key = Vec::with_capacity(Hash::LEN + 2); + wrong_key.extend_from_slice(b"h:"); + wrong_key.extend_from_slice(wrong_hash.as_bytes()); + batch.db.put_ser(&wrong_key, &header).unwrap(); + batch.commit().unwrap(); + drop(store); + + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + mwc_core::genesis::genesis_floo(&secp, 0), + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let handler = HeaderHandler { + chain: Arc::downgrade(&chain), + }; + let result = handler.get_header_v2(&wrong_hash); + + drop(handler); + drop(chain); + mwc_chain::pipe::release_context_data(0); + let _ = fs::remove_dir_all(&chain_dir); + + match result { + Err(Error::Chain(mwc_chain::Error::InvalidPersistedChainState(msg))) => { + assert!(msg.contains("key/hash mismatch"), "{}", msg); + assert!(msg.contains(&wrong_hash.to_string()), "{}", msg); + assert!(msg.contains(&actual_hash.to_string()), "{}", msg); + } + other => panic!("expected persisted-chain-state error, got {:?}", other), + } + } + #[test] fn get_header_for_output_returns_none_for_missing_output_probe() { mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); @@ -613,6 +808,7 @@ mod tests { std::collections::HashSet::new(), None, None, + false, ) .unwrap(), ); diff --git a/api/src/handlers/chain_api.rs b/api/src/handlers/chain_api.rs index 4a4568b048..86ff6e3f5b 100644 --- a/api/src/handlers/chain_api.rs +++ b/api/src/handlers/chain_api.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::utils::{get_output, get_output_v2, w}; +use super::utils::{parse_commitment, w}; use crate::rest::*; use crate::router::{Handler, ResponseFuture}; use crate::types::*; @@ -28,11 +28,12 @@ use mwc_crates::secp::constants::PEDERSEN_COMMITMENT_SIZE; use mwc_crates::secp::pedersen::Commitment; use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_crates::serde::de::IntoDeserializer; -use mwc_util::secp_static; use mwc_util::StopState; +use std::collections::HashSet; use std::sync::{Arc, Weak}; const MAX_GET_OUTPUTS_COMMITS: usize = 1_000; +const MAX_GET_OUTPUTS_MERKLE_PROOF_COMMITS: usize = 100; const MAX_OUTPUTS_BY_HEIGHT_RANGE: u64 = 100; /// Chain handler. Get the head details. @@ -99,19 +100,11 @@ impl Handler for ChainValidationHandler { }; let secp = match Secp256k1::with_caps(ContextFlag::Commit) { Ok(s) => s, - Err(e) => { - return response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Secp error, {}", e), - ); - } + Err(e) => return result_to_response::<()>(Err(e.into())), }; match self.validate_chain(&secp, fast_validation) { Ok(_) => response(StatusCode::OK, "{}"), - Err(e) => response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("chain validation is failed, {}", e), - ), + Err(e) => result_to_response::<()>(Err(e)), } } } @@ -119,6 +112,15 @@ impl Handler for ChainValidationHandler { /// Chain compaction handler. Trigger a compaction of the chain state to regain /// storage space. /// POST /v1/chain/compact +/// +/// This endpoint intentionally runs compaction synchronously. A concurrent +/// request can temporarily occupy an API runtime worker while waiting for the +/// chain locks, but `Chain::compact` repeats its eligibility check after +/// acquiring those locks, so a queued request skips compaction if an earlier +/// request has already completed it. Compaction is rare and normally not long +/// running, so the temporary delay is expected to pass without operational +/// impact. Keeping this synchronous request/response behavior is the preferred +/// design here. pub struct ChainCompactHandler { pub chain: Weak, pub sync_state: Weak, @@ -137,10 +139,7 @@ impl Handler for ChainCompactHandler { fn post(&self, _req: Request) -> ResponseFuture { match self.compact_chain() { Ok(_) => response(StatusCode::OK, "{}"), - Err(e) => response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("chain compact failed: {}", e), - ), + Err(e) => result_to_response::<()>(Err(e)), } } } @@ -156,7 +155,6 @@ pub struct OutputHandler { impl OutputHandler { pub fn get_outputs_v2( &self, - secp: &Secp256k1, commits: Option>, start_height: Option, end_height: Option, @@ -164,6 +162,17 @@ impl OutputHandler { include_merkle_proof: Option, ) -> Result, Error> { let commits = commits.unwrap_or_default(); + let include_proof = include_proof.unwrap_or(false); + let include_merkle_proof = include_merkle_proof.unwrap_or(false); + let height_range = match (start_height, end_height) { + (Some(start_height), Some(end_height)) => Some((start_height, end_height)), + (None, None) => None, + _ => { + return Err(Error::RequestError( + "start_height and end_height must be provided together".to_string(), + )); + } + }; if commits.len() > MAX_GET_OUTPUTS_COMMITS { return Err(Error::RequestError(format!( "too many output commitments requested: {}, max {}", @@ -171,8 +180,19 @@ impl OutputHandler { MAX_GET_OUTPUTS_COMMITS ))); } + if include_merkle_proof && commits.len() > MAX_GET_OUTPUTS_MERKLE_PROOF_COMMITS { + return Err(Error::RequestError(format!( + "too many output commitments requested with merkle proofs: {}, max {}", + commits.len(), + MAX_GET_OUTPUTS_MERKLE_PROOF_COMMITS + ))); + } + // Merkle proofs are built from the current read-only txhashset state. They + // are not proofs against each output's origin header; normal compaction does + // not retain the historical peaks required for that contract. Preserve the + // legacy behavior for duplicate commitments: every list entry counts toward + // the proof-specific limit above. - let mut outputs: Vec = Vec::with_capacity(commits.len()); // First check the commits length for commit in &commits { if commit.len() != 66 { @@ -182,28 +202,20 @@ impl OutputHandler { ))); } } + let mut parsed_commits = Vec::with_capacity(commits.len()); for commit in commits { - match get_output_v2( - secp, - &self.chain, - &commit, - include_proof.unwrap_or(false), - include_merkle_proof.unwrap_or(false), - ) { - Ok(Some((output, _))) => outputs.push(output), - Ok(None) => { - // Ignore outputs that are not found - } + match parse_commitment(&commit) { + Ok(parsed) => parsed_commits.push((commit, parsed)), Err(e) => { error!( - "Failure to get output for commitment {} with error {}", + "Failure to parse output commitment {} with error {}", commit, e ); return Err(e); } - }; + } } - if let (Some(start_height), Some(end_height)) = (start_height, end_height) { + if let Some((start_height, end_height)) = height_range { let height_count = end_height .checked_sub(start_height) .and_then(|span| span.checked_add(1)) @@ -219,25 +231,58 @@ impl OutputHandler { height_count, MAX_OUTPUTS_BY_HEIGHT_RANGE ))); } - for height in (start_height..=end_height).rev() { - if let Ok(block_outputs) = self.outputs_at_height( - secp, - height, - &[], - include_proof.unwrap_or(false), - include_merkle_proof.unwrap_or(false), - ) { + } + + if parsed_commits.is_empty() && height_range.is_none() { + return Ok(Vec::new()); + } + + let chain = w(&self.chain)?; + chain.with_output_read_snapshot(|snapshot| { + let mut outputs = Vec::with_capacity(parsed_commits.len()); + for (commit_text, commit) in &parsed_commits { + match snapshot.get_unspent_output(*commit, include_merkle_proof) { + Ok(Some((output, _, pos, merkle_proof))) => { + outputs.push(OutputPrintable::from_output_snapshot( + &output, + Some(pos), + merkle_proof, + snapshot.get_context_id(), + None, + include_proof, + )?); + } + Ok(None) => { + // Ignore outputs that are not found + } + Err(e) => { + error!( + "Failure to get output for commitment {} with error {}", + commit_text, e + ); + return Err(e.into()); + } + } + } + if let Some((start_height, end_height)) = height_range { + for height in (start_height..=end_height).rev() { + let block_outputs = self.outputs_at_height( + snapshot, + height, + None, + include_proof, + include_merkle_proof, + )?; outputs.extend(block_outputs.outputs); } } - } - Ok(outputs) + Ok::<_, Error>(outputs) + }) } // allows traversal of utxo set pub fn get_unspent_outputs( &self, - secp: &Secp256k1, start_index: u64, end_index: Option, mut max: u64, @@ -248,35 +293,39 @@ impl OutputHandler { max = 10_000; } let chain = w(&self.chain)?; - let outputs = chain - .unspent_outputs_by_pmmr_index(start_index, max, end_index) - .map_err(|e| { - let msg = format!( - "Unspent outputs for PMMR {}-{:?}, {}", - start_index, end_index, e - ); - Error::chain_read_error(e, msg) - })?; - let out = OutputListing { - last_retrieved_index: outputs.0, - highest_index: outputs.1, - outputs: outputs - .2 + let include_proof = include_proof.unwrap_or(false); + chain.with_output_read_snapshot(|snapshot| { + let (last_retrieved_index, highest_index, outputs) = snapshot + .unspent_outputs_by_pmmr_index(start_index, max, end_index) + .map_err(|e| { + let msg = format!( + "Unspent outputs for PMMR {}-{:?}, {}", + start_index, end_index, e + ); + Error::chain_read_error(e, msg) + })?; + let outputs = outputs .iter() - .map(|x| { - OutputPrintable::from_output( - secp, - x, - &chain, + .map(|output| { + let (pos, merkle_proof) = + snapshot.get_output_status(&output.identifier(), false)?; + OutputPrintable::from_output_snapshot( + output, + pos, + merkle_proof, + snapshot.get_context_id(), None, - include_proof.unwrap_or(false), - false, + include_proof, ) }) - .collect::, _>>() - .map_err(|e| Error::Internal(format!("chain error, {}", e)))?, - }; - Ok(out) + .collect::, mwc_chain::Error>>() + .map_err(|e| Error::Internal(format!("chain error, {}", e)))?; + Ok(OutputListing { + last_retrieved_index, + highest_index, + outputs, + }) + }) } fn outputs_by_ids(&self, req: &Request) -> Result, Error> { @@ -286,46 +335,62 @@ impl OutputHandler { let params = QueryParams::from_query_str(query)?; params.process_multival_param("id", |id| push_output_id_param(&mut commitments, id))?; - let mut outputs: Vec = vec![]; - for x in commitments { - match get_output(&self.chain, &x) { - Ok(Some((output, _))) => outputs.push(output), - Ok(None) => { - // Ignore outputs that are not found - } + let mut parsed_commits = Vec::with_capacity(commitments.len()); + for commit_text in commitments { + match parse_commitment(&commit_text) { + Ok(commit) => parsed_commits.push((commit_text, commit)), Err(e) => { error!( - "Failure to get output for commitment {} with error {}", - x, e + "Failure to parse output commitment {} with error {}", + commit_text, e ); return Err(e); } - }; + } } - Ok(outputs) + + let chain = w(&self.chain)?; + chain.with_output_read_snapshot(|snapshot| { + let mut outputs = Vec::with_capacity(parsed_commits.len()); + for (commit_text, commit) in &parsed_commits { + match snapshot.get_unspent_output_position(*commit) { + Ok(Some((output, pos))) => { + outputs.push(Output::new(&output.commitment(), pos.height, pos.pos)); + } + Ok(None) => { + // Ignore outputs that are not found + } + Err(e) => { + error!( + "Failure to get output for commitment {} with error {}", + commit_text, e + ); + return Err(e.into()); + } + } + } + Ok(outputs) + }) } fn outputs_at_height( &self, - secp: &Secp256k1, + snapshot: &mwc_chain::OutputReadSnapshot<'_>, block_height: u64, - commitments: &[Commitment], + commitment_filter: Option<&HashSet>, include_proof: bool, include_merkle_proof: bool, ) -> Result { - let header = w(&self.chain)? - .get_header_by_height(block_height) - .map_err(|e| { - let msg = format!("Header at height {}, {}", block_height, e); - Error::chain_read_error(e, msg) - })?; + let header = snapshot.get_header_by_height(block_height).map_err(|e| { + let msg = format!("Header at height {}, {}", block_height, e); + Error::chain_read_error(e, msg) + })?; // TODO - possible to compact away blocks we care about // in the period between accepting the block and refreshing the wallet - let chain = w(&self.chain)?; - let context_id = chain.get_context_id(); + let context_id = snapshot.get_context_id(); let header_hash = header.hash(context_id)?; - let block = chain.get_block(&header_hash).map_err(|e| { + let block = snapshot.get_block_for_header(&header).map_err(|e| { let msg = format!( "Block at height {} for hash {}, {}", block_height, header_hash, e @@ -335,15 +400,21 @@ impl OutputHandler { let outputs = block .outputs() .iter() - .filter(|output| commitments.is_empty() || commitments.contains(&output.commitment())) + .filter(|output| { + commitment_filter.map_or(true, |commitments| { + commitments.contains(&output.commitment()) + }) + }) .map(|output| { - OutputPrintable::from_output( - secp, + let (pos, merkle_proof) = + snapshot.get_output_status(&output.identifier(), include_merkle_proof)?; + OutputPrintable::from_output_snapshot( output, - &chain, + pos, + merkle_proof, + context_id, Some(&header), include_proof, - include_merkle_proof, ) }) .collect::, _>>() @@ -356,11 +427,7 @@ impl OutputHandler { } // returns outputs for a specified range of blocks - fn outputs_block_batch( - &self, - secp: &Secp256k1, - req: &Request, - ) -> Result, Error> { + fn outputs_block_batch(&self, req: &Request) -> Result, Error> { let mut commitments: Vec = vec![]; let query = must_get_query!(req); @@ -391,20 +458,33 @@ impl OutputHandler { "outputs_block_batch: {}-{}, {:?}, {:?}", start_height, end_height, commitments, include_rp, ); + let commitment_filter = if commitments.is_empty() { + None + } else { + Some(commitments.into_iter().collect::>()) + }; - let mut return_vec = vec![]; - for i in (start_height..=end_height).rev() { - match self.outputs_at_height(secp, i, &commitments, include_rp, true) { - Ok(res) => { - if !res.outputs.is_empty() { - return_vec.push(res); + let chain = w(&self.chain)?; + chain.with_output_read_snapshot(|snapshot| { + let mut return_vec = vec![]; + for i in (start_height..=end_height).rev() { + match self.outputs_at_height( + snapshot, + i, + commitment_filter.as_ref(), + include_rp, + true, + ) { + Ok(res) => { + if !res.outputs.is_empty() { + return_vec.push(res); + } } + Err(e) => return Err(e), } - Err(e) => return Err(e), } - } - - Ok(return_vec) + Ok(return_vec) + }) } } @@ -469,10 +549,7 @@ impl Handler for OutputHandler { fn get(&self, req: Request) -> ResponseFuture { match right_path_element!(req) { "byids" => result_to_response(self.outputs_by_ids(&req)), - "byheight" => result_to_response(secp_static::with_verify_only( - |e| Error::Internal(format!("failed to create secp instance: {}", e)), - |secp| self.outputs_block_batch(secp, &req), - )), + "byheight" => result_to_response(self.outputs_block_batch(&req)), _ => response(StatusCode::BAD_REQUEST, ""), } } @@ -768,6 +845,7 @@ mod tests { std::collections::HashSet::new(), None, None, + false, ) .unwrap(), ); @@ -799,11 +877,10 @@ mod tests { #[test] fn get_outputs_v2_rejects_commit_lists_above_limit() { let output_handler = OutputHandler { chain: Weak::new() }; - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let commits = vec!["00".repeat(33); MAX_GET_OUTPUTS_COMMITS + 1]; let err = output_handler - .get_outputs_v2(&secp, Some(commits), None, None, None, None) + .get_outputs_v2(Some(commits), None, None, None, None) .unwrap_err(); match err { @@ -822,11 +899,49 @@ mod tests { #[test] fn get_outputs_v2_allows_commit_lists_at_limit() { let output_handler = OutputHandler { chain: Weak::new() }; - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let commits = vec![VALID_COMMIT.to_string(); MAX_GET_OUTPUTS_COMMITS]; let err = output_handler - .get_outputs_v2(&secp, Some(commits), None, None, None, None) + .get_outputs_v2(Some(commits), None, None, None, None) + .unwrap_err(); + + match err { + Error::Internal(msg) => { + assert!(msg.contains("failed to upgrade weak reference"), "{}", msg); + } + other => panic!("expected internal weak reference error, got {:?}", other), + } + } + + #[test] + fn get_outputs_v2_rejects_merkle_proof_commit_lists_above_limit() { + let output_handler = OutputHandler { chain: Weak::new() }; + let commits = vec![VALID_COMMIT.to_string(); MAX_GET_OUTPUTS_MERKLE_PROOF_COMMITS + 1]; + + let err = output_handler + .get_outputs_v2(Some(commits), None, None, None, Some(true)) + .unwrap_err(); + + match err { + Error::RequestError(msg) => { + assert!( + msg.contains("too many output commitments requested with merkle proofs"), + "{}", + msg + ); + assert!(msg.contains("max 100"), "{}", msg); + } + other => panic!("expected request error, got {:?}", other), + } + } + + #[test] + fn get_outputs_v2_allows_merkle_proof_commit_lists_at_limit() { + let output_handler = OutputHandler { chain: Weak::new() }; + let commits = vec![VALID_COMMIT.to_string(); MAX_GET_OUTPUTS_MERKLE_PROOF_COMMITS]; + + let err = output_handler + .get_outputs_v2(Some(commits), None, None, None, Some(true)) .unwrap_err(); match err { @@ -837,6 +952,183 @@ mod tests { } } + #[test] + fn get_outputs_v2_rejects_incomplete_height_ranges() { + let output_handler = OutputHandler { chain: Weak::new() }; + + for (start_height, end_height) in [(Some(1), None), (None, Some(1))] { + let err = output_handler + .get_outputs_v2(None, start_height, end_height, None, None) + .unwrap_err(); + + match err { + Error::RequestError(msg) => { + assert!( + msg.contains("start_height and end_height must be provided together"), + "{}", + msg + ); + } + other => panic!("expected request error, got {:?}", other), + } + } + } + + #[test] + fn get_outputs_v2_propagates_missing_requested_heights() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("get_outputs_v2_missing_height"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + let err = { + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + mwc_core::genesis::genesis_floo(&secp, 0), + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let output_handler = OutputHandler { + chain: Arc::downgrade(&chain), + }; + + output_handler + .get_outputs_v2(None, Some(1), Some(1), None, None) + .unwrap_err() + }; + + let _ = fs::remove_dir_all(&chain_dir); + + match err { + Error::NotFound(msg) => { + assert!(msg.contains("Header at height 1"), "{}", msg); + } + other => panic!("expected not found error, got {:?}", other), + } + } + + #[test] + fn get_outputs_v2_returns_matching_merkle_proof_and_position() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("get_outputs_v2_matching_merkle_proof"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let output = *genesis.outputs().first().expect("genesis output"); + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let output_handler = OutputHandler { + chain: Arc::downgrade(&chain), + }; + + let outputs = output_handler + .get_outputs_v2( + Some(vec![output.commitment().to_hex()]), + None, + None, + Some(false), + Some(true), + ) + .unwrap(); + assert_eq!(outputs.len(), 1); + let printable = &outputs[0]; + let pos0 = printable + .mmr_index + .checked_sub(1) + .expect("one-based output position"); + let proof = printable + .merkle_proof + .as_ref() + .expect("coinbase merkle proof"); + let head = chain.head_header().unwrap(); + assert_eq!(proof.mmr_size, head.output_mmr_size); + proof + .verify(0, head.output_root, &output.identifier(), pos0) + .unwrap(); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn get_unspent_outputs_returns_snapshot_metadata() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("get_unspent_outputs_snapshot"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let output = *genesis.outputs().first().expect("genesis output"); + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let output_handler = OutputHandler { + chain: Arc::downgrade(&chain), + }; + + let listing = output_handler + .get_unspent_outputs(1, None, 10_000, Some(true)) + .unwrap(); + let printable = listing + .outputs + .iter() + .find(|printable| printable.commit == output.commitment()) + .expect("genesis output in unspent listing"); + let head = chain.head_header().unwrap(); + assert_eq!(listing.highest_index, head.output_mmr_size); + assert_eq!(listing.last_retrieved_index, head.output_mmr_size); + assert!(!printable.spent); + assert!(printable.mmr_index > 0); + assert_eq!(printable.block_height, Some(0)); + assert_eq!(printable.context_id, 0); + assert_eq!( + printable.proof.as_deref(), + Some(output.proof_bytes().unwrap().to_hex().as_str()) + ); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn outputs_by_ids_rejects_commit_lists_above_limit() { let mut commitments = Vec::new(); @@ -858,6 +1150,52 @@ mod tests { } } + #[test] + fn outputs_by_ids_returns_positions_from_snapshot() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("outputs_by_ids_snapshot"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let output = *genesis.outputs().first().expect("genesis output"); + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let output_handler = OutputHandler { + chain: Arc::downgrade(&chain), + }; + let req = Request::builder() + .uri(format!( + "/v1/chain/outputs/byids?id={}", + output.commitment().to_hex() + )) + .body(Bytes::new()) + .unwrap(); + + let outputs = output_handler.outputs_by_ids(&req).unwrap(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].commit.commit(), output.commitment()); + assert_eq!(outputs[0].height, 0); + assert!(outputs[0].mmr_index > 0); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn outputs_by_ids_rejects_invalid_commit_length_before_decode() { let output_handler = OutputHandler { chain: Weak::new() }; @@ -882,7 +1220,6 @@ mod tests { #[test] fn outputs_block_batch_rejects_height_ranges_above_limit() { let output_handler = OutputHandler { chain: Weak::new() }; - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let req = Request::builder() .uri(format!( "/v1/chain/outputs/byheight?start_height=1&end_height={}", @@ -891,7 +1228,7 @@ mod tests { .body(Bytes::new()) .unwrap(); - let err = output_handler.outputs_block_batch(&secp, &req).unwrap_err(); + let err = output_handler.outputs_block_batch(&req).unwrap_err(); match err { Error::RequestError(msg) => { @@ -923,6 +1260,7 @@ mod tests { std::collections::HashSet::new(), None, None, + false, ) .unwrap(), ); @@ -934,7 +1272,7 @@ mod tests { .body(Bytes::new()) .unwrap(); - output_handler.outputs_block_batch(&secp, &req).unwrap_err() + output_handler.outputs_block_batch(&req).unwrap_err() }; let _ = fs::remove_dir_all(&chain_dir); @@ -973,13 +1311,12 @@ mod tests { #[test] fn outputs_block_batch_rejects_invalid_commit_length_before_decode() { let output_handler = OutputHandler { chain: Weak::new() }; - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let req = Request::builder() .uri("/v1/chain/outputs/byheight?start_height=1&end_height=1&id=00") .body(Bytes::new()) .unwrap(); - let err = output_handler.outputs_block_batch(&secp, &req).unwrap_err(); + let err = output_handler.outputs_block_batch(&req).unwrap_err(); match err { Error::RequestError(msg) => { diff --git a/api/src/handlers/peers_api.rs b/api/src/handlers/peers_api.rs index 7040874c36..9d3058db8f 100644 --- a/api/src/handlers/peers_api.rs +++ b/api/src/handlers/peers_api.rs @@ -236,10 +236,10 @@ impl Handler for PeerHandler { StatusCode::NOT_FOUND, format!("peer {} not found", peer_addr), ), - Err(e) => response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unable to get peer for address {}, {}", peer_addr, e), - ), + Err(e) => result_to_response::<()>(Err(Error::P2pError(format!( + "Unable to get peer for address {}, {}", + peer_addr, e + )))), } } @@ -267,17 +267,17 @@ impl Handler for PeerHandler { match command { "ban" => match peers.ban_peer(&addr, ReasonForBan::ManualBan, "banned from CLI") { Ok(_) => response(StatusCode::OK, "{}"), - Err(e) => response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("ban for peer {} failed, {:?}", addr, e), - ), + Err(e) => result_to_response::<()>(Err(Error::P2pError(format!( + "ban for peer {} failed: {}", + addr, e + )))), }, "unban" => match peers.unban_peer(&addr) { Ok(_) => response(StatusCode::OK, "{}"), - Err(e) => response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("unban for peer {} failed, {:?}", addr, e), - ), + Err(e) => result_to_response::<()>(Err(Error::P2pError(format!( + "unban for peer {} failed: {}", + addr, e + )))), }, _ => response( StatusCode::BAD_REQUEST, diff --git a/api/src/handlers/transactions_api.rs b/api/src/handlers/transactions_api.rs index 2d9c0fe0dc..def11e75a2 100644 --- a/api/src/handlers/transactions_api.rs +++ b/api/src/handlers/transactions_api.rs @@ -20,7 +20,6 @@ use crate::types::*; use crate::web::*; use mwc_crates::bytes::Bytes; use mwc_crates::hyper::{Request, StatusCode}; -use mwc_crates::secp::Secp256k1; use mwc_util::{secp_static, ToHex}; use std::sync::Weak; // Sum tree handler. Retrieve the roots: @@ -36,7 +35,8 @@ use std::sync::Weak; // GET /v1/txhashset/outputs?start_index=1&max=100 // GET /v1/txhashset/heightstopmmr?start_height=1&end_height=1000 // -// Build a merkle proof for a given pos +// Build a Merkle proof for a currently unspent output. The proof targets the +// node's current output PMMR state, not the output's origin header. // GET /v1/txhashset/merkleproof?n=1 const MAX_LAST_TXHASHSET_INSERTIONS: u64 = 10_000; @@ -77,7 +77,6 @@ impl TxHashSetHandler { // allows traversal of utxo set fn outputs( &self, - secp: &Secp256k1, start_index: u64, end_index: Option, mut max: u64, @@ -87,45 +86,42 @@ impl TxHashSetHandler { max = 10_000; } let chain = w(&self.chain)?; - let outputs = chain - .unspent_outputs_by_pmmr_index(start_index, max, end_index) - .map_err(|e| { - let msg = format!( - "Unspent output for PMMR {}-{:?}, {}", - start_index, end_index, e - ); - Error::chain_read_error(e, msg) - })?; - let out = OutputListing { - last_retrieved_index: outputs.0, - highest_index: outputs.1, - outputs: outputs + chain.with_output_read_snapshot(|snapshot| { + let outputs = snapshot + .unspent_outputs_by_pmmr_index(start_index, max, end_index) + .map_err(|e| { + let msg = format!( + "Unspent output for PMMR {}-{:?}, {}", + start_index, end_index, e + ); + Error::chain_read_error(e, msg) + })?; + let printable_outputs = outputs .2 .iter() - .map(|x| { - // Requesting headers for voinbase only. Reson for that is: - // when include_merkle_proof is true, it only builds a - // Merkle proof for unspent coinbase outputs. That proof needs the block - // header so the chain can rewind the PMMR to the correct block state. - let header = if x.is_coinbase() { - Some(chain.get_header_for_output(x.commitment()).map_err(|e| { - let msg = format!( - "Header for output commitment {}, {}", - x.commitment().to_hex(), - e - ); - Error::chain_read_error(e, msg) - })?) - } else { - None - }; - - OutputPrintable::from_output(secp, x, &chain, header.as_ref(), true, true) - .map_err(|e| Error::Internal(format!("chain error: {}", e))) + .map(|output| { + // These are current-state proofs. An origin header is intentionally + // not fetched because it is not a valid verification target. + let (pos, merkle_proof) = snapshot + .get_output_status(&output.identifier(), true) + .map_err(|e| Error::Internal(format!("chain error: {}", e)))?; + OutputPrintable::from_output_snapshot( + output, + pos, + merkle_proof, + snapshot.get_context_id(), + None, + true, + ) + .map_err(|e| Error::Internal(format!("chain error: {}", e))) }) - .collect::, _>>()?, - }; - Ok(out) + .collect::, _>>()?; + Ok(OutputListing { + last_retrieved_index: outputs.0, + highest_index: outputs.1, + outputs: printable_outputs, + }) + }) } // allows traversal of utxo set bounded within a block range @@ -152,41 +148,34 @@ impl TxHashSetHandler { Ok(out) } - // return a dummy output with merkle proof for position filled out - // (to avoid having to create a new type to pass around) - fn get_merkle_proof_for_output( - &self, - context_id: u32, - id: &str, - ) -> Result { + // Return a dummy output carrying a current-state Merkle proof (to avoid + // introducing another legacy response type). The proof's `mmr_size`, not an + // origin block, identifies the output-root state used for verification. + fn get_merkle_proof_for_output(&self, id: &str) -> Result { let commit = parse_commitment(id)?; let commit_hex = commit.to_hex(); let chain = w(&self.chain)?; - let output_pos = chain.get_output_pos(&commit).map_err(|e| { - let msg = format!( - "Unable to get a MMR position for commit {}, {}", - commit_hex, e - ); - Error::chain_read_error(e, msg) - })?; - let merkle_proof = - mwc_chain::Chain::get_merkle_proof_for_pos(&chain, commit).map_err(|e| { - let msg = format!( - "Unable to get a merkle proof for commit {}, {}", - commit_hex, e - ); - Error::chain_read_error(e, msg) - })?; - Ok(OutputPrintable { - output_type: OutputType::Coinbase, - commit: secp_static::commit_to_zero_value(), - spent: false, - proof: None, - proof_hash: "".to_string(), - block_height: None, - merkle_proof: Some(merkle_proof), - mmr_index: output_pos, - context_id, + chain.with_output_read_snapshot(|snapshot| { + let (output_pos, merkle_proof) = snapshot + .get_output_pos_and_merkle_proof(commit) + .map_err(|e| { + let msg = format!( + "Unable to get a MMR position and merkle proof for commit {}, {}", + commit_hex, e + ); + Error::chain_read_error(e, msg) + })?; + Ok(OutputPrintable { + output_type: OutputType::Coinbase, + commit: secp_static::commit_to_zero_value(), + spent: false, + proof: None, + proof_hash: "".to_string(), + block_height: None, + merkle_proof: Some(merkle_proof), + mmr_index: output_pos, + context_id: snapshot.get_context_id(), + }) }) } } @@ -230,17 +219,11 @@ impl Handler for TxHashSetHandler { "lastoutputs" => result_to_response(self.get_last_n_output(last_n)), "lastrangeproofs" => result_to_response(self.get_last_n_rangeproof(last_n)), "lastkernels" => result_to_response(self.get_last_n_kernel(last_n)), - "outputs" => result_to_response(secp_static::with_verify_only( - |e| Error::Internal(format!("failed to create secp instance: {}", e)), - |secp| self.outputs(secp, start_index, end_index, max), - )), + "outputs" => result_to_response(self.outputs(start_index, end_index, max)), "heightstopmmr" => result_to_response( self.block_height_range_to_pmmr_indices(start_height, end_height), ), - "merkleproof" => result_to_response((|| { - let context_id = w(&self.chain)?.get_context_id(); - self.get_merkle_proof_for_output(context_id, &id) - })()), + "merkleproof" => result_to_response(self.get_merkle_proof_for_output(&id)), _ => response(StatusCode::BAD_REQUEST, ""), }) })(); @@ -256,13 +239,97 @@ impl Handler for TxHashSetHandler { mod tests { use super::*; use mwc_crates::secp::constants::PEDERSEN_COMMITMENT_SIZE; + use mwc_crates::secp::{ContextFlag, Secp256k1}; + use std::fs; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(test_name: &str) -> String { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!( + "mwc_api_{}_{}_{}", + test_name, + std::process::id(), + unique + )) + .to_string_lossy() + .into_owned() + } + + #[test] + fn legacy_output_listing_and_proof_use_matching_snapshot_data() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("legacy_output_snapshot"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let output = *genesis.outputs().first().expect("genesis output"); + let chain = Arc::new( + mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(), + ); + let handler = TxHashSetHandler { + chain: Arc::downgrade(&chain), + }; + + let listing = handler.outputs(1, None, 100).unwrap(); + let listed = listing + .outputs + .iter() + .find(|listed| listed.commit == output.commitment()) + .expect("genesis output in listing"); + let proof_output = handler + .get_merkle_proof_for_output(&output.commitment().to_hex()) + .unwrap(); + let proof = proof_output + .merkle_proof + .as_ref() + .expect("current-state merkle proof"); + let head = chain.head_header().unwrap(); + + assert_eq!(listing.highest_index, head.output_mmr_size); + assert_eq!(listed.context_id, proof_output.context_id); + assert_eq!( + listed.mmr_index.checked_sub(1), + Some(proof_output.mmr_index) + ); + assert_eq!(proof.mmr_size, head.output_mmr_size); + proof + .verify( + proof_output.context_id, + head.output_root, + &output.identifier(), + proof_output.mmr_index, + ) + .unwrap(); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } #[test] fn get_merkle_proof_rejects_overlong_commitment_with_bounded_error() { let handler = TxHashSetHandler { chain: Weak::new() }; let id = "00".repeat(PEDERSEN_COMMITMENT_SIZE + 1024); - let err = match handler.get_merkle_proof_for_output(0, &id) { + let err = match handler.get_merkle_proof_for_output(&id) { Err(err) => err, Ok(_) => panic!("expected oversized commitment to be rejected"), }; diff --git a/api/src/handlers/utils.rs b/api/src/handlers/utils.rs index a52ee39eff..67058ee79e 100644 --- a/api/src/handlers/utils.rs +++ b/api/src/handlers/utils.rs @@ -20,7 +20,6 @@ use mwc_core::core::OutputIdentifier; use mwc_core::libtx::secp_ser; use mwc_crates::secp::constants::PEDERSEN_COMMITMENT_SIZE; use mwc_crates::secp::pedersen::Commitment; -use mwc_crates::secp::Secp256k1; use mwc_crates::serde::de::IntoDeserializer; use std::sync::{Arc, Weak}; @@ -79,51 +78,6 @@ pub fn get_output( ))) } -/// Retrieves an output from the chain given a commit id (a tiny bit iteratively) -pub fn get_output_v2( - secp: &Secp256k1, - chain: &Weak, - id: &str, - include_proof: bool, - include_merkle_proof: bool, -) -> Result, Error> { - let chain = w(chain)?; - let (out, pos) = match get_unspent(&chain, id)? { - Some(x) => x, - None => return Ok(None), - }; - - let output = chain.get_unspent_output_at(pos.pos - 1)?; - if output.commitment() != out.commitment() { - return Err(Error::Internal(format!( - "output commitment mismatch at position {}: requested {:?}, found {:?}", - pos.pos, - out.commitment(), - output.commitment() - ))); - } - - let header = if include_merkle_proof && output.is_coinbase() { - Some(chain.get_header_for_output(out.commitment()).map_err(|e| { - let msg = format!("Header for output {:?}, {}", out, e); - Error::chain_read_error(e, msg) - })?) - } else { - None - }; - - let output_printable = OutputPrintable::from_output( - secp, - &output, - &chain, - header.as_ref(), - include_proof, - include_merkle_proof, - )?; - - Ok(Some((output_printable, out))) -} - #[cfg(test)] mod tests { use super::*; diff --git a/api/src/json_rpc.rs b/api/src/json_rpc.rs index 87e9d81442..b2590371f0 100644 --- a/api/src/json_rpc.rs +++ b/api/src/json_rpc.rs @@ -34,6 +34,9 @@ pub struct RpcMethodError(pub String); impl From for RpcMethodError { fn from(error: crate::rest::Error) -> Self { + // JSON-RPC clients intentionally receive the complete internal error text. + // Although this can reveal server implementation details, these diagnostics + // are more useful for debugging node and client failures than a generic error. RpcMethodError(error.to_string()) } } diff --git a/api/src/owner.rs b/api/src/owner.rs index 6a1baca4b4..61088d1d91 100644 --- a/api/src/owner.rs +++ b/api/src/owner.rs @@ -138,10 +138,11 @@ impl Owner { let msg = format!("Block header for hash {}, {}", hash, e); Error::chain_read_error(e, msg) })?; + let sync_state = w(&self.sync_state)?; chain.reset_chain_head(secp, &header, true)?; // Reset the sync status and clear out any sync error. - w(&self.sync_state)?.reset(); + sync_state.reset(); Ok(()) } diff --git a/api/src/stratum.rs b/api/src/stratum.rs index cfa4fc009a..5f6ab55f61 100644 --- a/api/src/stratum.rs +++ b/api/src/stratum.rs @@ -70,11 +70,15 @@ impl Stratum { }) } - pub fn get_ip_info( - &self, - ip: &String, - ) -> Result { - Ok(self.stratum_ip_pool.get_ip_info(ip)) + pub fn get_ip_info(&self, ip: &str) -> Result { + let canonical_ip = ip + .parse::() + .map_err(|e| Error::Argument(format!("invalid IP address {}: {}", ip, e)))? + .to_string(); + + self.stratum_ip_pool + .get_ip_info(&canonical_ip) + .ok_or_else(|| Error::NotFound(format!("IP {} not found", canonical_ip))) } } @@ -123,7 +127,7 @@ mod tests { } api.stratum_ip_pool.report_ok_shares(&ip).unwrap(); - let info = api.stratum_ip_pool.get_ip_info(&ip); + let info = api.stratum_ip_pool.get_ip_info(&ip).unwrap(); assert_eq!(info.workers, 1); assert_eq!(info.ok_shares, 1); } @@ -142,4 +146,38 @@ mod tests { other => panic!("expected not found error after cleanup, got {:?}", other), } } + + #[test] + fn get_ip_info_rejects_invalid_ip() { + let api = new_stratum(); + + match api.get_ip_info("not-an-ip") { + Err(Error::Argument(msg)) => assert!(msg.contains("invalid IP address")), + other => panic!("expected invalid IP argument error, got {:?}", other), + } + } + + #[test] + fn get_ip_info_reports_untracked_ip() { + let api = new_stratum(); + + match api.get_ip_info("127.0.0.1") { + Err(Error::NotFound(msg)) => assert!(msg.contains("127.0.0.1")), + other => panic!("expected not found error, got {:?}", other), + } + } + + #[test] + fn get_ip_info_canonicalizes_ipv6() { + let api = new_stratum(); + let canonical_ip = "2001:db8::1".to_string(); + api.stratum_ip_pool.add_worker(&canonical_ip); + + let info = api + .get_ip_info("2001:0db8:0000:0000:0000:0000:0000:0001") + .unwrap(); + + assert_eq!(info.ip, canonical_ip); + assert_eq!(info.workers, 1); + } } diff --git a/api/src/types.rs b/api/src/types.rs index b48852f9d1..e0a48f2565 100644 --- a/api/src/types.rs +++ b/api/src/types.rs @@ -17,7 +17,7 @@ use mwc_core::core::hash::Hashed; use mwc_core::core::merkle_proof::MerkleProof; use mwc_core::core::{FeeFields, KernelFeatures, TxKernel}; use mwc_core::{core, libtx::secp_ser, ser}; -use mwc_crates::secp::{pedersen, Secp256k1}; +use mwc_crates::secp::pedersen; use mwc_crates::serde::de::{IntoDeserializer, MapAccess}; use mwc_crates::serde::ser::SerializeStruct; use mwc_crates::serde::{self, Deserialize, Serialize}; @@ -287,7 +287,10 @@ pub struct OutputPrintable { pub proof_hash: String, /// Block height at which the output is found pub block_height: Option, - /// Merkle Proof + /// Merkle proof for this output against the node's current output PMMR state + /// at the time the response was built. This is not an origin-block proof; + /// `MerkleProof::mmr_size` identifies the MMR state whose root must be used + /// for verification. pub merkle_proof: Option, /// MMR Position pub mmr_index: u64, @@ -297,12 +300,33 @@ pub struct OutputPrintable { impl OutputPrintable { pub fn from_output( - secp: &Secp256k1, output: &core::Output, chain: &mwc_chain::Chain, block_header: Option<&core::BlockHeader>, include_proof: bool, include_merkle_proof: bool, + ) -> Result { + chain.with_output_read_snapshot(|snapshot| { + let (pos, merkle_proof) = + snapshot.get_output_status(&output.identifier(), include_merkle_proof)?; + Self::from_output_snapshot( + output, + pos, + merkle_proof, + snapshot.get_context_id(), + block_header, + include_proof, + ) + }) + } + + pub(crate) fn from_output_snapshot( + output: &core::Output, + pos: Option, + merkle_proof: Option, + context_id: u32, + block_header: Option<&core::BlockHeader>, + include_proof: bool, ) -> Result { let output_type = if output.is_coinbase() { OutputType::Coinbase @@ -310,8 +334,6 @@ impl OutputPrintable { OutputType::Transaction }; - let pos = chain.get_unspent(output.commitment())?; - let spent = pos.is_none(); // If output is unspent then we know its pos and height from the output_pos index. @@ -320,10 +342,8 @@ impl OutputPrintable { // api is currently doing the right thing here: // An output can be spent and then subsequently reused and the new instance unspent. // This would result in a height that differs from the provided block height. - let output_pos = pos.map(|(_, x)| x.pos).unwrap_or(0); - let block_height = pos - .map(|(_, x)| x.height) - .or(block_header.map(|x| x.height)); + let output_pos = pos.map(|x| x.pos).unwrap_or(0); + let block_height = pos.map(|x| x.height).or(block_header.map(|x| x.height)); let proof = if include_proof { Some( @@ -336,25 +356,6 @@ impl OutputPrintable { None }; - // Get the Merkle proof for all unspent coinbase outputs (to verify maturity on - // spend). We obtain the Merkle proof by rewinding the PMMR. - // We require the rewind() to be stable even after the PMMR is pruned and - // compacted so we can still recreate the necessary proof. - let mut merkle_proof = None; - if include_merkle_proof && output.is_coinbase() && !spent { - let fetched_header; - let block_header = match block_header { - Some(block_header) => block_header, - None => { - fetched_header = chain.get_header_for_output(output.commitment())?; - &fetched_header - } - }; - merkle_proof = Some(chain.get_merkle_proof(secp, output, block_header)?); - }; - - let context_id = chain.get_context_id(); - Ok(OutputPrintable { output_type, commit: output.commitment(), @@ -693,8 +694,15 @@ pub struct BlockHeaderPrintable { impl BlockHeaderPrintable { pub fn from_header(header: &core::BlockHeader) -> Result { + Self::from_header_with_context(header, header.pow.proof.context_id) + } + + pub(crate) fn from_header_with_context( + header: &core::BlockHeader, + context_id: u32, + ) -> Result { Ok(BlockHeaderPrintable { - hash: header.hash(header.pow.proof.context_id)?.to_hex(), + hash: header.hash(context_id)?.to_hex(), version: header.version.into(), height: header.height, previous: header.prev_hash.to_hex(), @@ -735,30 +743,42 @@ pub struct BlockPrintable { impl BlockPrintable { pub fn from_block( - secp: &Secp256k1, block: &core::Block, chain: &mwc_chain::Chain, include_proof: bool, include_merkle_proof: bool, ) -> Result { + chain.with_output_read_snapshot(|snapshot| { + Self::from_block_snapshot(block, snapshot, include_proof, include_merkle_proof) + }) + } + + pub(crate) fn from_block_snapshot( + block: &core::Block, + snapshot: &mwc_chain::OutputReadSnapshot<'_>, + include_proof: bool, + include_merkle_proof: bool, + ) -> Result { + let context_id = snapshot.get_context_id(); + mwc_chain::pipe::validate_header_context_id(context_id, &block.header)?; // Preserve the legacy printable block shape by returning input // commitments only. This intentionally drops Input::features; current // API clients do not require feature metadata for inputs. - let inputs = block - .inputs() - .into_commit_wrappers(chain.get_context_id())?; + let inputs = block.inputs().into_commit_wrappers(context_id)?; let inputs = inputs.iter().map(|x| x.commitment().to_hex()).collect(); let outputs = block .outputs() .iter() .map(|output| { - OutputPrintable::from_output( - secp, + let (pos, merkle_proof) = + snapshot.get_output_status(&output.identifier(), include_merkle_proof)?; + OutputPrintable::from_output_snapshot( output, - chain, + pos, + merkle_proof, + context_id, Some(&block.header), include_proof, - include_merkle_proof, ) }) .collect::, _>>()?; @@ -769,7 +789,7 @@ impl BlockPrintable { .map(|kernel| TxKernelPrintable::from_txkernel(kernel)) .collect(); Ok(BlockPrintable { - header: BlockHeaderPrintable::from_header(&block.header)?, + header: BlockHeaderPrintable::from_header_with_context(&block.header, context_id)?, inputs: inputs, outputs: outputs, kernels: kernels, @@ -782,6 +802,8 @@ impl BlockPrintable { pub struct CompactBlockPrintable { /// The block header pub header: BlockHeaderPrintable, + /// Nonce used with the block hash to derive kernel short IDs. + pub nonce: u64, /// Full outputs, specifically coinbase output(s) pub out_full: Vec, /// Full kernels, specifically coinbase kernel(s) @@ -794,23 +816,35 @@ impl CompactBlockPrintable { /// Convert a compact block into a printable representation suitable for /// api response pub fn from_compact_block( - secp: &Secp256k1, cb: &core::CompactBlock, chain: &mwc_chain::Chain, include_merkle_proof: bool, ) -> Result { - let block = chain.get_block(&cb.hash(chain.get_context_id())?)?; + chain.with_output_read_snapshot(|snapshot| { + Self::from_compact_block_snapshot(cb, snapshot, include_merkle_proof) + }) + } + + pub(crate) fn from_compact_block_snapshot( + cb: &core::CompactBlock, + snapshot: &mwc_chain::OutputReadSnapshot<'_>, + include_merkle_proof: bool, + ) -> Result { + let block = snapshot.get_block_for_header(&cb.header)?; + let context_id = snapshot.get_context_id(); let out_full = cb .out_full() .iter() - .map(|x| { - OutputPrintable::from_output( - secp, - x, - chain, + .map(|output| { + let (pos, merkle_proof) = + snapshot.get_output_status(&output.identifier(), include_merkle_proof)?; + OutputPrintable::from_output_snapshot( + output, + pos, + merkle_proof, + context_id, Some(&block.header), false, - include_merkle_proof, ) }) .collect::, _>>()?; @@ -821,6 +855,7 @@ impl CompactBlockPrintable { .collect(); Ok(CompactBlockPrintable { header: BlockHeaderPrintable::from_header(&cb.header)?, + nonce: cb.nonce, out_full, kern_full, kern_ids: cb.kern_ids().iter().map(|x| x.to_hex()).collect(), @@ -880,7 +915,7 @@ pub struct PoolInfo { #[cfg(test)] mod test { use super::*; - use mwc_crates::secp; + use mwc_crates::secp::{self, Secp256k1}; use mwc_util::secp_static; use std::fs; use std::sync::Arc; @@ -1010,16 +1045,16 @@ mod test { } #[test] - fn output_printable_merkle_proof_fetches_missing_header() { + fn output_printable_current_merkle_proof_does_not_require_origin_header() { mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); mwc_core::global::set_local_nrd_enabled(false); - let chain_dir = unique_test_dir("missing_merkle_header"); + let chain_dir = unique_test_dir("current_merkle_proof_without_origin_header"); let _ = fs::remove_dir_all(&chain_dir); let secp = Secp256k1::with_caps(secp::ContextFlag::Commit).unwrap(); let genesis = mwc_core::genesis::genesis_floo(&secp, 0); let output = *genesis.outputs().first().expect("genesis output"); - let printable = { + let (printable, current_mmr_size) = { let chain = mwc_chain::Chain::init( &secp, 0, @@ -1031,15 +1066,60 @@ mod test { std::collections::HashSet::new(), None, None, + false, ) .unwrap(); - OutputPrintable::from_output(&secp, &output, &chain, None, false, true).unwrap() + let current_mmr_size = chain.head_header().unwrap().output_mmr_size; + ( + OutputPrintable::from_output(&output, &chain, None, false, true).unwrap(), + current_mmr_size, + ) }; let _ = fs::remove_dir_all(&chain_dir); - assert!(printable.merkle_proof.is_some()); + assert_eq!(printable.merkle_proof.unwrap().mmr_size, current_mmr_size); + } + + #[test] + fn block_printable_rejects_header_context_mismatch() { + mwc_core::global::set_local_chain_type(mwc_core::global::ChainTypes::Floonet); + mwc_core::global::set_local_nrd_enabled(false); + let chain_dir = unique_test_dir("block_header_context_mismatch"); + let _ = fs::remove_dir_all(&chain_dir); + let secp = Secp256k1::with_caps(secp::ContextFlag::Commit).unwrap(); + let genesis = mwc_core::genesis::genesis_floo(&secp, 0); + let mut mismatched_block = genesis.clone(); + let chain = mwc_chain::Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(mwc_chain::types::NoopAdapter {}), + genesis, + mwc_core::pow::verify_size, + false, + std::collections::HashSet::new(), + None, + None, + false, + ) + .unwrap(); + mismatched_block.header.pow.proof.context_id = u32::MAX; + + let err = BlockPrintable::from_block(&mismatched_block, &chain, false, false) + .expect_err("mismatched block context"); + + match err { + mwc_chain::Error::InvalidHeaderContext { expected, actual } => { + assert_eq!(expected, 0); + assert_eq!(actual, u32::MAX); + } + other => panic!("expected InvalidHeaderContext, got {:?}", other), + } + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); } #[test] diff --git a/chain/Cargo.toml b/chain/Cargo.toml index 9431c08367..840cf54dee 100644 --- a/chain/Cargo.toml +++ b/chain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_chain" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Chain implementation for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,11 +10,11 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_keychain = { path = "../keychain", version = "7.0.0" } -mwc_store = { path = "../store", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_keychain = { path = "../keychain", version = "7.1.0" } +mwc_store = { path = "../store", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true [features] diff --git a/chain/src/chain.rs b/chain/src/chain.rs index e85946fa06..e1f74cee3a 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -20,12 +20,12 @@ use crate::error::Error; use crate::pibd_params::PibdParams; use crate::pipe; use crate::store; -use crate::store::{ChainOperationKind, PendingChainOperation}; +use crate::store::{ChainOperationKind, PendingChainOperation, PendingChainOperationGuard}; use crate::txhashset; use crate::txhashset::{Desegmenter, PMMRHandle, Segmenter, TxHashSet}; use crate::types::{ - BlockStatus, ChainAdapter, CommitPos, HashHeight, Options, SyncState, SyncStatus, - SyncStatusUpdateThrottle, Tip, HEADERS_PER_BATCH, + BlockStatus, ChainAdapter, CommitPos, HashHeight, Options, SpentCommitmentRecord, SpentOutput, + SyncState, SyncStatus, SyncStatusUpdateThrottle, Tip, HEADERS_PER_BATCH, }; use crate::ChainStore; use crate::{ @@ -35,10 +35,10 @@ use crate::{ use mwc_core::consensus; use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::merkle_proof::MerkleProof; -use mwc_core::core::pmmr::{ReadablePMMR, VecBackend, PMMR}; +use mwc_core::core::pmmr::{self, ReadablePMMR, VecBackend, PMMR}; use mwc_core::core::{ Block, BlockHeader, BlockSums, Committed, Inputs, KernelFeatures, Output, OutputIdentifier, - Transaction, TxKernel, + Transaction, TransactionBody, TxKernel, }; use mwc_core::difficulty_cache::DifficultyCache; use mwc_core::pow; @@ -54,17 +54,23 @@ use mwc_store::Error::NotFoundErr; use mwc_util::{StopState, ToHex}; use std::cmp::min; use std::collections::HashSet; +#[cfg(test)] use std::convert::TryFrom; use std::fs; use std::path::{Component, Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; +#[cfg(test)] +use std::sync::{mpsc, Mutex}; use std::time::{Duration, Instant}; use std::{collections::HashMap, io::Cursor}; /// When evicting, very old orphans are evicted first const MAX_ORPHAN_AGE_SECS: u64 = 3000; -const SPENT_COMMITMENT_INDEX_REBUILD_CHUNK: usize = 10_000; +/// Peer attribution is best-effort metadata and must remain bounded per orphan. +const MAX_ORPHAN_SOURCE_PEERS: usize = 6; +const SPENT_COMMITMENT_INDEX_CLEAR_CHUNK_SIZE: usize = 10_000; +const SPENT_COMMITMENT_INDEX_REBUILD_CHUNK_SIZE: usize = 1_000; const HISTORICAL_BLOCK_DELETE_CHUNK: usize = 100; #[derive(Debug, Clone)] @@ -86,6 +92,108 @@ pub struct OrphanBlockPool { pibd_params: Arc, } +/// Result of finalizing the recovery marker for an operation whose successful +/// value is produced only after its durable database batch has committed. +enum PendingChainOperationCompletion { + Complete(T), + CommittedNeedsRecovery { value: T, marker_error: Error }, +} + +/// Internal block-processing error classification. +/// +/// Only `NotCommitted` is safe for the batch caller to retry one block at a +/// time. Once the block batch committed, a recovery failure must be surfaced +/// without reclassifying the accepted block as a validation failure. +enum BlockProcessingError { + NotCommitted(Error), + CommittedRecoveryFailed(Error), +} + +#[cfg(test)] +struct RewindBadBlockAfterBodySyncHook { + reached: mpsc::SyncSender<()>, + resume: Mutex>, +} + +impl From for BlockProcessingError { + fn from(error: Error) -> Self { + Self::NotCommitted(error) + } +} + +/// Compare the portions of two block bodies committed by the header MMR roots. +/// +/// Inputs are intentionally excluded because a block header does not commit +/// their individual identities. Outputs include their rangeproofs in the full +/// encoding, and kernels include their excess signatures. +pub(crate) fn bodies_equal_header_committed( + context_id: u32, + lhs: &TransactionBody, + rhs: &TransactionBody, +) -> Result { + let version = ProtocolVersion::local(); + Ok(ser::ser_vec(context_id, &lhs.outputs, version)? + == ser::ser_vec(context_id, &rhs.outputs, version)? + && ser::ser_vec(context_id, &lhs.kernels, version)? + == ser::ser_vec(context_id, &rhs.kernels, version)?) +} + +/// Lossless comparison of unvalidated block bodies. +/// +/// Full-data serialization cannot be used for inputs: at protocol versions +/// >= 3, `Inputs::write` converts `FeaturesAndCommit` inputs into sorted +/// commit-only values, discarding the variant and consensus-relevant input +/// features. Outputs and kernels do not have this lossy protocol conversion, +/// so their full-data encodings retain rangeproofs and excess signatures. +fn bodies_equal_lossless( + context_id: u32, + lhs: &TransactionBody, + rhs: &TransactionBody, +) -> Result { + if !lhs.inputs.eq_by_hash(context_id, &rhs.inputs)? { + return Ok(false); + } + bodies_equal_header_committed(context_id, lhs, rhs) +} + +/// Compare a candidate block with a trusted stored block across the v2 wire and +/// v3 database representations. +/// +/// # Why this deliberately differs from `blocks_equal_lossless` +/// +/// Protocol v0-v2 carries `(features, commitment)` for every input, while the +/// v3 database format stores only the commitment. Consequently, the same valid +/// block is `FeaturesAndCommit` on a legacy connection and `CommitOnly` after it +/// is stored. `Inputs::eq_by_hash` correctly treats nonempty cross-representation +/// inputs as unequal: for two unvalidated bodies, the missing feature cannot be +/// proven without the parent UTXO state. Using that strict comparison here, +/// however, lets a legacy replay of an already stored block miss the duplicate +/// filter and repeat the expensive block-processing path. +/// +/// This helper is the narrow known-block exception. `stored` must be the already +/// validated block loaded from the block store. Serializing both complete bodies +/// as v3 removes legacy input features and orders those inputs by commitment, +/// while still comparing every input commitment, output and rangeproof, kernel +/// and signature, plus the exact header. It requires no parent or UTXO lookup. +/// +/// If the caller classifies an equal candidate as known, it is discarded and +/// the stored block remains authoritative, so a wrong legacy feature cannot +/// affect chain state. The deliberate tradeoff is that such a feature-only +/// mutation is suppressed as a duplicate instead of being used to ban a peer. +/// Callers still let a stored higher-work block continue to normal UTXO +/// validation when it needs reapplication. +/// +/// Never use this for orphan deduplication or between two unvalidated bodies; +/// those paths must retain and compare input features with +/// `blocks_equal_lossless`/`bodies_equal_lossless`. +pub(crate) fn blocks_equal_as_v3( + context_id: u32, + stored: &Block, + candidate: &Block, +) -> Result { + Ok(store::blocks_equal_as_v3(context_id, stored, candidate)?) +} + impl OrphanBlockPool { fn new(pibd_params: Arc) -> OrphanBlockPool { OrphanBlockPool { @@ -105,7 +213,57 @@ impl OrphanBlockPool { self.evicted.load(Ordering::Relaxed) } - fn add(&self, context_id: u32, orphan: Orphan) -> Result<(), Error> { + fn extend_source_peers_capped(source_peers: &mut HashSet, incoming: HashSet) { + for source_peer in incoming { + if source_peers.len() >= MAX_ORPHAN_SOURCE_PEERS { + break; + } + source_peers.insert(source_peer); + } + } + + /// Add an unvalidated orphan, deduplicating bodies losslessly. + /// + /// Why this is needed: the orphan pool is keyed by the block hash, which + /// is the header hash only, while orphan bodies have not yet been + /// validated against the roots committed to by that header. Deduplication + /// (discarding a newly arrived body and merging its `source_peers` into + /// the cached entry) is therefore only sound if the two bodies are + /// losslessly identical. Any field the comparison fails to cover becomes + /// a cache-poisoning and peer-misattribution vector. + /// + /// Attack vector with a lossy comparison: + /// 1. A malicious peer sends an orphan with the valid header and valid + /// commitments but corrupted input features. It is cached with the + /// attacker in `source_peers`. + /// 2. An honest peer later sends the valid body for the same header. If + /// the comparison cannot see the corrupted field, the valid body is + /// discarded as a "duplicate" and the honest peer's address is merged + /// into the poisoned entry's `source_peers`. + /// 3. Once the parent arrives, the cached (poisoned) body is processed + /// and deferred validation fails with a bad-data error (e.g. + /// `InputMismatch`). `process_block` then reports *every* merged + /// source peer via `block_rejected`, so the honest peer is banned + /// alongside the attacker. This gives an attacker cheap, repeatable + /// ban amplification against honest peers. + /// + /// How the comparison is made lossless: + /// - Inputs use the variant-aware hash comparison, which preserves the + /// `Inputs` variant, input feature bytes and ordering. Full-data + /// serialization cannot be used for inputs: at protocol versions >= 3, + /// `Inputs::write` converts `FeaturesAndCommit` inputs into + /// `CommitWrapper` values (and sorts them), discarding the + /// consensus-relevant `Input::features` bytes — the exact gap that + /// made the attack above possible. + /// - Outputs (including rangeproofs) and kernels (including excess + /// signatures) serialize losslessly in full-data mode at any supported + /// protocol version, so their bytes are compared directly. + fn add(&self, context_id: u32, mut orphan: Orphan) -> Result<(), Error> { + // Enforce the per-orphan bound for every caller, including an oversized + // source set supplied with the first insertion. + let incoming = std::mem::take(&mut orphan.source_peers); + Self::extend_source_peers_capped(&mut orphan.source_peers, incoming); + let mut orphans = self.orphans.write(); let mut height_idx = self.height_idx.write(); { @@ -113,19 +271,20 @@ impl OrphanBlockPool { let hash = orphan.block.hash(context_id)?; // The block hash is the header hash. Orphan bodies have not yet been // validated against the roots committed to by that header, so only - // byte-identical orphan blocks can safely be deduplicated. + // losslessly identical orphan blocks can safely be deduplicated. + // Anything less lets a poisoned body absorb the source peers of a + // later valid body, misattributing its validation failure and + // banning honest peers; see bodies_equal_lossless for the attack + // walkthrough. The source_peers merge below must stay behind this + // equality check. if let Some(existing) = orphans.get_mut(&hash) { - let existing_bytes = - ser::ser_vec(context_id, &existing.block, ProtocolVersion::local())?; - let orphan_bytes = - ser::ser_vec(context_id, &orphan.block, ProtocolVersion::local())?; - if existing_bytes != orphan_bytes { + if !bodies_equal_lossless(context_id, &existing.block.body, &orphan.block.body)? { return Err(Error::Unfit( "conflicting orphan body for header".to_owned(), )); } - existing.source_peers.extend(orphan.source_peers); + Self::extend_source_peers_capped(&mut existing.source_peers, orphan.source_peers); } else { orphans.insert(hash.clone(), orphan); let height_hashes = height_idx.entry(height).or_insert_with(|| vec![]); @@ -244,6 +403,274 @@ pub struct Chain { cache_header_difficulty: Arc>, pibd_params: Arc, requires_init_recovery: Arc, + pibd_state_generation: Arc, + #[cfg(test)] + fail_next_process_block_marker_clear: AtomicBool, + #[cfg(test)] + fail_next_process_block_header_marker_clear: AtomicBool, + #[cfg(test)] + fail_next_committed_recovery_with_bad_data: AtomicBool, + #[cfg(test)] + process_block_batch_safety_depth: AtomicU64, + #[cfg(test)] + rewind_bad_block_after_body_sync_hook: RwLock>>, +} + +/// A coherent read-only view of the header PMMR, body PMMRs, and chain store. +/// +/// Instances exist only for the duration of [`Chain::with_output_read_snapshot`]. +/// Chain writers cannot change or reorganize the viewed state while the snapshot +/// is in use. +pub struct OutputReadSnapshot<'a> { + chain: &'a Chain, + header_pmmr: &'a PMMRHandle, + txhashset: &'a TxHashSet, + batch: Batch<'a>, +} + +impl OutputReadSnapshot<'_> { + /// Context id associated with this chain snapshot. + pub fn get_context_id(&self) -> u32 { + self.batch.get_context_id() + } + + /// Return the earliest retained block tip from this snapshot. + pub fn get_tail(&self) -> Result { + self.batch + .tail() + .map_err(|e| Error::StoreErr(e, "output snapshot get tail".to_owned())) + } + + /// Load a block header by hash from this snapshot and verify its store key. + pub fn get_block_header(&self, hash: &Hash) -> Result { + crate::checked_header_by_hash( + self.get_context_id(), + hash, + "output snapshot get header by hash", + |hash| self.batch.get_block_header(hash), + ) + } + + /// Return unspent outputs by insertion index from this snapshot. + pub fn unspent_outputs_by_pmmr_index( + &self, + start_index: u64, + max_count: u64, + max_pmmr_index: Option, + ) -> Result<(u64, u64, Vec), Error> { + let output_mmr_size = self.txhashset.output_mmr_size(); + let last_index = match max_pmmr_index { + Some(index) => min(index, output_mmr_size), + None => output_mmr_size, + }; + let outputs = + self.txhashset + .outputs_by_pmmr_index(start_index, max_count, max_pmmr_index)?; + let rangeproofs = + self.txhashset + .rangeproofs_by_pmmr_index(start_index, max_count, max_pmmr_index)?; + let (index, output_vec) = combine_positioned_outputs_and_rangeproofs(outputs, rangeproofs)?; + Ok((index, last_index, output_vec)) + } + + /// Return the header selected by the header PMMR at `height`. + /// + /// This is a request-driven read path, not a PoW validation boundary. Normal + /// writes validate headers before persistence, and startup/recovery validates + /// persisted ancestry. Revalidating Cuckoo PoW here would let API clients turn + /// cheap height lookups into expensive CPU work and create a DoS surface. + /// The checks below intentionally provide inexpensive store/PMMR consistency + /// checks only; raw local database modification is outside this threat model. + pub fn get_header_by_height(&self, height: u64) -> Result { + let hash = self.header_pmmr.get_header_hash_by_height(height)?; + let header = self + .batch + .get_block_header(&hash) + .map_err(|e| Error::StoreErr(e, format!("output snapshot get header {}", hash)))?; + let actual_hash = header.hash(self.get_context_id())?; + if header.height != height || actual_hash != hash { + return Err(Error::InvalidPersistedChainState(format!( + "output snapshot header entry {} at height {} resolved to header {} at height {}", + hash, height, actual_hash, header.height + ))); + } + self.header_pmmr + .authenticate_header_at_height(height, &header)?; + Ok(header) + } + + /// Load the full block whose complete header must equal `expected`. + pub fn get_block_for_header(&self, expected: &BlockHeader) -> Result { + let expected_hash = expected.hash(self.get_context_id())?; + let stored_header = crate::checked_header_by_hash( + self.get_context_id(), + &expected_hash, + "output snapshot get block for header", + |hash| self.batch.get_block_header(hash), + )?; + if stored_header != *expected { + return Err(Error::InvalidPersistedChainState(format!( + "output snapshot stored header {} does not exactly match the requested header", + expected_hash + ))); + } + crate::checked_block_for_header( + self.get_context_id(), + expected, + "output snapshot", + |hash| self.batch.get_block(hash), + ) + } + + fn get_unspent_entry( + &self, + commit: Commitment, + ) -> Result, Error> { + let indexed_pos = self + .batch + .get_output_pos_height(&commit) + .map_err(|e| Error::StoreErr(e, "output snapshot get output position".to_owned()))?; + let Some((output, pos)) = self + .txhashset + .get_unspent_with_position(commit, indexed_pos)? + else { + return Ok(None); + }; + let body_head = self + .batch + .head() + .map_err(|e| Error::StoreErr(e, "output snapshot get body head".to_owned()))?; + if self + .chain + .body_chain_header_for_output_pos(self.header_pmmr, &self.batch, &body_head, pos)? + .is_none() + { + return Err(Error::InvalidPersistedChainState(format!( + "output position index entry for commitment {} has invalid position {} or height {}", + commit.to_hex(), + pos.pos, + pos.height + ))); + } + Ok(Some((output, pos))) + } + + /// Return an unspent output identifier and its one-based PMMR position from + /// this snapshot without loading its rangeproof. + pub fn get_unspent_output_position( + &self, + commit: Commitment, + ) -> Result, Error> { + self.get_unspent_entry(commit) + } + + fn get_merkle_proof( + &self, + output: &OutputIdentifier, + pos: CommitPos, + ) -> Result { + let pos0 = pos.pos.checked_sub(1).ok_or_else(|| { + Error::DataOverflow(format!( + "OutputReadSnapshot::get_merkle_proof pos={}", + pos.pos + )) + })?; + let indexed_pos0 = self.txhashset.get_output_pos(&output.commitment())?; + if indexed_pos0 != pos0 { + return Err(Error::InvalidPersistedChainState(format!( + "output position changed inside snapshot for commitment {}: {} versus {}", + output.commitment().to_hex(), + pos0, + indexed_pos0 + ))); + } + let proof = self.txhashset.merkle_proof(output.commitment())?; + if proof.mmr_size != self.txhashset.output_mmr_size() { + return Err(Error::InvalidPersistedChainState(format!( + "merkle proof MMR size {} does not match output PMMR size {}", + proof.mmr_size, + self.txhashset.output_mmr_size() + ))); + } + Ok(proof) + } + + /// Return the zero-based PMMR position and current-state Merkle proof for an + /// unspent commitment from this snapshot. + pub fn get_output_pos_and_merkle_proof( + &self, + commit: Commitment, + ) -> Result<(u64, MerkleProof), Error> { + let Some((output, pos)) = self.get_unspent_entry(commit)? else { + return Err(Error::OutputNotFound(commit.to_hex())); + }; + let pos0 = pos.pos.checked_sub(1).ok_or_else(|| { + Error::DataOverflow(format!( + "OutputReadSnapshot::get_output_pos_and_merkle_proof pos={}", + pos.pos + )) + })?; + let proof = self.get_merkle_proof(&output, pos)?; + Ok((pos0, proof)) + } + + /// Read an output's unspent position and optional current-state Merkle proof + /// from this snapshot. + pub fn get_output_status( + &self, + expected: &OutputIdentifier, + include_merkle_proof: bool, + ) -> Result<(Option, Option), Error> { + let Some((stored, pos)) = self.get_unspent_entry(expected.commitment())? else { + return Ok((None, None)); + }; + if !ser::hashes_equal(self.get_context_id(), &stored, expected)? { + return Err(Error::TxHashSetErr(format!( + "unspent output identifier mismatch for commitment {}", + expected.commitment().to_hex() + ))); + } + let merkle_proof = if include_merkle_proof && expected.is_coinbase() { + Some(self.get_merkle_proof(&stored, pos)?) + } else { + None + }; + Ok((Some(pos), merkle_proof)) + } + + /// Read a complete unspent output and its optional current-state Merkle proof + /// from this snapshot. + pub fn get_unspent_output( + &self, + commit: Commitment, + include_merkle_proof: bool, + ) -> Result)>, Error> { + let Some((stored, pos)) = self.get_unspent_entry(commit)? else { + return Ok(None); + }; + let pos0 = pos.pos.checked_sub(1).ok_or_else(|| { + Error::DataOverflow(format!( + "OutputReadSnapshot::get_unspent_output pos={}", + pos.pos + )) + })?; + let output = txhashset::utxo_view(self.header_pmmr, self.txhashset, |utxo, _| { + utxo.get_unspent_output_at(pos0) + })?; + if !ser::hashes_equal(self.get_context_id(), &output.identifier(), &stored)? { + return Err(Error::InvalidPersistedChainState(format!( + "output data at position {} does not match commitment {}", + pos.pos, + commit.to_hex() + ))); + } + let merkle_proof = if include_merkle_proof && stored.is_coinbase() { + Some(self.get_merkle_proof(&stored, pos)?) + } else { + None + }; + Ok(Some((output, stored, pos, merkle_proof))) + } } impl Chain { @@ -291,6 +718,34 @@ impl Chain { } } + /// Execute output-related reads against one coherent chain snapshot. + /// + /// The supplied closure must not attempt a chain write. Header and body PMMR + /// read locks, together with one database read transaction, remain held until + /// the closure returns. + pub fn with_output_read_snapshot(&self, f: F) -> Result + where + E: From, + F: Fn(&OutputReadSnapshot<'_>) -> Result, + { + let result = self + .with_robust_chain_read(|header_pmmr, txhashset| { + let batch = self + .store + .batch_read() + .map_err(|e| Error::StoreErr(e, "create output read snapshot".to_owned()))?; + let snapshot = OutputReadSnapshot { + chain: self, + header_pmmr, + txhashset, + batch, + }; + Ok(f(&snapshot)) + }) + .map_err(E::from)?; + result + } + fn ensure_header_pmmr_locked_for_marker(&self, op_name: &str) -> Result<(), Error> { // Defensive invariant check: parking_lot does not expose lock ownership, // so this proves only that the lock is not currently free. @@ -304,11 +759,27 @@ impl Chain { Ok(()) } - fn set_pending_chain_operation_checked(&self, op: &PendingChainOperation) -> Result<(), Error> { + fn set_pending_chain_operation_checked( + &self, + op: &PendingChainOperation, + ) -> Result { self.ensure_header_pmmr_locked_for_marker("set_pending_chain_operation")?; - match self.store.set_pending_chain_operation(op) { - Ok(()) => Ok(()), - Err(e) => Err(e.into()), + match self.store.set_pending_chain_operation_if_absent(op) { + Ok(true) => Ok(PendingChainOperationGuard::new( + self.requires_init_recovery.clone(), + )), + Ok(false) => { + self.requires_init_recovery.store(true, Ordering::SeqCst); + Err(Error::Other( + "pending chain operation requires chain init recovery".into(), + )) + } + Err(e) => { + // A failed durable commit can have an uncertain outcome. Fail closed so + // the next top-level chain access checks for and recovers any marker. + self.requires_init_recovery.store(true, Ordering::SeqCst); + Err(e.into()) + } } } @@ -322,17 +793,85 @@ impl Chain { } } + #[cfg(test)] + fn should_fail_process_marker_clear(&self, op_name: &str) -> bool { + match op_name { + "process_block_single" | "process_block_multiple" => self + .fail_next_process_block_marker_clear + .swap(false, Ordering::SeqCst), + "process_block_header" => self + .fail_next_process_block_header_marker_clear + .swap(false, Ordering::SeqCst), + _ => false, + } + } + + /// Invalidate every PIBD producer or consumer derived from the current PMMR state. + /// + /// Callers that can invalidate a PIBD session's PMMR snapshot must advance this + /// generation while holding the PMMR locks. Do not take `pibd_segmenter` + /// here: `segmenter()` takes the cache lock before the PMMR locks, so doing so + /// would invert the lock order. + /// + /// Exhaustion fails closed instead of wrapping to a value held by an old + /// Segmenter or Desegmenter. A process restart safely resets the generation + /// because no in-memory PIBD objects survive it. + fn advance_pibd_state_generation(&self) -> Result<(), Error> { + self.pibd_state_generation + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |generation| { + generation.checked_add(1) + }) + .map(|_| ()) + .map_err(|_| { + self.requires_init_recovery.store(true, Ordering::SeqCst); + Error::DataOverflow("PIBD state generation exhausted; restart is required".into()) + }) + } + + #[cfg(test)] + fn wait_after_rewind_bad_block_body_sync(&self) { + let hook = self + .rewind_bad_block_after_body_sync_hook + .read_recursive() + .clone(); + if let Some(hook) = hook { + hook.reached + .send(()) + .expect("rewind_bad_block body-sync test receiver dropped"); + hook.resume + .lock() + .expect("rewind_bad_block body-sync test mutex poisoned") + .recv() + .expect("rewind_bad_block body-sync test sender dropped"); + } + } + fn recover_pending_chain_operation_checked(&self, recovery_context: &str) -> Result<(), Error> { warn!("attempting recovery: {}", recovery_context); let secp = Secp256k1::with_caps(ContextFlag::Commit)?; let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); + // Reserve a non-repeating generation before recovery can clear its durable + // marker. On exhaustion, leave both the marker and in-memory recovery latch + // installed so no old PIBD object can become current again. + self.advance_pibd_state_generation()?; + #[cfg(test)] + if self + .fail_next_committed_recovery_with_bad_data + .swap(false, Ordering::SeqCst) + { + self.requires_init_recovery.store(true, Ordering::SeqCst); + return Err(Error::InvalidRoot( + "forced local committed-recovery failure".into(), + )); + } match recover_pending_chain_operation( &self.genesis, &self.store, &mut header_pmmr, &mut txhashset, &secp, + self.pow_verifier, ) { Ok(()) => { self.requires_init_recovery.store(false, Ordering::SeqCst); @@ -348,53 +887,86 @@ impl Chain { } } - fn handle_failed_pending_chain_operation(&self, op_name: &str, e: &Error) { + fn handle_failed_pending_chain_operation( + &self, + op_name: &str, + e: &Error, + marker_guard: &mut PendingChainOperationGuard, + ) { warn!( - "{} failed after marker was set; will attempting recovery: {}", + "{} failed after marker was set; will attempt recovery: {}", op_name, e ); - self.requires_init_recovery.store(true, Ordering::SeqCst); + marker_guard.require_recovery(); } - fn set_readonly_pmmr_discard_marker(&self, op_name: &str) -> Result { - if self.store.pending_chain_operation()?.is_some() { - trace!( - "{} using existing pending chain operation marker for readonly PMMR discard recovery", - op_name - ); - return Ok(false); + fn set_readonly_pmmr_discard_marker( + &self, + op_name: &str, + ) -> Result { + match self.store.pending_chain_operation() { + Ok(None) => {} + Ok(Some(existing_op)) => { + self.requires_init_recovery.store(true, Ordering::SeqCst); + warn!( + "{} found existing pending {:?} marker; chain init recovery is required before readonly PMMR access", + op_name, + existing_op.kind() + ); + return Err(Error::Other( + "pending chain operation requires chain init recovery".into(), + )); + } + Err(e) => { + // An unreadable marker means we cannot prove that no interrupted + // operation requires recovery. Keep all later chain access fail-closed. + self.requires_init_recovery.store(true, Ordering::SeqCst); + return Err(e.into()); + } } let op = prepare_reconcile_heads_operation( &self.store, ChainOperationKind::ReadonlyPmmrDiscard, )?; - let marker_set = self.store.set_pending_chain_operation_if_absent(&op)?; - if marker_set { - trace!( - "{} set readonly PMMR discard recovery marker before operation", - op_name - ); - } else { - trace!( - "{} using concurrently-set pending chain operation marker for readonly PMMR discard recovery", - op_name - ); - } - Ok(marker_set) + // This fails closed and latches recovery if another marker wins the + // conditional insert or if the durable commit has an uncertain outcome. + let marker_guard = self.set_pending_chain_operation_checked(&op)?; + trace!( + "{} set readonly PMMR discard recovery marker before operation", + op_name + ); + Ok(marker_guard) } fn finish_readonly_pmmr_discard_marker( &self, op_name: &str, res: Result, - marker_set: bool, + mut marker_guard: PendingChainOperationGuard, ) -> Result { match res { Ok(res) => { - if marker_set { - self.clear_pending_chain_operation_checked()?; + if self.requires_init_recovery.load(Ordering::SeqCst) { + warn!( + "{} completed while chain init recovery was required; retaining readonly PMMR recovery marker", + op_name + ); + marker_guard.require_recovery(); + return Err(Error::Other( + "pending chain operation requires chain init recovery".into(), + )); + } + self.clear_pending_chain_operation_checked()?; + // A non-PMMR path can latch recovery while the durable marker is + // being cleared. Never publish a successful result in that case. + if self.requires_init_recovery.load(Ordering::SeqCst) { + marker_guard.require_recovery(); + return Err(Error::Other( + "pending chain operation requires chain init recovery".into(), + )); } + marker_guard.disarm(); Ok(res) } Err(e) => { @@ -403,11 +975,14 @@ impl Chain { "{} failed to discard txhashset/header PMMR changes; chain marked for recovery: {}", op_name, e ); - self.requires_init_recovery.store(true, Ordering::SeqCst); + marker_guard.require_recovery(); Err(e) - } else if marker_set { + } else { match self.clear_pending_chain_operation_checked() { - Ok(()) => Err(e), + Ok(()) => { + marker_guard.disarm(); + Err(e) + } Err(clear_err) => { error!( "{} failed before readonly PMMR state changed: {}; additionally failed to clear readonly PMMR discard recovery marker: {}", @@ -419,8 +994,6 @@ impl Chain { Err(e) } } - } else { - Err(e) } } } @@ -438,10 +1011,22 @@ impl Chain { F: FnOnce() -> Result, { self.ensure_header_pmmr_locked_for_marker(op_name)?; + // Recovery takes the PMMR locks itself, so it cannot be performed here. + // Reject the operation instead, ensuring no caller can run the closure + // against state that a preceding writer left marked as uncertain. + if self.requires_init_recovery.load(Ordering::SeqCst) { + warn!( + "{} refused readonly PMMR access because chain init recovery is required", + op_name + ); + return Err(Error::Other( + "pending chain operation requires chain init recovery".into(), + )); + } - let marker_set = self.set_readonly_pmmr_discard_marker(op_name)?; + let marker_guard = self.set_readonly_pmmr_discard_marker(op_name)?; let res = f(); - self.finish_readonly_pmmr_discard_marker(op_name, res, marker_set) + self.finish_readonly_pmmr_discard_marker(op_name, res, marker_guard) } fn finish_pending_chain_operation( @@ -449,19 +1034,49 @@ impl Chain { op_name: &str, res: Result, state_may_have_changed: bool, - ) -> Result { + mut marker_guard: PendingChainOperationGuard, + ) -> Result, Error> { match res { Ok(res) => { - self.clear_pending_chain_operation_checked()?; - Ok(res) + #[cfg(test)] + let clear_res = if self.should_fail_process_marker_clear(op_name) { + self.requires_init_recovery.store(true, Ordering::SeqCst); + Err(Error::Other(format!( + "forced pending marker clear failure for {}", + op_name + ))) + } else { + self.clear_pending_chain_operation_checked() + }; + #[cfg(not(test))] + let clear_res = self.clear_pending_chain_operation_checked(); + + match clear_res { + Ok(()) => { + marker_guard.disarm(); + Ok(PendingChainOperationCompletion::Complete(res)) + } + Err(marker_error) => { + // The operation's batch is already committed. Retain its value + // while recovery reconciles PMMR state and clears the marker. + marker_guard.require_recovery(); + Ok(PendingChainOperationCompletion::CommittedNeedsRecovery { + value: res, + marker_error, + }) + } + } } Err(e) => { - if state_may_have_changed || e.is_txhashset_discard_failure() { - self.handle_failed_pending_chain_operation(op_name, &e); + if state_may_have_changed || e.requires_chain_recovery() { + self.handle_failed_pending_chain_operation(op_name, &e, &mut marker_guard); Err(e) } else { match self.clear_pending_chain_operation_checked() { - Ok(()) => Err(e), + Ok(()) => { + marker_guard.disarm(); + Err(e) + } Err(clear_err) => { error!( "{} failed before chain state changed: {}; additionally failed to clear pending chain operation marker: {}", @@ -478,7 +1093,9 @@ impl Chain { /// Initializes the blockchain and returns a new Chain instance. Does a /// check on the current chain head to make sure it exists and creates one - /// based on the genesis block if necessary. + /// based on the genesis block if necessary. Routine persisted blockchain + /// validation can be skipped, but genesis validation and incomplete-operation + /// recovery remain mandatory initialization safety checks. pub fn init( secp: &Secp256k1, context_id: u32, @@ -490,8 +1107,16 @@ impl Chain { invalid_blocks: HashSet, sync_state: Option>, stop_state: Option>, + skip_start_blockchain_validation: bool, ) -> Result { validate_genesis_for_init(secp, context_id, &genesis, pow_verifier)?; + if skip_start_blockchain_validation { + warn!("init: skipping startup blockchain data validation"); + } else { + println!( + "Starting blockchain data validation. It might take few minutes, please wait..." + ); + } let store = Arc::new(store::ChainStore::new(context_id, &db_root)?); @@ -514,7 +1139,26 @@ impl Chain { VariableSizeMetadataValidation::Full, )?; - recover_pending_chain_operation(&genesis, &store, &mut header_pmmr, &mut txhashset, secp)?; + // Migrate legacy positions-only per-block spent indexes to the exact + // occurrence format before recovery can rewind blocks that carry them. + // The output PMMR is open at this point, so the migration resolves each + // active-window legacy position's commitment from retained leaf data. + Chain::migrate_spent_index(&store, &txhashset, stop_state.clone())?; + + // The spent-occurrence index must be complete before recovery runs: + // rewind and reconciliation paths consume it. An index left incomplete + // by a crash or an older version is rebuilt from the retained full + // blocks here. + Chain::init_spent_commitment_index(&store, stop_state.clone())?; + + recover_pending_chain_operation( + &genesis, + &store, + &mut header_pmmr, + &mut txhashset, + secp, + pow_verifier, + )?; setup_head( &genesis, @@ -522,6 +1166,8 @@ impl Chain { &mut header_pmmr, &mut txhashset, &secp, + pow_verifier, + skip_start_blockchain_validation, None, )?; @@ -535,7 +1181,6 @@ impl Chain { txhashset.init_output_pos_index(&batch, sync_state.clone(), stop_state.clone())?; } txhashset.init_recent_kernel_pos_index( - &header_pmmr, &batch, sync_state.clone(), stop_state.clone(), @@ -551,7 +1196,10 @@ impl Chain { stop_state.clone(), )?; } - Chain::init_spent_commitment_index(&store)?; + // Re-check after recovery and head setup: on a fresh DB the head did not + // exist at the first call, and recovery may have reset chain state. + // No-op if the index is already complete. + Chain::init_spent_commitment_index(&store, stop_state.clone())?; let chain = Chain { db_root, @@ -567,6 +1215,17 @@ impl Chain { cache_header_difficulty: Arc::new(RwLock::new(DifficultyCache::new())), pibd_params, requires_init_recovery: Arc::new(AtomicBool::new(false)), + pibd_state_generation: Arc::new(AtomicU64::new(0)), + #[cfg(test)] + fail_next_process_block_marker_clear: AtomicBool::new(false), + #[cfg(test)] + fail_next_process_block_header_marker_clear: AtomicBool::new(false), + #[cfg(test)] + fail_next_committed_recovery_with_bad_data: AtomicBool::new(false), + #[cfg(test)] + process_block_batch_safety_depth: AtomicU64::new(100), + #[cfg(test)] + rewind_bad_block_after_body_sync_hook: RwLock::new(None), }; chain.apply_invalid_blocks(secp, invalid_blocks)?; @@ -601,22 +1260,30 @@ impl Chain { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_chain_operation_checked(&PendingChainOperation::PibdReset)?; + let mut marker_guard = + self.set_pending_chain_operation_checked(&PendingChainOperation::PibdReset)?; let res = reset_pibd_chain_state( &self.genesis, &self.store, &mut header_pmmr, &mut txhashset, &secp, + self.pow_verifier, ); match res { Ok(()) => { + self.advance_pibd_state_generation()?; self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); Ok(()) } Err(e) => { - self.handle_failed_pending_chain_operation("reset_pibd_chain", &e); + self.handle_failed_pending_chain_operation( + "reset_pibd_chain", + &e, + &mut marker_guard, + ); Err(e) } } @@ -639,7 +1306,7 @@ impl Chain { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); let op = prepare_reset_chain_head_operation(&self.store, header, rewind_headers)?; - self.set_pending_chain_operation_checked(&op)?; + let mut marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = reset_chain_head_state( &self.store, &mut header_pmmr, @@ -651,11 +1318,17 @@ impl Chain { match res { Ok(()) => { + self.advance_pibd_state_generation()?; self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); Ok(()) } Err(e) => { - self.handle_failed_pending_chain_operation("reset_chain_head", &e); + self.handle_failed_pending_chain_operation( + "reset_chain_head", + &e, + &mut marker_guard, + ); Err(e) } } @@ -669,21 +1342,29 @@ impl Chain { let secp = Secp256k1::with_caps(ContextFlag::Commit)?; let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_chain_operation_checked(&PendingChainOperation::ResetToGenesis)?; + let mut marker_guard = + self.set_pending_chain_operation_checked(&PendingChainOperation::ResetToGenesis)?; let res = reset_chain_head_to_genesis_state( &self.genesis, &self.store, &mut header_pmmr, &mut txhashset, &secp, + self.pow_verifier, ); match res { Ok(()) => { + self.advance_pibd_state_generation()?; self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); Ok(()) } Err(e) => { - self.handle_failed_pending_chain_operation("reset_chain_head_to_genesis", &e); + self.handle_failed_pending_chain_operation( + "reset_chain_head_to_genesis", + &e, + &mut marker_guard, + ); Err(e) } } @@ -721,6 +1402,11 @@ impl Chain { } /// Known bad block that we must rewind prior to if seen on "current chain". + /// + /// Missing denylisted headers are intentionally skipped without an active-chain + /// membership check. This feature blocks known headers or blocks present in a + /// healthy database; database corruption is explicitly outside its threat model + /// and must be handled separately. pub fn rewind_bad_block( &self, secp: &Secp256k1, @@ -736,7 +1422,12 @@ impl Chain { }; let header_hash = header.hash(context_id)?; - let header_tip = Tip::try_from_header(&header)?; + if header_hash != *hash { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block loaded header {} from denylisted key {}", + header_hash, hash + ))); + } loop { self.ensure_chain_robust()?; @@ -748,172 +1439,314 @@ impl Chain { continue; } - let old_header_head = self - .store + let read_batch = self.store.batch_read()?; + let persisted_header_head = read_batch .header_head() .map_err(|e| Error::StoreErr(e, "header head".to_owned()))?; - if !self.is_on_current_chain_with_header_pmmr( + // `last_block_h` is the authoritative HEADER_HEAD selector. Rebuild + // the redundant Tip fields before using its cached height to bound a + // PMMR membership check. + let (old_header_head_header, old_header_head) = canonical_tip_header( + "rewind_bad_block HEADER_HEAD", + &persisted_header_head, + &read_batch, + )?; + let old_body_head = read_batch + .head() + .map_err(|e| Error::StoreErr(e, "chain head".to_owned()))?; + + // Establish whether the initially loaded denied header is relevant + // before following its prev_hash. Canonical cleanup may retain an + // off-chain child after deleting its formerly canonical parent; that + // detached child must not turn an otherwise successful denylist pass + // into a persistent missing-header error. + let mut rewind_headers = self.is_on_current_chain_with_header_pmmr( &header_pmmr, - header_tip, + Tip::try_from_header(&header)?, old_header_head.clone(), - )? { + )?; + let mut rewind_body = + self.is_on_body_chain_with_batch(&read_batch, &header, &old_body_head)?; + if !rewind_headers && !rewind_body { break; } - debug!( - "rewind_bad_block: found header: {} at {}", - header_hash, header.height - ); - - let read_batch = self.store.batch_read()?; - let mut prev_header = read_batch.get_previous_header(&header)?; - let mut skipped_denied_ancestors = Vec::new(); + // The header is active on at least one durable chain. Missing or + // malformed ancestry from this point is active-chain corruption and + // must remain fatal. + let mut ancestry_visited = HashSet::new(); + let mut prev_header = crate::checked_previous_header( + context_id, + &header, + &mut ancestry_visited, + "rewind_bad_block ancestry", + |hash| read_batch.get_block_header(hash), + )?; + let mut denied_headers = vec![header.clone()]; // A stronger implementation could batch all denied current-chain // blocks into one atomic rewind, but validation is expected to // report a single bad block in normal operation. Keep this per-hash // path, but do not choose a rewind target that is also denied if a // HashSet happens to visit adjacent bad blocks descendant-first. while invalid_blocks.contains(&prev_header.hash(context_id)?) { - skipped_denied_ancestors.push(prev_header.clone()); - prev_header = read_batch.get_previous_header(&prev_header)?; + denied_headers.push(prev_header.clone()); + prev_header = crate::checked_previous_header( + context_id, + &prev_header, + &mut ancestry_visited, + "rewind_bad_block denied ancestry", + |hash| read_batch.get_block_header(hash), + )?; } - drop(read_batch); let new_head = Tip::try_from_header(&prev_header)?; + // HEADER_HEAD and BODY_HEAD may legitimately be on competing forks. + // Determine membership independently, considering every adjacent denied + // ancestor that the rewind target skips. + for denied_header in denied_headers.iter().skip(1) { + if rewind_headers { + break; + } + if self.is_on_current_chain_with_header_pmmr( + &header_pmmr, + Tip::try_from_header(denied_header)?, + old_header_head.clone(), + )? { + rewind_headers = true; + break; + } + } + + for denied_header in denied_headers.iter().skip(1) { + if rewind_body { + break; + } + if self.is_on_body_chain_with_batch( + &read_batch, + denied_header, + &old_body_head, + )? { + rewind_body = true; + break; + } + } + debug!( + "rewind_bad_block: found denied header {} at {}; rewind_headers={}, rewind_body={}", + header_hash, header.height, rewind_headers, rewind_body + ); + + // Preflight the complete body cleanup path before mutating a PMMR or + // setting the durable recovery marker. Rewinding below BODY_TAIL would + // leave the tail above HEAD and pointing at a deleted full block. + let mut body_cleanup_headers = Vec::new(); + if rewind_body { + let body_tail = read_batch + .tail() + .map_err(|e| Error::StoreErr(e, "body tail".to_owned()))?; + if body_tail.height > old_body_head.height { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block BODY_TAIL height {} is above BODY_HEAD height {}", + body_tail.height, old_body_head.height + ))); + } + let canonical_tail = self.body_chain_header_at_height( + &read_batch, + &old_body_head, + body_tail.height, + )?; + let canonical_tail_hash = canonical_tail.hash(context_id)?; + let body_tail_hash = body_tail.hash(context_id)?; + if canonical_tail_hash != body_tail_hash { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block BODY_TAIL {} at height {} is not on the body chain; found {}", + body_tail_hash, body_tail.height, canonical_tail_hash + ))); + } + if new_head.height < body_tail.height { + return Err(Error::Other(format!( + "rewind_bad_block cannot rewind BODY_HEAD to {} at height {} below BODY_TAIL {} at height {}; a full chain-state reset is required", + new_head.hash(context_id)?, + new_head.height, + body_tail_hash, + body_tail.height + ))); + } + + let old_body_hash = old_body_head.hash(context_id)?; + let mut current = read_batch.get_block_header(&old_body_hash)?; + let computed_body_hash = current.hash(context_id)?; + if computed_body_hash != old_body_hash { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block BODY_HEAD selected {}, header hashes to {}", + old_body_hash, computed_body_hash + ))); + } + let mut cleanup_visited = HashSet::new(); + while current.height > new_head.height { + crate::checked_block_for_header( + context_id, + ¤t, + "rewind_bad_block body cleanup preflight", + |hash| read_batch.get_block(hash), + )?; + let previous = crate::checked_previous_header( + context_id, + ¤t, + &mut cleanup_visited, + "rewind_bad_block body cleanup ancestry", + |hash| read_batch.get_block_header(hash), + )?; + body_cleanup_headers.push(current); + current = previous; + } + if current != prev_header { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block body cleanup reached {} at height {}, expected {} at height {}", + current.hash(context_id)?, + current.height, + prev_header.hash(context_id)?, + prev_header.height + ))); + } + // The loop above authenticates only descendants that will be + // deleted. The target remains in the retained body window and + // becomes BODY_HEAD, so require its exact full-block record too. + crate::checked_block_for_header( + context_id, + &prev_header, + "rewind_bad_block body target preflight", + |hash| read_batch.get_block(hash), + )?; + } + + // Preflight the header cleanup path and every optional full block. + // A full block must remain paired with its header until delete_block() + // has removed its dependent records. + let mut header_cleanup_headers = Vec::new(); + if rewind_headers { + let mut current = old_header_head_header.clone(); + let mut cleanup_visited = HashSet::new(); + while current.height > new_head.height { + let current_hash = current.hash(context_id)?; + if read_batch.block_exists(¤t_hash)? { + crate::checked_block_for_header( + context_id, + ¤t, + "rewind_bad_block header cleanup preflight", + |hash| read_batch.get_block(hash), + )?; + } + let previous = crate::checked_previous_header( + context_id, + ¤t, + &mut cleanup_visited, + "rewind_bad_block header cleanup ancestry", + |hash| read_batch.get_block_header(hash), + )?; + header_cleanup_headers.push(current); + current = previous; + } + if current != prev_header { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_bad_block header cleanup reached {} at height {}, expected {} at height {}", + current.hash(context_id)?, + current.height, + prev_header.hash(context_id)?, + prev_header.height + ))); + } + } + drop(read_batch); + + // A body rewind needs both PMMR locks and an LMDB writer. Acquire + // them in the documented header_pmmr -> txhashset -> store order. + // Keep the optional body guard alive until the marker is finalized so + // readers cannot pair speculative PMMR sizes with old database heads. + let mut body_txhashset = if rewind_body { + Some(self.txhashset.write()) + } else { + None + }; let op = prepare_reconcile_heads_operation( &self.store, ChainOperationKind::RewindBadBlock, )?; - self.set_pending_chain_operation_checked(&op)?; + let mut marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = (|| { - let read_batch = self.store.batch_read()?; - let body_head = read_batch - .head() - .map_err(|e| Error::StoreErr(e, "chain head".to_owned()))?; - let mut body_rewind_header = - if self.is_on_body_chain_with_batch(&read_batch, &header, &body_head)? { - Some(header.clone()) - } else { - None - }; - for skipped_header in &skipped_denied_ancestors { - if body_rewind_header.is_none() - && self.is_on_body_chain_with_batch( - &read_batch, - skipped_header, - &body_head, - )? { - body_rewind_header = Some(skipped_header.clone()); - } - } - let body_block = if let Some(body_rewind_header) = body_rewind_header { - let body_rewind_hash = body_rewind_header.hash(context_id)?; - match read_batch.get_block(&body_rewind_hash) { - Ok(block) => Some(block), - Err(e @ NotFoundErr(_)) => { - warn!( - "rewind_bad_block: denied block {} at height {} is on the body chain \ - but the full block is missing; cannot safely rewind HEAD/txhashset", - body_rewind_hash, body_rewind_header.height - ); - return Err(Error::StoreErr(e, "chain get block".to_owned())); - } - Err(e) => { - return Err(Error::StoreErr(e, "chain get block".to_owned())); - } - } - } else { - None - }; - drop(read_batch); - - if let Some(block) = body_block { - // Fix the (full) block chain. - debug!( - "rewind_bad_block: denied block {} at {} is on the body chain", - block.hash(context_id)?, - block.header.height - ); + let mut batch = self.store.batch_write()?; + if let Some(txhashset) = body_txhashset.as_deref_mut() { debug!( - "rewind_bad_block: rewinding to prev: {} at {}", + "rewind_bad_block: rewinding BODY_HEAD to {} at {}", prev_header.hash(context_id)?, prev_header.height ); - let mut txhashset = self.txhashset.write(); - let mut batch = self.store.batch_write()?; - - let old_head = batch.head()?; - txhashset::extending( &mut header_pmmr, - &mut txhashset, + txhashset, &mut batch, |ext, batch| { self.rewind_and_apply_fork(secp, &prev_header, ext, batch)?; - - // Ensure the rewound txhashset actually matches the header - // we are about to make active. ext.extension.validate_roots(&prev_header)?; ext.extension.validate_sizes(&prev_header)?; - - // Reset chain head. + // HEADER_HEAD is independent and may be on a competing fork. batch.save_body_head(&new_head)?; - batch.save_header_head(&new_head)?; - Ok(()) }, )?; - // Cleanup all subsequent bad blocks (back from old head). - let mut current = batch.get_block_header(&old_head.hash(context_id)?)?; - while current.height > new_head.height { - let prev_block = batch.get_previous_header(¤t)?; - batch.delete_block(¤t.hash(context_id)?)?; - current = prev_block; + for removed_header in &body_cleanup_headers { + batch.delete_block(&removed_header.hash(context_id)?)?; } + } + #[cfg(test)] + if rewind_body { + self.wait_after_rewind_bad_block_body_sync(); + } - batch.commit()?; - } else { + if rewind_headers { debug!( - "rewind_bad_block: denied header {} at {} is only on the header chain", - header_hash, header.height + "rewind_bad_block: rewinding HEADER_HEAD to {} at {}", + prev_header.hash(context_id)?, + prev_header.height ); - } - - let mut batch = self.store.batch_write()?; - - txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, batch| { - self.rewind_and_apply_header_fork(&prev_header, ext, batch)?; - - // Reset chain head. - batch.save_header_head(&new_head)?; - - Ok(()) - })?; + txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, batch| { + self.rewind_and_apply_header_fork(&prev_header, ext, batch)?; + batch.save_header_head(&new_head)?; + Ok(()) + })?; - // Use the header head captured under the header_pmmr lock, before - // the body rewind above. - // When the denied hash is also on the full-block chain, the body - // rewind saves HEADER_HEAD to new_head before this branch starts. - // Reading header_head here would then make old_header_head == new_head, - // causing the cleanup loop to delete no denied successor headers. - let mut current = batch.get_block_header(&old_header_head.hash(context_id)?)?; - while current.height > new_head.height { - let prev_hdr = batch.get_previous_header(¤t)?; - batch.delete_block_header(¤t.hash(context_id)?)?; - current = prev_hdr; + for removed_header in &header_cleanup_headers { + let removed_hash = removed_header.hash(context_id)?; + // delete_block() requires the separately stored header, so the + // full block and its dependent indices must be removed first. + if batch.block_exists(&removed_hash)? { + batch.delete_block(&removed_hash)?; + } + batch.delete_block_header(&removed_hash)?; + } } batch.commit()?; + // The committed rewind invalidates every PIBD snapshot derived from + // the previous PMMR state. Advance the generation before clearing the + // marker and while the affected PMMR write guards remain held. + self.advance_pibd_state_generation()?; Ok(()) })(); match res { - Ok(()) => self.clear_pending_chain_operation_checked()?, + Ok(()) => { + self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); + } Err(e) => { - self.handle_failed_pending_chain_operation("rewind_bad_block", &e); + self.handle_failed_pending_chain_operation( + "rewind_bad_block", + &e, + &mut marker_guard, + ); return Err(e); } } @@ -960,12 +1793,16 @@ impl Chain { source_peers: HashSet, ) -> Result, Error> { self.ensure_chain_robust()?; - let block_hash = b.hash(self.store.get_context_id())?; + let context_id = self.store.get_context_id(); + pipe::validate_header_context_id(context_id, &b.header)?; + let block_hash = b.hash(context_id)?; let report_peers = source_peers.clone(); // Check if block can be processed now. Overwise add it to orphans and returns error if let Err(e) = self.check_block(&b, opts, source_peers) { - if e.is_bad_data() && !report_peers.is_empty() { + // OldBlock is also a known-block outcome. Exact stored duplicates must + // never be attributed to their source peers as invalid remote data. + if e.is_bad_data() && !e.is_known_block() && !report_peers.is_empty() { self.adapter.block_rejected(&block_hash, &report_peers, &e); } return Err(e); @@ -979,7 +1816,14 @@ impl Chain { // We can't process as multiple during sync because it is slow. // But also we better to process blocks one by one when node running because of possible reorg. // Reord requires to roll back single block, not a whole package. - let multiple_processing_height_limit = self.header_head()?.height.saturating_sub(100); + #[cfg(test)] + let batch_safety_depth = self.process_block_batch_safety_depth.load(Ordering::SeqCst); + #[cfg(not(test))] + let batch_safety_depth = 100; + let multiple_processing_height_limit = self + .header_head()? + .height + .saturating_sub(batch_safety_depth); if b.header.height < multiple_processing_height_limit { // if it is a block on the chain, let's try to add many of them match self.get_header_by_height(b.header.height) { @@ -1041,14 +1885,30 @@ impl Chain { } return Ok(tip); // Done with success } - Err(e) => { + Err(BlockProcessingError::NotCommitted(e)) => { self.ensure_chain_robust()?; if e.is_bad_data() { - info!("Failed to process multiple blocks, will try process one by one. {}",e); + info!( + "Failed to process multiple blocks, will try process one by one. {}", + e + ); } else { - debug!("Failed to process multiple blocks, will try process one by one. {}",e); + debug!( + "Failed to process multiple blocks, will try process one by one. {}", + e + ); } } + Err(BlockProcessingError::CommittedRecoveryFailed(e)) => { + error!( + "Committed block batch could not complete chain recovery: {}", + e + ); + return Err(Error::committed_recovery_failed( + "process_block_multiple committed marker cleanup", + e, + )); + } } } } @@ -1068,7 +1928,7 @@ impl Chain { self.check_orphans(secp, next_height)?; return Ok(tip); } - Err(e) => { + Err(BlockProcessingError::NotCommitted(e)) => { // A duplicate can pass the initial unlocked known-block check and // then lose a race to another peer response before the pipeline // acquires its write locks. The block is valid and already stored, @@ -1088,6 +1948,16 @@ impl Chain { } return Err(e); } + Err(BlockProcessingError::CommittedRecoveryFailed(e)) => { + error!( + "Committed single block could not complete chain recovery: {}", + e + ); + return Err(Error::committed_recovery_failed( + "process_block_single committed marker cleanup", + e, + )); + } } } @@ -1114,16 +1984,32 @@ impl Chain { }); } - let inputs: Vec<_> = { + let inputs: Vec<_> = loop { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.with_locked_readonly_pmmr_discard_marker("convert_block_v2", || { + + // A writer may have failed while this thread was waiting for locks. + // Do not convert against PMMR/txhashset state that now requires recovery. + if self.requires_init_recovery.load(Ordering::SeqCst) { + drop(txhashset); + drop(header_pmmr); + self.ensure_chain_robust()?; + continue; + } + + break self.with_locked_readonly_pmmr_discard_marker("convert_block_v2", || { txhashset::extending_readonly( self.store.get_context_id(), &mut header_pmmr, &mut txhashset, |ext, batch| { - let previous_header = batch.get_previous_header(&block.header)?; + let previous_header = crate::checked_previous_header( + context_id, + &block.header, + &mut HashSet::new(), + "convert_block_v2 predecessor", + |hash| batch.get_block_header(hash), + )?; self.rewind_and_apply_fork(secp, &previous_header, ext, batch)?; ext.extension .utxo_view(ext.header_extension) @@ -1131,7 +2017,7 @@ impl Chain { .map(|outputs| outputs.into_iter().map(|(out, _)| out).collect()) }, ) - }) + }); }?; let inputs = Inputs::from_output_identifiers(context_id, inputs.as_slice())?; Ok(Block { @@ -1146,29 +2032,28 @@ impl Chain { prev: Tip, prev_head: Tip, fork_point: Tip, - ) -> Result { + ) -> BlockStatus { // If head is updated then we are either "next" block or we just experienced a "reorg" to new head. // Otherwise this is a "fork" off the main chain. if head.is_some() { - let context_id = self.store.get_context_id(); let fork_point_is_prev_head = fork_point.height == prev_head.height - && fork_point.hash(context_id)? == prev_head.hash(context_id)?; + && fork_point.last_block_h == prev_head.last_block_h; if fork_point_is_prev_head { - Ok(BlockStatus::Next { prev }) + BlockStatus::Next { prev } } else { - Ok(BlockStatus::Reorg { + BlockStatus::Reorg { prev, prev_head, fork_point, - }) + } } } else { - Ok(BlockStatus::Fork { + BlockStatus::Fork { prev, head: prev_head, fork_point, - }) + } } } @@ -1199,9 +2084,9 @@ impl Chain { Err(e) => return Err(Error::StoreErr(e, "chain get exact known block".to_owned())), }; - let existing_bytes = ser::ser_vec(context_id, &existing, ProtocolVersion::local())?; - let incoming_bytes = ser::ser_vec(context_id, b, ProtocolVersion::local())?; - if existing_bytes != incoming_bytes { + // The store side is already validated v3 data. Normalize the legacy + // candidate only for this known-block decision; see blocks_equal_as_v3. + if !blocks_equal_as_v3(context_id, &existing, b)? { return Ok(()); } @@ -1272,9 +2157,10 @@ impl Chain { // If valid then continue with block processing with header_head committed to db etc. self.process_block_header(&b.header, opts)?; - // Only exact full-block duplicates are accepted as known here. The block - // hash is proof-derived, so a same-hash block with different serialized - // body/header bytes must continue into normal validation. + // Header processing above has already validated PoW and exact stored-header + // identity. Only an exact full-block duplicate is known here; a different + // body under the same validated header must continue through body/state + // validation rather than being misclassified as a duplicate. self.check_exact_known_block(b)?; // Check if this block is an orphan. @@ -1292,20 +2178,21 @@ impl Chain { secp: &mut Secp256k1, b: Block, opts: Options, - ) -> Result, Error> { + ) -> Result, BlockProcessingError> { let mut state_may_have_changed = false; - let (head, fork_point, prev_head, b) = { + let completion = { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); let op = prepare_reconcile_heads_operation(&self.store, ChainOperationKind::ProcessBlock)?; - self.set_pending_chain_operation_checked(&op)?; + let marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = (|| { + let context_id = self.store.get_context_id(); let batch = self.store.batch_write()?; let prev_head = batch.head()?; let mut ctx = self.new_ctx(opts, batch, &mut header_pmmr, &mut txhashset)?; - let mut bv = vec![b.clone()]; + let bv = vec![b.clone()]; let (head, fork_point) = pipe::process_blocks_series( self.store.get_context_id(), &bv, @@ -1314,25 +2201,52 @@ impl Chain { secp, )?; + // Prepare every fallible input to the acceptance callback before the + // durable commit. A successful commit must leave only marker recovery + // and infallible publication work. + let prev = ctx.batch.get_previous_header(&b.header).map_err(|e| { + Error::StoreErr(e, "process_block_single get previous header".into()) + })?; + let status = self.determine_status( + head, + Tip::try_from_header(&prev)?, + prev_head, + Tip::try_from_header(&fork_point)?, + ); + let block_hash = b.hash(context_id)?; ctx.batch.commit()?; - Ok((head, fork_point, prev_head, bv.remove(0))) + Ok((head, status, block_hash)) })(); - self.finish_pending_chain_operation("process_block_single", res, state_may_have_changed) + self.finish_pending_chain_operation( + "process_block_single", + res, + state_may_have_changed, + marker_guard, + ) }?; - let prev = self.get_previous_header(&b.header)?; - let status = self.determine_status( - head, - Tip::try_from_header(&prev)?, - prev_head, - Tip::try_from_header(&fork_point)?, - )?; + let (head, status, block_hash) = match completion { + PendingChainOperationCompletion::Complete(value) => value, + PendingChainOperationCompletion::CommittedNeedsRecovery { + value, + marker_error, + } => { + warn!( + "process_block_single committed its block but failed to clear the recovery marker; recovering before publishing acceptance: {}", + marker_error + ); + self.recover_pending_chain_operation_checked( + "process_block_single committed marker cleanup", + ) + .map_err(BlockProcessingError::CommittedRecoveryFailed)?; + value + } + }; info!( "Accepted single block {} for height {}", - b.hash(self.store.get_context_id())?, - b.header.height + block_hash, b.header.height ); // notifying other parts of the system of the update self.adapter.block_accepted(secp, &b, status, opts); @@ -1348,14 +2262,25 @@ impl Chain { secp: &mut Secp256k1, blocks: &Vec, opts: Options, - ) -> Result, Error> { + ) -> Result, BlockProcessingError> { + let first_height = blocks + .first() + .ok_or_else(|| { + Error::Other("Internal error, empty blocks at process_block_multiple".into()) + })? + .header + .height; + let last_block = blocks.last().ok_or_else(|| { + Error::Other("Internal error, empty blocks at process_block_multiple".into()) + })?; + let last_height = last_block.header.height; let mut state_may_have_changed = false; - let (head, fork_point, prev_head) = { + let completion = { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); let op = prepare_reconcile_heads_operation(&self.store, ChainOperationKind::ProcessBlock)?; - self.set_pending_chain_operation_checked(&op)?; + let marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = (|| { let batch = self.store.batch_write()?; let prev_head = batch.head()?; @@ -1369,45 +2294,55 @@ impl Chain { secp, )?; + // The predecessor of the final block may have been written earlier in + // this same batch, so read it from the batch before committing. + let prev = ctx + .batch + .get_previous_header(&last_block.header) + .map_err(|e| { + Error::StoreErr(e, "process_block_multiple get previous header".into()) + })?; + let status = self.determine_status( + head, + Tip::try_from_header(&prev)?, + prev_head, + Tip::try_from_header(&fork_point)?, + ); ctx.batch.commit()?; - Ok((head, fork_point, prev_head)) + Ok((head, status)) })(); self.finish_pending_chain_operation( "process_block_multiple", res, state_may_have_changed, + marker_guard, ) }?; - let last_block = blocks.last().ok_or(Error::Other( - "Internal error, empty blocks at process_block_multiple".into(), - ))?; - let prev = self.get_previous_header(&last_block.header)?; - let status = self.determine_status( - head, - Tip::try_from_header(&prev)?, - prev_head, - Tip::try_from_header(&fork_point)?, - )?; + let (head, status) = match completion { + PendingChainOperationCompletion::Complete(value) => value, + PendingChainOperationCompletion::CommittedNeedsRecovery { + value, + marker_error, + } => { + warn!( + "process_block_multiple committed its block batch but failed to clear the recovery marker; recovering before publishing acceptance: {}", + marker_error + ); + self.recover_pending_chain_operation_checked( + "process_block_multiple committed marker cleanup", + ) + .map_err(BlockProcessingError::CommittedRecoveryFailed)?; + value + } + }; debug!( "Accepted multiple {} block from height {} to {}", blocks.len(), - blocks - .first() - .ok_or(Error::Other( - "Internal error, empty blocks at process_block_multiple".into() - ))? - .header - .height, - blocks - .last() - .ok_or(Error::Other( - "Internal error, empty blocks at process_block_multiple".into() - ))? - .header - .height + first_height, + last_height ); // Notify other parts of the system of the update. @@ -1435,26 +2370,67 @@ impl Chain { /// if total work increases (on the header chain). pub fn process_block_header(&self, bh: &BlockHeader, opts: Options) -> Result<(), Error> { self.ensure_chain_robust()?; + let context_id = self.store.get_context_id(); + pipe::validate_header_context_id(context_id, bh)?; + pipe::validate_header_hash(context_id, &bh.hash(context_id)?)?; + // Most propagated headers are repeats. Check an exact stored header in a + // read-only snapshot before taking the two global write locks or creating + // the durable operation marker. The header_pmmr read lock serializes this + // check against header-state writers (e.g. reset_chain_head): without it a + // downward transition could commit a lower HEADER_HEAD concurrently, and a + // header classified as non-improving against the old head would be skipped + // even though it must now be reapplied. The check inside the locked + // pipeline stays authoritative for stored headers that now need reapplying. + let is_known = self.with_robust_header_pmmr_read(|_| { + let batch = self.store.batch_read()?; + let header_head = batch.header_head()?; + pipe::is_exact_known_header(context_id, bh, &header_head, &batch) + })?; + if is_known { + return Ok(()); + } let mut state_may_have_changed = false; - { + let completion = { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); let op = prepare_reconcile_heads_operation(&self.store, ChainOperationKind::ProcessHeader)?; - self.set_pending_chain_operation_checked(&op)?; + let marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = (|| { let batch = self.store.batch_write()?; let mut ctx = self.new_ctx(opts, batch, &mut header_pmmr, &mut txhashset)?; - pipe::process_block_header( - self.store.get_context_id(), - bh, - &mut ctx, - &mut state_may_have_changed, - )?; + pipe::process_block_header(context_id, bh, &mut ctx, &mut state_may_have_changed)?; ctx.batch.commit()?; Ok(()) })(); - self.finish_pending_chain_operation("process_block_header", res, state_may_have_changed) + self.finish_pending_chain_operation( + "process_block_header", + res, + state_may_have_changed, + marker_guard, + ) + }?; + + match completion { + PendingChainOperationCompletion::Complete(()) => Ok(()), + PendingChainOperationCompletion::CommittedNeedsRecovery { + value: (), + marker_error, + } => { + warn!( + "process_block_header committed its header but failed to clear the recovery marker; recovering before returning success: {}", + marker_error + ); + self.recover_pending_chain_operation_checked( + "process_block_header committed marker cleanup", + ) + .map_err(|e| { + Error::committed_recovery_failed( + "process_block_header committed marker cleanup", + e, + ) + }) + } } } @@ -1469,6 +2445,10 @@ impl Chain { opts: Options, ) -> Result, Error> { self.ensure_chain_robust()?; + let context_id = self.store.get_context_id(); + for header in headers { + pipe::validate_header_context_id(context_id, header)?; + } { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); @@ -1480,18 +2460,14 @@ impl Chain { // sensitive: future validation changes may move mutations earlier, and // misclassifying a partial mutation as harmless is worse than doing an // unnecessary recovery pass. - self.set_pending_chain_operation_checked(&op)?; + let mut marker_guard = self.set_pending_chain_operation_checked(&op)?; let res = (|| { let batch = self.store.batch_write()?; // Sync the chunk of block headers, updating header_head if total work increases. let mut ctx = self.new_ctx(opts, batch, &mut header_pmmr, &mut txhashset)?; - let sync_head = pipe::process_block_headers( - self.store.get_context_id(), - headers, - sync_head, - &mut ctx, - )?; + let sync_head = + pipe::process_block_headers(context_id, headers, sync_head, &mut ctx)?; ctx.batch.commit()?; Ok(sync_head) @@ -1499,10 +2475,15 @@ impl Chain { match res { Ok(sync_head) => { self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); Ok(sync_head) } Err(e) => { - self.handle_failed_pending_chain_operation("sync_block_headers", &e); + self.handle_failed_pending_chain_operation( + "sync_block_headers", + &e, + &mut marker_guard, + ); Err(e) } } @@ -1570,11 +2551,11 @@ impl Chain { // The orphan pool is a recoverable in-memory cache, and draining it is // intentionally best-effort. Many entries are expected to be stale, // invalid, or otherwise rejected when retried. We preserve bad-data - // failures for peer attribution, but do not let a child orphan failure - // make the already-accepted parent block fail. If a still-valid orphan - // is dropped because of a validation, storage, or txhashset processing - // error, normal sync will request the missing block again when it is - // needed. + // failures for peer attribution and propagate failures after durable child + // state was committed, but do not let an ordinary child orphan failure make + // the already-accepted parent block fail. If a still-valid orphan is dropped + // because of a validation, storage, or txhashset processing error, normal + // sync will request the missing block again when it is needed. if let Some(orphans) = self.orphans.remove_by_height(height) { let orphans_len = orphans.len(); for (i, orphan) in orphans.into_iter().enumerate() { @@ -1592,19 +2573,36 @@ impl Chain { }, ); let height = orphan.block.header.height; - let res = self - .check_block(&orphan.block, orphan.opts.clone(), source_peers.clone()) - .and_then(|_| self.process_block_single(secp, orphan.block, orphan.opts)); + let res = match self.check_block( + &orphan.block, + orphan.opts.clone(), + source_peers.clone(), + ) { + Ok(()) => self.process_block_single(secp, orphan.block, orphan.opts), + // Header processing can commit before marker cleanup and recovery + // fail. Preserve that post-commit phase instead of treating it as + // an ordinary best-effort orphan rejection. + Err(e @ Error::CommittedRecoveryFailed { .. }) => return Err(e), + Err(e) => Err(BlockProcessingError::NotCommitted(e)), + }; match res { Ok(_) => { orphan_accepted = true; height_accepted = height; } - Err(e) => { - if e.is_bad_data() && !source_peers.is_empty() { + Err(BlockProcessingError::NotCommitted(e)) => { + // A concurrent response can make a drained orphan an exact + // known duplicate before it is retried here. + if e.is_bad_data() && !e.is_known_block() && !source_peers.is_empty() { self.adapter.block_rejected(&block_hash, &source_peers, &e); } } + Err(BlockProcessingError::CommittedRecoveryFailed(e)) => { + return Err(Error::committed_recovery_failed( + "check_orphans committed block recovery", + e, + )); + } } } @@ -1717,16 +2715,29 @@ impl Chain { body_head: &Tip, pos: CommitPos, ) -> Result, Error> { - if pos.height > body_head.height { - return Ok(None); - } - - let header = - self.body_chain_header_at_height_maybe_fast(header_pmmr, batch, body_head, pos.height)?; + // `body_head.height` is a redundant cache, so let the lookup canonicalize + // `last_block_h` before treating a position above the body head as absent. + let header = match self.body_chain_header_at_height_maybe_fast( + header_pmmr, + batch, + body_head, + pos.height, + ) { + Ok(header) => header, + Err(Error::ChainInSyncing(_)) => return Ok(None), + Err(err) => return Err(err), + }; let prev_output_mmr_size = if pos.height == 0 { 0 } else { - batch.get_previous_header(&header)?.output_mmr_size + crate::checked_previous_header( + self.store.get_context_id(), + &header, + &mut HashSet::new(), + "body_chain_header_for_output_pos predecessor", + |hash| batch.get_block_header(hash), + )? + .output_mmr_size }; if pos.pos > prev_output_mmr_size && pos.pos <= header.output_mmr_size { @@ -1753,6 +2764,16 @@ impl Chain { Ok(()) } + /// Validate candidate outputs against the current UTXO set without checking + /// transaction inputs or kernels. + pub fn validate_outputs(&self, outputs: &[Output]) -> Result<(), Error> { + self.with_robust_chain_read(|header_pmmr, txhashset| { + txhashset::utxo_view(header_pmmr, txhashset, |utxo, batch| { + utxo.validate_outputs(outputs, batch) + }) + }) + } + /// Validates NRD relative height locks against "recent" kernel history. /// Applies the kernels to the current kernel MMR in a readonly extension. /// The extension and the db batch are discarded. @@ -1891,7 +2912,14 @@ impl Chain { txhashset::header_extending_readonly(&mut header_pmmr, batch_read, |ext, batch| { let body_header = batch.head_header()?; self.rewind_and_apply_header_fork(&body_header, ext, batch)?; - pipe::check_against_spent_output(&tx.body, None, None, ext, batch)?; + pipe::check_against_spent_output( + &tx.body, + body_header.height, + None, + None, + ext, + batch, + )?; Ok(()) }) }); @@ -1946,16 +2974,31 @@ impl Chain { /// Sets prev_root on a brand new block header by applying the previous header to the header MMR. pub fn set_prev_root_only(&self, header: &mut BlockHeader) -> Result<(), Error> { self.ensure_chain_robust()?; - let prev_root = { + let prev_root = loop { let mut header_pmmr = self.header_pmmr.write(); - self.with_locked_readonly_pmmr_discard_marker("set_prev_root_only", || { + + // A writer may have failed while this thread was waiting for the lock. + // Recovery acquires header_pmmr itself, so release it before retrying. + if self.requires_init_recovery.load(Ordering::SeqCst) { + drop(header_pmmr); + self.ensure_chain_robust()?; + continue; + } + + break self.with_locked_readonly_pmmr_discard_marker("set_prev_root_only", || { let batch_read = self.store.batch_read()?; txhashset::header_extending_readonly(&mut header_pmmr, batch_read, |ext, batch| { - let prev_header = batch.get_previous_header(header)?; + let prev_header = crate::checked_previous_header( + self.store.get_context_id(), + header, + &mut HashSet::new(), + "set_prev_root_only predecessor", + |hash| batch.get_block_header(hash), + )?; self.rewind_and_apply_header_fork(&prev_header, ext, batch)?; ext.root() }) - }) + }); }?; // Set the prev_root on the header. @@ -1968,18 +3011,41 @@ impl Chain { /// the current txhashset state. pub fn set_txhashset_roots(&self, secp: &Secp256k1, b: &mut Block) -> Result<(), Error> { self.ensure_chain_robust()?; - let (prev_root, roots, sizes) = { + let (prev_root, roots, sizes) = loop { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.with_locked_readonly_pmmr_discard_marker("set_txhashset_roots", || { + + // A writer may have failed while this thread was waiting for locks. + // Do not calculate roots from PMMR/txhashset state that now requires recovery. + if self.requires_init_recovery.load(Ordering::SeqCst) { + drop(txhashset); + drop(header_pmmr); + self.ensure_chain_robust()?; + continue; + } + + break self.with_locked_readonly_pmmr_discard_marker("set_txhashset_roots", || { txhashset::extending_readonly( self.store.get_context_id(), &mut header_pmmr, &mut txhashset, |ext, batch| { - let previous_header = batch.get_previous_header(&b.header)?; + let previous_header = crate::checked_previous_header( + self.store.get_context_id(), + &b.header, + &mut HashSet::new(), + "set_txhashset_roots predecessor", + |hash| batch.get_block_header(hash), + )?; self.rewind_and_apply_fork(secp, &previous_header, ext, batch)?; + // rewind_and_apply_fork validates roots/sizes only for fork blocks it + // reapplies; when previous_header is already on the current body chain + // that list is empty and rewind only truncates to header-declared + // positions. Verify the parent state before deriving new consensus roots. + ext.extension.validate_roots(&previous_header)?; + ext.extension.validate_sizes(&previous_header)?; + let extension = &mut ext.extension; let header_extension = &mut ext.header_extension; @@ -1992,7 +3058,7 @@ impl Chain { Ok((prev_root, extension.roots()?, extension.sizes())) }, ) - }) + }); }?; // Set the output and kernel MMR sizes. @@ -2016,39 +3082,44 @@ impl Chain { Ok(()) } - /// Return a Merkle proof for the given commitment from the store. + /// Return a Merkle proof for the given unspent output against the current + /// output PMMR state. + /// + /// Historical/origin-header proofs are intentionally not supported. PMMR + /// compaction preserves the roots needed for proofs at the current MMR size, + /// but may roll an old peak into a larger pruned-subtree root. Supporting + /// arbitrary old headers would therefore require retaining additional state + /// for every historical header. Callers must verify the returned proof against + /// an output root whose MMR size equals `proof.mmr_size`, normally the current + /// head observed for this request. pub fn get_merkle_proof>( &self, - secp: &Secp256k1, out_id: T, - header: &BlockHeader, ) -> Result { - self.ensure_chain_robust()?; - let merkle_proof = { - let mut header_pmmr = self.header_pmmr.write(); - let mut txhashset = self.txhashset.write(); - self.with_locked_readonly_pmmr_discard_marker("get_merkle_proof", || { - txhashset::extending_readonly( - self.store.get_context_id(), - &mut header_pmmr, - &mut txhashset, - |ext, batch| { - self.rewind_and_apply_fork(secp, &header, ext, batch)?; - ext.extension.merkle_proof(out_id, batch) - }, - ) - }) - }?; - - Ok(merkle_proof) + let out_id = out_id.as_ref(); + let commit = out_id.commitment(); + let context_id = self.store.get_context_id(); + self.with_robust_chain_read(|_, txhashset| { + let Some((stored_out, _)) = txhashset.get_unspent(commit)? else { + return Err(Error::OutputSpent); + }; + if !ser::hashes_equal(context_id, &stored_out, out_id)? { + return Err(Error::TxHashSetErr(format!( + "unspent output identifier mismatch for commitment {}", + commit.to_hex() + ))); + } + txhashset.merkle_proof(commit) + }) } - /// Return a merkle proof valid for the current output pmmr state at the - /// given pos + /// Return a Merkle proof valid for the current output PMMR state for a + /// commitment looked up through the output-position index. + /// + /// This is the commitment-only variant used by the legacy txhashset API. It + /// has the same current-state-only contract as [`Chain::get_merkle_proof`]. pub fn get_merkle_proof_for_pos(&self, commit: Commitment) -> Result { - self.ensure_chain_robust()?; - let mut txhashset = self.txhashset.write(); - txhashset.merkle_proof(commit) + self.with_robust_chain_read(|_, txhashset| txhashset.merkle_proof(commit)) } /// Rewind and apply fork with the chain specific header validation (denylist) rules. @@ -2087,32 +3158,57 @@ impl Chain { /// Caller should verify a peer segment request is valid before calling this for example. /// pub fn segmenter(&self) -> Result { - self.ensure_chain_robust()?; - // The archive header corresponds to the data we will segment. - let ref archive_header = self.txhashset_archive_header()?; - - // Use our cached segmenter if we have one and the associated header matches. - if let Some(x) = self.pibd_segmenter.read_recursive().as_ref() { - if x.header() == archive_header { - return Ok(x.clone()); + loop { + self.ensure_chain_robust()?; + // The archive header corresponds to the data we will segment. + let archive_header = self.txhashset_archive_header()?; + + // Use the cached segmenter only if it was built after the most recent + // recovery and recovery has not been latched again. + if let Some(x) = self.pibd_segmenter.read_recursive().as_ref() { + if x.header() == &archive_header && x.is_current() { + return Ok(x.clone()); + } } - } - // We have no cached segmenter or the cached segmenter is no longer useful. - // Take the write lock before initializing so concurrent callers do not - // all run the expensive rewind/build path on the same cache miss. - let mut cache = self.pibd_segmenter.write(); - if let Some(x) = cache.as_ref() { - if x.header() == archive_header { - return Ok(x.clone()); + // We have no current cached segmenter. Take the write lock before + // initializing so concurrent callers do not all run the expensive path. + let mut cache = self.pibd_segmenter.write(); + if self.requires_init_recovery.load(Ordering::SeqCst) { + drop(cache); + continue; + } + if let Some(x) = cache.as_ref() { + if x.header() == &archive_header && x.is_current() { + return Ok(x.clone()); + } } - } - // Initialize a new segmenter, cache it and return it. - let segmenter = self.init_segmenter(archive_header)?; - *cache = Some(segmenter.clone()); + let segmenter = match self.init_segmenter(&archive_header) { + Ok(segmenter) => segmenter, + Err(_) if self.requires_init_recovery.load(Ordering::SeqCst) => { + drop(cache); + self.ensure_chain_robust()?; + continue; + } + Err(e) => return Err(e), + }; + if !segmenter.is_current() { + drop(cache); + continue; + } - return Ok(segmenter); + *cache = Some(segmenter.clone()); + // Close the window between construction and cache publication. An old + // entry is harmless because Segmenter also guards every data method, + // but remove it eagerly so the next request rebuilds immediately. + if !segmenter.is_current() { + *cache = None; + drop(cache); + continue; + } + return Ok(segmenter); + } } /// Root hash for the header hashes MMR at the provided txhashset archive header. @@ -2159,16 +3255,36 @@ impl Chain { header_hash, header.height ); - let (bitmap_snapshot, segm_header_pmmr_backend) = { + let (bitmap_snapshot, segm_header_pmmr_backend, state_generation) = loop { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.with_locked_readonly_pmmr_discard_marker("init_segmenter", || { - let body_head = self - .store - .head() - .map_err(|e| Error::StoreErr(e, "chain head".to_owned()))?; - let archive_height = - Self::height_2_archive_height(context_id, body_head.height); + + // A writer may have failed while segmenter initialization was waiting + // for the PMMR locks. Recover before deriving or caching any segment data. + if self.requires_init_recovery.load(Ordering::SeqCst) { + drop(txhashset); + drop(header_pmmr); + self.ensure_chain_robust()?; + continue; + } + + break self.with_locked_readonly_pmmr_discard_marker("init_segmenter", || { + // `last_block_h` is the authoritative HEAD selector. Rebuild the + // redundant Tip fields from its selected header in one DB snapshot + // before deriving the archive period. In particular, a stale cached + // height must not make an older, otherwise valid archive acceptable. + let archive_height = { + let batch = self.store.batch_read()?; + let persisted_body_head = batch + .head() + .map_err(|e| Error::StoreErr(e, "init_segmenter HEAD".to_owned()))?; + let (_, canonical_body_head) = canonical_tip_header( + "init_segmenter HEAD", + &persisted_body_head, + &batch, + )?; + Self::height_2_archive_height(context_id, canonical_body_head.height) + }; if header.height != archive_height { return Err(Error::ChainInSyncing(format!( "archive header {} at {} no longer matches current archive height {}", @@ -2201,8 +3317,7 @@ impl Chain { &mut txhashset, |ext, batch| { let extension = &mut ext.extension; - let header_extension = &mut ext.header_extension; - extension.rewind(header, batch, header_extension, None)?; + extension.rewind(header, batch, None)?; Ok(extension.build_bitmap_accumulator()?) }, ) @@ -2228,49 +3343,54 @@ impl Chain { } } - Ok((bitmap_snapshot, segm_header_pmmr_backend)) - }) - }) - }?; - - debug!("init_segmenter: done, took {}ms", now.elapsed().as_millis()); - - // Let's check if mmr roots are matching the header - { - use mwc_core::core::pmmr::ReadablePMMR; + // Validate the live PMMR roots while the same locks and recovery + // marker still protect the snapshot. A separate read here would + // reopen a wait-then-read race with a failing writer. + let output_pmmr = txhashset.output_pmmr_at(header); + let output_pmmr_root = output_pmmr + .root() + .map_err(|e| Error::Other(format!("Invalid output_pmmr, {}", e)))?; + if header.output_root != output_pmmr_root { + return Err(Error::InvalidRoot("output PMMR root mismatch".into())); + } - let txhashset = self.txhashset.read_recursive(); + let rangeproof_pmmr = txhashset.rangeproof_pmmr_at(header); + let rangeproof_pmmr_root = rangeproof_pmmr + .root() + .map_err(|e| Error::Other(format!("Invalid rangeproof_pmmr, {}", e)))?; + if header.range_proof_root != rangeproof_pmmr_root { + return Err(Error::InvalidRoot( + "rangeproof PMMR root mismatch".into(), + )); + } - let output_pmmr = txhashset.output_pmmr_at(&header); - let output_pmmr_root = output_pmmr - .root() - .map_err(|e| Error::Other(format!("Invalid output_pmmr, {}", e)))?; - if header.output_root != output_pmmr_root { - return Err(Error::InvalidRoot("output PMMR root mismatch".into())); - } + let kernel_pmmr = txhashset.kernel_pmmr_at(header); + let kernel_pmmr_root = kernel_pmmr + .root() + .map_err(|e| Error::Other(format!("Invalid kernel_pmmr, {}", e)))?; + if header.kernel_root != kernel_pmmr_root { + return Err(Error::InvalidRoot("kernel PMMR root mismatch".into())); + } - let rangeproof_pmmr = txhashset.rangeproof_pmmr_at(&header); - let rangeproof_pmmr_root = rangeproof_pmmr - .root() - .map_err(|e| Error::Other(format!("Invalid rangeproof_pmmr, {}", e)))?; - if header.range_proof_root != rangeproof_pmmr_root { - return Err(Error::InvalidRoot("rangeproof PMMR root mismatch".into())); - } + Ok(( + bitmap_snapshot, + segm_header_pmmr_backend, + self.pibd_state_generation.load(Ordering::SeqCst), + )) + }) + }); + }?; - let kernel_pmmr = txhashset.kernel_pmmr_at(&header); - let kernel_pmmr_root = kernel_pmmr - .root() - .map_err(|e| Error::Other(format!("Invalid kernel_pmmr, {}", e)))?; - if header.kernel_root != kernel_pmmr_root { - return Err(Error::InvalidRoot("kernel PMMR root mismatch".into())); - } - } + debug!("init_segmenter: done, took {}ms", now.elapsed().as_millis()); - Segmenter::new( + Segmenter::new_guarded( Arc::new(RwLock::new(segm_header_pmmr_backend)), self.txhashset.clone(), bitmap_snapshot, header.clone(), + self.requires_init_recovery.clone(), + self.pibd_state_generation.clone(), + state_generation, ) } @@ -2282,25 +3402,37 @@ impl Chain { archive_header_hegiht: u64, bitmap_root_hash: Hash, ) -> Result { - self.ensure_chain_robust()?; - // Even not all headers are uploaded, Headers until archive must be uploaded, so we can request it - let archive_header = self.get_header_by_height(archive_header_hegiht)?; - debug!( - "init_desegmenter: initializing new desegmenter for {} at {}", - archive_header.hash(self.store.get_context_id())?, - archive_header.height - ); + loop { + self.ensure_chain_robust()?; + let state_generation = self.pibd_state_generation.load(Ordering::SeqCst); + // Even if not all headers are uploaded, headers through the archive height + // must be present so PIBD can request segments for this exact header. + let archive_header = self.get_header_by_height(archive_header_hegiht)?; + debug!( + "init_desegmenter: initializing new desegmenter for {} at {}", + archive_header.hash(self.store.get_context_id())?, + archive_header.height + ); - Ok(Desegmenter::new( - self.txhashset.clone(), - self.header_pmmr.clone(), - archive_header.clone(), - bitmap_root_hash, - self.genesis.header.clone(), - self.store.clone(), - self.pibd_params.clone(), - self.requires_init_recovery.clone(), - )?) + let desegmenter = Desegmenter::new_guarded( + self.txhashset.clone(), + self.header_pmmr.clone(), + archive_header, + bitmap_root_hash, + self.genesis.header.clone(), + self.store.clone(), + self.pibd_params.clone(), + self.requires_init_recovery.clone(), + self.pibd_state_generation.clone(), + state_generation, + )?; + // Close the construction race with recovery. Every operation repeats this + // check, so recovery starting after this point still makes the returned + // instance permanently stale. + if desegmenter.is_current() { + return Ok(desegmenter); + } + } } /// Static method to convert height to archive height. Used in chain and also in Sync process @@ -2321,7 +3453,18 @@ impl Chain { pub fn txhashset_archive_header(&self) -> Result { self.ensure_chain_robust()?; let context_id = self.store.get_context_id(); - let body_head = self.head()?; + // Derive the archive period from the header selected by HEAD rather than + // trusting its redundant cached height. Keep the selector and selected + // header in one LMDB snapshot. + let body_head = { + let batch = self.store.batch_read()?; + let persisted_body_head = batch + .head() + .map_err(|e| Error::StoreErr(e, "txhashset archive HEAD".to_owned()))?; + let (_, canonical_body_head) = + canonical_tip_header("txhashset archive HEAD", &persisted_body_head, &batch)?; + canonical_body_head + }; let txhashset_height = Self::height_2_archive_height(context_id, body_head.height); debug!( @@ -2369,6 +3512,7 @@ impl Chain { let mut count: u64 = 0; let total = header.height; let mut current = header.clone(); + let mut visited = HashSet::new(); if let Some(status) = status { status.update(SyncStatus::ValidatingKernelsHistory { headers: 0, @@ -2376,6 +3520,7 @@ impl Chain { }); } txhashset::rewindable_kernel_view(&txhashset, |view, batch| { + let context_id = batch.get_context_id(); let status_throttle = SyncStatusUpdateThrottle::new(); while current.height > 0 { if let Some(stop_state) = stop_state { @@ -2385,7 +3530,13 @@ impl Chain { } view.rewind(¤t)?; view.validate_root()?; - current = batch.get_previous_header(¤t)?; + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "validate_kernel_history ancestry", + |hash| batch.get_block_header(hash), + )?; // Increment is safe because if can't begger that the number of the blocks. Also // count used for logging, any failure will not be critical count += 1; @@ -2414,13 +3565,36 @@ impl Chain { /// the next header is known but we do not yet have the full block. /// i.e. This is the last known full block and all subsequent blocks are missing. pub fn fork_point(&self) -> Result { - self.ensure_chain_robust()?; - let body_head = self.head()?; - let mut current = self.get_block_header(&body_head.hash(self.store.get_context_id())?)?; - while !self.is_on_current_chain(Tip::try_from_header(¤t)?, body_head)? { - current = self.get_previous_header(¤t)?; - } - Ok(current) + // Preserve lock order: header_pmmr (1) before store (3). A store batch retains + // the LMDB resize read lock and must not be live while acquiring header_pmmr. + self.with_robust_header_pmmr_read(|header_pmmr| { + let context_id = self.store.get_context_id(); + let batch = self.store.batch_read()?; + let stored_body_head = batch.head()?; + let stored_header_head = batch.header_head()?; + // Verify the header stored under the HEAD selector hashes back to it, + // and rebuild the Tip from the header so `height` is not a stale cache. + let (mut current, _) = canonical_tip_header("HEAD", &stored_body_head, &batch)?; + // Header-chain membership is bounded by HEADER_HEAD, not the body HEAD. + // A higher-work header fork may legitimately end below the body HEAD. + let (_, header_head) = + canonical_tip_header("HEADER_HEAD", &stored_header_head, &batch)?; + let mut visited = HashSet::new(); + while !self.is_on_current_chain_with_header_pmmr( + header_pmmr, + Tip::try_from_header(¤t)?, + header_head, + )? { + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "fork_point body ancestry", + |hash| batch.get_block_header(hash), + )?; + } + Ok(current) + }) } /// Clean the temporary sandbox folder @@ -2530,6 +3704,7 @@ impl Chain { let batch = self.store.batch_write()?; txhashset.verify_kernel_pos_index( &self.genesis.header, + &header, &header_pmmr, &batch, None, @@ -2590,7 +3765,7 @@ impl Chain { txhashset.init_output_pos_index(&batch)?; // Rebuild our NRD kernel_pos index based on recent kernel history. - txhashset.init_recent_kernel_pos_index(&header_pmmr, &batch)?; + txhashset.init_recent_kernel_pos_index(&batch)?; // Rebuild the full kernel excess index based on fresh kernel history. txhashset.init_kernel_pos_index(&batch)?; @@ -2664,7 +3839,7 @@ impl Chain { let hashes_to_delete = { let batch = self.store.batch_read()?; - let mut hashes_to_delete = Vec::new(); + let mut hashes_to_delete: Vec<(Hash, u64)> = Vec::new(); let context_id = self.store.get_context_id(); // Remove old blocks (including short lived fork blocks) which height < tail.height for block in batch.blocks_iter()? { @@ -2672,27 +3847,85 @@ impl Chain { return Err(Error::Stopped); } let block = block?; + let block_hash = block.hash(context_id)?; + let expected_header = batch.get_block_header(&block_hash).map_err(|e| { + Error::StoreErr( + e, + format!("historical block cleanup load header {}", block_hash), + ) + })?; + if block.header != expected_header { + return Err(Error::InvalidPersistedChainState(format!( + "historical block cleanup full block {} does not exactly match its separately stored header", + block_hash + ))); + } if block.header.height < new_tail.height { - hashes_to_delete.push(block.hash(context_id)?); + hashes_to_delete.push((block_hash, block.header.height)); } } hashes_to_delete }; - let count = hashes_to_delete.len(); + let mut count = 0; for hashes in hashes_to_delete.chunks(HISTORICAL_BLOCK_DELETE_CHUNK) { if stop_state.is_stopped() { return Err(Error::Stopped); } let batch = self.store.batch_write()?; - for hash in hashes { + // Re-read BODY_TAIL in the same transaction as the deletes. A + // concurrent reset_pibd_chain / reset_chain_head_to_genesis may + // have lowered the tail to genesis and recreated blocks below our + // stale cutoff; those blocks are required chain state again and + // must not be deleted. + let stored_current_tail = batch + .tail() + .map_err(|e| Error::StoreErr(e, "historical block cleanup load tail".to_owned()))?; + let (current_tail_header, current_tail) = canonical_tip_header( + "historical block cleanup BODY_TAIL", + &stored_current_tail, + &batch, + )?; + if current_tail.height < new_tail.height { + // Tail moved backward under us (chain reset). The cutoff this + // cleanup was started with is void; abort and let a later + // compaction re-derive a correct one. + return Err(Error::InvalidPersistedChainState(format!( + "historical block cleanup tail moved backward: started at {}, now {}", + new_tail.height, current_tail.height + ))); + } + let new_tail_on_current_chain = body_chain_ancestor_at_height( + self.store.get_context_id(), + &batch, + ¤t_tail_header, + new_tail.height, + "historical block cleanup BODY_TAIL ancestry", + )?; + if new_tail_on_current_chain != *new_tail { + return Err(Error::InvalidPersistedChainState(format!( + "historical block cleanup cutoff {} at height {} is not an ancestor of current BODY_TAIL {} at height {}", + new_tail.hash(self.store.get_context_id())?, + new_tail.height, + current_tail.last_block_h, + current_tail.height + ))); + } + for (hash, height) in hashes { if stop_state.is_stopped() { return Err(Error::Stopped); } - match batch.delete_block(hash) { - Ok(()) => {} - // Removing blocks with a best effort. If block doesn't exist - it is ok. - Err(NotFoundErr(_)) => {} - Err(e) => return Err(e.into()), + // Skip candidates no longer below the current tail. + if *height >= current_tail.height { + continue; + } + // The full block may already have been removed after the read + // pass. Only that initial absence is benign; failures after + // loading the block, such as a missing separately stored + // header, must abort. Note the reverse direction — a block + // recreated by a concurrent reset — is not benign either and + // is handled by the tail revalidation above. + if batch.delete_block_if_exists(hash)? { + count += 1; } } if stop_state.is_stopped() { @@ -2711,27 +3944,80 @@ impl Chain { context_id: u32, tail_height: u64, head_height: u64, - ) -> (bool, u64) { - let horizon = global::cut_through_horizon(context_id) as u64; - let threshold = horizon.saturating_add(horizon / 10); - let next_compact = tail_height.saturating_add(threshold); - (next_compact <= head_height, next_compact) + ) -> Result<(bool, u64), Error> { + let horizon = u64::from(global::cut_through_horizon(context_id)); + let threshold = horizon + horizon / 10; + let next_compact = tail_height.checked_add(threshold).ok_or_else(|| { + Error::DataOverflow(format!( + "compact eligibility height overflow: tail_height={}, threshold={}", + tail_height, threshold + )) + })?; + Ok((next_compact <= head_height, next_compact)) + } + + fn validate_compact_tail_ancestry( + context_id: u32, + batch: &Batch<'_>, + body_chain_anchor: &BlockHeader, + body_tail_header: &BlockHeader, + body_tail: &Tip, + operation: &str, + ) -> Result<(), Error> { + let tail_on_body_chain = body_chain_ancestor_at_height( + context_id, + batch, + body_chain_anchor, + body_tail.height, + operation, + )?; + if tail_on_body_chain != *body_tail_header { + return Err(Error::InvalidPersistedChainState(format!( + "{} BODY_TAIL {} at height {} is not on the durable body chain", + operation, body_tail.last_block_h, body_tail.height + ))); + } + Ok(()) } fn compact_eligibility(&self) -> Result<(bool, u64), Error> { - let tail = self - .store + // `last_block_h` is the authoritative selector for both tips. Derive the + // heights from the selected headers in one snapshot instead of trusting + // their redundant persisted height fields. + let batch = self.store.batch_read()?; + let stored_tail = batch .tail() - .map_err(|e| Error::StoreErr(e, "chain tail".to_owned()))?; - let head = self - .store + .map_err(|e| Error::StoreErr(e, "compact eligibility BODY_TAIL".to_owned()))?; + let (tail_header, tail) = + canonical_tip_header("compact eligibility BODY_TAIL", &stored_tail, &batch)?; + let stored_head = batch .head() - .map_err(|e| Error::StoreErr(e, "chain head".to_owned()))?; - Ok(Self::compact_eligibility_for_heights( - self.store.get_context_id(), - tail.height, - head.height, - )) + .map_err(|e| Error::StoreErr(e, "compact eligibility HEAD".to_owned()))?; + let (head_header, head) = + canonical_tip_header("compact eligibility HEAD", &stored_head, &batch)?; + if tail.height > head.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact eligibility BODY_TAIL height {} is above HEAD height {}", + tail.height, head.height + ))); + } + let context_id = self.store.get_context_id(); + let eligibility = + Self::compact_eligibility_for_heights(context_id, tail.height, head.height)?; + // If compaction is skipped, no later planning pass will authenticate the + // relationship between these individually valid selectors. The ineligible + // interval is bounded by the compact threshold, so this traversal is short. + if !eligibility.0 { + Self::validate_compact_tail_ancestry( + context_id, + &batch, + &head_header, + &tail_header, + &tail, + "compact eligibility", + )?; + } + Ok(eligibility) } /// Triggers chain compaction. @@ -2770,104 +4056,211 @@ impl Chain { if stop_state.is_stopped() { return Err(Error::Stopped); } - // A queued compact call may have become ineligible while waiting. - let (should_compact, next_compact) = self.compact_eligibility()?; - if !should_compact { - debug!( - "compact: skipping queued compaction (next at {})", - next_compact - ); - Ok(None) - } else { - let op = - prepare_reconcile_heads_operation(&self.store, ChainOperationKind::Compact)?; - self.set_pending_chain_operation_checked(&op)?; - let res = (|| { - let batch = self.store.batch_write()?; + // Select and authenticate the exact compact horizon before installing + // the marker. Once installed, this target lets recovery publish the + // matching BODY_TAIL even if the PMMR file replacements reached disk but + // the enclosing LMDB batch did not. + let ( + op, + planned_body_head, + planned_header_head, + planned_body_tail, + horizon_header, + target_body_tail, + ) = { + let batch = self.store.batch_read()?; + let stored_body_head = batch.head()?; + let (body_head_header, body_head) = + canonical_tip_header("compact HEAD", &stored_body_head, &batch)?; + let stored_header_head = batch.header_head()?; + let (_, header_head) = + canonical_tip_header("compact HEADER_HEAD", &stored_header_head, &batch)?; + let stored_body_tail = batch.tail()?; + let (body_tail_header, body_tail) = + canonical_tip_header("compact BODY_TAIL", &stored_body_tail, &batch)?; + + if body_tail.height > body_head.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact BODY_TAIL height {} is above HEAD height {}", + body_tail.height, body_head.height + ))); + } + // A queued compact call may have become ineligible while waiting. + // Repeat the decision from these canonical tips in this same snapshot. + let (should_compact, next_compact) = Self::compact_eligibility_for_heights( + context_id, + body_tail.height, + body_head.height, + )?; + if !should_compact { + Self::validate_compact_tail_ancestry( + context_id, + &batch, + &body_head_header, + &body_tail_header, + &body_tail, + "compact queued eligibility", + )?; + debug!( + "compact: skipping queued compaction (next at {})", + next_compact + ); + return Ok(()); + } - // Compact the txhashset itself (rewriting the pruned backend files). + let horizon_height = body_head + .height + .saturating_sub(global::cut_through_horizon(context_id) as u64); + let horizon_header = + self.body_chain_header_at_height(&batch, &body_head, horizon_height)?; + let horizon_hash = horizon_header.hash(context_id)?; + let header_pmmr_hash = header_pmmr.get_header_hash_by_height(horizon_height)?; + if header_pmmr_hash != horizon_hash { + return Err(Error::ChainInSyncing(format!( + "compact horizon {} at {} is not on current header chain", + horizon_hash, horizon_height + ))); + } + let body_head_hash = body_head.hash(context_id)?; + let header_pmmr_body_hash = + header_pmmr.get_header_hash_by_height(body_head.height)?; + if header_pmmr_body_hash != body_head_hash { + return Err(Error::ChainInSyncing(format!( + "compact body head {} at {} is not on current header chain", + body_head_hash, body_head.height + ))); + } - let body_head = batch.head()?; - let horizon_height = body_head.height.saturating_sub( - global::cut_through_horizon(self.store.get_context_id()) as u64, - ); - // Compaction must use the body-chain ancestor at this height. A plain - // header-by-height lookup is against the header PMMR view, which can be - // ahead of or different from the fully validated body chain. - let horizon_header = - self.body_chain_header_at_height(&batch, &body_head, horizon_height)?; - let horizon_hash = horizon_header.hash(context_id)?; - let header_pmmr_hash = header_pmmr.get_header_hash_by_height(horizon_height)?; - if header_pmmr_hash != horizon_hash { - return Err(Error::ChainInSyncing(format!( - "compact horizon {} at {} is not on current header chain", - horizon_hash, horizon_height - ))); - } - let body_head_hash = body_head.hash(context_id)?; - let header_pmmr_body_hash = - header_pmmr.get_header_hash_by_height(body_head.height)?; - if header_pmmr_body_hash != body_head_hash { - return Err(Error::ChainInSyncing(format!( - "compact body head {} at {} is not on current header chain", - body_head_hash, body_head.height - ))); - } + let target_body_tail = Tip::try_from_header(&horizon_header)?; + if target_body_tail.height < body_tail.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact target BODY_TAIL height {} is below current BODY_TAIL height {}", + target_body_tail.height, body_tail.height + ))); + } + // The target is on the body chain and is now known to be at or above + // BODY_TAIL, so validate the current tail by traversing only the short + // interval between them instead of walking down from HEAD a second time. + Self::validate_compact_tail_ancestry( + context_id, + &batch, + &horizon_header, + &body_tail_header, + &body_tail, + "compact BODY_TAIL ancestry", + )?; + // Recovery requires this exact retained full-block record. Establish + // that precondition before the durable marker or any PMMR mutation. + crate::checked_block_for_header( + context_id, + &horizon_header, + "compact target BODY_TAIL preflight", + |hash| batch.get_block(hash), + )?; - txhashset.compact(&horizon_header, &batch)?; - if stop_state.is_stopped() { - return Err(Error::Stopped); - } + let op = PendingChainOperation::Compact { + original_body_head: body_head, + original_header_head: header_head, + target_body_tail, + }; + ( + op, + body_head, + header_head, + body_tail, + horizon_header, + target_body_tail, + ) + }; + let mut marker_guard = self.set_pending_chain_operation_checked(&op)?; + let res = (|| { + let batch = self.store.batch_write()?; - // NOTE: Compaction selects a different horizon - // block from txhashset horizon/PIBD segmenter. That block is allways above or equal - // It is expected, we don't want all nodes go into compaction at the same time becase - // it might take a while on slow hardware. + // The PMMR locks make this stable in normal operation. Recheck after + // the marker commit so an unexpected out-of-band selector change cannot + // apply a horizon planned for a different chain. + let stored_body_head = batch.head()?; + let (_, current_body_head) = + canonical_tip_header("compact current HEAD", &stored_body_head, &batch)?; + let stored_header_head = batch.header_head()?; + let (_, current_header_head) = canonical_tip_header( + "compact current HEADER_HEAD", + &stored_header_head, + &batch, + )?; + let stored_body_tail = batch.tail()?; + let (_, current_body_tail) = + canonical_tip_header("compact current BODY_TAIL", &stored_body_tail, &batch)?; + if current_body_head != planned_body_head + || current_header_head != planned_header_head + || current_body_tail != planned_body_tail + { + return Err(Error::ChainInSyncing( + "compact chain selectors changed after planning the durable horizon".into(), + )); + } + if target_body_tail.height < current_body_tail.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact target BODY_TAIL height {} is below current BODY_TAIL height {} after marker installation", + target_body_tail.height, current_body_tail.height + ))); + } - // Archive is 2 days + 12 hours. horizon is a week. Guaranteed that archive_header.height is larger than horizon height - debug_assert!( - Self::height_2_archive_height(context_id, body_head.height) - > horizon_header.height - ); + // Compact the txhashset itself (rewriting the pruned backend files). - batch.save_body_tail(&Tip::try_from_header(&horizon_header)?)?; + txhashset.compact(&horizon_header, &batch)?; + if stop_state.is_stopped() { + return Err(Error::Stopped); + } - // Make sure our output_pos index is consistent with the UTXO set. - // Normal block processing maintains this index incrementally, so - // avoid the full historical header scan unless a recovery path - // explicitly marked the index incomplete. - if batch.is_output_pos_index_complete()? { - debug!("compact: output_pos index is complete, skipping rebuild"); - } else { - txhashset.init_output_pos_index( - &batch, - sync_state.clone(), - Some(stop_state.clone()), - )?; - } + // NOTE: Compaction selects a different horizon + // block from txhashset horizon/PIBD segmenter. That block is allways above or equal + // It is expected, we don't want all nodes go into compaction at the same time becase + // it might take a while on slow hardware. - // TODO - Why is this part of chain compaction? - // Rebuild our NRD kernel_pos index based on recent kernel history. - txhashset.init_recent_kernel_pos_index( - &header_pmmr, + // Archive is 2 days + 12 hours. horizon is a week. Guaranteed that archive_header.height is larger than horizon height + debug_assert!( + Self::height_2_archive_height(context_id, planned_body_head.height) + > horizon_header.height + ); + + batch.save_body_tail(&target_body_tail)?; + + // Make sure our output_pos index is consistent with the UTXO set. + // Normal block processing maintains this index incrementally, so + // avoid the full historical header scan unless a recovery path + // explicitly marked the index incomplete. + if batch.is_output_pos_index_complete()? { + debug!("compact: output_pos index is complete, skipping rebuild"); + } else { + txhashset.init_output_pos_index( &batch, sync_state.clone(), Some(stop_state.clone()), )?; + } - // Commit all the above db changes. - batch.commit()?; - Ok(horizon_header) - })(); - match res { - Ok(horizon_header) => { - self.clear_pending_chain_operation_checked()?; - Ok(Some(horizon_header)) - } - Err(e) => { - self.handle_failed_pending_chain_operation("compact", &e); - Err(e) - } + // TODO - Why is this part of chain compaction? + // Rebuild our NRD kernel_pos index based on recent kernel history. + txhashset.init_recent_kernel_pos_index( + &batch, + sync_state.clone(), + Some(stop_state.clone()), + )?; + + // Commit all the above db changes. + batch.commit()?; + Ok(horizon_header) + })(); + match res { + Ok(horizon_header) => { + self.clear_pending_chain_operation_checked()?; + marker_guard.disarm(); + Ok(Some(horizon_header)) + } + Err(e) => { + self.handle_failed_pending_chain_operation("compact", &e, &mut marker_guard); + Err(e) } } }?; @@ -2906,19 +4299,8 @@ impl Chain { max_count: u64, max_pmmr_index: Option, ) -> Result<(u64, u64, Vec), Error> { - self.with_robust_chain_read(|_, txhashset| { - let output_mmr_size = txhashset.output_mmr_size(); - let last_index = match max_pmmr_index { - Some(i) => min(i, output_mmr_size), - None => output_mmr_size, - }; - let outputs = - txhashset.outputs_by_pmmr_index(start_index, max_count, max_pmmr_index)?; - let rangeproofs = - txhashset.rangeproofs_by_pmmr_index(start_index, max_count, max_pmmr_index)?; - let (index, output_vec) = - combine_positioned_outputs_and_rangeproofs(outputs, rangeproofs)?; - Ok((index, last_index, output_vec)) + self.with_output_read_snapshot(|snapshot| { + snapshot.unspent_outputs_by_pmmr_index(start_index, max_count, max_pmmr_index) }) } @@ -3014,12 +4396,33 @@ impl Chain { .map_err(|e| Error::StoreErr(e, "chain head header".to_owned())) } - /// Gets a block by hash - pub fn get_block(&self, h: &Hash) -> Result { - self.ensure_chain_robust()?; - self.store - .get_block(h) - .map_err(|e| Error::StoreErr(e, "chain get block".to_owned())) + /// Load a full block and require its complete header to equal `expected`. + pub fn get_block_for_header(&self, expected: &BlockHeader) -> Result { + self.with_robust_chain_read(|_, _| { + let batch = self + .store + .batch_read() + .map_err(|e| Error::StoreErr(e, "chain get block for header batch".to_owned()))?; + let expected_hash = expected.hash(self.store.get_context_id())?; + let stored_header = crate::checked_header_by_hash( + self.store.get_context_id(), + &expected_hash, + "chain get block for header", + |hash| batch.get_block_header(hash), + )?; + if stored_header != *expected { + return Err(Error::InvalidPersistedChainState(format!( + "chain get block for header stored header {} does not exactly match the requested header", + expected_hash + ))); + } + crate::checked_block_for_header( + self.store.get_context_id(), + expected, + "chain get block for header", + |hash| batch.get_block(hash), + ) + }) } /// Gets the earliest stored block (tail) @@ -3033,9 +4436,9 @@ impl Chain { /// Gets a block header by hash pub fn get_block_header(&self, h: &Hash) -> Result { self.ensure_chain_robust()?; - self.store - .get_block_header(h) - .map_err(|e| Error::StoreErr(e, "chain get header".to_owned())) + crate::checked_header_by_hash(self.store.get_context_id(), h, "chain get header", |hash| { + self.store.get_block_header(hash) + }) } /// Get previous block header. @@ -3056,18 +4459,32 @@ impl Chain { /// Gets the block header at the provided height. /// Note: Takes a read lock on the header_pmmr. + /// + /// Do not add PoW verification to this request-reachable getter. Header PoW is + /// authenticated during admission and controlled startup/recovery. Repeating + /// Cuckoo verification per lookup would expose attacker-controlled CPU work; + /// this method deliberately performs only cheap persisted-state consistency + /// checks and trusts those validation boundaries. pub fn get_header_by_height(&self, height: u64) -> Result { - self.ensure_chain_robust()?; - let hash = self.get_header_hash_by_height(height)?; - self.get_block_header(&hash) - } - - /// Gets the header hash at the provided height. - /// Note: Takes a read lock on the header_pmmr. - fn get_header_hash_by_height(&self, height: u64) -> Result { - self.header_pmmr - .read_recursive() - .get_header_hash_by_height(height) + self.with_robust_header_pmmr_read(|header_pmmr| { + let hash = header_pmmr.get_header_hash_by_height(height)?; + let batch = self + .store + .batch_read() + .map_err(|e| Error::StoreErr(e, "chain get header by height batch".to_owned()))?; + let header = batch + .get_block_header(&hash) + .map_err(|e| Error::StoreErr(e, format!("chain get header by height {}", hash)))?; + let actual_hash = header.hash(self.store.get_context_id())?; + if header.height != height || actual_hash != hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR entry {} at height {} resolved to header {} at height {}", + hash, height, actual_hash, header.height + ))); + } + header_pmmr.authenticate_header_at_height(height, &header)?; + Ok(header) + }) } /// Migrate our local db from v2 to v3. @@ -3133,7 +4550,7 @@ impl Chain { }) } - fn set_retained_spent_commitment_index_complete( + fn set_spent_commitment_record_index_complete( store: &ChainStore, complete: bool, ) -> Result<(), Error> { @@ -3143,7 +4560,7 @@ impl Chain { "spent commitment index completeness write batch".to_owned(), ) })?; - batch.set_retained_spent_commitment_index_complete(complete)?; + batch.set_spent_commitment_record_index_complete(complete)?; batch.commit().map_err(|e| { Error::StoreErr(e, "spent commitment index completeness commit".to_owned()) }) @@ -3154,8 +4571,8 @@ impl Chain { let batch = store.batch_write().map_err(|e| { Error::StoreErr(e, "clear spent commitment index write batch".to_owned()) })?; - let deleted = - batch.clear_spent_commitment_index_chunk(SPENT_COMMITMENT_INDEX_REBUILD_CHUNK)?; + let deleted = batch + .clear_spent_commitment_index_chunk(SPENT_COMMITMENT_INDEX_CLEAR_CHUNK_SIZE)?; batch.commit().map_err(|e| { Error::StoreErr(e, "clear spent commitment index commit".to_owned()) })?; @@ -3165,233 +4582,582 @@ impl Chain { } } - fn save_spent_commitment_index_entries( + /// Resolve the persisted body HEAD and walk its canonical ancestry to the + /// cut-through horizon boundary. The returned headers are strictly above the + /// boundary: rewinding to `window_start` undoes only those blocks, and PMMR + /// compaction does not preserve leaf data spent by the boundary block itself. + /// Callers pass the body `HEAD` selector, never `HEADER_HEAD`. + fn canonical_body_headers_in_horizon( + context_id: u32, + persisted_body_head: &Tip, + batch: &Batch<'_>, + operation: &str, + ) -> Result<(BlockHeader, Tip, u64, Vec), Error> { + let (mut body_head, canonical_head) = + canonical_tip_header(operation, persisted_body_head, batch)?; + let selected_body_head = body_head.clone(); + let window_start = canonical_head + .height + .saturating_sub(u64::from(global::cut_through_horizon(context_id))); + + let mut headers = Vec::new(); + let mut visited = HashSet::new(); + while body_head.height > window_start { + let current_hash = body_head.hash(context_id)?; + let current_height = body_head.height; + let previous_hash = body_head.prev_hash; + headers.push(body_head.clone()); + body_head = crate::checked_previous_header( + context_id, + &body_head, + &mut visited, + operation, + |hash| batch.get_block_header(hash), + ) + .map_err(|e| match e { + Error::StoreErr(store_err, _) if store_err.store_error_is_not_found() => { + Error::InvalidPersistedChainState(format!( + "{}: canonical body block {} at height {} is missing predecessor {}", + operation, current_hash, current_height, previous_hash + )) + } + other => other, + })?; + } + Ok((selected_body_head, canonical_head, window_start, headers)) + } + + pub(crate) fn init_spent_commitment_index( store: &ChainStore, - entries: &[(Commitment, HashHeight)], + stop_state: Option>, ) -> Result<(), Error> { - if entries.is_empty() { - return Ok(()); - } - let batch = store.batch_write().map_err(|e| { - Error::StoreErr(e, "rebuild spent commitment index write batch".to_owned()) - })?; - for (commitment, hash_height) in entries { - batch.save_spent_commitments(commitment, *hash_height)?; - } - batch - .commit() - .map_err(|e| Error::StoreErr(e, "rebuild spent commitment index commit".to_owned())) - } - - fn retained_body_block_hashes(store: &ChainStore) -> Result, Error> { - // Normal nodes prune full block bodies below BODY_TAIL. This replay - // index can only be proven complete for the retained body-chain window, - // not for all historical chain data. Walk BODY_HEAD back to BODY_TAIL - // and require each full block body to exist before setting the durable - // completeness marker. Do not use a raw full-block iterator here: it can - // include non-body-chain fork blocks and it cannot prove that the - // retained canonical window has no gaps. let batch = store .batch_read() - .map_err(|e| Error::StoreErr(e, "retained body block window read batch".to_owned()))?; - let head = batch - .head() - .map_err(|e| Error::StoreErr(e, "retained body block window head".to_owned()))?; - let tail = match batch.tail() { - Ok(tail) => tail, - // Fresh chains can have HEAD before BODY_TAIL is initialized. Treat - // only the genesis-height case as a one-block retained window rooted at HEAD. - Err(NotFoundErr(_)) if head.height == 0 => head.clone(), - Err(NotFoundErr(_)) => { - return Err(Error::Other(format!( - "body tail is missing for non-fresh chain head height {}", - head.height - ))); - } + .map_err(|e| Error::StoreErr(e, "spent occurrence index read batch".into()))?; + let complete = batch.is_spent_commitment_record_index_complete()?; + let head = match batch.head() { + Ok(head) => head, + Err(e) if e.store_error_is_not_found() => return Ok(()), Err(e) => { return Err(Error::StoreErr( e, - "retained body block window tail".to_owned(), + "spent occurrence index read head".into(), )); } }; - if tail.height > head.height { - return Err(Error::Other(format!( - "body tail height {} is above body head height {}", - tail.height, head.height - ))); - } - let context_id = store.get_context_id(); - let head_hash = head.hash(context_id)?; - let tail_hash = tail.hash(context_id)?; - let mut current = batch - .get_block_header(&head_hash) - .map_err(|e| Error::StoreErr(e, "retained body block window head header".to_owned()))?; - let retained_window_len = head - .height - .checked_sub(tail.height) - .and_then(|len| len.checked_add(1)) - .ok_or_else(|| { - Error::DataOverflow(format!( - "retained_body_block_hashes length overflow: head height {}, tail height {}", - head.height, tail.height - )) - })?; - let retained_window_capacity = usize::try_from(retained_window_len).map_err(|_| { - Error::DataOverflow(format!( - "retained_body_block_hashes length {} does not fit usize", - retained_window_len - )) - })?; - let mut hashes = Vec::with_capacity(retained_window_capacity); - let mut expected_height = head.height; - let mut next_tail_header = None; - for _ in 0..retained_window_len { - let hash = current.hash(context_id)?; - if current.height != expected_height { - return Err(Error::Other(format!( - "body chain retained window expected height {}, found {} at {}", - expected_height, current.height, hash - ))); + if complete { + return Ok(()); + } + + // `last_block_h` is the authoritative body HEAD selector; use its selected + // header rather than the redundant persisted Tip height. The same helper + // supplies the canonical body window to both rebuild and migration. + let (header, canonical_head, window_start, canonical_headers) = + Self::canonical_body_headers_in_horizon( + store.get_context_id(), + &head, + &batch, + "spent occurrence index HEAD", + )?; + if canonical_head.height != 0 { + // An incomplete index left by a crash or an older version is rebuilt + // from canonical body blocks strictly above the horizon boundary. Every + // consumer is bounded by one cut-through horizon from the head: the replay + // check ignores spends below half the horizon, rewinds beyond one horizon + // are rejected with RewindBeyondHorizon, and compaction authenticates only + // blocks in the compact window. Walking body HEAD ancestry avoids archive + // history and retained bodies on competing forks. + info!( + "Rebuilding spent occurrence index from canonical body blocks at HEAD {} height {} (window start {})", + canonical_head.last_block_h, canonical_head.height, window_start + ); + drop(batch); + return Self::rebuild_spent_commitment_index( + store, + window_start, + canonical_headers, + stop_state, + ); + } + + // Genesis has no inputs, so an empty index can be established without + // trusting any persisted spent-position cache. + let block = crate::checked_block_for_header( + store.get_context_id(), + &header, + "spent occurrence index genesis", + |hash| batch.get_block(hash), + )?; + if !block.inputs().is_empty() { + return Err(Error::InvalidPersistedChainState( + "genesis block contains inputs while initializing spent occurrence index".into(), + )); + } + drop(batch); + Self::init_empty_spent_commitment_record_index(store) + } + + /// One-time migration of per-block spent indexes written before the exact + /// occurrence format (positions only) to the current `SpentOutput` format. + /// + /// Iterates the raw `BLOCK_SPENT_PREFIX` records keyed by block hash. A + /// record is migrated only when the header stored under its key exists, + /// verifiably belongs to the canonical chain, and sits strictly above the + /// cut-through horizon boundary. Records at or below the boundary are deleted: + /// they are derived caches that are not needed to rewind to the boundary. + /// Archive mode retains historical full blocks, not these inactive indexes. + /// Dangling records and active-window fork records are also deleted. + /// + /// Positions come from the legacy entry. The commitment at each position is + /// resolved authoritatively from the output MMR leaf data, which compaction + /// deliberately preserves for every in-window spend. No full block or input + /// ordering is needed: each position identifies its exact output occurrence, + /// so duplicated commitments migrate exactly. The output creation height is + /// carried over from the legacy entry; it is not independently verified. + pub(crate) fn migrate_spent_index( + store: &ChainStore, + txhashset: &TxHashSet, + stop_state: Option>, + ) -> Result<(), Error> { + let now = Instant::now(); + let context_id = store.get_context_id(); + let (window_start, canonical_hashes) = { + let batch = store + .batch_read() + .map_err(|e| Error::StoreErr(e, "spent index migration read batch".into()))?; + if batch.is_spent_index_migrated()? { + return Ok(()); + } + let head = match batch.head() { + Ok(head) => head, + Err(e) if e.store_error_is_not_found() => { + // Fresh DB before head setup: no blocks to migrate yet. The + // flag is set on a later start, once a head exists. + return Ok(()); + } + Err(e) => return Err(Error::StoreErr(e, "spent index migration read head".into())), + }; + info!("Migrating spent index, might take some time..."); + let (_, _, window_start, canonical_headers) = Self::canonical_body_headers_in_horizon( + context_id, + &head, + &batch, + "spent index migration HEAD", + )?; + // The migration must inspect every spent-index record so it can delete + // stale and fork entries. Membership in this bounded body-HEAD ancestry + // distinguishes the canonical records without consulting HEADER_HEAD. + let mut canonical_hashes = HashSet::with_capacity(canonical_headers.len()); + for header in canonical_headers { + canonical_hashes.insert(header.hash(context_id)?); } - let block_exists = batch.block_exists(&hash)?; + (window_start, canonical_hashes) + }; - if current.height == tail.height { - if hash != tail_hash { - return Err(Error::Other(format!( - "body chain retained window reached {} at tail height {}, expected {}", - hash, tail.height, tail_hash - ))); + let mut processed = 0u64; + let mut migrated = 0u64; + let mut deleted = 0u64; + let mut last_key: Option> = None; + loop { + if let Some(stop_state) = stop_state.as_ref() { + if stop_state.is_stopped() { + return Err(Error::Stopped); } - if !block_exists { - let next_tail_header = match next_tail_header { - Some(h) => h, - None => { - error!( - "retained spent commitment index rebuild found missing BODY_TAIL full block body with no BODY_TAIL + 1: height {}, hash {}, body_head height {}", - tail.height, tail_hash, head.height - ); - return Err(Error::SpentCommitmentIndexIncomplete); - } + } + // Use a fresh read transaction for each chunk and release it before + // opening the write batch. Besides avoiding a long-lived LMDB snapshot, + // this permits batch_write() to resize the map as migrated values grow. + let chunk = { + let read_batch = store + .batch_read() + .map_err(|e| Error::StoreErr(e, "spent index migration read batch".into()))?; + let start = last_key.as_deref().unwrap_or(&[]); + let mut keys = read_batch + .spent_index_key_iter_from(start) + .map_err(|e| Error::StoreErr(e, "spent index migration records iter".into()))?; + let mut chunk = Vec::with_capacity(SPENT_COMMITMENT_INDEX_REBUILD_CHUNK_SIZE); + while chunk.len() < SPENT_COMMITMENT_INDEX_REBUILD_CHUNK_SIZE { + let Some(key) = keys.next() else { + break; }; - let next_tail = Tip::try_from_header(&next_tail_header)?; - drop(batch); - let write_batch = store.batch_write().map_err(|e| { - Error::StoreErr(e, "retained body tail repair write batch".to_owned()) + let key = key.map_err(|e| { + Error::StoreErr(e, "spent index migration load record key".into()) })?; - write_batch.save_body_tail(&next_tail)?; - write_batch.commit().map_err(|e| { - Error::StoreErr(e, "retained body tail repair commit".to_owned()) + // iter_from() is inclusive. Skip the last committed key when it + // survived the preceding chunk as a migrated/current record. + if last_key.as_deref() == Some(key.as_slice()) { + continue; + } + chunk.push(key); + } + chunk + }; + if chunk.is_empty() { + break; + } + if let Some(first) = chunk.first() { + info!( + "Migrating spent index, processing {} records, starting from hash {}", + chunk.len(), + Self::spent_index_key_hash(first)? + ); + } + let batch = store + .batch_write() + .map_err(|e| Error::StoreErr(e, "spent index migration write batch".into()))?; + for key in &chunk { + let block_hash = Self::spent_index_key_hash(key)?; + let header = match batch.get_block_header(&block_hash) { + Ok(header) => header, + Err(e) if e.store_error_is_not_found() => { + debug!( + "spent index migration: deleting record for {} with no stored header", + block_hash + ); + batch.delete(key).map_err(|e| { + Error::StoreErr(e, "spent index migration delete record".into()) + })?; + deleted += 1; + continue; + } + Err(e) => { + return Err(Error::StoreErr( + e, + format!("spent index migration load header {}", block_hash), + )); + } + }; + let stored_hash = header.hash(context_id)?; + if stored_hash != block_hash { + return Err(Error::InvalidPersistedChainState(format!( + "spent index migration: header stored under {} hashes to {}", + block_hash, stored_hash + ))); + } + if header.height <= window_start { + debug!( + "spent index migration: deleting inactive record for block {} at height {} at or below window start {}", + block_hash, header.height, window_start + ); + batch.delete(key).map_err(|e| { + Error::StoreErr(e, "spent index migration delete record".into()) })?; - warn!( - "retained spent commitment index rebuild advanced stale body_tail from height {}, hash {} to height {}, hash {}", - tail.height, tail_hash, next_tail.height, next_tail.last_block_h + deleted += 1; + continue; + } + if !canonical_hashes.contains(&block_hash) { + debug!( + "spent index migration: deleting record for non-canonical block {} at height {}", + block_hash, header.height ); - hashes.reverse(); - return Ok(hashes); + batch.delete(key).map_err(|e| { + Error::StoreErr(e, "spent index migration delete record".into()) + })?; + deleted += 1; + continue; } - hashes.push(hash); - if hashes.len() != retained_window_capacity { - return Err(Error::Other(format!( - "body chain retained window reached tail with {} blocks, expected {}", - hashes.len(), - retained_window_capacity - ))); + match batch.get_spent_index(&block_hash) { + // Already in the exact occurrence format. + Ok(_) => {} + Err(current_err) => { + let legacy = match batch.get_spent_index_legacy(&block_hash) { + Ok(legacy) => legacy, + Err(_) => { + // The legacy read is only a compatibility + // fallback. Report the original current-format + // error, which describes the entry as it is + // expected to be. + return Err(Error::StoreErr( + current_err, + format!( + "spent index migration load spent index {}", + block_hash + ), + )); + } + }; + let mut entries = Vec::with_capacity(legacy.len()); + for position in &legacy { + if position.height >= header.height { + return Err(Error::InvalidPersistedChainState(format!( + "spent index migration: legacy spent index of block {} at height {} pairs an input with an output created at height {}", + block_hash, header.height, position.height + ))); + } + let commitment = txhashset + .output_commitment_at_pos(position.pos)? + .ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "spent index migration: no retained output data at position {} spent by block {} at height {}", + position.pos, block_hash, header.height + )) + })?; + entries.push(SpentOutput { + commitment, + position: *position, + }); + } + batch.save_spent_index(&block_hash, &entries).map_err(|e| { + Error::StoreErr( + e, + format!("spent index migration save spent index {}", block_hash), + ) + })?; + migrated += 1; + } } - hashes.reverse(); - return Ok(hashes); } - if !block_exists { - error!( - "retained spent commitment index rebuild is missing retained full block body: height {}, hash {}, body_head height {}, body_tail height {}", - current.height, hash, head.height, tail.height - ); - return Err(Error::SpentCommitmentIndexIncomplete); - } - hashes.push(hash); - next_tail_header = Some(current.clone()); - expected_height = expected_height.checked_sub(1).ok_or_else(|| { - Error::DataOverflow(format!( - "retained_body_block_hashes expected height underflow at {}", - current.height - )) - })?; - current = batch.get_previous_header(¤t)?; + let committed_last_key = chunk.last().cloned(); + processed += chunk.len() as u64; + batch + .commit() + .map_err(|e| Error::StoreErr(e, "spent index migration commit".into()))?; + last_key = committed_last_key; + } + { + let batch = store + .batch_write() + .map_err(|e| Error::StoreErr(e, "spent index migration marker batch".into()))?; + batch.set_spent_index_migrated(true)?; + batch + .commit() + .map_err(|e| Error::StoreErr(e, "spent index migration marker commit".into()))?; } - Err(Error::Other(format!( - "body chain retained window did not reach tail {} after {} blocks", - tail_hash, retained_window_len - ))) + info!( + "Spent index migration: {} entries migrated, {} deleted ({} records scanned, window start {}) in {}s", + migrated, + deleted, + processed, + window_start, + now.elapsed().as_secs() + ); + Ok(()) } - pub(crate) fn init_spent_commitment_index(store: &ChainStore) -> Result<(), Error> { - // Rebuild only the replay index that a local node can prove: commitments - // spent by retained full blocks on the canonical body chain. Normal - // nodes prune historical block bodies below BODY_TAIL, so compacted - // historical spends cannot be reconstructed here. - let complete = store - .batch_read() - .map_err(|e| Error::StoreErr(e, "spent commitment index read batch".to_owned()))? - .is_retained_spent_commitment_index_complete()?; - if complete { - return Ok(()); + /// Extract the block hash from a raw `BLOCK_SPENT_PREFIX` record key. + fn spent_index_key_hash(key: &[u8]) -> Result { + if key.len() != 2 + Hash::LEN { + return Err(Error::InvalidPersistedChainState(format!( + "spent index migration: malformed spent index key {:?}", + key + ))); } + Ok(Hash::from_vec(&key[2..])) + } - info!("Rebuilding spent commitment replay index for retained full blocks"); - Self::set_retained_spent_commitment_index_complete(store, false)?; - let block_hashes = Self::retained_body_block_hashes(store)?; + /// Rebuild the exact spent-occurrence index from canonical full blocks + /// strictly above `window_start`. + /// + /// `canonical_headers` is the bounded ancestry selected by the persisted body + /// HEAD. Only those exact block hashes are loaded, so archive history at or + /// below the horizon and retained bodies from other forks are never scanned. + /// Each loaded body must match its separately stored canonical header. Exact output + /// positions come from the per-block spent index cache, whose commitment + /// multiset is cross-checked against the body inputs before use. + /// + /// The replay check ignores spends older than half the horizon, rewinds beyond + /// one horizon are rejected, and compaction only authenticates this body-chain + /// window. Older and noncanonical records are therefore not rebuild inputs. + fn rebuild_spent_commitment_index( + store: &ChainStore, + window_start: u64, + mut canonical_headers: Vec, + stop_state: Option>, + ) -> Result<(), Error> { + let now = Instant::now(); + let context_id = store.get_context_id(); + Self::set_spent_commitment_record_index_complete(store, false)?; Self::clear_spent_commitment_index(store)?; - let mut entries = Vec::with_capacity(SPENT_COMMITMENT_INDEX_REBUILD_CHUNK); - let mut blocks = 0u64; - let mut retained_spent_commitments = 0u64; - for block_hash in block_hashes { - let block = store.get_block(&block_hash)?; - let hash_height = HashHeight { - hash: block_hash, - height: block.header.height, - }; - let entries_before = entries.len(); - match block.inputs() { - Inputs::CommitOnly(inputs) => { - for input in inputs { - entries.push((input.commitment(), hash_height)); - } - } - Inputs::FeaturesAndCommit(inputs) => { - for input in inputs { - entries.push((input.commitment(), hash_height)); - } + // The ancestry walk returns HEAD first. Rebuild oldest-to-newest so records + // for a reused commitment have stable body-chain order. + canonical_headers.reverse(); + let mut processed = 0u64; + for chunk in canonical_headers.chunks(SPENT_COMMITMENT_INDEX_REBUILD_CHUNK_SIZE) { + if let Some(stop_state) = stop_state.as_ref() { + if stop_state.is_stopped() { + return Err(Error::Stopped); } } - blocks += 1; - retained_spent_commitments += (entries.len() - entries_before) as u64; - if entries.len() >= SPENT_COMMITMENT_INDEX_REBUILD_CHUNK { - Self::save_spent_commitment_index_entries(store, &entries)?; - entries.clear(); + if let Some(first) = chunk.first() { + info!( + "Building spent commitment index, processing {} canonical blocks from height {}, starting from hash {}", + chunk.len(), + first.height, + first.hash(context_id)? + ); + } + let batch = store.batch_write().map_err(|e| { + Error::StoreErr(e, "spent occurrence index rebuild write batch".into()) + })?; + for header in chunk { + if header.height <= window_start { + return Err(Error::InvalidPersistedChainState(format!( + "spent occurrence index rebuild: canonical header at height {} is not above window start {}", + header.height, window_start + ))); + } + let block_hash = header.hash(context_id)?; + let block = crate::checked_block_for_header( + context_id, + header, + "spent occurrence index rebuild canonical body", + |hash| batch.get_block(hash), + )?; + let spent = Self::load_spent_index_for_rebuild(&batch, block_hash, &block)?; + for (commitment, position) in &spent { + batch + .save_spent_commitments( + commitment, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: block_hash, + height: header.height, + }, + spent_output: *position, + }, + ) + .map_err(|e| { + Error::StoreErr( + e, + format!( + "spent occurrence index rebuild save record for {}", + block_hash + ), + ) + })?; + } } + processed += chunk.len() as u64; + batch + .commit() + .map_err(|e| Error::StoreErr(e, "spent occurrence index rebuild commit".into()))?; + debug!( + "spent occurrence index rebuild: processed {} canonical blocks", + processed + ); } - Self::save_spent_commitment_index_entries(store, &entries)?; - Self::set_retained_spent_commitment_index_complete(store, true)?; + Self::set_spent_commitment_record_index_complete(store, true)?; info!( - "Rebuilt spent commitment replay index for {} retained full blocks and {} retained spent commitments", - blocks, retained_spent_commitments + "Rebuilt spent occurrence index from {} canonical blocks above height {} in {}s", + processed, + window_start, + now.elapsed().as_secs() ); Ok(()) } - pub(crate) fn init_empty_retained_spent_commitment_index( + fn block_input_commitments(block: &Block) -> Vec { + match block.inputs() { + Inputs::CommitOnly(inputs) => inputs.iter().map(|input| input.commitment()).collect(), + Inputs::FeaturesAndCommit(inputs) => { + inputs.iter().map(|input| input.commitment()).collect() + } + } + } + + /// Load one retained block's spent occurrences for the rebuild as + /// (commitment, position) pairs. + /// + /// Entries are expected in the exact `SpentOutput` format; legacy + /// positions-only entries are converted by + /// `migrate_spent_index` before the rebuild runs. The + /// commitment multiset is cross-checked against the authenticated body + /// inputs before any position is used. + /// + /// `BLOCK_SPENT_PREFIX` is trusted local derived state, not peer-supplied + /// position data. Production entries are written from the exact `CommitPos` + /// values returned by UTXO validation when the block is applied. Migrated + /// entries retain those previously validated positions and additionally + /// resolve their commitments from the raw output PMMR. This rebuild therefore + /// deliberately preserves each cached position after authenticating the full + /// block against its canonical header and checking the commitment multiset. + /// Re-resolving an occurrence from the commitment alone would be incorrect + /// because a commitment can be reused after its earlier occurrence is spent. + fn load_spent_index_for_rebuild( + batch: &Batch<'_>, + block_hash: Hash, + block: &Block, + ) -> Result, Error> { + let spent_index = match batch.get_spent_index(&block_hash) { + Ok(spent_index) => spent_index, + Err(e) + if e.store_error_is_not_found() + && Self::block_input_commitments(block).is_empty() => + { + // Inputless blocks (notably genesis) may have no entry. + return Ok(Vec::new()); + } + Err(e) if e.store_error_is_not_found() => { + return Err(Error::InvalidPersistedChainState(format!( + "spent occurrence index rebuild: retained full block {} at height {} has no spent index", + block_hash, block.header.height + ))); + } + Err(e) => { + return Err(Error::StoreErr( + e, + format!( + "spent occurrence index rebuild load spent index {}", + block_hash + ), + )); + } + }; + Self::validate_spent_index_against_body(block_hash, block, &spent_index)?; + Ok(spent_index + .iter() + .map(|spent| (spent.commitment, spent.position)) + .collect()) + } + + /// Cross-check a retained block's spent index cache against its + /// authenticated body. The cache supplies exact output positions only; the + /// multiset of spent commitments must match the body inputs exactly. + fn validate_spent_index_against_body( + block_hash: Hash, + block: &Block, + spent_index: &[SpentOutput], + ) -> Result<(), Error> { + let input_commitments = Self::block_input_commitments(block); + let mut counts: HashMap = HashMap::with_capacity(input_commitments.len()); + for commitment in input_commitments { + *counts.entry(commitment).or_insert(0) += 1; + } + for spent in spent_index { + let count = counts.get_mut(&spent.commitment).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "spent occurrence index rebuild: spent index of block {} contains commitment {:?} that is not a body input", + block_hash, spent.commitment + )) + })?; + *count -= 1; + if *count == 0 { + counts.remove(&spent.commitment); + } + } + if !counts.is_empty() { + return Err(Error::InvalidPersistedChainState(format!( + "spent occurrence index rebuild: body inputs of block {} are missing from its spent index", + block_hash + ))); + } + Ok(()) + } + + pub(crate) fn init_empty_spent_commitment_record_index( store: &ChainStore, ) -> Result<(), Error> { - // PIBD restores the txhashset and headers, not retained full block - // bodies. Until the first post-PIBD full block is stored there are no - // retained bodies to replay, so the complete retained replay index is - // intentionally empty. + // PIBD restores state, not old block bodies. Replay protection is + // intentionally best effort after that point: this empty index describes + // the locally retained post-snapshot block window and is populated as body + // sync validates subsequent blocks. It must not be used as a reason to + // reset the chain during PIBD, reorg, or recovery. info!("Initializing empty spent commitment replay index for PIBD state"); - Self::set_retained_spent_commitment_index_complete(store, false)?; + Self::set_spent_commitment_record_index_complete(store, false)?; Self::clear_spent_commitment_index(store)?; - Self::set_retained_spent_commitment_index_complete(store, true)?; + Self::set_spent_commitment_record_index_complete(store, true)?; Ok(()) } @@ -3632,22 +5398,13 @@ impl Chain { Ok(header) } - /// Verifies the given block header is actually on the current chain. - /// Checks the header_by_height index to verify the header is where we say - /// it is - fn is_on_current_chain(&self, x: Tip, head: Tip) -> Result { - self.with_robust_header_pmmr_read(|header_pmmr| { - self.is_on_current_chain_with_header_pmmr(header_pmmr, x, head) - }) - } - fn is_on_current_chain_with_header_pmmr( &self, header_pmmr: &PMMRHandle, x: Tip, - head: Tip, + header_head: Tip, ) -> Result { - if x.height > head.height { + if x.height > header_head.height { return Ok(false); } @@ -3666,24 +5423,27 @@ impl Chain { body_head: &Tip, height: u64, ) -> Result { - if height > body_head.height { + // Treat last_block_h as the authoritative selector. Verify the header + // stored under it hashes back to that selector and rebuild the redundant + // Tip fields before consulting the cached height. + let (mut current, canonical_head) = canonical_tip_header("BODY_HEAD", body_head, batch)?; + if height > canonical_head.height { return Err(Error::ChainInSyncing(format!( "body chain head is at {}, below requested height {}", - body_head.height, height + canonical_head.height, height ))); } let context_id = self.store.get_context_id(); - let mut current = batch.get_block_header(&body_head.hash(context_id)?)?; + let mut visited = HashSet::new(); while current.height > height { - let prev = batch.get_previous_header(¤t)?; - if prev.height >= current.height { - return Err(Error::Other(format!( - "body chain header traversal did not decrease height: {} -> {}", - current.height, prev.height - ))); - } - current = prev; + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "body_chain_header_at_height ancestry", + |hash| batch.get_block_header(hash), + )?; } if current.height != height { return Err(Error::Other(format!( @@ -3701,19 +5461,26 @@ impl Chain { body_head: &Tip, height: u64, ) -> Result { - if height > body_head.height { + // Canonicalize the body-chain anchor before using either its height or + // its selected header. This also protects the zero-step fast return. + let (mut current, canonical_head) = canonical_tip_header("BODY_HEAD", body_head, batch)?; + if height > canonical_head.height { return Err(Error::ChainInSyncing(format!( "body chain head is at {}, below requested height {}", - body_head.height, height + canonical_head.height, height ))); } let context_id = self.store.get_context_id(); - let header_head_height = batch - .header_head() - .map_err(|e| Error::StoreErr(e, "body chain header fast path header head".to_owned()))? - .height; - let mut current = batch.get_block_header(&body_head.hash(context_id)?)?; + let stored_header_head = batch.header_head().map_err(|e| { + Error::StoreErr(e, "body chain header fast path header head".to_owned()) + })?; + // HEADER_HEAD.height is another redundant cache. Verify the selected + // header and derive the PMMR fast-path bound from that header instead. + let (_, canonical_header_head) = + canonical_tip_header("HEADER_HEAD", &stored_header_head, batch)?; + let header_head_height = canonical_header_head.height; + let mut visited = HashSet::new(); loop { if current.height == height { @@ -3729,30 +5496,55 @@ impl Chain { if current.height <= header_head_height { let header_pmmr_hash = header_pmmr.get_header_hash_by_height(current.height)?; if current.hash(context_id)? == header_pmmr_hash { + header_pmmr.authenticate_header_at_height(current.height, ¤t)?; let hash = header_pmmr.get_header_hash_by_height(height)?; - return batch.get_block_header(&hash).map_err(|e| { + let header = batch.get_block_header(&hash).map_err(|e| { Error::StoreErr(e, "body chain header fast path get header".to_owned()) - }); + })?; + if header.height != height || header.hash(context_id)? != hash { + return Err(Error::InvalidPersistedChainState(format!( + "body chain header fast path entry {} at height {} resolved to header {} at height {}", + hash, + height, + header.hash(context_id)?, + header.height + ))); + } + header_pmmr.authenticate_header_at_height(height, &header)?; + return Ok(header); } } - current = batch.get_previous_header(¤t)?; + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "body_chain_header_at_height_maybe_fast ancestry", + |hash| batch.get_block_header(hash), + )?; } } fn is_on_body_chain(&self, header: &BlockHeader, body_head: Tip) -> Result { - if header.height > body_head.height { + let batch = self.store.batch_read()?; + let (mut current, canonical_head) = canonical_tip_header("BODY_HEAD", &body_head, &batch)?; + if header.height > canonical_head.height { return Ok(false); } let context_id = self.store.get_context_id(); - let header_hash = header.hash(context_id)?; - let mut current = self.get_block_header(&body_head.hash(context_id)?)?; + let mut visited = HashSet::new(); while current.height > header.height { - current = self.get_previous_header(¤t)?; + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "is_on_body_chain ancestry", + |hash| batch.get_block_header(hash), + )?; } - Ok(current.hash(context_id)? == header_hash) + Ok(current == *header) } fn is_on_body_chain_with_batch( @@ -3761,15 +5553,14 @@ impl Chain { header: &BlockHeader, body_head: &Tip, ) -> Result { - if header.height > body_head.height { + let (_, canonical_head) = canonical_tip_header("BODY_HEAD", body_head, batch)?; + if header.height > canonical_head.height { return Ok(false); } - let context_id = self.store.get_context_id(); - let header_hash = header.hash(context_id)?; - let current = self.body_chain_header_at_height(batch, body_head, header.height)?; + let current = self.body_chain_header_at_height(batch, &canonical_head, header.height)?; - Ok(current.hash(context_id)? == header_hash) + Ok(current == *header) } /// Gets multiple headers at the provided heights. @@ -3939,8 +5730,9 @@ fn reset_pibd_chain_state( header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, ) -> Result<(), Error> { - reset_chain_head_to_genesis_state(genesis, store, header_pmmr, txhashset, secp)?; + reset_chain_head_to_genesis_state(genesis, store, header_pmmr, txhashset, secp, pow_verifier)?; Ok(()) } @@ -3949,11 +5741,12 @@ fn prepare_reset_chain_head_operation( header: &BlockHeader, rewind_headers: bool, ) -> Result { + pipe::validate_header_context_id(store.get_context_id(), header)?; let batch = store.batch_read()?; let original_body_head = batch.head()?; let original_header_head = batch.header_head()?; - let target_header = batch.get_block_header(&header.hash(store.get_context_id())?)?; - let target_body_head = Tip::try_from_header(&target_header)?; + let requested_target = Tip::try_from_header(header)?; + let (_, target_body_head) = canonical_tip_header("reset target", &requested_target, &batch)?; let target_header_head = if rewind_headers { target_body_head } else { @@ -3986,6 +5779,7 @@ fn recover_pending_chain_operation( header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, ) -> Result<(), Error> { let op = match store.pending_chain_operation()? { None => return Ok(()), @@ -3993,7 +5787,15 @@ fn recover_pending_chain_operation( }; warn!("Detected incomplete chain operation: {:?}", op.kind()); - recover_marked_chain_operation(genesis, store, header_pmmr, txhashset, secp, &op, true) + recover_marked_chain_operation( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + &op, + ) } fn recover_marked_chain_operation( @@ -4002,19 +5804,53 @@ fn recover_marked_chain_operation( header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, op: &PendingChainOperation, - allow_full_reset: bool, ) -> Result<(), Error> { let res = match op { PendingChainOperation::PibdReset => { - reset_pibd_chain_state(genesis, store, header_pmmr, txhashset, secp) - } - PendingChainOperation::ResetToGenesis => { - reset_chain_head_to_genesis_state(genesis, store, header_pmmr, txhashset, secp) + reset_pibd_chain_state(genesis, store, header_pmmr, txhashset, secp, pow_verifier) } + PendingChainOperation::ResetToGenesis => reset_chain_head_to_genesis_state( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + ), + PendingChainOperation::Compact { + original_body_head, + original_header_head, + target_body_tail, + } => recover_compact_chain_operation( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + original_body_head, + original_header_head, + target_body_tail, + ), + PendingChainOperation::ReconcileHeads { + kind: ChainOperationKind::Compact, + original_body_head, + original_header_head, + } => recover_legacy_compact_chain_operation( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + original_body_head, + original_header_head, + ), PendingChainOperation::ResetChainHead { .. } | PendingChainOperation::ReconcileHeads { .. } => { - reconcile_pmmrs_to_db_heads(store, header_pmmr, txhashset, secp) + reconcile_pmmrs_to_db_heads(genesis, store, header_pmmr, txhashset, secp, pow_verifier) } }; @@ -4023,93 +5859,565 @@ fn recover_marked_chain_operation( store.clear_pending_chain_operation()?; Ok(()) } - Err(e) if allow_full_reset => { - // The original recovery error is logged but not propagated here. - // If the full reset and marker cleanup succeed, treat recovery as - // successful and let callers continue with the reset chain state. + Err(e) => { + // A marked-operation recovery failure means the code cannot prove that + // the durable DB selectors and PMMR files describe one state. Do not hide + // that failure by silently rebuilding the body at genesis. Keep the marker + // so every restart fails closed until the operator has investigated and + // explicitly cleaned, reset, or resynchronized the chain data. error!( - "failed to recover {:?} by reconciling heads; resetting chain state to genesis: {}", + "Failed to recover pending {:?}: {}. Automatic fallback reset is disabled; the pending marker is retained. Inspect the chain data and explicitly clean or reset it before restarting", op.kind(), e ); - reset_pibd_chain_state(genesis, store, header_pmmr, txhashset, secp)?; - store.clear_pending_chain_operation()?; - Ok(()) + Err(e) } - Err(e) => Err(e), } } -fn reconcile_pmmrs_to_db_heads( +/// Recover a compact marker written before the marker carried an explicit +/// BODY_TAIL. The old operation selected its horizon deterministically from +/// the recorded body head, so derive that target and then apply the same full +/// validation as a new marker. If configuration changed incompatibly, the +/// PMMR rewind-target checks fail closed and leave the marker installed. +fn recover_legacy_compact_chain_operation( + genesis: &Block, store: &store::ChainStore, header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + original_body_head: &Tip, + original_header_head: &Tip, ) -> Result<(), Error> { - let context_id = store.get_context_id(); let batch = store.batch_read()?; - let body_head = batch.head()?; - let header_head = batch.header_head()?; - let body_header = batch.get_block_header(&body_head.hash(context_id)?)?; - let header_header = batch.get_block_header(&header_head.hash(context_id)?)?; + let (body_head_header, canonical_body_head) = canonical_tip_header( + "legacy compact marker original HEAD", + original_body_head, + &batch, + )?; + let (_, canonical_header_head) = canonical_tip_header( + "legacy compact marker original HEADER_HEAD", + original_header_head, + &batch, + )?; + let horizon_height = + canonical_body_head + .height + .saturating_sub(u64::from(global::cut_through_horizon( + store.get_context_id(), + ))); + let horizon_header = body_chain_ancestor_at_height( + store.get_context_id(), + &batch, + &body_head_header, + horizon_height, + "legacy compact marker horizon ancestry", + )?; + let target_body_tail = Tip::try_from_header(&horizon_header)?; drop(batch); - reconcile_body_pmmr_to_header(store, header_pmmr, txhashset, secp, &body_header)?; - reconcile_header_pmmr_to_header(store, header_pmmr, &header_header)?; - Ok(()) + warn!( + "Recovering legacy compact marker by deriving BODY_TAIL {} at height {} from recorded HEAD", + target_body_tail.last_block_h, target_body_tail.height + ); + recover_compact_chain_operation( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + &canonical_body_head, + &canonical_header_head, + &target_body_tail, + ) } -fn reconcile_body_pmmr_to_header( +/// Recover the cross-durability boundary changed by chain compaction. +/// +/// PMMR compaction replaces files before the LMDB batch containing BODY_TAIL +/// commits. The marker's target tail is therefore the authoritative minimum +/// rewind horizon if recovery observes the compacted files with an older DB +/// tail. Validate it against both the durable body chain and every body PMMR +/// before publishing it. +fn recover_compact_chain_operation( + genesis: &Block, store: &store::ChainStore, header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, - header: &BlockHeader, -) -> Result<(), Error> { - let mut batch = store.batch_write()?; - txhashset::extending(header_pmmr, txhashset, &mut batch, |ext, batch| { - pipe::rewind_and_apply_fork(store.get_context_id(), header, ext, batch, secp)?; - ext.extension.validate_roots(header)?; - ext.extension.validate_sizes(header)?; - Ok(()) - })?; - batch.commit()?; - Ok(()) -} - -fn reconcile_header_pmmr_to_header( - store: &store::ChainStore, - header_pmmr: &mut PMMRHandle, - header: &BlockHeader, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + original_body_head: &Tip, + original_header_head: &Tip, + target_body_tail: &Tip, ) -> Result<(), Error> { - let mut batch = store.batch_write()?; - txhashset::header_extending(header_pmmr, &mut batch, |ext, batch| { - pipe::rewind_and_apply_header_fork(store.get_context_id(), header, ext, batch) - })?; - batch.commit()?; - Ok(()) -} + let context_id = store.get_context_id(); + let batch = store.batch_read()?; + let stored_body_head = batch.head()?; + let (body_head_header, body_head) = + canonical_tip_header("compact recovery HEAD", &stored_body_head, &batch)?; + let stored_header_head = batch.header_head()?; + let (_, header_head) = + canonical_tip_header("compact recovery HEADER_HEAD", &stored_header_head, &batch)?; + let (_, marker_body_head) = + canonical_tip_header("compact marker original HEAD", original_body_head, &batch)?; + let (_, marker_header_head) = canonical_tip_header( + "compact marker original HEADER_HEAD", + original_header_head, + &batch, + )?; + if marker_body_head != *original_body_head || marker_header_head != *original_header_head { + return Err(Error::InvalidPersistedChainState( + "compact recovery marker contains non-canonical original chain heads".into(), + )); + } + if body_head != marker_body_head || header_head != marker_header_head { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery chain heads changed after the horizon was selected: marker HEAD {:?}, current HEAD {:?}, marker HEADER_HEAD {:?}, current HEADER_HEAD {:?}", + marker_body_head, body_head, marker_header_head, header_head + ))); + } -fn reset_chain_head_state( + let (target_header, canonical_target_tail) = + canonical_tip_header("compact marker target BODY_TAIL", target_body_tail, &batch)?; + if canonical_target_tail != *target_body_tail { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery marker target BODY_TAIL {:?} is not canonical {:?}", + target_body_tail, canonical_target_tail + ))); + } + if canonical_target_tail.height > body_head.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery target BODY_TAIL height {} is above HEAD height {}", + canonical_target_tail.height, body_head.height + ))); + } + let target_on_body_chain = body_chain_ancestor_at_height( + context_id, + &batch, + &body_head_header, + canonical_target_tail.height, + "compact recovery target BODY_TAIL ancestry", + )?; + if target_on_body_chain != target_header { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery target BODY_TAIL {} at height {} is not on durable body chain", + canonical_target_tail.last_block_h, canonical_target_tail.height + ))); + } + crate::checked_block_for_header( + context_id, + &target_header, + "compact recovery target BODY_TAIL preflight", + |hash| batch.get_block(hash), + )?; + txhashset.validate_recovery_rewind_targets_for( + "compact target BODY_TAIL", + &canonical_target_tail, + &target_header, + )?; + drop(batch); + + // Compaction does not intentionally change either head, but its marker also + // protects against unrelated speculative PMMR writes. Repair those first. + reconcile_pmmrs_to_db_heads(genesis, store, header_pmmr, txhashset, secp, pow_verifier)?; + + let batch = store.batch_read()?; + let stored_body_head = batch.head()?; + let (body_head_header, body_head) = + canonical_tip_header("compact recovery repaired HEAD", &stored_body_head, &batch)?; + if body_head != marker_body_head { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery HEAD changed while repairing PMMRs: marker {:?}, current {:?}", + marker_body_head, body_head + ))); + } + + let repaired_tail = match batch.tail() { + Ok(stored_tail) => { + let (tail_header, tail) = + canonical_tip_header("compact recovery current BODY_TAIL", &stored_tail, &batch)?; + if tail.height > body_head.height { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery current BODY_TAIL height {} is above HEAD height {}", + tail.height, body_head.height + ))); + } + if tail.height >= canonical_target_tail.height { + let tail_on_body_chain = body_chain_ancestor_at_height( + context_id, + &batch, + &body_head_header, + tail.height, + "compact recovery current BODY_TAIL ancestry", + )?; + if tail_on_body_chain != tail_header { + return Err(Error::InvalidPersistedChainState(format!( + "compact recovery current BODY_TAIL {} at height {} is not on durable body chain", + tail.last_block_h, tail.height + ))); + } + crate::checked_block_for_header( + context_id, + &tail_header, + "compact recovery current BODY_TAIL preflight", + |hash| batch.get_block(hash), + )?; + txhashset.validate_recovery_rewind_targets_for( + "current BODY_TAIL", + &tail, + &tail_header, + )?; + tail + } else { + canonical_target_tail + } + } + Err(NotFoundErr(_)) => canonical_target_tail, + Err(e) => { + return Err(Error::StoreErr(e, "compact recovery load BODY_TAIL".into())); + } + }; + drop(batch); + + let batch = store.batch_write()?; + batch.save_body_tail(&repaired_tail)?; + batch.commit()?; + Ok(()) +} + +fn body_chain_ancestor_at_height( + context_id: u32, + batch: &store::Batch<'_>, + body_head: &BlockHeader, + height: u64, + operation: &str, +) -> Result { + if height > body_head.height { + return Err(Error::InvalidPersistedChainState(format!( + "{} requested height {} above body head height {}", + operation, height, body_head.height + ))); + } + + let mut current = body_head.clone(); + let mut visited = HashSet::new(); + while current.height > height { + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + operation, + |hash| batch.get_block_header(hash), + )?; + } + if current.height != height { + return Err(Error::InvalidPersistedChainState(format!( + "{} stopped at height {}, below requested height {}", + operation, current.height, height + ))); + } + Ok(current) +} + +/// Refuse rewind-only recovery when a durable DB head is ahead of a PMMR file. +/// +/// PMMR extensions sync their files before their caller commits the enclosing +/// LMDB batch. A crash or failed outer commit after a rewind can therefore leave +/// a completely valid but shorter PMMR on disk while HEAD or HEADER_HEAD still +/// selects the previous, higher state. This is an expected cross-durability- +/// domain failure mode; it does not imply that either individual store is +/// internally corrupt. +/// +/// The normal reconciliation code cannot repair that direction of mismatch. It +/// initializes Extension and HeaderExtension from the durable DB tips and then +/// uses rewind operations. If the files are shorter, the extensions incorrectly +/// appear to be at the requested heads and eventually ask PMMRBackend::rewind to +/// move a hash or data file forward. Rewind correctly rejects that request and +/// cannot recreate the missing headers, outputs, rangeproofs, or kernels. +/// +/// Validate the targets before mutating any PMMR. Returning a dedicated error on +/// the first missing component makes node initialization fail with an actionable +/// reason, bypasses the unsuitable body-only fallback, and preserves the pending- +/// operation marker for an explicit reset or resynchronization. +fn preflight_reconciliation_pmmr_capacity( + body_head: &Tip, + body_header: &BlockHeader, + header_head: &Tip, + header_header: &BlockHeader, + header_pmmr: &PMMRHandle, + txhashset: &TxHashSet, +) -> Result<(), Error> { + let header_leaf_count = header_header.height.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "recovery HEADER_HEAD height overflow: {}", + header_header.height + )) + })?; + let header_position = pmmr::insertion_to_pmmr_index(header_leaf_count)?; + + match header_pmmr.backend.validate_rewind_target(header_position) { + Ok(()) => {} + Err(pmmr::Error::InvalidState(reason)) => { + return Err(Error::PmmrRecoveryRequired(format!( + "durable HEADER_HEAD {} at height {} requires header PMMR position {}, but the current backend files are shorter: {}", + header_head.last_block_h, header_head.height, header_position, reason + ))); + } + Err(err) => return Err(err.into()), + } + txhashset.validate_recovery_rewind_targets(body_head, body_header) +} + +/// Authenticate the complete persisted header before recovery trusts the +/// proof-derived identity stored in the header PMMR. +/// +/// The fixed production genesis proofs predate the current runtime verifier, +/// so height zero is authenticated by exact equality with the configured +/// genesis header. Every later header must pass the verifier configured for +/// this Chain instance. +fn authenticate_persisted_header_for_recovery( + context_id: u32, + genesis: &BlockHeader, + header: &BlockHeader, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, +) -> Result<(), Error> { + pipe::validate_header_context_id(context_id, header).map_err(|e| { + Error::InvalidPersistedChainState(format!( + "persisted header at height {} failed context authentication: {}", + header.height, e + )) + })?; + + if header.height == 0 { + if header != genesis { + return Err(Error::InvalidPersistedChainState( + "persisted height-zero header does not exactly match configured genesis".into(), + )); + } + return Ok(()); + } + + if !header.pow.is_primary(context_id) && !header.pow.is_secondary() { + return Err(Error::InvalidPersistedChainState(format!( + "persisted header at height {} has invalid proof edge bits", + header.height + ))); + } + + pow_verifier(context_id, header).map_err(|e| { + Error::InvalidPersistedChainState(format!( + "persisted header at height {} failed PoW authentication: {}", + header.height, e + )) + }) +} + +fn reconcile_pmmrs_to_db_heads( + genesis: &Block, + store: &store::ChainStore, + header_pmmr: &mut PMMRHandle, + txhashset: &mut TxHashSet, + secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, +) -> Result<(), Error> { + // Reconciliation repairs PMMRs to the durable DB-selected heads. It + // intentionally does not apply INVALID_BLOCK_HASHES to ancestry already + // represented by those heads: denylist checks belong to normal header/block + // admission and explicit rewind_bad_block handling, not this durability-repair + // path. + let batch = store.batch_read()?; + let body_head = batch.head()?; + let header_head = batch.header_head()?; + // Canonicalize both tips: each selected header must hash back to its + // persisted selector before any PMMR is mutated toward it. + let (body_header, canonical_body_head) = canonical_tip_header("HEAD", &body_head, &batch)?; + let (header_header, canonical_header_head) = + canonical_tip_header("HEADER_HEAD", &header_head, &batch)?; + preflight_reconciliation_pmmr_capacity( + &canonical_body_head, + &body_header, + &canonical_header_head, + &header_header, + header_pmmr, + txhashset, + )?; + crate::checked_block_for_header( + store.get_context_id(), + &body_header, + "reconcile_pmmrs_to_db_heads HEAD preflight", + |hash| batch.get_block(hash), + )?; + drop(batch); + + // Repair the header PMMR first. Body reconciliation uses it as a temporary + // fork index, so it must no longer expose speculative entries whose DB records + // were rolled back with an interrupted outer batch. + reconcile_header_pmmr_to_header( + &genesis.header, + store, + header_pmmr, + &header_header, + pow_verifier, + )?; + reconcile_body_pmmr_to_header( + &genesis.header, + store, + header_pmmr, + txhashset, + secp, + &body_header, + pow_verifier, + )?; + + // Publish both repaired caches only after both PMMRs authenticate. Keeping + // these writes in one final batch prevents recovery from exposing one + // canonical Tip while leaving the other stale. + let batch = store.batch_write()?; + batch.save_body_head(&canonical_body_head)?; + batch.save_header_head(&canonical_header_head)?; + batch.commit()?; + Ok(()) +} + +fn reconcile_body_pmmr_to_header( + genesis: &BlockHeader, store: &store::ChainStore, header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, header: &BlockHeader, - rewind_headers: bool, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, +) -> Result<(), Error> { + let context_id = store.get_context_id(); + let authenticate_header = |candidate: &BlockHeader| { + authenticate_persisted_header_for_recovery(context_id, genesis, candidate, pow_verifier) + }; + let mut batch = store.batch_write()?; + txhashset::extending(header_pmmr, txhashset, &mut batch, |ext, batch| { + pipe::rewind_and_apply_fork_for_recovery( + context_id, + header, + ext, + batch, + secp, + &authenticate_header, + )?; + // A same-head rewind truncates appended PMMR data but has no block-derived + // spend bitmap with which to restore leaves removed by an interrupted, + // uncommitted extension. Roots and kernel sums do not authenticate exact + // leaf membership, so require a bidirectional match with the committed + // output-position index before recovery can clear its marker. + ext.extension.validate_output_pos_index(batch, header)?; + ext.extension + .validate(genesis, true, None, header, None, secp)?; + Ok(()) + })?; + batch.commit()?; + Ok(()) +} + +fn reconcile_header_pmmr_to_header( + genesis: &BlockHeader, + store: &store::ChainStore, + header_pmmr: &mut PMMRHandle, + header: &BlockHeader, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, ) -> Result<(), Error> { - let head = Tip::try_from_header(header)?; + let context_id = store.get_context_id(); + let authenticate_header = |candidate: &BlockHeader| { + authenticate_persisted_header_for_recovery(context_id, genesis, candidate, pow_verifier) + }; let mut batch = store.batch_write()?; + let durable_head = Tip::try_from_header(header)?; + txhashset::header_extending_with_explicit_head( + header_pmmr, + &mut batch, + durable_head, + |ext, batch| { + pipe::rewind_and_apply_header_fork_for_recovery( + context_id, + header, + ext, + batch, + &authenticate_header, + )?; + ext.validate_persisted_ancestry(header, batch, pow_verifier) + }, + )?; + batch.commit()?; + Ok(()) +} - let header = batch.get_block_header(&head.hash(store.get_context_id())?)?; - // rebuilding head from loaded data, input header can be tweaked - let head = Tip::try_from_header(&header)?; +fn reset_chain_head_state( + store: &store::ChainStore, + header_pmmr: &mut PMMRHandle, + txhashset: &mut TxHashSet, + secp: &Secp256k1, + header: &BlockHeader, + rewind_headers: bool, +) -> Result<(), Error> { + let context_id = store.get_context_id(); + pipe::validate_header_context_id(context_id, header)?; + let requested_target = Tip::try_from_header(header)?; + let mut batch = store.batch_write()?; + let (header, head) = canonical_tip_header("reset target", &requested_target, &batch)?; + + // A zero-step reset and a rewind to an existing ancestor do not otherwise + // load the target block. + crate::checked_block_for_header( + context_id, + &header, + "reset_chain_head target preflight", + |hash| batch.get_block(hash), + )?; + + let (tail_header, tail) = match batch.tail() { + Ok(stored_tail) => canonical_tip_header("BODY_TAIL", &stored_tail, &batch)?, + Err(NotFoundErr(_)) => { + let stored_head = batch.head()?; + let (head_header, canonical_head) = canonical_tip_header("HEAD", &stored_head, &batch)?; + if canonical_head.height != 0 { + return Err(Error::InvalidPersistedChainState(format!( + "reset_chain_head BODY_TAIL is missing for HEAD at height {}", + canonical_head.height + ))); + } + (head_header, canonical_head) + } + Err(e) => return Err(Error::StoreErr(e, "reset_chain_head load BODY_TAIL".into())), + }; + if head.height < tail.height { + return Err(Error::Other(format!( + "reset_chain_head cannot reset HEAD to height {} below BODY_TAIL at height {}", + head.height, tail.height + ))); + } + let mut target_at_tail = header.clone(); + let mut visited = HashSet::new(); + while target_at_tail.height > tail.height { + target_at_tail = crate::checked_previous_header( + context_id, + &target_at_tail, + &mut visited, + "reset_chain_head target ancestry", + |hash| batch.get_block_header(hash), + )?; + } + if target_at_tail != tail_header { + return Err(Error::Other(format!( + "reset_chain_head target ancestry does not contain BODY_TAIL {} at height {}", + tail.last_block_h, tail.height + ))); + } // Rewind and reapply blocks to reset the output/rangeproof/kernel MMR. txhashset::extending(header_pmmr, txhashset, &mut batch, |ext, batch| { - pipe::rewind_and_apply_fork(store.get_context_id(), &header, ext, batch, secp)?; + pipe::rewind_and_apply_fork(context_id, &header, ext, batch, secp)?; ext.extension.validate_roots(&header)?; ext.extension.validate_sizes(&header)?; + batch.save_body_tail(&tail)?; batch.save_body_head(&head)?; Ok(()) })?; @@ -4118,7 +6426,7 @@ fn reset_chain_head_state( // If the rewind of full blocks was successful then we can rewind the header MMR. // Rewind and reapply headers to reset the header MMR. txhashset::header_extending(header_pmmr, &mut batch, |ext, batch| { - pipe::rewind_and_apply_header_fork(store.get_context_id(), &header, ext, batch)?; + pipe::rewind_and_apply_header_fork(context_id, &header, ext, batch)?; batch.save_header_head(&head)?; Ok(()) })?; @@ -4165,17 +6473,31 @@ fn reset_chain_head_to_genesis_state( header_pmmr: &mut PMMRHandle, txhashset: &mut TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, ) -> Result<(), Error> { validate_genesis_context_id(genesis, store.get_context_id())?; let head = Tip::try_from_header(&genesis.header)?; - setup_head(genesis, store, header_pmmr, txhashset, secp, Some(head)) -} - -fn is_recoverable_mmr_corruption(e: &Error) -> bool { - matches!( - e, - Error::PMMRErr(mwc_core::core::pmmr::Error::DataCorruption(_)) - ) + setup_head( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + false, + Some(head), + )?; + + // Resetting body state to genesis intentionally preserves the downloaded + // header chain. init_head() and rewind() establish only its logical head and + // size; they do not authenticate HeaderEntry data, leaf hashes, parent hashes, + // or persisted prev_hash ancestry. Reconcile the retained PMMR so callers may + // clear a recovery marker only after validate_persisted_ancestry succeeds. + let batch = store.batch_read()?; + let stored_header_head = batch.header_head()?; + let (header, _) = canonical_tip_header("HEADER_HEAD", &stored_header_head, &batch)?; + drop(batch); + reconcile_header_pmmr_to_header(&genesis.header, store, header_pmmr, &header, pow_verifier) } fn validate_genesis_context_id(genesis: &Block, context_id: u32) -> Result<(), Error> { @@ -4342,29 +6664,178 @@ fn save_genesis_block_metadata( context_id: u32, ) -> Result<(), Error> { batch.save_block(genesis)?; - batch.save_spent_index(genesis_hash, &vec![])?; + batch.save_spent_index(genesis_hash, &[])?; batch.save_block_sums(genesis_hash, genesis_block_sums(genesis, context_id, secp)?)?; Ok(()) } +/// Rebuild the redundant fields of a persisted chain-tip cache from the +/// trusted block-header store. +/// +/// Trust policy: `persisted.last_block_h` is the authoritative head selector, +/// and the header stored under that key is authoritative after its recomputed +/// hash has been verified against the selector. The remaining Tip fields +/// (`height`, `prev_block_h`, and `total_difficulty`) are cached copies that may +/// be repaired from the header. Header validity, PoW, and selection of the +/// winning fork have already been established before data reaches this store +/// and are intentionally not revalidated here. +fn canonical_tip_header( + name: &str, + persisted: &Tip, + batch: &store::Batch<'_>, +) -> Result<(BlockHeader, Tip), Error> { + let header = batch + .get_block_header(&persisted.last_block_h) + .map_err(|e| { + Error::StoreErr( + e, + format!("chain init load {} header {}", name, persisted.last_block_h), + ) + })?; + let canonical = Tip::try_from_header(&header)?; + if canonical.last_block_h != persisted.last_block_h { + return Err(Error::InvalidPersistedChainState(format!( + "{} header key/hash mismatch: selected {}, header hashes to {}", + name, persisted.last_block_h, canonical.last_block_h + ))); + } + Ok((header, canonical)) +} + +/// Permit implicit genesis initialization only for an empty chain store. +/// +/// A missing HEAD is normal on the first startup, but it is also possible after +/// local database damage. Treating every missing selector as a fresh node would +/// silently replace an existing body chain with genesis. Normal startup must +/// therefore fail closed if any authoritative DB selector, PMMR content, header, +/// or full block proves that this store was already used. The operator can then +/// investigate and explicitly clean or reset the node data. Explicit reset/PIBD +/// paths pass a body-head override and intentionally do not use this preflight. +/// +/// This also covers an intentionally fail-closed first-run failure mode. The +/// PMMR extension helpers sync their files before setup_head() commits its outer +/// LMDB batch. A process exit or propagated error in that interval can therefore +/// leave genesis PMMR data on disk without a durable HEAD. There is deliberately +/// no automatic recovery for that unmarked state: on the next startup it is +/// indistinguishable from loss of HEAD in a previously used store. The node must +/// report the inconsistency and require the operator to inspect and explicitly +/// clean or reset the chain data instead of implicitly deleting or rebuilding it. +fn ensure_missing_head_is_fresh( + batch: &store::Batch<'_>, + header_pmmr: &PMMRHandle, + txhashset: &TxHashSet, +) -> Result<(), Error> { + match batch.head() { + Ok(_) => return Ok(()), + Err(NotFoundErr(_)) => {} + Err(e) => return Err(Error::StoreErr(e, "fresh-chain preflight HEAD".into())), + } + + let mut existing_state = Vec::new(); + match batch.header_head() { + Ok(head) => existing_state.push(format!( + "HEADER_HEAD {} at height {}", + head.last_block_h, head.height + )), + Err(NotFoundErr(_)) => {} + Err(e) => { + return Err(Error::StoreErr( + e, + "fresh-chain preflight HEADER_HEAD".into(), + )); + } + } + match batch.tail() { + Ok(tail) => existing_state.push(format!( + "BODY_TAIL {} at height {}", + tail.last_block_h, tail.height + )), + Err(NotFoundErr(_)) => {} + Err(e) => return Err(Error::StoreErr(e, "fresh-chain preflight BODY_TAIL".into())), + } + + if header_pmmr.size != 0 { + existing_state.push(format!("header PMMR size {}", header_pmmr.size)); + } + let body_sizes = ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ); + if body_sizes != (0, 0, 0) { + existing_state.push(format!( + "body PMMR sizes output/rangeproof/kernel {}/{}/{}", + body_sizes.0, body_sizes.1, body_sizes.2 + )); + } + if batch + .has_any_block_headers() + .map_err(|e| Error::StoreErr(e, "fresh-chain preflight block-header records".into()))? + { + existing_state.push("persisted block-header records".into()); + } + if batch + .has_any_full_blocks() + .map_err(|e| Error::StoreErr(e, "fresh-chain preflight full-block records".into()))? + { + existing_state.push("persisted full-block records".into()); + } + if batch + .has_any_auxiliary_chain_state() + .map_err(|e| Error::StoreErr(e, "fresh-chain preflight auxiliary records".into()))? + { + existing_state.push("persisted auxiliary chain records or cache flags".into()); + } + + if existing_state.is_empty() { + return Ok(()); + } + + let msg = format!( + "HEAD is missing from non-fresh chain state ({}). Automatic genesis rebuild is disabled; inspect the chain data and explicitly clean or reset it before restarting", + existing_state.join(", ") + ); + error!("{}", msg); + Err(Error::InvalidPersistedChainState(msg)) +} + fn setup_head( genesis: &Block, store: &store::ChainStore, header_pmmr: &mut txhashset::PMMRHandle, txhashset: &mut txhashset::TxHashSet, secp: &Secp256k1, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + skip_start_blockchain_validation: bool, body_head_override: Option, ) -> Result<(), Error> { let context_id = store.get_context_id(); validate_genesis_context_id(genesis, context_id)?; let mut batch = store.batch_write()?; + if body_head_override.is_none() { + ensure_missing_head_is_fresh(&batch, header_pmmr, txhashset)?; + } let genesis_hash = genesis.hash(context_id)?; validate_genesis_matches_header_pmmr(&genesis_hash, header_pmmr)?; + // Durability policy: header_extending() and extending() sync PMMR files + // before the enclosing LMDB batch below is committed. If initialization + // terminates after either sync, the PMMR data may survive while all staged + // genesis records, including HEAD, are rolled back. We intentionally leave + // that state for explicit operator investigation/cleanup rather than infer + // that a missing HEAD authorizes automatic PMMR deletion or genesis rebuild. + // ensure_missing_head_is_fresh() enforces this fail-closed policy on restart. // Apply the genesis header to header and sync MMRs. { match batch.get_block_header(&genesis_hash) { - Ok(_) => {} + Ok(stored) => { + if stored != genesis.header { + return Err(Error::InvalidPersistedChainState(format!( + "stored genesis header {} does not exactly match configured genesis", + genesis_hash + ))); + } + } Err(NotFoundErr(_)) => { batch.save_block_header(&genesis.header)?; } @@ -4381,22 +6852,47 @@ fn setup_head( // Make sure our header PMMR is consistent with header_head from db if it exists. // If header_head is missing in db then use head of header PMMR. match batch.header_head() { - Ok(head) => { + Ok(stored_head) => { + let (header, head) = canonical_tip_header("HEADER_HEAD", &stored_head, &batch)?; header_pmmr.init_head(&head)?; txhashset::header_extending(header_pmmr, &mut batch, |ext, batch| { - let header = batch.get_block_header(&head.hash(context_id)?)?; - ext.rewind(&header) + ext.rewind(&header)?; + if skip_start_blockchain_validation { + Ok(()) + } else { + ext.validate_persisted_ancestry(&header, batch, pow_verifier) + } })?; + if stored_head != head { + warn!( + "Repairing inconsistent HEADER_HEAD cache: stored {:?}, canonical {:?}", + stored_head, head + ); + batch.save_header_head(&head)?; + } } Err(NotFoundErr(_)) => { let hash = header_pmmr.head_hash()?; let header = batch.get_block_header(&hash)?; - batch.save_header_head(&Tip::try_from_header(&header)?)?; + let head = Tip::try_from_header(&header)?; + if !skip_start_blockchain_validation { + txhashset::header_extending(header_pmmr, &mut batch, |ext, batch| { + ext.validate_persisted_ancestry(&header, batch, pow_verifier) + })?; + } + batch.save_header_head(&head)?; } Err(e) => return Err(Error::StoreErr(e, "chain init load header head".to_owned())), } - if let Some(head) = body_head_override { + if let Some(stored_head) = body_head_override { + let (_, head) = canonical_tip_header("HEAD override", &stored_head, &batch)?; + if stored_head != head { + return Err(Error::InvalidPersistedChainState(format!( + "HEAD override {:?} does not match canonical {:?}", + stored_head, head + ))); + } // Stage intentional HEAD resets in the same batch as the rewind and // validation below. Committing HEAD first can leave durable chain // metadata reset even if setup_head later fails while syncing or @@ -4419,8 +6915,12 @@ fn setup_head( })?; txhashset.init_output_pos_index(&batch, None, None)?; - txhashset.init_recent_kernel_pos_index(header_pmmr, &batch, None, None)?; + txhashset.init_recent_kernel_pos_index(&batch, None, None)?; batch.commit()?; + + // Clear any full kernel_pos entries left above genesis and rebuild the + // complete index before the reset operation reports success. + txhashset.init_kernel_pos_index_chunked(store, None, None)?; return Ok(()); } batch.save_body_head(&head)?; @@ -4428,68 +6928,83 @@ fn setup_head( // check if we have a head in store, otherwise the genesis block is it let head_res = batch.head(); - let mut head: Tip; + let head: Tip; match head_res { - Ok(h) => { - head = h; - loop { + Ok(stored_head) => { + let (_, canonical_head) = canonical_tip_header("HEAD", &stored_head, &batch)?; + head = canonical_head; + if stored_head != head { + warn!( + "Repairing inconsistent HEAD cache: stored {:?}, canonical {:?}", + stored_head, head + ); + batch.save_body_head(&head)?; + } + // Reconcile the PMMRs to the durable HEAD. Unless explicitly disabled, + // validate the selected block and all authenticated chain state exactly + // once; startup does not select an older block and retry after failure. + { // Use current chain tip if we have one. // Note: We are rewinding and validating against a writeable extension. // If validation is successful we will truncate the backend files // to match the provided block header. let header = batch.get_block_header(&head.last_block_h)?; + if !skip_start_blockchain_validation { + crate::checked_block_for_header( + context_id, + &header, + "setup_head durable HEAD preflight", + |hash| batch.get_block(hash), + )?; + } let output_mmr_size_before = txhashset.output_mmr_size(); let kernel_mmr_size_before = txhashset.kernel_mmr_size(); + let body_pmmr_rewind_expected = output_mmr_size_before > header.output_mmr_size + || kernel_mmr_size_before > header.kernel_mmr_size; let res = txhashset::extending(header_pmmr, txhashset, &mut batch, |ext, batch| { - // If we're still downloading via PIBD, don't worry about sums and validations just yet - // We still want to rewind to the last completed block to ensure a consistent state - pipe::rewind_and_apply_fork(store.get_context_id(), &header, ext, batch, secp)?; - let extension = &mut ext.extension; + if skip_start_blockchain_validation { + debug!( + "init: rewound without startup blockchain validation... {} at {}", + header.hash(context_id)?, + header.height, + ); + return Ok(()); + } - extension.validate_roots(&header)?; - extension.validate_sizes(&header)?; - - // now check we have the "block sums" for the block in question - // if we have no sums (migrating an existing node) we need to go - // back to the txhashset and sum the outputs and kernels - if header.height > 0 { - let header_hash = header.hash(context_id)?; - match batch.get_block_sums(&header_hash) { - Ok(_) => {} - Err(NotFoundErr(_)) => { - debug!( - "init: building (missing) block sums for {} @ {}", - header.height, header_hash - ); - - // Do a full (and slow) validation of the txhashset extension - // to calculate the utxo_sum and kernel_sum at this block height. - let (utxo_sum, kernel_sum) = extension.validate_kernel_sums( - &genesis.header, - &header, - None, - None, - secp, - )?; - - // Save the block_sums to the db for use later. - batch.save_block_sums( - &header_hash, - BlockSums::new(utxo_sum, kernel_sum), - )?; - } - Err(e) => { - return Err(Error::StoreErr( - e, - "chain init load block sums".to_owned(), - )); - } - } + let extension = &mut ext.extension; + // Authenticate persisted PMMR data against its hash trees and the + // selected header before trusting or rebuilding any derived cache. + // Fast validation still verifies the MMRs, roots, sizes, leaf-set + // pairing, and kernel sums; it skips only rangeproof and kernel + // signature verification. + let (utxo_sum, kernel_sum) = + extension.validate(&genesis.header, true, None, &header, None, secp)?; + // PMMR roots authenticate append history, but not the exact + // membership of the prunable output/rangeproof leaf sets. For a + // zero-step startup rewind, bind those leaf sets to the independently + // committed output_pos index before accepting the durable HEAD or + // rebuilding derived caches. + // + // If the backend is actually being rewound, output_pos still describes + // the pre-rewind state and is not a valid authentication anchor. The + // success path below marks it incomplete so Chain::init rebuilds it. + if !body_pmmr_rewind_expected { + extension.validate_output_pos_index(batch, &header)?; } + let header_hash = header.hash(context_id)?; + let block_sums = if header.height == 0 { + genesis_block_sums(genesis, context_id, secp)? + } else { + BlockSums::new(utxo_sum, kernel_sum) + }; + // BlockSums is a derived acceleration cache. Replace it from the + // validated txhashset rather than accepting any persisted value. + batch.save_block_sums(&header_hash, block_sums)?; + debug!( "init: rewinding and validating before we start... {} at {}", header.hash(context_id)?, @@ -4518,50 +7033,41 @@ fn setup_head( ); batch.set_kernel_pos_index_complete(false)?; } - if output_mmr_rewound || kernel_mmr_rewound { - batch.set_retained_spent_commitment_index_complete(false)?; - } - break; } Err(e) => { - if !is_recoverable_mmr_corruption(&e) { - return Err(e); - } - - // We may have corrupted the MMR backend files last time we stopped the - // node. If this happens we rewind to the previous header, - // delete the "bad" block and try again. - let prev_header = batch.get_block_header(&head.prev_block_h)?; - - warn!( - "Corrupted MMR: {}. Trying to recover it by rewinding blocks to height {}", - e, prev_header.height - ); - - txhashset::extending(header_pmmr, txhashset, &mut batch, |ext, batch| { - pipe::rewind_and_apply_fork( - store.get_context_id(), - &prev_header, - ext, - batch, - secp, - ) - })?; - - // Now "undo" the latest block and forget it ever existed. - // We will request it from a peer during sync as necessary. - { - batch.delete_block(&header.hash(context_id)?)?; - head = Tip::try_from_header(&prev_header)?; - batch.save_body_head(&head)?; + if matches!( + &e, + Error::PMMRErr(mwc_core::core::pmmr::Error::DataCorruption(_)) + ) { + // The durable HEAD is operator-visible chain state. Do not + // silently select its parent and delete the corresponding block + // merely because local PMMR validation failed. The extension has + // already discarded its tentative changes; return the original + // corruption error and leave HEAD untouched so the operator can + // investigate and explicitly clean or reset the node data. + error!( + "PMMR corruption while validating durable HEAD {} at height {}: {}. Automatic block rollback is disabled; inspect the chain data and explicitly clean or reset it before restarting", + head.last_block_h, head.height, e + ); } + return Err(e); } } } } Err(NotFoundErr(_)) => { + // Normal startup reaches this branch only after + // ensure_missing_head_is_fresh() proved that no prior chain state exists. + // A missing HEAD in a used store is reported above instead of being + // converted into an implicit reset. Explicit PIBD/reset callers supply an + // override and intentionally retain their existing behavior. // Save the genesis header with a "zero" header_root. // We will update this later once we have the correct header_root. + // Keep every derived index explicitly non-authoritative until the + // post-setup initialization below has rebuilt and verified it. + batch.set_output_pos_index_complete(false)?; + batch.set_kernel_pos_index_complete(false)?; + batch.set_spent_commitment_record_index_complete(false)?; save_genesis_block_metadata(genesis, &genesis_hash, &batch, secp, context_id)?; batch.save_body_head(&Tip::try_from_header(&genesis.header)?)?; @@ -4585,8 +7091,18 @@ fn setup_head( #[cfg(test)] mod tests { use super::*; - use mwc_core::core::OutputFeatures; + use crate::tests::chain_test_helper::{clean_output_dir, genesis_block, mine_chain}; + use crate::types::SpentOutput; + use mwc_core::core::pmmr::Backend; + use mwc_core::core::{CommitWrapper, Input, OutputFeatures}; + use mwc_core::libtx::{aggsig, proof::ProofBuilder, reward as reward_builder}; + use mwc_core::ser::PMMRIndexHashable; + use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::constants::MAX_PROOF_SIZE; + use mwc_crates::secp::SecretKey; + use mwc_keychain::{ExtKeychain, Keychain}; + use std::fs::OpenOptions; + use std::io::{Seek, SeekFrom, Write}; fn reject_pow(_: u32, _: &BlockHeader) -> Result<(), pow::Error> { Err(pow::Error::Verification( @@ -4594,6 +7110,16 @@ mod tests { )) } + fn reject_non_genesis_pow(_: u32, header: &BlockHeader) -> Result<(), pow::Error> { + if header.height == 0 { + Ok(()) + } else { + Err(pow::Error::Verification( + "forced non-genesis PoW failure".into(), + )) + } + } + fn test_output_identifier() -> OutputIdentifier { let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let commit = secp.commit_value(1).unwrap(); @@ -4607,145 +7133,4283 @@ mod tests { } } - fn retained_test_block(height: u64, prev_hash: Hash, proof_nonce: u64) -> Block { - let mut block = Block::default(0); - block.header.height = height; - block.header.prev_hash = prev_hash; - if let Some(last_nonce) = block.header.pow.proof.nonces.last_mut() { - *last_nonce = proof_nonce; - } - block + fn test_input(features: OutputFeatures) -> Input { + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let commit = secp.commit_value(1).unwrap(); + Input::new(features, commit) } - fn save_retained_test_blocks( - store: &ChainStore, - head: &Block, - tail: &Block, - blocks: &[&Block], - ) { - let batch = store.batch_write().unwrap(); - for block in blocks { - batch.save_block_header(&block.header).unwrap(); - batch.save_block(block).unwrap(); + #[derive(Default)] + struct RecordingAdapter { + accepted: RwLock>, + rejected: RwLock)>>, + } + + impl ChainAdapter for RecordingAdapter { + fn block_accepted( + &self, + _secp: &mut Secp256k1, + block: &Block, + status: BlockStatus, + _opts: Options, + ) { + let context_id = block.header.pow.proof.context_id; + self.accepted + .write() + .push((block.hash(context_id).unwrap(), status)); } - batch - .save_body_head(&Tip::try_from_header(&head.header).unwrap()) + + fn block_rejected(&self, hash: &Hash, source_peers: &HashSet, _err: &Error) { + self.rejected + .write() + .push((hash.clone(), source_peers.clone())); + } + } + + fn init_chain_with_recording_adapter( + chain_dir: &str, + secp: &Secp256k1, + genesis: Block, + adapter: Arc, + ) -> Chain { + Chain::init( + secp, + 0, + chain_dir.to_owned(), + adapter, + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap() + } + + #[test] + fn startup_validates_persisted_pow_unless_explicitly_skipped() { + let chain_dir = format!("target/startup_pow_validation_{}", std::process::id()); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 2); + let genesis = chain.genesis.clone(); + let expected_head = chain.head().unwrap(); + drop(chain); + + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis.clone(), + reject_non_genesis_pow, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("startup accepted persisted PoW rejected by its verifier"), + Err(err) => err, + }; + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("persisted header at height 1 failed PoW validation") + )); + + let restarted = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + reject_non_genesis_pow, + false, + HashSet::new(), + None, + None, + true, + ) + .unwrap(); + assert_eq!(restarted.head().unwrap(), expected_head); + + drop(restarted); + clean_output_dir(&chain_dir); + } + + fn assert_committed_recovery_failure(err: &Error, expected_context: &str) { + assert!(!err.is_bad_data(), "{:?}", err); + assert!(err.requires_chain_recovery(), "{:?}", err); + assert!(matches!( + err, + Error::CommittedRecoveryFailed { context, source } + if context == expected_context && matches!(source.as_ref(), Error::InvalidRoot(_)) + )); + } + + #[test] + fn output_read_snapshot_holds_chain_state_and_returns_matching_proof() { + let chain_dir = format!("target/output_read_snapshot_{}", std::process::id()); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + + let result: Result<(), Error> = chain.with_output_read_snapshot(|snapshot| { + assert!(chain.header_pmmr.try_write().is_none()); + assert!(chain.txhashset.try_write().is_none()); + + let header = snapshot.get_header_by_height(0)?; + let header_hash = header.hash(snapshot.get_context_id())?; + assert_eq!(snapshot.get_block_header(&header_hash)?, header); + let block = snapshot.get_block_for_header(&header)?; + let output = block.outputs().first().expect("genesis output"); + let (last_index, highest_index, outputs) = + snapshot.unspent_outputs_by_pmmr_index(1, 10_000, None)?; + assert_eq!(last_index, header.output_mmr_size); + assert_eq!(highest_index, header.output_mmr_size); + assert!(outputs.contains(output)); + let (stored_output, stored_pos) = snapshot + .get_unspent_output_position(output.commitment())? + .expect("unspent genesis output position"); + assert!(ser::hashes_equal( + snapshot.get_context_id(), + &stored_output, + &output.identifier() + )?); + let (legacy_pos0, legacy_proof) = + snapshot.get_output_pos_and_merkle_proof(output.commitment())?; + assert_eq!( + legacy_pos0, + stored_pos.pos.checked_sub(1).expect("one-based position") + ); + assert_eq!(legacy_proof.mmr_size, header.output_mmr_size); + let (pos, proof) = snapshot.get_output_status(&output.identifier(), true)?; + let pos0 = pos + .expect("unspent genesis output") + .pos + .checked_sub(1) + .expect("one-based output position"); + let proof = proof.expect("coinbase merkle proof"); + assert_eq!(proof.mmr_size, header.output_mmr_size); + proof + .verify( + snapshot.get_context_id(), + header.output_root, + &output.identifier(), + pos0, + ) + .unwrap(); + Ok(()) + }); + result.unwrap(); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn output_read_snapshot_uses_held_batch_for_output_position() { + let chain_dir = format!( + "target/output_read_snapshot_position_batch_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + let commit = chain.genesis.outputs()[0].commitment(); + let expected_pos = chain + .store + .get_output_pos_height(&commit) + .unwrap() + .expect("genesis output position"); + + let writer_store = chain.store.clone(); + let result: Result<(), Error> = chain.with_output_read_snapshot(|snapshot| { + // Model a cache-only repair committing after this snapshot's LMDB read + // transaction began. PMMR read locks intentionally do not block it. + let writer_store = writer_store.clone(); + std::thread::spawn(move || { + let batch = writer_store.batch_write().unwrap(); + batch + .save_output_pos_height( + &commit, + CommitPos { + pos: 0, + height: expected_pos.height, + }, + ) + .unwrap(); + batch.commit().unwrap(); + }) + .join() + .unwrap(); + + let (_, observed_pos) = snapshot + .get_unspent_output_position(commit)? + .expect("snapshot retains its original output position"); + assert_eq!(observed_pos, expected_pos); + Ok(()) + }); + + // Restore the cache so normal Chain teardown sees a valid index even if + // the assertion above failed by returning an error. + let batch = chain.store.batch_write().unwrap(); + batch.save_output_pos_height(&commit, expected_pos).unwrap(); + batch.commit().unwrap(); + result.unwrap(); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn output_read_snapshot_rejects_header_pmmr_hash_data_split() { + let chain_dir = format!( + "target/output_read_snapshot_header_split_{}", + std::process::id() + ); + let corrupt_header_dir = format!("{}_corrupt_header", chain_dir); + clean_output_dir(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + let chain = mine_chain(&chain_dir, 1); + + let canonical = chain.genesis.header.clone(); + let redirected = retained_test_block(0, canonical.prev_hash, 1).header; + let redirected_hash = redirected.hash(0).unwrap(); + assert_ne!(redirected_hash, canonical.hash(0).unwrap()); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&redirected).unwrap(); + batch.commit().unwrap(); + } + + // The data file redirects height zero to `redirected`, while the hash + // file retains the indexed leaf hash for the canonical header. + let mut corrupt_header_pmmr = recovery_header_pmmr(&corrupt_header_dir); + let canonical_leaf_hash = canonical.hash_with_index(0, 0).unwrap(); + corrupt_header_pmmr + .backend + .append(&redirected, &[canonical_leaf_hash]) + .unwrap(); + corrupt_header_pmmr.backend.sync().unwrap(); + corrupt_header_pmmr.size = 1; + assert_eq!( + corrupt_header_pmmr.get_header_hash_by_height(0).unwrap(), + redirected_hash + ); + + let txhashset = chain.txhashset.read(); + let batch = chain.store.batch_read().unwrap(); + let snapshot = OutputReadSnapshot { + chain: &chain, + header_pmmr: &corrupt_header_pmmr, + txhashset: &txhashset, + batch, + }; + let err = snapshot.get_header_by_height(0).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not authenticate loaded header") + )); + + drop(snapshot); + drop(txhashset); + drop(corrupt_header_pmmr); + drop(chain); + clean_output_dir(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + } + + #[test] + fn get_block_for_header_rejects_same_hash_header_mismatch() { + let chain_dir = format!("target/get_block_for_header_{}", std::process::id()); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + + let original = chain.get_header_by_height(0).unwrap(); + let mut altered = original.clone(); + altered.prev_root = Hash::from_vec(&[42; Hash::LEN]); + assert_ne!(altered, original); + assert_eq!(altered.hash(0).unwrap(), original.hash(0).unwrap()); + + let err = chain.get_block_for_header(&altered).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not exactly match the requested header") + )); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn get_block_for_header_rejects_block_without_stored_header() { + let chain_dir = format!( + "target/get_block_for_header_missing_header_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + + let header = chain.head_header().unwrap(); + let hash = header.hash(chain.get_context_id()).unwrap(); + assert!(chain.store.get_block(&hash).is_ok()); + { + let batch = chain.store.batch_write().unwrap(); + batch.delete_block_header(&hash).unwrap(); + batch.commit().unwrap(); + } + assert!(chain.store.get_block(&hash).is_ok()); + + let err = chain.get_block_for_header(&header).unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(NotFoundErr(_), context) + if context.contains("chain get block for header load header") + )); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn single_block_marker_clear_failure_preserves_acceptance() { + let source_dir = format!( + "target/single_block_marker_clear_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/single_block_marker_clear_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 2); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let header = source.get_header_by_height(1).unwrap(); + let block_hash = header.hash(0).unwrap(); + let block = source.get_block_for_header(&header).unwrap(); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + + chain + .fail_next_process_block_marker_clear + .store(true, Ordering::SeqCst); + let tip = chain + .process_block(&mut secp, block.clone(), Options::SKIP_POW, HashSet::new()) + .unwrap() + .unwrap(); + + assert_eq!(tip.height, 1); + assert_eq!(tip.last_block_h, block_hash); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + let accepted = adapter.accepted.read_recursive(); + assert_eq!(accepted.len(), 1); + assert_eq!(accepted[0].0, block_hash); + assert!(accepted[0].1.is_next()); + drop(accepted); + + let retry = chain.process_block(&mut secp, block, Options::SKIP_POW, HashSet::new()); + assert!(matches!(retry, Err(ref e) if e.is_known_block())); + assert_eq!(adapter.accepted.read_recursive().len(), 1); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn old_known_block_does_not_reject_source_peer() { + let chain_dir = format!( + "target/old_known_block_no_peer_rejection_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + + // A height-1 block is classified as OldBlock once the head reaches 52. + let source = mine_chain(&chain_dir, 53); + let genesis = source.genesis.clone(); + let old_header = source.get_header_by_height(1).unwrap(); + let old_block = source.get_block_for_header(&old_header).unwrap(); + assert_eq!(source.head().unwrap().height, 52); + drop(source); + + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter(&chain_dir, &secp, genesis, adapter.clone()); + let err = chain + .process_block( + &mut secp, + old_block, + Options::SKIP_POW, + std::iter::once("honest-source-peer".to_owned()).collect(), + ) + .unwrap_err(); + + assert!(matches!(err, Error::OldBlock)); + assert!(err.is_bad_data()); + assert!(err.is_known_block()); + assert!(adapter.rejected.read_recursive().is_empty()); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn known_block_with_valid_alternate_kernel_signature_rejects_source_peer() { + let chain_dir = format!( + "target/known_block_alternate_signature_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = genesis_block(&mut secp, &keychain); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter(&chain_dir, &secp, genesis, adapter.clone()); + + let prev = chain.head_header().unwrap(); + let key_id = ExtKeychain::derive_key_id(1, 2, 0, 0, 0).unwrap(); + let reward = reward_builder::output( + 0, + &keychain, + &ProofBuilder::new(&secp, &keychain).unwrap(), + &key_id, + 0, + false, + 1, + &mut secp, + ) + .unwrap(); + let mut stored = Block::new( + 0, + &prev, + &[], + mwc_core::pow::Difficulty::min(), + reward, + &secp, + ) + .unwrap(); + stored.header.timestamp = prev.timestamp + mwc_crates::chrono::Duration::seconds(60); + chain.set_txhashset_roots(&secp, &mut stored).unwrap(); + let block_hash = stored.hash(0).unwrap(); + chain + .process_block(&mut secp, stored.clone(), Options::SKIP_POW, HashSet::new()) + .unwrap(); + + // Re-sign the same valid coinbase kernel with a different nonce. Internal + // block cryptography still validates, but the new signature changes the + // kernel MMR leaf and therefore cannot match the stored header root. + let mut candidate = stored.clone(); + let kernel = candidate + .body + .kernels + .first_mut() + .expect("reward block has one kernel"); + let original_signature = kernel.excess_sig; + let message = kernel.msg_to_sign(0).unwrap(); + let public_key = kernel.excess.to_pubkey(&secp).unwrap(); + let alternate_nonce = SecretKey::from_slice(&secp, &[2; 32]).unwrap(); + kernel.excess_sig = aggsig::sign_from_key_id( + &secp, + &keychain, + &message, + consensus::reward(0, 0, 1).unwrap(), + &key_id, + Some(&alternate_nonce), + &public_key, + ) + .unwrap(); + assert_ne!(kernel.excess_sig, original_signature); + kernel.verify(0, &secp).unwrap(); + candidate + .validate(0, &prev.total_kernel_offset, &mut secp) + .unwrap(); + assert_eq!(candidate.hash(0).unwrap(), block_hash); + + let source_peers: HashSet<_> = + std::iter::once("alternate-signature-peer".to_owned()).collect(); + let err = chain + .process_block( + &mut secp, + candidate, + Options::SKIP_POW, + source_peers.clone(), + ) + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidRoot(msg) if msg.contains("header-committed body conflicts")), + "{:?}", + err + ); + assert!(err.is_bad_data()); + assert!(!err.is_known_block()); + + let rejected = adapter.rejected.read_recursive(); + assert_eq!(rejected.as_slice(), &[(block_hash, source_peers)]); + drop(rejected); + + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn committed_single_block_recovery_failure_stays_non_bad_data() { + let source_dir = format!( + "target/committed_single_recovery_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/committed_single_recovery_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 2); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let header = source.get_header_by_height(1).unwrap(); + let block_hash = header.hash(0).unwrap(); + let block = source.get_block_for_header(&header).unwrap(); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + + chain + .fail_next_process_block_marker_clear + .store(true, Ordering::SeqCst); + chain + .fail_next_committed_recovery_with_bad_data + .store(true, Ordering::SeqCst); + let err = chain + .process_block( + &mut secp, + block, + Options::SKIP_POW, + std::iter::once("single-source-peer".to_owned()).collect(), + ) + .unwrap_err(); + + assert_committed_recovery_failure(&err, "process_block_single committed marker cleanup"); + assert_eq!(chain.store.head().unwrap().last_block_h, block_hash); + assert!(chain.store.pending_chain_operation().unwrap().is_some()); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(adapter.accepted.read_recursive().is_empty()); + assert!(adapter.rejected.read_recursive().is_empty()); + + chain.ensure_chain_robust().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn committed_header_recovery_failure_does_not_reject_source_peer() { + let source_dir = format!( + "target/committed_header_recovery_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/committed_header_recovery_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 2); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let header = source.get_header_by_height(1).unwrap(); + let block_hash = header.hash(0).unwrap(); + let block = source.get_block_for_header(&header).unwrap(); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + + chain + .fail_next_process_block_header_marker_clear + .store(true, Ordering::SeqCst); + chain + .fail_next_committed_recovery_with_bad_data + .store(true, Ordering::SeqCst); + let err = chain + .process_block( + &mut secp, + block, + Options::SKIP_POW, + std::iter::once("header-source-peer".to_owned()).collect(), + ) + .unwrap_err(); + + assert_committed_recovery_failure(&err, "process_block_header committed marker cleanup"); + assert_eq!(chain.store.header_head().unwrap().last_block_h, block_hash); + assert_eq!(chain.store.head().unwrap().height, 0); + assert!(chain.store.pending_chain_operation().unwrap().is_some()); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(adapter.accepted.read_recursive().is_empty()); + assert!(adapter.rejected.read_recursive().is_empty()); + + chain.ensure_chain_robust().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn orphan_header_committed_recovery_failure_is_propagated() { + let source_dir = format!( + "target/orphan_header_recovery_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/orphan_header_recovery_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 3); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let header_one = source.get_header_by_height(1).unwrap(); + let header_two = source.get_header_by_height(2).unwrap(); + let block_one = source.get_block_for_header(&header_one).unwrap(); + let block_two = source.get_block_for_header(&header_two).unwrap(); + let block_two_hash = block_two.hash(0).unwrap(); + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + + // Cache block two while its full parent is unavailable. Its header is + // already known, as required by the orphan admission path. + chain + .process_block_header(&header_one, Options::SKIP_POW) + .unwrap(); + chain + .process_block_header(&header_two, Options::SKIP_POW) + .unwrap(); + let orphan_result = chain.process_block( + &mut secp, + block_two, + Options::SKIP_POW, + std::iter::once("orphan-source-peer".to_owned()).collect(), + ); + assert!(matches!(orphan_result, Err(Error::Orphan(_)))); + assert!(chain.is_orphan(&block_two_hash)); + + // Rewind the header chain, then restore only header one. Retrying the + // orphan must now reapply and commit header two. + chain + .reset_chain_head(&secp, &source.genesis.header, true) + .unwrap(); + chain + .process_block_header(&header_one, Options::SKIP_POW) + .unwrap(); + + chain + .fail_next_process_block_header_marker_clear + .store(true, Ordering::SeqCst); + chain + .fail_next_committed_recovery_with_bad_data + .store(true, Ordering::SeqCst); + let err = chain + .process_block(&mut secp, block_one, Options::SKIP_POW, HashSet::new()) + .unwrap_err(); + + assert_committed_recovery_failure(&err, "process_block_header committed marker cleanup"); + assert_eq!(chain.store.head().unwrap().height, 1); + assert_eq!( + chain.store.header_head().unwrap().last_block_h, + block_two_hash + ); + assert!(chain.store.pending_chain_operation().unwrap().is_some()); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(!chain.is_orphan(&block_two_hash)); + assert_eq!(adapter.accepted.read_recursive().len(), 1); + assert!(adapter.rejected.read_recursive().is_empty()); + + chain.ensure_chain_robust().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn block_batch_marker_clear_failure_notifies_and_cleans_orphans() { + let source_dir = format!( + "target/block_batch_marker_clear_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/block_batch_marker_clear_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 4); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut source_blocks = Vec::new(); + for height in 1..=3 { + let header = source.get_header_by_height(height).unwrap(); + source_blocks.push(source.get_block_for_header(&header).unwrap()); + } + let block_one = source_blocks[0].clone(); + let block_two = source_blocks[1].clone(); + let block_one_hash = block_one.hash(0).unwrap(); + let block_two_hash = block_two.hash(0).unwrap(); + + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + for block in &source_blocks { + chain + .process_block_header(&block.header, Options::SKIP_POW) + .unwrap(); + } + + let orphan_result = chain.process_block( + &mut secp, + block_two.clone(), + Options::SKIP_POW, + HashSet::new(), + ); + assert!(matches!(orphan_result, Err(Error::Orphan(_)))); + assert!(chain.is_orphan(&block_two_hash)); + assert!(!chain.store.block_exists(&block_two_hash).unwrap()); + assert!(chain + .store + .batch_read() + .unwrap() + .get_spent_index(&block_two_hash) + .is_err()); + + // Exercise the production batch branch with a short chain. The production + // default remains 100; only this Chain instance uses a zero-depth guard. + chain + .process_block_batch_safety_depth + .store(0, Ordering::SeqCst); + chain + .fail_next_process_block_marker_clear + .store(true, Ordering::SeqCst); + let tip = chain + .process_block( + &mut secp, + block_one.clone(), + Options::SKIP_POW, + HashSet::new(), + ) + .unwrap() + .unwrap(); + + assert_eq!(tip.height, 2); + assert_eq!(tip.last_block_h, block_two_hash); + assert!(!chain.is_orphan(&block_two_hash)); + assert!(chain.store.block_exists(&block_two_hash).unwrap()); + assert!(chain + .store + .batch_read() + .unwrap() + .get_spent_index(&block_two_hash) + .is_ok()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + let accepted = adapter.accepted.read_recursive(); + assert_eq!(accepted.len(), 2); + assert_eq!(accepted[0].0, block_one_hash); + assert_eq!(accepted[1].0, block_two_hash); + assert!(accepted.iter().all(|(_, status)| status.is_next())); + drop(accepted); + + let retry = chain.process_block(&mut secp, block_one, Options::SKIP_POW, HashSet::new()); + assert!(matches!(retry, Err(ref e) if e.is_known_block())); + assert_eq!(adapter.accepted.read_recursive().len(), 2); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn committed_block_batch_recovery_failure_stays_non_bad_data() { + let source_dir = format!( + "target/committed_batch_recovery_source_{}", + std::process::id() + ); + let target_dir = format!( + "target/committed_batch_recovery_target_{}", + std::process::id() + ); + clean_output_dir(&source_dir); + clean_output_dir(&target_dir); + + let source = mine_chain(&source_dir, 4); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut source_blocks = Vec::new(); + for height in 1..=3 { + let header = source.get_header_by_height(height).unwrap(); + source_blocks.push(source.get_block_for_header(&header).unwrap()); + } + let block_one = source_blocks[0].clone(); + let block_two = source_blocks[1].clone(); + let block_two_hash = block_two.hash(0).unwrap(); + + let adapter = Arc::new(RecordingAdapter::default()); + let chain = init_chain_with_recording_adapter( + &target_dir, + &secp, + source.genesis.clone(), + adapter.clone(), + ); + for block in &source_blocks { + chain + .process_block_header(&block.header, Options::SKIP_POW) + .unwrap(); + } + + let orphan_result = + chain.process_block(&mut secp, block_two, Options::SKIP_POW, HashSet::new()); + assert!(matches!(orphan_result, Err(Error::Orphan(_)))); + + chain + .process_block_batch_safety_depth + .store(0, Ordering::SeqCst); + chain + .fail_next_process_block_marker_clear + .store(true, Ordering::SeqCst); + chain + .fail_next_committed_recovery_with_bad_data + .store(true, Ordering::SeqCst); + let err = chain + .process_block( + &mut secp, + block_one, + Options::SKIP_POW, + std::iter::once("batch-source-peer".to_owned()).collect(), + ) + .unwrap_err(); + + assert_committed_recovery_failure(&err, "process_block_multiple committed marker cleanup"); + assert_eq!(chain.store.head().unwrap().last_block_h, block_two_hash); + assert!(chain.store.pending_chain_operation().unwrap().is_some()); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(adapter.accepted.read_recursive().is_empty()); + assert!(adapter.rejected.read_recursive().is_empty()); + + chain.ensure_chain_robust().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + drop(source); + clean_output_dir(&target_dir); + clean_output_dir(&source_dir); + } + + #[test] + fn block_comparisons_keep_untrusted_features_but_normalize_known_blocks() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + let mut plain = Block::default(0); + plain.body.inputs = Inputs::FeaturesAndCommit(vec![test_input(OutputFeatures::Plain)]); + + let mut coinbase = plain.clone(); + coinbase.body.inputs = + Inputs::FeaturesAndCommit(vec![test_input(OutputFeatures::Coinbase)]); + + // Protocol v3+ full-data serialization normalizes both inputs to the + // same commit-only representation, despite their different features. + assert_eq!( + ser::ser_vec(0, &plain, ProtocolVersion::local()).unwrap(), + ser::ser_vec(0, &coinbase, ProtocolVersion::local()).unwrap() + ); + assert!(blocks_equal_as_v3(0, &plain, &coinbase).unwrap()); + + let mut commit_only = plain.clone(); + commit_only.body.inputs = Inputs::CommitOnly(vec![CommitWrapper::from( + test_input(OutputFeatures::Plain).commitment(), + )]); + assert_eq!( + ser::ser_vec(0, &plain, ProtocolVersion::local()).unwrap(), + ser::ser_vec(0, &commit_only, ProtocolVersion::local()).unwrap() + ); + assert!(blocks_equal_as_v3(0, &plain, &commit_only).unwrap()); + + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut different_commit = commit_only.clone(); + different_commit.body.inputs = + Inputs::CommitOnly(vec![CommitWrapper::from(secp.commit_value(2).unwrap())]); + assert!(!blocks_equal_as_v3(0, &commit_only, &different_commit).unwrap()); + + let mut first_proof = test_rangeproof(); + first_proof.plen = 1; + first_proof.proof[0] = 1; + let mut second_proof = first_proof; + second_proof.proof[0] = 2; + let mut first_output = Block::default(0); + first_output.body.outputs.push(Output { + identifier: test_output_identifier(), + proof: first_proof, + }); + let mut second_output = first_output.clone(); + second_output.body.outputs[0].proof = second_proof; + assert!(!blocks_equal_as_v3(0, &first_output, &second_output).unwrap()); + + let mut different_header = plain.clone(); + different_header.header.height = different_header.header.height.saturating_add(1); + assert_eq!(different_header.hash(0).unwrap(), plain.hash(0).unwrap()); + assert!(!blocks_equal_as_v3(0, &plain, &different_header).unwrap()); + } + + #[test] + fn exact_known_header_readonly_check_is_collision_and_work_safe() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/exact_known_header_readonly_check_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + + let mut header = BlockHeader::default(0); + header.pow.total_difficulty = mwc_core::pow::Difficulty::from_num(10); + let mut header_head = Tip::try_from_header(&header).unwrap(); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&header).unwrap(); + batch.save_header_head(&header_head).unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + assert!(pipe::is_exact_known_header(0, &header, &header_head, &batch).unwrap()); + + let mut collision = header.clone(); + collision.height = collision.height.saturating_add(1); + assert_eq!(collision.hash(0).unwrap(), header.hash(0).unwrap()); + assert!(matches!( + pipe::is_exact_known_header(0, &collision, &header_head, &batch), + Err(Error::Block(mwc_core::core::block::Error::Other(ref msg))) + if msg == "known header hash matches a different header" + )); + } + + // A stored header above the current header head must go through the + // locked pipeline so it can be reapplied after a reset. + header_head.total_difficulty = mwc_core::pow::Difficulty::from_num(9); + { + let batch = store.batch_read().unwrap(); + assert!(!pipe::is_exact_known_header(0, &header, &header_head, &batch).unwrap()); + } + + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn denylisted_known_header_fast_paths_return_invalid_hash() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/denylisted_known_header_fast_paths_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let context_id = chain.get_context_id(); + + // Persist a full block under a distinct proof-derived hash without making + // it current. Both the read-only Chain shortcut and pipe's known-store + // shortcut would previously accept this exact header without consulting + // the denylist. + let genesis_header = chain.genesis(); + let genesis_hash = genesis_header.hash(context_id).unwrap(); + let mut known_block = chain.get_block_for_header(&genesis_header).unwrap(); + let last_nonce = known_block + .header + .pow + .proof + .nonces + .last_mut() + .expect("automated-test genesis proof has at least one nonce"); + *last_nonce = (*last_nonce).wrapping_add(1); + let known_hash = known_block.hash(context_id).unwrap(); + assert_ne!(known_hash, genesis_hash); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&known_block.header).unwrap(); + batch.save_block(&known_block).unwrap(); + batch.commit().unwrap(); + } + + let mut denied = HashSet::new(); + denied.insert(known_hash); + pipe::init_invalid_block_hashes(context_id, denied); + + let chain_err = chain + .process_block_header(&known_block.header, Options::SKIP_POW) + .unwrap_err(); + assert!(matches!(chain_err, Error::InvalidHash)); + assert!(chain_err.is_bad_data()); + + let pipe_err = { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let batch = chain.store.batch_write().unwrap(); + let mut ctx = chain + .new_ctx(Options::SKIP_POW, batch, &mut header_pmmr, &mut txhashset) + .unwrap(); + let mut state_may_have_changed = false; + let err = pipe::process_block_header( + context_id, + &known_block.header, + &mut ctx, + &mut state_may_have_changed, + ) + .unwrap_err(); + assert!(!state_may_have_changed); + err + }; + assert!(matches!(pipe_err, Error::InvalidHash)); + + pipe::release_context_data(context_id); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn pending_chain_operation_guard_marks_recovery_during_unwind() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/pending_chain_operation_unwind_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _header_pmmr = chain.header_pmmr.write(); + let _txhashset = chain.txhashset.write(); + let _marker_guard = chain.set_pending_chain_operation_checked(&op).unwrap(); + panic!("forced unwind after installing pending-operation marker"); + })); + + assert!(result.is_err()); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert_eq!(chain.store.pending_chain_operation().unwrap(), Some(op)); + + chain.ensure_chain_robust().unwrap(); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn unreadable_readonly_pmmr_marker_latches_recovery() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/unreadable_readonly_pmmr_marker_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + + // Corrupt the durable marker version so reading PendingChainOperation fails. + let batch = chain.store.batch_write().unwrap(); + batch + .db + .put(&mwc_store::to_key(b'O', "last_chain_operation"), &[u8::MAX]) + .unwrap(); + batch.commit().unwrap(); + + let mut operation_ran = false; + { + let _header_pmmr = chain.header_pmmr.write(); + let _txhashset = chain.txhashset.write(); + let result = + chain.with_locked_readonly_pmmr_discard_marker("unreadable_marker_test", || { + operation_ran = true; + Ok(()) + }); + assert!(result.is_err()); + } + + assert!(!operation_ran); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().is_err()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn existing_marker_aborts_readonly_pmmr_operation() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/existing_readonly_pmmr_marker_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let stale_op = + prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&stale_op).unwrap(); + + let mut operation_ran = false; + { + let _header_pmmr = chain.header_pmmr.write(); + let _txhashset = chain.txhashset.write(); + let result = + chain.with_locked_readonly_pmmr_discard_marker("existing_marker_test", || { + operation_ran = true; + Ok(()) + }); + assert!(matches!( + result, + Err(Error::Other(ref msg)) + if msg == "pending chain operation requires chain init recovery" + )); + } + + assert!(!operation_ran); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert_eq!( + chain.store.pending_chain_operation().unwrap(), + Some(stale_op) + ); + + chain.ensure_chain_robust().unwrap(); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn init_segmenter_rechecks_recovery_after_acquiring_pmmr_locks() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/init_segmenter_rechecks_recovery_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + + let result = chain.init_segmenter(&chain.genesis()); + if let Err(e) = result { + panic!("segmenter initialization failed after recovery: {}", e); + } + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn segmenter_archive_height_uses_header_selected_by_head() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!( + "target/segmenter_canonical_head_height_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + + // Put the canonical head exactly on an archive-period transition. Moving + // only the redundant cached height down by one then selects the preceding + // archive period if a caller trusts Tip.height directly. + let head_height = + u64::from(global::state_sync_threshold(0)) + global::txhashset_archive_interval(0); + let chain = mine_chain(&chain_dir, head_height + 1); + let canonical_head = chain.store.head().unwrap(); + assert_eq!(canonical_head.height, head_height); + let canonical_archive_height = Chain::height_2_archive_height(0, canonical_head.height); + + let mut stale_head = canonical_head; + stale_head.height = stale_head.height.checked_sub(1).unwrap(); + let stale_archive_height = Chain::height_2_archive_height(0, stale_head.height); + assert_ne!(stale_archive_height, canonical_archive_height); + let stale_archive_header = chain.get_header_by_height(stale_archive_height).unwrap(); + let canonical_archive_header = chain + .get_header_by_height(canonical_archive_height) + .unwrap(); + + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_head(&stale_head).unwrap(); + batch.commit().unwrap(); + } + assert_eq!(chain.head().unwrap(), stale_head); + + let err = match chain.init_segmenter(&stale_archive_header) { + Ok(_) => panic!("stale archive header unexpectedly initialized a segmenter"), + Err(err) => err, + }; + assert!(matches!( + err, + Error::ChainInSyncing(ref msg) + if msg.contains(&format!("current archive height {}", canonical_archive_height)) + )); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + // The public selector must also use the canonicalized height so callers + // propose the candidate that init_segmenter will accept. + assert_eq!( + chain.txhashset_archive_header().unwrap(), + canonical_archive_header + ); + let segmenter = chain.segmenter().unwrap(); + assert_eq!(segmenter.header(), &canonical_archive_header); + assert!(segmenter.is_current()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn readonly_pmmr_helper_rejects_latched_recovery() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/readonly_pmmr_helper_rejects_recovery_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + + let mut operation_ran = false; + { + let _header_pmmr = chain.header_pmmr.write(); + let _txhashset = chain.txhashset.write(); + let result = + chain.with_locked_readonly_pmmr_discard_marker("latched_recovery_test", || { + operation_ran = true; + Ok(()) + }); + assert!(matches!( + result, + Err(Error::Other(ref msg)) + if msg == "pending chain operation requires chain init recovery" + )); + } + + assert!(!operation_ran); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + chain.ensure_chain_robust().unwrap(); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn readonly_pmmr_success_is_rejected_if_recovery_latches_during_operation() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/readonly_pmmr_success_recovery_gate_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + + let result = { + let _header_pmmr = chain.header_pmmr.write(); + let _txhashset = chain.txhashset.write(); + chain.with_locked_readonly_pmmr_discard_marker("success_recovery_gate_test", || { + chain.requires_init_recovery.store(true, Ordering::SeqCst); + Ok(42_u64) + }) + }; + + assert!(matches!( + result, + Err(Error::Other(ref msg)) + if msg == "pending chain operation requires chain init recovery" + )); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + let marker = chain.store.pending_chain_operation().unwrap().unwrap(); + assert_eq!(marker.kind(), ChainOperationKind::ReadonlyPmmrDiscard); + + chain.ensure_chain_robust().unwrap(); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn recovery_invalidates_cached_and_cloned_segmenters() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/recovery_invalidates_segmenters_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + + let old_segmenter = chain.segmenter().unwrap(); + assert!(old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_ok()); + let generation_before = chain.pibd_state_generation.load(Ordering::SeqCst); + + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + + assert!(!old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_err()); + chain.ensure_chain_robust().unwrap(); + assert_eq!( + chain.pibd_state_generation.load(Ordering::SeqCst), + generation_before + 1 + ); + assert!(!old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_err()); + + let fresh_segmenter = chain.segmenter().unwrap(); + assert!(fresh_segmenter.is_current()); + assert!(fresh_segmenter.bitmap_root().is_ok()); + assert!(chain + .pibd_segmenter + .read_recursive() + .as_ref() + .unwrap() + .is_current()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn pibd_state_generation_exhaustion_fails_closed() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/pibd_state_generation_exhaustion_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let old_segmenter = chain.segmenter().unwrap(); + assert!(old_segmenter.is_current()); + + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + chain + .pibd_state_generation + .store(u64::MAX, Ordering::SeqCst); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + + let err = chain.ensure_chain_robust().unwrap_err(); + assert!(matches!( + err, + Error::DataOverflow(ref msg) if msg.contains("PIBD state generation exhausted") + )); + assert_eq!(chain.pibd_state_generation.load(Ordering::SeqCst), u64::MAX); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert_eq!(chain.store.pending_chain_operation().unwrap(), Some(op)); + assert!(!old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_err()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_skips_detached_denied_child_after_parent_cleanup() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/rewind_bad_block_detached_child_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 4); + + let denied_parent = chain.get_header_by_height(2).unwrap(); + let denied_parent_hash = denied_parent.hash(0).unwrap(); + let rewind_target = chain.get_header_by_height(1).unwrap(); + let rewind_target_tip = Tip::try_from_header(&rewind_target).unwrap(); + let detached_child = recovery_test_header(3, denied_parent_hash, 901); + let detached_child_hash = detached_child.hash(0).unwrap(); + assert_ne!( + detached_child_hash, + chain.head_header().unwrap().hash(0).unwrap() + ); + + // Keep an off-chain child of the denied canonical parent. Rewinding the + // parent removes its canonical header but intentionally does not scan and + // delete arbitrary off-chain descendants. + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&detached_child).unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&chain.genesis()).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + chain + .rewind_bad_block(&secp, &std::iter::once(denied_parent_hash).collect()) + .unwrap(); + assert_eq!(chain.head().unwrap(), rewind_target_tip); + assert_eq!(chain.header_head().unwrap(), rewind_target_tip); + assert!(chain.get_block_header(&denied_parent_hash).is_err()); + assert_eq!( + chain.get_block_header(&detached_child_hash).unwrap(), + detached_child + ); + + // The retained child now has a missing parent, but it is above and off both + // active chains. It must be skipped without dereferencing prev_hash. + chain + .rewind_bad_block(&secp, &std::iter::once(detached_child_hash).collect()) + .unwrap(); + assert_eq!(chain.head().unwrap(), rewind_target_tip); + assert_eq!(chain.header_head().unwrap(), rewind_target_tip); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_keeps_missing_active_ancestry_fatal() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/rewind_bad_block_missing_active_parent_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 3); + let denied_header = chain.head_header().unwrap(); + let denied_hash = denied_header.hash(0).unwrap(); + let parent_hash = denied_header.prev_hash; + let body_head_before = chain.head().unwrap(); + let header_head_before = chain.header_head().unwrap(); + + { + let batch = chain.store.batch_write().unwrap(); + batch.delete_block_header(&parent_hash).unwrap(); + batch.commit().unwrap(); + } + + let err = chain + .rewind_bad_block(&secp, &std::iter::once(denied_hash).collect()) + .unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(NotFoundErr(_), ref context) + if context.contains("rewind_bad_block ancestry") + )); + assert_eq!(chain.head().unwrap(), body_head_before); + assert_eq!(chain.header_head().unwrap(), header_head_before); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_rejects_missing_retained_target_before_marker() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/rewind_bad_block_missing_retained_target_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 3); + let denied_header = chain.head_header().unwrap(); + let denied_hash = denied_header.hash(0).unwrap(); + let target_header = chain + .get_header_by_height(denied_header.height - 1) + .unwrap(); + let target_hash = target_header.hash(0).unwrap(); + let body_head_before = chain.head().unwrap(); + let header_head_before = chain.header_head().unwrap(); + let body_tail = Tip::try_from_header(&chain.genesis()).unwrap(); + + // Automated-testing compaction keeps BODY_TAIL at HEAD. Retain the + // target, then model raw loss of its full-block record. + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_tail(&body_tail).unwrap(); + batch.delete(&mwc_store::to_key(b'b', target_hash)).unwrap(); + batch.commit().unwrap(); + } + + let err = chain + .rewind_bad_block(&secp, &std::iter::once(denied_hash).collect()) + .unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(NotFoundErr(_), ref context) + if context.contains("rewind_bad_block body target preflight load full block") + )); + assert_eq!(chain.head().unwrap(), body_head_before); + assert_eq!(chain.header_head().unwrap(), header_head_before); + assert_eq!(chain.tail().unwrap(), body_tail); + assert_eq!(chain.get_block_header(&target_hash).unwrap(), target_header); + assert!(chain.get_block_for_header(&denied_header).is_ok()); + assert!(chain.get_block_header(&denied_hash).is_ok()); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_rejects_mismatched_retained_target_before_marker() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/rewind_bad_block_mismatched_retained_target_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 3); + let denied_header = chain.head_header().unwrap(); + let denied_hash = denied_header.hash(0).unwrap(); + let target_header = chain + .get_header_by_height(denied_header.height - 1) + .unwrap(); + let target_hash = target_header.hash(0).unwrap(); + let mut corrupted_target = chain.get_block_for_header(&target_header).unwrap(); + corrupted_target.header.height = corrupted_target.header.height.saturating_add(100); + assert_eq!(corrupted_target.hash(0).unwrap(), target_hash); + assert_ne!(corrupted_target.header, target_header); + let body_head_before = chain.head().unwrap(); + let header_head_before = chain.header_head().unwrap(); + let body_tail = Tip::try_from_header(&chain.genesis()).unwrap(); + + // Bypass normal block-write validation to model a legacy or raw-corruption + // record stored under the target hash with a different complete header. + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_tail(&body_tail).unwrap(); + batch + .db + .put_ser(&mwc_store::to_key(b'b', target_hash), &corrupted_target) + .unwrap(); + batch.commit().unwrap(); + } + + let err = chain + .rewind_bad_block(&secp, &std::iter::once(denied_hash).collect()) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("rewind_bad_block body target preflight") + && msg.contains("does not exactly match persisted ancestry header") + )); + assert_eq!(chain.head().unwrap(), body_head_before); + assert_eq!(chain.header_head().unwrap(), header_head_before); + assert_eq!(chain.tail().unwrap(), body_tail); + assert_eq!(chain.get_block_header(&target_hash).unwrap(), target_header); + assert_eq!( + chain.store.get_block(&target_hash).unwrap().header, + corrupted_target.header + ); + assert!(chain.get_block_for_header(&denied_header).is_ok()); + assert!(chain.get_block_header(&denied_hash).is_ok()); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_holds_body_lock_until_finalization() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!("target/rewind_bad_block_body_lock_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = Arc::new(mine_chain(&chain_dir, 3)); + let denied_header = chain.head_header().unwrap(); + let denied_hash = denied_header.hash(0).unwrap(); + let parent = chain + .get_header_by_height(denied_header.height - 1) + .unwrap(); + let parent_tip = Tip::try_from_header(&parent).unwrap(); + + // Automated-testing compaction keeps BODY_TAIL at HEAD. Move the tail back + // to genesis so this test can exercise a real body rewind. + { + let batch = chain.store.batch_write().unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&chain.genesis()).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + let (reached_tx, reached_rx) = mpsc::sync_channel(1); + let (resume_tx, resume_rx) = mpsc::sync_channel(1); + *chain.rewind_bad_block_after_body_sync_hook.write() = + Some(Arc::new(RewindBadBlockAfterBodySyncHook { + reached: reached_tx, + resume: Mutex::new(resume_rx), + })); + + let worker_chain = chain.clone(); + let worker = std::thread::spawn(move || { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + worker_chain.apply_invalid_blocks(&secp, std::iter::once(denied_hash).collect()) + }); + + let reached = reached_rx.recv_timeout(Duration::from_secs(30)); + if let Err(e) = reached { + let _ = resume_tx.send(()); + if worker.is_finished() { + match worker.join() { + Ok(Ok(())) => panic!( + "rewind completed without reaching the post-body-sync hook: {}", + e + ), + Ok(Err(worker_err)) => panic!( + "rewind failed before the post-body-sync hook: {}; {}", + e, worker_err + ), + Err(_) => panic!("rewind panicked before the post-body-sync hook: {}", e), + } + } + panic!("rewind timed out before the post-body-sync hook: {}", e); + } + let body_lock_was_held = chain.txhashset.try_read().is_none(); + resume_tx.send(()).unwrap(); + worker.join().unwrap().unwrap(); + *chain.rewind_bad_block_after_body_sync_hook.write() = None; + + assert!( + body_lock_was_held, + "txhashset became readable after PMMR sync but before database and marker finalization" + ); + assert_eq!(chain.head().unwrap(), parent_tip); + assert_eq!(chain.header_head().unwrap(), parent_tip); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + pipe::release_context_data(chain.get_context_id()); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn rewind_bad_block_generation_exhaustion_retains_recovery_marker() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/rewind_bad_block_generation_exhaustion_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 3); + let denied_header = chain.head_header().unwrap(); + let denied_hash = denied_header.hash(0).unwrap(); + let parent = chain + .get_header_by_height(denied_header.height - 1) + .unwrap(); + let parent_tip = Tip::try_from_header(&parent).unwrap(); + + { + let batch = chain.store.batch_write().unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&chain.genesis()).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + chain + .pibd_state_generation + .store(u64::MAX, Ordering::SeqCst); + + let err = chain + .apply_invalid_blocks(&secp, std::iter::once(denied_hash).collect()) + .unwrap_err(); + assert!(matches!( + err, + Error::DataOverflow(ref msg) if msg.contains("PIBD state generation exhausted") + )); + assert_eq!(chain.store.head().unwrap(), parent_tip); + assert_eq!(chain.store.header_head().unwrap(), parent_tip); + assert_eq!(chain.pibd_state_generation.load(Ordering::SeqCst), u64::MAX); + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert_eq!( + chain + .store + .pending_chain_operation() + .unwrap() + .unwrap() + .kind(), + ChainOperationKind::RewindBadBlock + ); + + pipe::release_context_data(chain.get_context_id()); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn recovery_invalidates_marked_and_markerless_desegmenters() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/recovery_invalidates_desegmenters_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + + let marked_desegmenter = chain.init_desegmenter(0, Hash::default()).unwrap(); + assert!(marked_desegmenter.is_current()); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + chain.ensure_chain_robust().unwrap(); + assert!(!marked_desegmenter.is_current()); + assert!(matches!( + marked_desegmenter.check_update_leaf_set_state(), + Err(Error::ChainRestartRequired) + )); + + let markerless_desegmenter = chain.init_desegmenter(0, Hash::default()).unwrap(); + assert!(markerless_desegmenter.is_current()); + // Bitmap-accumulator failures latch recovery without a durable marker. The + // no-marker recovery path must still invalidate the partially mutated object. + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + chain.ensure_chain_robust().unwrap(); + assert!(!markerless_desegmenter.is_current()); + assert!(matches!( + markerless_desegmenter.check_update_leaf_set_state(), + Err(Error::ChainRestartRequired) + )); + + let fresh_desegmenter = chain.init_desegmenter(0, Hash::default()).unwrap(); + assert!(fresh_desegmenter.is_current()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + fn assert_successful_reset_invalidates_segmenters(chain: &Chain, reset: F) + where + F: FnOnce() -> Result<(), Error>, + { + let old_segmenter = chain.segmenter().unwrap(); + assert!(old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_ok()); + let generation_before = chain.pibd_state_generation.load(Ordering::SeqCst); + + reset().unwrap(); + + assert_eq!( + chain.pibd_state_generation.load(Ordering::SeqCst), + generation_before + 1 + ); + assert!(!old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_err()); + assert!(!chain + .pibd_segmenter + .read_recursive() + .as_ref() + .unwrap() + .is_current()); + + let fresh_segmenter = chain.segmenter().unwrap(); + assert!(fresh_segmenter.is_current()); + assert!(fresh_segmenter.bitmap_root().is_ok()); + } + + #[test] + fn successful_resets_invalidate_cached_and_cloned_segmenters() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/successful_resets_invalidate_segmenters_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis(); + + assert_successful_reset_invalidates_segmenters(&chain, || { + chain.reset_chain_head(&secp, &genesis, true) + }); + assert_successful_reset_invalidates_segmenters(&chain, || chain.reset_pibd_chain()); + assert_successful_reset_invalidates_segmenters(&chain, || { + chain.reset_chain_head_to_genesis() + }); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn existing_pending_chain_operation_marks_recovery_required() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/existing_pending_chain_operation_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let stale_op = + prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&stale_op).unwrap(); + + { + let _header_pmmr = chain.header_pmmr.write(); + let result = chain.set_pending_chain_operation_checked(&stale_op); + assert!(matches!( + result, + Err(Error::Other(ref msg)) + if msg == "pending chain operation requires chain init recovery" + )); + } + + assert!(chain.requires_init_recovery.load(Ordering::SeqCst)); + assert_eq!( + chain.store.pending_chain_operation().unwrap(), + Some(stale_op) + ); + + chain.ensure_chain_robust().unwrap(); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn legacy_compact_marker_derives_and_recovers_body_tail() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/legacy_compact_marker_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let expected_tail = Tip::try_from_header(&chain.genesis()).unwrap(); + let op = + prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::Compact).unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + + chain.ensure_chain_robust().unwrap(); + + assert_eq!(chain.tail().unwrap(), expected_tail); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn process_block_header_rejects_mismatched_context_before_hashing() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/process_block_header_context_mismatch_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let header_head_before = chain.header_head().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + let mut header = chain.genesis(); + header.pow.proof.context_id = u32::MAX; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + chain.process_block_header(&header, Options::SKIP_POW) + })); + let err = result + .expect("context mismatch must return an error instead of panicking") + .unwrap_err(); + assert!(err.is_bad_data()); + match err { + Error::InvalidHeaderContext { expected, actual } => { + assert_eq!(expected, chain.get_context_id()); + assert_eq!(actual, u32::MAX); + } + other => panic!("unexpected context mismatch error: {:?}", other), + } + assert_eq!(chain.header_head().unwrap(), header_head_before); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn missing_parent_header_validates_pow_before_returning_orphan() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/missing_parent_header_pow_validation_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + global::get_genesis_block(&secp, 0).unwrap(), + reject_non_genesis_pow, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap(); + + let mut header = BlockHeader::default(0); + header.height = 1; + header.prev_hash = Hash::from_vec(&[0xa5; Hash::LEN]); + *header + .pow + .proof + .nonces + .last_mut() + .expect("automated-test proof has at least one nonce") = 1; + assert_ne!(header.hash(0).unwrap(), chain.head().unwrap().last_block_h); + + let err = chain + .process_block_header(&header, Options::NONE) + .unwrap_err(); + assert!(matches!(err, Error::InvalidPow)); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn reset_chain_head_rejects_mismatched_context_before_hashing() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/reset_chain_head_context_mismatch_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let body_head_before = chain.head().unwrap(); + let header_head_before = chain.header_head().unwrap(); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + let mut header = chain.genesis(); + header.pow.proof.context_id = u32::MAX; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + chain.reset_chain_head(&secp, &header, true) + })); + let err = result + .expect("context mismatch must return an error instead of panicking") + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidHeaderContext { expected, actual } + if expected == chain.get_context_id() && actual == u32::MAX + )); + + // Keep the state helper safe if a future internal caller bypasses operation + // preparation and invokes it directly. + let state_result = { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + reset_chain_head_state( + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + &header, + true, + ) + })) + }; + let err = state_result + .expect("state context mismatch must return an error instead of panicking") + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidHeaderContext { expected, actual } + if expected == chain.get_context_id() && actual == u32::MAX + )); + + assert_eq!(chain.head().unwrap(), body_head_before); + assert_eq!(chain.header_head().unwrap(), header_head_before); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + fn retained_test_block(height: u64, prev_hash: Hash, proof_nonce: u64) -> Block { + let mut block = Block::default(0); + block.header.height = height; + block.header.prev_hash = prev_hash; + if let Some(last_nonce) = block.header.pow.proof.nonces.last_mut() { + *last_nonce = proof_nonce; + } + block + } + + fn init_automated_test_chain(chain_dir: &str, secp: &Secp256k1) -> Chain { + Chain::init( + secp, + 0, + chain_dir.to_owned(), + Arc::new(crate::types::NoopAdapter {}), + global::get_genesis_block(secp, 0).unwrap(), + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap() + } + + fn recovery_test_header(height: u64, prev_hash: Hash, proof_nonce: u64) -> BlockHeader { + let mut header = BlockHeader::default(0); + header.height = height; + header.prev_hash = prev_hash; + header.pow.proof.nonces[0] = proof_nonce; + header + } + + fn accept_recovery_test_pow(_context_id: u32, _header: &BlockHeader) -> Result<(), pow::Error> { + Ok(()) + } + + fn recovery_header_pmmr(chain_dir: &str) -> PMMRHandle { + PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap() + } + + #[test] + fn startup_recovers_speculative_header_pmmr_suffix_missing_from_db() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/recovery_speculative_header_suffix_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_tip = Tip::try_from_header(&genesis.header).unwrap(); + let genesis_hash = genesis.hash(0).unwrap(); + let genesis_root = { + let mut header_pmmr = chain.header_pmmr.write(); + let size = header_pmmr.size; + PMMR::at(&mut header_pmmr.backend, size).root().unwrap() + }; + + let mut speculative = recovery_test_header(1, genesis_hash, 801); + speculative.prev_root = genesis_root; + let speculative_hash = speculative.hash(0).unwrap(); + let speculative_tip = Tip::try_from_header(&speculative).unwrap(); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::ProcessHeader) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut batch = chain.store.batch_write().unwrap(); + txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, _| { + ext.validate_root(&speculative)?; + ext.apply_header(&speculative) + }) + .unwrap(); + + // Model process_block_header's writes after the PMMR sync, then abort + // the enclosing LMDB transaction as a crash or failed commit would. + batch.save_block_header(&speculative).unwrap(); + batch.save_header_head(&speculative_tip).unwrap(); + drop(batch); + assert_eq!(header_pmmr.head_hash().unwrap(), speculative_hash); + } + + assert_eq!(chain.store.header_head().unwrap(), genesis_tip); + assert!(chain.store.get_block_header(&speculative_hash).is_err()); + assert_eq!( + chain.store.pending_chain_operation().unwrap(), + Some(op.clone()) + ); + + drop(chain); + let restarted = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap(); + + assert_eq!(restarted.header_head().unwrap(), genesis_tip); + assert_eq!(restarted.header_pmmr.read().size, 1); + assert_eq!( + restarted.header_pmmr.read().head_hash().unwrap(), + genesis_hash + ); + assert!(restarted.store.pending_chain_operation().unwrap().is_none()); + assert!(restarted.store.get_block_header(&speculative_hash).is_err()); + + drop(restarted); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn startup_recovers_speculative_header_pmmr_fork_missing_from_db() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/recovery_speculative_header_fork_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_hash = genesis.hash(0).unwrap(); + let genesis_root = { + let mut header_pmmr = chain.header_pmmr.write(); + let size = header_pmmr.size; + PMMR::at(&mut header_pmmr.backend, size).root().unwrap() + }; + + let mut durable = recovery_test_header(1, genesis_hash, 811); + durable.prev_root = genesis_root; + let durable_hash = durable.hash(0).unwrap(); + let durable_tip = Tip::try_from_header(&durable).unwrap(); + let mut speculative = recovery_test_header(1, genesis_hash, 812); + speculative.prev_root = genesis_root; + let speculative_hash = speculative.hash(0).unwrap(); + let speculative_tip = Tip::try_from_header(&speculative).unwrap(); + assert_ne!(durable_hash, speculative_hash); + + // Establish a fully durable header branch first. + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut batch = chain.store.batch_write().unwrap(); + txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, _| { + ext.validate_root(&durable)?; + ext.apply_header(&durable) + }) + .unwrap(); + batch.save_block_header(&durable).unwrap(); + batch.save_header_head(&durable_tip).unwrap(); + batch.commit().unwrap(); + } + + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::SyncHeaders) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut batch = chain.store.batch_write().unwrap(); + txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, _| { + ext.rewind(&genesis.header)?; + ext.validate_root(&speculative)?; + ext.apply_header(&speculative) + }) + .unwrap(); + + // The PMMR fork is durable, but these enclosing DB writes are not. + batch.save_block_header(&speculative).unwrap(); + batch.save_header_head(&speculative_tip).unwrap(); + drop(batch); + assert_eq!(header_pmmr.head_hash().unwrap(), speculative_hash); + } + + assert_eq!(chain.store.header_head().unwrap(), durable_tip); + assert_eq!( + chain.store.get_block_header(&durable_hash).unwrap(), + durable + ); + assert!(chain.store.get_block_header(&speculative_hash).is_err()); + drop(chain); + + let restarted = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + accept_recovery_test_pow, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap(); + + assert_eq!(restarted.header_head().unwrap(), durable_tip); + assert_eq!(restarted.header_pmmr.read().size, 3); + assert_eq!( + restarted.header_pmmr.read().head_hash().unwrap(), + durable_hash + ); + assert!(restarted.store.pending_chain_operation().unwrap().is_none()); + assert!(restarted.store.get_block_header(&speculative_hash).is_err()); + + drop(restarted); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn reset_chain_head_requires_target_full_block() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/reset_target_block_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_hash = genesis.hash(0).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch + .delete(&mwc_store::to_key(b'b', genesis_hash)) + .unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let err = reset_chain_head_state( + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + &genesis.header, + true, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(_, context) + if context.contains("reset_chain_head target preflight load full block") + )); + + drop(txhashset); + drop(header_pmmr); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn reset_chain_head_preserves_body_tail_ancestry() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/reset_body_tail_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let main_1 = retained_test_block(1, genesis.hash(0).unwrap(), 601); + let main_2 = retained_test_block(2, main_1.hash(0).unwrap(), 602); + let fork_1 = retained_test_block(1, genesis.hash(0).unwrap(), 701); + let fork_2 = retained_test_block(2, fork_1.hash(0).unwrap(), 702); + let body_head = Tip::try_from_header(&main_2.header).unwrap(); + let body_tail = Tip::try_from_header(&main_1.header).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + for block in [&main_1, &main_2, &fork_1, &fork_2] { + batch.save_block_header(&block.header).unwrap(); + batch.save_block(block).unwrap(); + } + batch.save_body_head(&body_head).unwrap(); + batch.save_body_tail(&body_tail).unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let below_tail = reset_chain_head_state( + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + &genesis.header, + true, + ) + .unwrap_err(); + assert!(matches!(below_tail, Error::Other(msg) if msg.contains("below BODY_TAIL"))); + + let wrong_fork = reset_chain_head_state( + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + &fork_2.header, + true, + ) + .unwrap_err(); + assert!(matches!( + wrong_fork, + Error::Other(msg) if msg.contains("does not contain BODY_TAIL") + )); + + drop(txhashset); + drop(header_pmmr); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn header_recovery_preserves_complete_odd_height_pmmr_size() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/header_recovery_odd_height_size_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let genesis = BlockHeader::default(0); + let header = recovery_test_header(1, genesis.hash(0).unwrap(), 1); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&genesis).unwrap(); + batch.save_block_header(&header).unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = recovery_header_pmmr(&chain_dir); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + pmmr.push(&genesis).unwrap(); + pmmr.push(&header).unwrap(); + pmmr.size() + }; + assert_eq!(header_pmmr.size, 3); + + reconcile_header_pmmr_to_header( + &genesis, + &store, + &mut header_pmmr, + &header, + accept_recovery_test_pow, + ) + .unwrap(); + assert_eq!(header_pmmr.size, 3); + + drop(header_pmmr); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn header_recovery_rejects_hash_data_split() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/header_recovery_hash_data_split_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let genesis = BlockHeader::default(0); + let old_header = recovery_test_header(1, genesis.hash(0).unwrap(), 1); + let new_header = recovery_test_header(1, genesis.hash(0).unwrap(), 2); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&genesis).unwrap(); + batch.save_block_header(&old_header).unwrap(); + batch.commit().unwrap(); + } + + // Model a crash after the new-fork hash file flush but before the data + // file flush: hashes describe the new fork while HeaderEntry data still + // describes the old durable HEADER_HEAD fork. + let mut header_pmmr = recovery_header_pmmr(&chain_dir); + let genesis_hash = genesis.hash_with_index(0, 0).unwrap(); + let new_leaf_hash = new_header.hash_with_index(0, 1).unwrap(); + let new_parent_hash = (genesis_hash, new_leaf_hash).hash_with_index(0, 2).unwrap(); + header_pmmr + .backend + .append(&genesis, &[genesis_hash]) + .unwrap(); + header_pmmr + .backend + .append(&old_header, &[new_leaf_hash, new_parent_hash]) + .unwrap(); + header_pmmr.size = 3; + + let err = reconcile_header_pmmr_to_header( + &genesis, + &store, + &mut header_pmmr, + &old_header, + accept_recovery_test_pow, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not authenticate authoritative header") + )); + + drop(header_pmmr); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn header_recovery_rejects_same_hash_cached_metadata_mismatch() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/header_recovery_cached_metadata_mismatch_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let genesis = BlockHeader::default(0); + let cached_header = recovery_test_header(1, genesis.hash(0).unwrap(), 1); + let mut authoritative_header = cached_header.clone(); + authoritative_header.pow.total_difficulty = mwc_core::pow::Difficulty::from_num( + cached_header.total_difficulty().to_num().saturating_add(1), + ); + assert_eq!( + cached_header.hash(0).unwrap(), + authoritative_header.hash(0).unwrap() + ); + assert_ne!(cached_header, authoritative_header); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&genesis).unwrap(); + batch.save_block_header(&authoritative_header).unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = recovery_header_pmmr(&chain_dir); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + pmmr.push(&genesis).unwrap(); + pmmr.push(&cached_header).unwrap(); + pmmr.size() + }; + + let err = reconcile_header_pmmr_to_header( + &genesis, + &store, + &mut header_pmmr, + &authoritative_header, + accept_recovery_test_pow, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not match authoritative header") + )); + + drop(header_pmmr); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn header_recovery_rejects_same_hash_header_with_invalid_pow_binding() { + let chain_dir = format!( + "target/header_recovery_invalid_pow_binding_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 2); + let genesis = chain.genesis.clone(); + let selected_hash = chain.header_head().unwrap().last_block_h; + let original = chain.store.get_block_header(&selected_hash).unwrap(); + assert_eq!(original.height, 1); + pow::verify_size(0, &original).unwrap(); + + let mut altered = original.clone(); + altered.prev_root = Hash::from_vec(&[42; Hash::LEN]); + assert_ne!(altered, original); + assert_eq!(altered.hash(0).unwrap(), selected_hash); + let pos0 = pmmr::insertion_to_pmmr_index(altered.height).unwrap(); + assert_eq!( + altered.hash_with_index(0, pos0).unwrap(), + original.hash_with_index(0, pos0).unwrap() + ); + assert!(pow::verify_size(0, &altered).is_err()); + + { + let batch = chain.store.batch_write().unwrap(); + // Bypass the normal overwrite guard to model a persisted header whose + // noncached fields changed while its proof-derived key stayed the same. + batch + .db + .put_ser(&mwc_store::to_key(b'h', selected_hash), &altered) + .unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = chain.header_pmmr.write(); + let size_before = header_pmmr.size; + let head_before = header_pmmr.head_hash().unwrap(); + let err = reconcile_header_pmmr_to_header( + &genesis.header, + &chain.store, + &mut header_pmmr, + &altered, + pow::verify_size, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("failed PoW authentication") + )); + assert_eq!(header_pmmr.size, size_before); + assert_eq!(header_pmmr.head_hash().unwrap(), head_before); + + drop(header_pmmr); + drop(chain); + clean_output_dir(&chain_dir); + } + + #[test] + fn marked_recovery_failure_does_not_rebuild_body_at_genesis() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/marked_recovery_no_genesis_fallback_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_hash = genesis.hash(0).unwrap(); + let original_body_head = chain.head().unwrap(); + let original_header_head = chain.header_head().unwrap(); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::ProcessBlock) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch + .delete(&mwc_store::to_key(b'b', genesis_hash)) + .unwrap(); + batch.commit().unwrap(); + } + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let header_size_before = header_pmmr.size; + let body_sizes_before = ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ); + let err = recover_pending_chain_operation( + &genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(_, context) + if context.contains("reconcile_pmmrs_to_db_heads HEAD preflight") + )); + assert_eq!(header_pmmr.size, header_size_before); + assert_eq!( + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ), + body_sizes_before + ); + } + assert_eq!( + chain.store.pending_chain_operation().unwrap(), + Some(op.clone()) + ); + assert_eq!(chain.head().unwrap(), original_body_head); + assert_eq!(chain.header_head().unwrap(), original_header_head); + assert!(!chain.store.block_exists(&genesis_hash).unwrap()); + + drop(chain); + let restart_err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("startup silently rebuilt the body at genesis"), + Err(err) => err, + }; + assert!(matches!( + restart_err, + Error::StoreErr(_, context) + if context.contains("reconcile_pmmrs_to_db_heads HEAD preflight") + )); + let store = ChainStore::new(0, &chain_dir).unwrap(); + assert_eq!(store.pending_chain_operation().unwrap(), Some(op)); + assert_eq!(store.head().unwrap(), original_body_head); + assert_eq!(store.header_head().unwrap(), original_header_head); + assert!(!store.block_exists(&genesis_hash).unwrap()); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn marked_recovery_reports_short_header_pmmr_and_startup_stops() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/recovery_short_header_pmmr_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let original_body_head = chain.head().unwrap(); + let target_header = recovery_test_header(1, genesis.hash(0).unwrap(), 41); + let target_header_head = Tip::try_from_header(&target_header).unwrap(); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::ProcessHeader) + .unwrap(); + + // Model the restart-visible result of a rewind that reached the PMMR + // durability domain without the matching LMDB transition: HEADER_HEAD + // requires two header leaves, while the file still contains only genesis. + chain.store.set_pending_chain_operation(&op).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&target_header).unwrap(); + batch.save_header_head(&target_header_head).unwrap(); + batch.commit().unwrap(); + } + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let header_size_before = header_pmmr.size; + let body_sizes_before = ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ); + let err = recover_pending_chain_operation( + &genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + ) + .unwrap_err(); + let details = match err { + Error::PmmrRecoveryRequired(details) => details, + other => panic!("unexpected short-header recovery error: {:?}", other), + }; + assert!(details.contains("durable HEADER_HEAD"), "{}", details); + assert!(details.contains("header PMMR position 3"), "{}", details); + assert_eq!(header_pmmr.size, header_size_before); + assert_eq!( + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ), + body_sizes_before + ); + } + assert_eq!( + chain.store.pending_chain_operation().unwrap(), + Some(op.clone()) + ); + assert_eq!(chain.head().unwrap(), original_body_head); + assert_eq!(chain.header_head().unwrap(), target_header_head); + + // Chain::init invokes marked-operation recovery before setup_head. The same + // typed error therefore aborts node initialization instead of entering the + // body-only genesis fallback or clearing the marker. + drop(chain); + let restart_err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("startup accepted a durable head beyond the header PMMR"), + Err(err) => err, + }; + assert!(matches!( + restart_err, + Error::PmmrRecoveryRequired(ref details) + if details.contains("durable HEADER_HEAD") + )); + + let store = ChainStore::new(0, &chain_dir).unwrap(); + assert_eq!(store.pending_chain_operation().unwrap(), Some(op)); + assert_eq!(store.head().unwrap(), original_body_head); + assert_eq!(store.header_head().unwrap(), target_header_head); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn marked_recovery_reports_short_body_pmmr_before_mutation() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/recovery_short_body_pmmrs_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let original_header_head = chain.header_head().unwrap(); + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::ProcessBlock) + .unwrap(); + + let body_sizes_before = { + let txhashset = chain.txhashset.read(); + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ) + }; + let output_leaf_count = pmmr::n_leaves(body_sizes_before.0).unwrap(); + let target_output_size = pmmr::insertion_to_pmmr_index(output_leaf_count + 1).unwrap(); + assert!(target_output_size > body_sizes_before.0); + + let mut target_block = retained_test_block(1, genesis.hash(0).unwrap(), 42); + target_block.header.output_mmr_size = target_output_size; + target_block.header.kernel_mmr_size = body_sizes_before.2; + let target_body_head = Tip::try_from_header(&target_block.header).unwrap(); + + chain.store.set_pending_chain_operation(&op).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&target_block.header).unwrap(); + batch.save_block(&target_block).unwrap(); + batch.save_body_head(&target_body_head).unwrap(); + batch.commit().unwrap(); + } + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let header_size_before = header_pmmr.size; + let err = recover_pending_chain_operation( + &genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + ) + .unwrap_err(); + let details = match err { + Error::PmmrRecoveryRequired(details) => details, + other => panic!("unexpected short-body recovery error: {:?}", other), + }; + assert!(details.contains("durable HEAD"), "{}", details); + assert!(details.contains("output PMMR"), "{}", details); + assert_eq!(header_pmmr.size, header_size_before); + assert_eq!( + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ), + body_sizes_before + ); + } + + assert_eq!(chain.store.pending_chain_operation().unwrap(), Some(op)); + assert_eq!(chain.head().unwrap(), target_body_head); + assert_eq!(chain.header_head().unwrap(), original_header_head); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn body_reconciliation_requires_complete_output_pos_index() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/body_reconciliation_requires_complete_output_pos_index_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + { + let batch = chain.store.batch_write().unwrap(); + batch.set_output_pos_index_complete(false).unwrap(); + batch.commit().unwrap(); + } + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + let err = reconcile_body_pmmr_to_header( + &genesis.header, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + &genesis.header, + pow::verify_size, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("output_pos index is incomplete") + )); + } + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn marked_recovery_failure_retains_marker_without_genesis_fallback() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/marked_recovery_retains_marker_{}", + std::process::id() + ); + let corrupt_header_dir = format!("{}_corrupt_header", chain_dir); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + + // Preserve the authoritative HeaderEntry while corrupting its PMMR leaf + // hash. init_head() and a same-size rewind accept this split state because + // they derive the block hash from HeaderEntry rather than the hash file. + let mut corrupt_header_pmmr = recovery_header_pmmr(&corrupt_header_dir); + let expected_leaf_hash = genesis.header.hash_with_index(0, 0).unwrap(); + let corrupt_leaf_hash = Hash::from_vec(&[42; Hash::LEN]); + assert_ne!(corrupt_leaf_hash, expected_leaf_hash); + corrupt_header_pmmr + .backend + .append(&genesis.header, &[corrupt_leaf_hash]) + .unwrap(); + corrupt_header_pmmr.backend.sync().unwrap(); + corrupt_header_pmmr.size = 1; + assert_eq!( + corrupt_header_pmmr.get_header_hash_by_height(0).unwrap(), + genesis.hash(0).unwrap() + ); + + let op = prepare_reconcile_heads_operation(&chain.store, ChainOperationKind::ProcessHeader) + .unwrap(); + chain.store.set_pending_chain_operation(&op).unwrap(); + { + let mut txhashset = chain.txhashset.write(); + let err = recover_marked_chain_operation( + &genesis, + &chain.store, + &mut corrupt_header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + &op, + ) + .unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not authenticate authoritative header") + ), + "unexpected recovery error: {:?}", + err + ); + } + + // Recovery must not convert this validation failure into a body reset. The + // marker tells every restart to stop until the operator investigates and + // explicitly repairs or cleans the state. + assert_eq!(chain.store.pending_chain_operation().unwrap(), Some(op)); + + drop(corrupt_header_pmmr); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + } + + #[test] + fn reconciliation_persists_both_canonical_tip_caches() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/reconciliation_persists_canonical_tips_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let canonical = Tip::try_from_header(&chain.genesis.header).unwrap(); + + let mut stale_body_head = canonical; + stale_body_head.height = 41; + stale_body_head.prev_block_h = Hash::from_vec(&[1; Hash::LEN]); + stale_body_head.total_difficulty = mwc_core::pow::Difficulty::from_num(101); + let mut stale_header_head = canonical; + stale_header_head.height = 73; + stale_header_head.prev_block_h = Hash::from_vec(&[2; Hash::LEN]); + stale_header_head.total_difficulty = mwc_core::pow::Difficulty::from_num(202); + assert_ne!(stale_body_head, canonical); + assert_ne!(stale_header_head, canonical); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_head(&stale_body_head).unwrap(); + batch.save_header_head(&stale_header_head).unwrap(); + batch.commit().unwrap(); + } + + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + reconcile_pmmrs_to_db_heads( + &chain.genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + ) + .unwrap(); + } + + let batch = chain.store.batch_read().unwrap(); + assert_eq!(batch.head().unwrap(), canonical); + assert_eq!(batch.header_head().unwrap(), canonical); + drop(batch); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn remove_historical_blocks_aborts_when_tail_moved_backward() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/remove_historical_blocks_stale_tail_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let genesis_header = chain.genesis(); + let genesis_hash = genesis_header.hash(0).unwrap(); + + // Persist headers and full blocks above genesis as cleanup candidates. + let mut blocks = Vec::new(); + { + let batch = store.batch_write().unwrap(); + let mut prev_hash = genesis_hash; + for n in 1..=5u64 { + let block = retained_test_block(n, prev_hash, n); + prev_hash = block.hash(0).unwrap(); + batch.save_block_header(&block.header).unwrap(); + batch.save_block(&block).unwrap(); + blocks.push(block); + } + // Tail as captured by a completed compaction at height 3. + let stale_tail = Tip::try_from_header(&blocks[2].header).unwrap(); + batch.save_body_tail(&stale_tail).unwrap(); + batch.commit().unwrap(); + } + let stale_tail_header = blocks[2].header.clone(); + + // Simulate a concurrent reset_pibd_chain/reset_chain_head_to_genesis + // landing between compaction and cleanup: BODY_TAIL drops to genesis. + { + let batch = store.batch_write().unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&genesis_header).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + let stop_state = StopState::new(); + let err = chain + .remove_historical_blocks(&stale_tail_header, &stop_state) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) if msg.contains("tail moved backward") + )); + + // Nothing may have been deleted: the genesis block and the candidates + // below the stale cutoff are required chain state again. + chain.get_block_for_header(&genesis_header).unwrap(); + for block in &blocks { + chain.get_block_for_header(&block.header).unwrap(); + } + + // The selector is authoritative and the height is only a cache. A + // reset with a stale high cached height must still be recognized as a + // backward move and must not authorize deletion below that cache. + { + let mut reset_tail = Tip::try_from_header(&genesis_header).unwrap(); + reset_tail.height = stale_tail_header.height; + let batch = store.batch_write().unwrap(); + batch.save_body_tail(&reset_tail).unwrap(); + batch.commit().unwrap(); + } + let err = chain + .remove_historical_blocks(&stale_tail_header, &stop_state) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) if msg.contains("tail moved backward") + )); + chain.get_block_for_header(&genesis_header).unwrap(); + for block in &blocks { + chain.get_block_for_header(&block.header).unwrap(); + } + + // With the tail restored at the cutoff, cleanup proceeds normally. + { + let batch = store.batch_write().unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&stale_tail_header).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + chain + .remove_historical_blocks(&stale_tail_header, &stop_state) + .unwrap(); + assert!(chain.get_block_for_header(&genesis_header).is_err()); + for block in &blocks[..2] { + assert!(chain.get_block_for_header(&block.header).is_err()); + } + for block in &blocks[2..] { + chain.get_block_for_header(&block.header).unwrap(); + } + + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn remove_historical_blocks_rejects_cutoff_off_current_tail_chain() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/remove_historical_blocks_off_chain_tail_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let genesis_hash = chain.genesis().hash(0).unwrap(); + + let main_1 = retained_test_block(1, genesis_hash, 101); + let main_2 = retained_test_block(2, main_1.hash(0).unwrap(), 102); + let main_3 = retained_test_block(3, main_2.hash(0).unwrap(), 103); + let fork_1 = retained_test_block(1, genesis_hash, 201); + let fork_2 = retained_test_block(2, fork_1.hash(0).unwrap(), 202); + let fork_3 = retained_test_block(3, fork_2.hash(0).unwrap(), 203); + let blocks = [&main_1, &main_2, &main_3, &fork_1, &fork_2, &fork_3]; + { + let batch = store.batch_write().unwrap(); + for block in blocks { + batch.save_block_header(&block.header).unwrap(); + batch.save_block(block).unwrap(); + } + batch + .save_body_tail(&Tip::try_from_header(&fork_3.header).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + let stop_state = StopState::new(); + let err = chain + .remove_historical_blocks(&main_3.header, &stop_state) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) if msg.contains("is not an ancestor") + )); + + chain.get_block_for_header(&chain.genesis()).unwrap(); + for block in blocks { + chain.get_block_for_header(&block.header).unwrap(); + } + + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn canonical_tip_header_rejects_misindexed_header() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!( + "target/canonical_tip_header_rejects_misindexed_header_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let header = BlockHeader::default(0); + let actual_hash = header.hash(0).unwrap(); + let selected_hash = Hash::from_vec(&[7; Hash::LEN]); + assert_ne!(selected_hash, actual_hash); + + let mut persisted = Tip::try_from_header(&header).unwrap(); + persisted.last_block_h = selected_hash; + { + let batch = store.batch_write().unwrap(); + batch + .db + .put_ser(&mwc_store::to_key(b'h', selected_hash), &header) + .unwrap(); + batch.commit().unwrap(); + } + + let batch = store.batch_read().unwrap(); + let err = canonical_tip_header("HEAD", &persisted, &batch).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("HEAD header key/hash mismatch") + && msg.contains(&selected_hash.to_string()) + && msg.contains(&actual_hash.to_string()) + )); + + drop(batch); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn body_chain_lookups_reject_misindexed_anchor_without_traversal() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/body_chain_misindexed_anchor_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + + let header = retained_test_block(5, Hash::from_vec(&[1; Hash::LEN]), 17).header; + let actual_hash = header.hash(0).unwrap(); + let selected_hash = Hash::from_vec(&[7; Hash::LEN]); + assert_ne!(selected_hash, actual_hash); + let mut body_head = Tip::try_from_header(&header).unwrap(); + body_head.last_block_h = selected_hash; + + { + let batch = store.batch_write().unwrap(); + // Bypass the normal header write invariant to model a misindexed + // persisted record selected by BODY_HEAD. + batch + .db + .put_ser(&mwc_store::to_key(b'h', selected_hash), &header) + .unwrap(); + batch.commit().unwrap(); + } + + let header_pmmr_handle = chain.get_header_pmmr_for_test(); + let header_pmmr = header_pmmr_handle.read(); + let batch = store.batch_read().unwrap(); + let slow_err = chain + .body_chain_header_at_height(&batch, &body_head, header.height) + .unwrap_err(); + let fast_err = chain + .body_chain_header_at_height_maybe_fast(&header_pmmr, &batch, &body_head, header.height) + .unwrap_err(); + let batched_membership_err = chain + .is_on_body_chain_with_batch(&batch, &header, &body_head) + .unwrap_err(); + + for err in [slow_err, fast_err, batched_membership_err] { + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("BODY_HEAD header key/hash mismatch") + && msg.contains(&selected_hash.to_string()) + && msg.contains(&actual_hash.to_string()) + )); + } + drop(batch); + drop(header_pmmr); + drop(header_pmmr_handle); + + let membership_err = chain.is_on_body_chain(&header, body_head).unwrap_err(); + assert!(matches!( + membership_err, + Error::InvalidPersistedChainState(msg) + if msg.contains("BODY_HEAD header key/hash mismatch") + )); + + drop(chain); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn body_chain_fast_lookup_rejects_misindexed_header_head_before_zero_step() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/body_chain_misindexed_header_head_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let body_head = chain.head().unwrap(); + let header = chain.genesis(); + let actual_hash = header.hash(0).unwrap(); + let selected_hash = Hash::from_vec(&[7; Hash::LEN]); + assert_ne!(selected_hash, actual_hash); + let mut header_head = Tip::try_from_header(&header).unwrap(); + header_head.last_block_h = selected_hash; + + { + let batch = store.batch_write().unwrap(); + // Bypass the normal header write invariant to model a misindexed + // persisted record selected by HEADER_HEAD. + batch + .db + .put_ser(&mwc_store::to_key(b'h', selected_hash), &header) + .unwrap(); + batch.save_header_head(&header_head).unwrap(); + batch.commit().unwrap(); + } + + let header_pmmr_handle = chain.get_header_pmmr_for_test(); + let header_pmmr = header_pmmr_handle.read(); + let batch = store.batch_read().unwrap(); + let err = chain + .body_chain_header_at_height_maybe_fast( + &header_pmmr, + &batch, + &body_head, + body_head.height, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("HEADER_HEAD header key/hash mismatch") + && msg.contains(&selected_hash.to_string()) + && msg.contains(&actual_hash.to_string()) + )); + + drop(batch); + drop(header_pmmr); + drop(header_pmmr_handle); + drop(chain); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn body_chain_fast_lookup_uses_canonical_header_head_height() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/body_chain_canonical_header_head_height_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let genesis = chain.genesis(); + let genesis_hash = genesis.hash(0).unwrap(); + let body_header = retained_test_block(1, genesis_hash, 23).header; + let body_head = Tip::try_from_header(&body_header).unwrap(); + let mut stale_header_head = Tip::try_from_header(&genesis).unwrap(); + stale_header_head.height = body_header.height; + + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&body_header).unwrap(); + batch.save_header_head(&stale_header_head).unwrap(); + batch.commit().unwrap(); + } + + let header_pmmr_handle = chain.get_header_pmmr_for_test(); + let header_pmmr = header_pmmr_handle.read(); + let batch = store.batch_read().unwrap(); + let resolved = chain + .body_chain_header_at_height_maybe_fast(&header_pmmr, &batch, &body_head, 0) .unwrap(); - batch - .save_body_tail(&Tip::try_from_header(&tail.header).unwrap()) + assert_eq!(resolved, genesis); + + drop(batch); + drop(header_pmmr); + drop(header_pmmr_handle); + drop(chain); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn body_chain_fast_lookup_rejects_target_header_hash_data_split() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/body_chain_fast_target_header_split_{}", + std::process::id() + ); + let corrupt_header_dir = format!("{}_corrupt_header", chain_dir); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let context_id = chain.get_context_id(); + let genesis = chain.genesis(); + let genesis_hash = genesis.hash(context_id).unwrap(); + let canonical_target = recovery_test_header(1, genesis_hash, 31); + let redirected_target = recovery_test_header(1, genesis_hash, 32); + let body_header = recovery_test_header(2, canonical_target.hash(context_id).unwrap(), 33); + assert_ne!( + canonical_target.hash(context_id).unwrap(), + redirected_target.hash(context_id).unwrap() + ); + + let body_head = Tip::try_from_header(&body_header).unwrap(); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&canonical_target).unwrap(); + batch.save_block_header(&redirected_target).unwrap(); + batch.save_block_header(&body_header).unwrap(); + batch.save_body_head(&body_head).unwrap(); + batch.save_header_head(&body_head).unwrap(); + batch.commit().unwrap(); + } + + // Keep the canonical leaf hash at height one while redirecting its + // independently stored HeaderEntry to another same-height header. + let mut corrupt_header_pmmr = recovery_header_pmmr(&corrupt_header_dir); + let genesis_leaf_hash = genesis.hash_with_index(context_id, 0).unwrap(); + let canonical_target_leaf_hash = canonical_target.hash_with_index(context_id, 1).unwrap(); + let parent_hash = (genesis_leaf_hash, canonical_target_leaf_hash) + .hash_with_index(context_id, 2) .unwrap(); - batch - .set_retained_spent_commitment_index_complete(false) + let body_leaf_hash = body_header.hash_with_index(context_id, 3).unwrap(); + corrupt_header_pmmr + .backend + .append(&genesis, &[genesis_leaf_hash]) .unwrap(); - batch.commit().unwrap(); + corrupt_header_pmmr + .backend + .append( + &redirected_target, + &[canonical_target_leaf_hash, parent_hash], + ) + .unwrap(); + corrupt_header_pmmr + .backend + .append(&body_header, &[body_leaf_hash]) + .unwrap(); + corrupt_header_pmmr.backend.sync().unwrap(); + corrupt_header_pmmr.size = 4; + + let batch = store.batch_read().unwrap(); + let err = chain + .body_chain_header_at_height_maybe_fast( + &corrupt_header_pmmr, + &batch, + &body_head, + canonical_target.height, + ) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not authenticate loaded header") + )); + + drop(batch); + drop(corrupt_header_pmmr); + drop(chain); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + } + + #[test] + fn body_chain_lookups_use_canonical_anchor_height() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/body_chain_canonical_anchor_height_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + + let mut predecessor = retained_test_block(4, Hash::from_vec(&[1; Hash::LEN]), 18).header; + predecessor.output_mmr_size = 10; + let predecessor_hash = predecessor.hash(chain.get_context_id()).unwrap(); + let mut header = retained_test_block(5, predecessor_hash, 19).header; + header.output_mmr_size = 12; + let canonical_head = Tip::try_from_header(&header).unwrap(); + let mut stale_low = canonical_head; + stale_low.height = header.height - 1; + let mut stale_high = canonical_head; + stale_high.height = header.height + 1; + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&predecessor).unwrap(); + batch.save_block_header(&header).unwrap(); + batch.commit().unwrap(); + } + + assert!(chain.is_on_body_chain(&header, stale_low).unwrap()); + + let header_pmmr_handle = chain.get_header_pmmr_for_test(); + let header_pmmr = header_pmmr_handle.read(); + let batch = store.batch_read().unwrap(); + assert_eq!( + chain + .body_chain_header_at_height(&batch, &stale_low, header.height) + .unwrap(), + header + ); + assert_eq!( + chain + .body_chain_header_at_height_maybe_fast( + &header_pmmr, + &batch, + &stale_low, + header.height, + ) + .unwrap(), + header + ); + assert!(chain + .is_on_body_chain_with_batch(&batch, &header, &stale_low) + .unwrap()); + assert_eq!( + chain + .body_chain_header_for_output_pos( + &header_pmmr, + &batch, + &stale_low, + CommitPos { + pos: 11, + height: header.height, + }, + ) + .unwrap(), + Some(header.clone()) + ); + assert_eq!( + chain + .body_chain_header_for_output_pos( + &header_pmmr, + &batch, + &stale_high, + CommitPos { + pos: 13, + height: header.height + 1, + }, + ) + .unwrap(), + None + ); + + let slow_err = chain + .body_chain_header_at_height(&batch, &stale_high, header.height + 1) + .unwrap_err(); + let fast_err = chain + .body_chain_header_at_height_maybe_fast( + &header_pmmr, + &batch, + &stale_high, + header.height + 1, + ) + .unwrap_err(); + for err in [slow_err, fast_err] { + assert!(matches!( + err, + Error::ChainInSyncing(msg) + if msg.contains("body chain head is at 5, below requested height 6") + )); + } + + drop(batch); + drop(header_pmmr); + drop(header_pmmr_handle); + drop(chain); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn missing_head_freshness_rejects_auxiliary_cache_state() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/missing_head_auxiliary_cache_state_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = Arc::new(ChainStore::new(0, &chain_dir).unwrap()); + + // The block migration runs before setup_head and writes this flag even for + // a genuinely unused database, so it must remain an allowed fresh state. + { + let batch = store.batch_write().unwrap(); + batch.set_blocks_v3_migrated(true).unwrap(); + batch.commit().unwrap(); + } + let header_pmmr = recovery_header_pmmr(&chain_dir); + let txhashset = TxHashSet::open(chain_dir.clone(), store.clone(), None, &secp).unwrap(); + { + let batch = store.batch_read().unwrap(); + ensure_missing_head_is_fresh(&batch, &header_pmmr, &txhashset).unwrap(); + } + + // A derived index record proves prior chain use even if its completeness + // flag is absent and all authoritative selectors have been lost. + let stale_commit = secp.commit_value(42).unwrap(); + { + let batch = store.batch_write().unwrap(); + batch + .save_output_pos_height(&stale_commit, CommitPos { pos: 1, height: 1 }) + .unwrap(); + batch.commit().unwrap(); + } + { + let batch = store.batch_read().unwrap(); + let err = ensure_missing_head_is_fresh(&batch, &header_pmmr, &txhashset).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("persisted auxiliary chain records or cache flags") + )); + } + + // The existence of a completeness flag is itself evidence, including a + // false value that would otherwise decode to the default fresh value. + { + let batch = store.batch_write().unwrap(); + batch.delete_output_pos_height(&stale_commit).unwrap(); + batch.set_output_pos_index_complete(false).unwrap(); + batch.commit().unwrap(); + } + { + let batch = store.batch_read().unwrap(); + let err = ensure_missing_head_is_fresh(&batch, &header_pmmr, &txhashset).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("persisted auxiliary chain records or cache flags") + )); + } + + drop(txhashset); + drop(header_pmmr); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn setup_head_rejects_missing_head_in_used_store_without_resetting() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!("target/setup_head_missing_used_head_{}", std::process::id()); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_hash = genesis.hash(0).unwrap(); + let original_header_head = chain.header_head().unwrap(); + let header_size_before = chain.header_pmmr.read().size; + let body_sizes_before = { + let txhashset = chain.txhashset.read(); + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ) + }; + + // Model loss of the authoritative HEAD selector in an otherwise used + // store. This must not be mistaken for first-run initialization. + { + let batch = chain.store.batch_write().unwrap(); + batch.delete(&[b'H']).unwrap(); + batch.commit().unwrap(); + } + drop(chain); + + let restart_err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("startup treated a used store with missing HEAD as fresh"), + Err(err) => err, + }; + assert!(matches!( + &restart_err, + Error::InvalidPersistedChainState(msg) + if msg.contains("HEAD is missing from non-fresh chain state") + && msg.contains("Automatic genesis rebuild is disabled") + )); + + let store = Arc::new(ChainStore::new(0, &chain_dir).unwrap()); + assert!(matches!(store.head(), Err(NotFoundErr(_)))); + assert_eq!(store.header_head().unwrap(), original_header_head); + assert!(store.block_exists(&genesis_hash).unwrap()); + assert!(store.pending_chain_operation().unwrap().is_none()); + let header_pmmr = recovery_header_pmmr(&chain_dir); + let txhashset = TxHashSet::open(chain_dir.clone(), store.clone(), None, &secp).unwrap(); + assert_eq!(header_pmmr.size, header_size_before); + assert_eq!( + ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ), + body_sizes_before + ); + + drop(txhashset); + drop(header_pmmr); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn setup_head_rejects_header_pmmr_hash_data_split() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/setup_head_header_hash_data_split_{}", + std::process::id() + ); + let corrupt_header_dir = format!("{}_corrupt_header", chain_dir); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + + // Keep the proof-derived HeaderEntry valid while storing a different PMMR + // leaf hash. The lightweight head checks accept this split representation; + // persisted-ancestry validation must reject it during ordinary startup. + let mut corrupt_header_pmmr = recovery_header_pmmr(&corrupt_header_dir); + let expected_leaf_hash = genesis.header.hash_with_index(0, 0).unwrap(); + let corrupt_leaf_hash = Hash::from_vec(&[42; Hash::LEN]); + assert_ne!(corrupt_leaf_hash, expected_leaf_hash); + corrupt_header_pmmr + .backend + .append(&genesis.header, &[corrupt_leaf_hash]) + .unwrap(); + corrupt_header_pmmr.backend.sync().unwrap(); + corrupt_header_pmmr.size = 1; + + let err = { + let mut txhashset = chain.txhashset.write(); + setup_head( + &genesis, + &chain.store, + &mut corrupt_header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + false, + None, + ) + .unwrap_err() + }; + assert!( + matches!( + &err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not authenticate authoritative header") + ), + "unexpected startup error: {:?}", + err + ); + + drop(corrupt_header_pmmr); + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + let _ = fs::remove_dir_all(&corrupt_header_dir); + } + + #[test] + fn setup_head_requires_durable_head_full_block() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/setup_head_requires_durable_head_block_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let genesis = chain.genesis.clone(); + let genesis_hash = genesis.hash(0).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch + .delete(&mwc_store::to_key(b'b', genesis_hash)) + .unwrap(); + batch.commit().unwrap(); + } + + let err = { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + setup_head( + &genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + false, + None, + ) + .unwrap_err() + }; + assert!(matches!( + err, + Error::StoreErr(_, context) + if context.contains("setup_head durable HEAD preflight load full block") + )); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); } - fn assert_retained_spent_commitment_index_incomplete(store: &ChainStore) { - assert!(!store - .batch_read() - .unwrap() - .is_retained_spent_commitment_index_complete() - .unwrap()); + #[test] + fn startup_body_validation_rejects_corruption_unless_skipped() { + let chain_dir = format!( + "target/setup_head_body_hash_data_split_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + let genesis = chain.genesis.clone(); + let head_hash = chain.head().unwrap().last_block_h; + assert!(chain.store.get_block_sums(&head_hash).is_ok()); + drop(chain); + + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let replacement_commit = secp.commit_value(42).unwrap(); + let replacement_output = OutputIdentifier::new( + genesis.outputs()[0].identifier().features, + &replacement_commit, + ); + let mut replacement_bytes = Vec::new(); + ser::serialize( + &mut replacement_bytes, + ProtocolVersion(1), + 0, + &replacement_output, + ) + .unwrap(); + let output_data_path = Path::new(&chain_dir) + .join("txhashset") + .join("output") + .join("pmmr_data.bin"); + let mut output_data = OpenOptions::new() + .write(true) + .open(&output_data_path) + .unwrap(); + assert_eq!( + output_data.metadata().unwrap().len(), + u64::try_from(replacement_bytes.len()).unwrap() + ); + output_data.seek(SeekFrom::Start(0)).unwrap(); + output_data.write_all(&replacement_bytes).unwrap(); + output_data.sync_all().unwrap(); + drop(output_data); + + let err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis.clone(), + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("startup accepted split body PMMR data and hashes"), + Err(err) => err, + }; + assert!( + matches!( + &err, + Error::PMMRErr(pmmr::Error::DataCorruption(msg)) + if msg.contains("leaf data") && msg.contains("stored hash") + ), + "unexpected body PMMR validation error: {:?}", + err + ); + + let restarted = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + true, + ) + .unwrap(); + assert_eq!(restarted.head().unwrap().last_block_h, head_hash); + drop(restarted); + + let _ = fs::remove_dir_all(&chain_dir); } #[test] - fn replay_attack_check_fails_when_spent_commitment_index_incomplete() { - global::set_local_chain_type(global::ChainTypes::AutomatedTesting); - global::set_local_nrd_enabled(false); - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + fn setup_head_rejects_complete_output_pos_index_missing_utxo() { let chain_dir = format!( - "target/replay_attack_check_fails_when_spent_commitment_index_incomplete_{}", + "target/setup_head_missing_complete_output_pos_{}", std::process::id() ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + let genesis = chain.genesis.clone(); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let genesis_commit = genesis.outputs()[0].commitment(); + + { + let batch = chain.store.batch_write().unwrap(); + batch.delete_output_pos_height(&genesis_commit).unwrap(); + // Model a corrupted cache whose durable completeness assertion was + // left intact. Startup must not trust the flag without checking the + // index bidirectionally against the UTXO leaf set. + batch.set_output_pos_index_complete(true).unwrap(); + batch.commit().unwrap(); + } + + let err = { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + setup_head( + &genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + false, + None, + ) + .unwrap_err() + }; + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("UTXO leaf") && msg.contains("has no committed output_pos entry") + )); + + drop(chain); let _ = fs::remove_dir_all(&chain_dir); - let chain = Chain::init( + } + + #[test] + fn setup_head_rebuilds_existing_block_sums_from_validated_txhashset() { + let chain_dir = format!( + "target/setup_head_rebuilds_block_sums_{}", + std::process::id() + ); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 1); + let genesis = chain.genesis.clone(); + let head_hash = chain.head().unwrap().last_block_h; + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let expected = genesis_block_sums(&genesis, 0, &secp).unwrap(); + let corrupt = BlockSums::new( + secp.commit_value(41).unwrap(), + secp.commit_value(42).unwrap(), + ); + let mut expected_bytes = Vec::new(); + ser::serialize_default(0, &mut expected_bytes, &expected).unwrap(); + let mut corrupt_bytes = Vec::new(); + ser::serialize_default(0, &mut corrupt_bytes, &corrupt).unwrap(); + assert_ne!(corrupt_bytes, expected_bytes); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_sums(&head_hash, corrupt).unwrap(); + batch.commit().unwrap(); + } + drop(chain); + + let restarted = Chain::init( &secp, 0, chain_dir.clone(), Arc::new(crate::types::NoopAdapter {}), - global::get_genesis_block(&secp, 0).unwrap(), + genesis, pow::verify_size, false, HashSet::new(), None, None, + false, ) .unwrap(); + let rebuilt = restarted.store.get_block_sums(&head_hash).unwrap(); + let mut rebuilt_bytes = Vec::new(); + ser::serialize_default(0, &mut rebuilt_bytes, &rebuilt).unwrap(); + assert_eq!(rebuilt_bytes, expected_bytes); + + drop(restarted); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn setup_head_rejects_same_hash_altered_stored_genesis_header() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/setup_head_rejects_altered_genesis_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let genesis_hash = genesis.hash(0).unwrap(); + let chain = init_automated_test_chain(&chain_dir, &secp); + let store = chain.get_store_for_tests(); + let mut altered = genesis.header.clone(); + altered.height = 1; + assert_eq!(altered.hash(0).unwrap(), genesis_hash); + assert_ne!(altered, genesis.header); { - let store = chain.get_store_for_tests(); let batch = store.batch_write().unwrap(); + // Bypass the ChainStore overwrite guard to model raw persisted-state + // corruption discovered during initialization. batch - .set_retained_spent_commitment_index_complete(false) + .db + .put_ser(&mwc_store::to_key(b'h', genesis_hash), &altered) .unwrap(); batch.commit().unwrap(); } + drop(chain); + drop(store); - let err = chain - .replay_attack_check(&Transaction::empty()) - .unwrap_err(); - assert!(matches!(err, Error::SpentCommitmentIndexIncomplete)); + let err = match Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis, + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) { + Ok(_) => panic!("altered stored genesis header was accepted"), + Err(err) => err, + }; + // The rejection point depends on init ordering: canonical spent-index + // traversal can reject the impossible height-one genesis ancestry before + // setup_head compares the stored header against configured genesis. + assert!( + matches!( + &err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not exactly match configured genesis") + || msg.contains("header differs from the separately stored header") + || msg.contains("is missing predecessor") + ), + "unexpected error: {:?}", + err + ); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn setup_head_repairs_header_and_body_tip_caches_from_headers() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/setup_head_repairs_tip_caches_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let expected_header_head = chain.header_head().unwrap(); + let expected_body_head = chain.head().unwrap(); + let store = chain.get_store_for_tests(); + let corrupt_prev = Hash::from_vec(&[7; Hash::LEN]); + { + let batch = store.batch_write().unwrap(); + let mut header_head = expected_header_head; + header_head.prev_block_h = corrupt_prev; + header_head.total_difficulty = mwc_core::pow::Difficulty::from_num( + header_head.total_difficulty.to_num().saturating_add(1), + ); + let mut body_head = expected_body_head; + body_head.prev_block_h = corrupt_prev; + body_head.total_difficulty = mwc_core::pow::Difficulty::from_num( + body_head.total_difficulty.to_num().saturating_add(1), + ); + batch.save_header_head(&header_head).unwrap(); + batch.save_body_head(&body_head).unwrap(); + batch.commit().unwrap(); + } drop(chain); + drop(store); + + let restarted = init_automated_test_chain(&chain_dir, &secp); + assert_eq!(restarted.header_head().unwrap(), expected_header_head); + assert_eq!(restarted.head().unwrap(), expected_body_head); + + drop(restarted); let _ = fs::remove_dir_all(&chain_dir); } + fn test_spent_commitment_record(hash: Hash, height: u64) -> SpentCommitmentRecord { + SpentCommitmentRecord { + spending_block: HashHeight { hash, height }, + spent_output: CommitPos { pos: 1, height: 0 }, + } + } + #[test] - fn init_spent_commitment_index_rebuilds_retained_blocks() { + fn init_spent_commitment_index_rebuilds_from_retained_blocks() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_rebuilds_retained_blocks_{}", + "target/init_spent_commitment_index_rebuilds_from_retained_blocks_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); - let spent_commit = secp.commit_value(7).unwrap(); - let stale_commit = secp.commit_value(8).unwrap(); - let mut block = Block::default(0); - block.header.height = 9; - block.body.inputs = Inputs::FeaturesAndCommit(vec![mwc_core::core::Input::new( - OutputFeatures::Plain, - spent_commit, - )]); + let reused_commitment = secp.commit_value(17).unwrap(); + + let genesis = Block::default(0); + let mut block = retained_test_block(1, genesis.hash(0).unwrap(), 2); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, reused_commitment)]); let block_hash = block.hash(0).unwrap(); + let cached_old_occurrence = CommitPos { pos: 1, height: 0 }; { let batch = store.batch_write().unwrap(); - let tip = Tip::try_from_header(&block.header).unwrap(); - batch.save_block_header(&block.header).unwrap(); - batch.save_block(&block).unwrap(); - batch.save_body_head(&tip).unwrap(); - batch.save_body_tail(&tip).unwrap(); + for canonical_block in [&genesis, &block] { + batch.save_block_header(&canonical_block.header).unwrap(); + batch.save_block(canonical_block).unwrap(); + } batch - .save_spent_commitments( - &stale_commit, - HashHeight { - hash: Hash::from_vec(&[9; Hash::LEN]), - height: 99, - }, + .save_body_head(&Tip::try_from_header(&block.header).unwrap()) + .unwrap(); + batch + .save_body_tail(&Tip::try_from_header(&block.header).unwrap()) + .unwrap(); + // The per-block spent index is the position source for the rebuild. + batch + .save_spent_index( + &block_hash, + &[SpentOutput { + commitment: reused_commitment, + position: cached_old_occurrence, + }], ) .unwrap(); + // A stale conflicting record must be cleared before the rebuild + // rewrites the exact occurrence from the retained block. batch - .set_retained_spent_commitment_index_complete(false) + .save_spent_commitments( + &reused_commitment, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: block_hash, + height: block.header.height, + }, + spent_output: CommitPos { pos: 99, height: 5 }, + }, + ) .unwrap(); + // The completeness flag is unset, e.g. after a crash or an upgrade + // from a version without the index. batch.commit().unwrap(); } - Chain::init_spent_commitment_index(&store).unwrap(); - + Chain::init_spent_commitment_index(&store, None).unwrap(); let batch = store.batch_read().unwrap(); - assert!(batch.is_retained_spent_commitment_index_complete().unwrap()); - assert!(batch - .get_spent_commitments(&stale_commit) - .unwrap() - .is_none()); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); assert_eq!( - batch.get_spent_commitments(&spent_commit).unwrap(), - Some(vec![HashHeight { - hash: block_hash, - height: 9, + batch.get_spent_commitments(&reused_commitment).unwrap(), + Some(vec![SpentCommitmentRecord { + spending_block: HashHeight { + hash: block_hash, + height: block.header.height, + }, + spent_output: cached_old_occurrence, }]) ); @@ -4755,48 +11419,76 @@ mod tests { } #[test] - fn init_empty_retained_spent_commitment_index_clears_stale_entries_without_body_tail() { + fn init_spent_commitment_index_rebuild_uses_body_chain_only() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_empty_retained_spent_commitment_index_clears_stale_entries_without_body_tail_{}", + "target/init_spent_commitment_index_rebuild_uses_body_chain_only_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); - let stale_commit = secp.commit_value(8).unwrap(); - let block = retained_test_block(9, Hash::from_vec(&[1; Hash::LEN]), 1); - let tip = Tip::try_from_header(&block.header).unwrap(); + let canonical_commitment = secp.commit_value(61).unwrap(); + let fork_commitment = secp.commit_value(62).unwrap(); + + let genesis = Block::default(0); + let genesis_hash = genesis.hash(0).unwrap(); + let mut canonical_1 = retained_test_block(1, genesis_hash, 61); + canonical_1.body.inputs = Inputs::FeaturesAndCommit(vec![Input::new( + OutputFeatures::Plain, + canonical_commitment, + )]); + let canonical_1_hash = canonical_1.hash(0).unwrap(); + let canonical_2 = retained_test_block(2, canonical_1_hash, 63); + + let mut header_fork_1 = retained_test_block(1, genesis_hash, 71); + header_fork_1.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, fork_commitment)]); + let header_fork_2 = retained_test_block(2, header_fork_1.hash(0).unwrap(), 72); { let batch = store.batch_write().unwrap(); - batch.save_block_header(&block.header).unwrap(); - batch.save_body_head(&tip).unwrap(); + for block in [ + &genesis, + &canonical_1, + &canonical_2, + &header_fork_1, + &header_fork_2, + ] { + batch.save_block_header(&block.header).unwrap(); + batch.save_block(block).unwrap(); + } batch - .save_spent_commitments( - &stale_commit, - HashHeight { - hash: Hash::from_vec(&[9; Hash::LEN]), - height: 99, - }, - ) + .save_body_head(&Tip::try_from_header(&canonical_2.header).unwrap()) + .unwrap(); + // Deliberately select the competing branch as HEADER_HEAD. Rebuild + // canonicality must still come exclusively from body HEAD ancestry. + batch + .save_header_head(&Tip::try_from_header(&header_fork_2.header).unwrap()) .unwrap(); batch - .set_retained_spent_commitment_index_complete(false) + .save_spent_index( + &canonical_1_hash, + &[SpentOutput { + commitment: canonical_commitment, + position: CommitPos { pos: 1, height: 0 }, + }], + ) .unwrap(); + // No spent index is saved for header_fork_1. Scanning all retained + // bodies would fail here; a body-chain-only rebuild never loads it. batch.commit().unwrap(); } - Chain::init_empty_retained_spent_commitment_index(&store).unwrap(); - + Chain::init_spent_commitment_index(&store, None).unwrap(); let batch = store.batch_read().unwrap(); - assert!(batch.tail().is_err()); - assert!(batch.is_retained_spent_commitment_index_complete().unwrap()); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); assert!(batch - .get_spent_commitments(&stale_commit) + .get_spent_commitments(&canonical_commitment) .unwrap() - .is_none()); + .is_some()); + assert_eq!(batch.get_spent_commitments(&fork_commitment).unwrap(), None); drop(batch); drop(store); @@ -4804,97 +11496,228 @@ mod tests { } #[test] - fn init_spent_commitment_index_rejects_missing_retained_block() { + fn init_spent_commitment_index_rebuild_uses_canonical_head_height() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_rejects_missing_retained_block_{}", + "target/init_spent_commitment_index_rebuild_uses_canonical_head_height_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); - let mut block = Block::default(0); - block.header.height = 7; - let tip = Tip::try_from_header(&block.header).unwrap(); + let spent_commitment = secp.commit_value(29).unwrap(); + let genesis = Block::default(0); + let mut block = retained_test_block(1, genesis.hash(0).unwrap(), 29); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, spent_commitment)]); + let block_hash = block.hash(0).unwrap(); + let spent_output = CommitPos { pos: 1, height: 0 }; { let batch = store.batch_write().unwrap(); - batch.save_block_header(&block.header).unwrap(); - batch.save_body_head(&tip).unwrap(); - batch.save_body_tail(&tip).unwrap(); + for canonical_block in [&genesis, &block] { + batch.save_block_header(&canonical_block.header).unwrap(); + batch.save_block(canonical_block).unwrap(); + } + let mut cached_head = Tip::try_from_header(&block.header).unwrap(); + cached_head.height = 0; + batch.save_body_head(&cached_head).unwrap(); + batch + .save_spent_index( + &block_hash, + &[SpentOutput { + commitment: spent_commitment, + position: spent_output, + }], + ) + .unwrap(); + // Stale record with a corrupted height; the rebuild clears it and + // recreates the record from the retained block. batch - .set_retained_spent_commitment_index_complete(false) + .save_spent_commitments( + &spent_commitment, + test_spent_commitment_record(block_hash, 0), + ) .unwrap(); batch.commit().unwrap(); } - let err = Chain::init_spent_commitment_index(&store).unwrap_err(); - assert!(matches!(err, Error::SpentCommitmentIndexIncomplete)); - assert!(!store - .batch_read() - .unwrap() - .is_retained_spent_commitment_index_complete() - .unwrap()); + // The persisted Tip height is corrupted to 0. The canonical head is + // selected by `last_block_h`, so its real height drives a rebuild rather + // than the genesis empty-index path. + Chain::init_spent_commitment_index(&store, None).unwrap(); + let batch = store.batch_read().unwrap(); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); + assert_eq!( + batch.get_spent_commitments(&spent_commitment).unwrap(), + Some(vec![SpentCommitmentRecord { + spending_block: HashHeight { + hash: block_hash, + height: block.header.height, + }, + spent_output, + }]) + ); + drop(batch); drop(store); let _ = fs::remove_dir_all(&chain_dir); } #[test] - fn init_spent_commitment_index_advances_stale_body_tail_when_tail_body_is_missing() { + fn init_spent_commitment_index_rebuild_rejects_spent_index_body_mismatch() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_advances_stale_body_tail_when_tail_body_is_missing_{}", + "target/init_spent_commitment_index_rebuild_rejects_spent_index_body_mismatch_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); - let spent_commit = secp.commit_value(7).unwrap(); + let body_commitment = secp.commit_value(31).unwrap(); + let cached_commitment = secp.commit_value(32).unwrap(); - let old_tail = retained_test_block(1, Hash::from_vec(&[1; Hash::LEN]), 1); - let old_tail_hash = old_tail.hash(0).unwrap(); - let mut new_tail = retained_test_block(2, old_tail_hash, 2); - new_tail.body.inputs = Inputs::FeaturesAndCommit(vec![mwc_core::core::Input::new( - OutputFeatures::Plain, - spent_commit, - )]); - let new_tail_hash = new_tail.hash(0).unwrap(); - let head = retained_test_block(3, new_tail_hash, 3); + let genesis = Block::default(0); + let mut block = retained_test_block(1, genesis.hash(0).unwrap(), 31); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, body_commitment)]); + let block_hash = block.hash(0).unwrap(); + + { + let batch = store.batch_write().unwrap(); + for canonical_block in [&genesis, &block] { + batch.save_block_header(&canonical_block.header).unwrap(); + batch.save_block(canonical_block).unwrap(); + } + batch + .save_body_head(&Tip::try_from_header(&block.header).unwrap()) + .unwrap(); + // The cached spent index disagrees with the authenticated body. + // Local corruption must abort the rebuild instead of being promoted + // into the trusted index. + batch + .save_spent_index( + &block_hash, + &[SpentOutput { + commitment: cached_commitment, + position: CommitPos { pos: 1, height: 0 }, + }], + ) + .unwrap(); + batch.commit().unwrap(); + } + + let err = Chain::init_spent_commitment_index(&store, None).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("contains commitment") && msg.contains("not a body input") + )); + let batch = store.batch_read().unwrap(); + assert!(!batch.is_spent_commitment_record_index_complete().unwrap()); + + drop(batch); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn init_spent_commitment_index_rebuild_skips_blocks_below_horizon_window() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/init_spent_commitment_index_rebuild_skips_blocks_below_horizon_window_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let old_commitment = secp.commit_value(41).unwrap(); + let recent_commitment = secp.commit_value(42).unwrap(); + + // The testing horizon is 70 blocks. With the head at height 100 the + // rebuild window starts at height 30. Retain the complete body history to + // model an archive node; the rebuild must load only heights 31 through 100. + let mut canonical_blocks = vec![Block::default(0)]; + let mut prev_hash = canonical_blocks[0].hash(0).unwrap(); + for height in 1..=100 { + let mut block = retained_test_block(height, prev_hash, 100 + height); + if height == 2 { + block.body.inputs = Inputs::FeaturesAndCommit(vec![Input::new( + OutputFeatures::Plain, + old_commitment, + )]); + } else if height == 50 { + block.body.inputs = Inputs::FeaturesAndCommit(vec![Input::new( + OutputFeatures::Plain, + recent_commitment, + )]); + } + prev_hash = block.hash(0).unwrap(); + canonical_blocks.push(block); + } + let old_block = &canonical_blocks[2]; + let old_hash = old_block.hash(0).unwrap(); + let recent_block = &canonical_blocks[50]; + let recent_hash = recent_block.hash(0).unwrap(); + let head_block = &canonical_blocks[100]; + let head_hash = head_block.hash(0).unwrap(); { let batch = store.batch_write().unwrap(); - batch.save_block_header(&old_tail.header).unwrap(); - for block in [&new_tail, &head] { + for block in &canonical_blocks { batch.save_block_header(&block.header).unwrap(); batch.save_block(block).unwrap(); } + // Make one archived body below the horizon unreadable. A global + // BLOCK_PREFIX iterator would deserialize it and fail; a bounded body + // ancestry rebuild never touches the record. batch - .save_body_head(&Tip::try_from_header(&head.header).unwrap()) + .db + .put(&mwc_store::to_key(b'b', old_hash), &[1]) .unwrap(); batch - .save_body_tail(&Tip::try_from_header(&old_tail.header).unwrap()) + .save_spent_index( + &old_hash, + &[SpentOutput { + commitment: old_commitment, + position: CommitPos { pos: 1, height: 0 }, + }], + ) + .unwrap(); + batch + .save_spent_index( + &recent_hash, + &[SpentOutput { + commitment: recent_commitment, + position: CommitPos { pos: 2, height: 1 }, + }], + ) .unwrap(); + batch.save_spent_index(&head_hash, &[]).unwrap(); batch - .set_retained_spent_commitment_index_complete(false) + .save_body_head(&Tip::try_from_header(&head_block.header).unwrap()) .unwrap(); batch.commit().unwrap(); } - Chain::init_spent_commitment_index(&store).unwrap(); - + Chain::init_spent_commitment_index(&store, None).unwrap(); let batch = store.batch_read().unwrap(); - assert!(batch.is_retained_spent_commitment_index_complete().unwrap()); - assert_eq!( - batch.tail().unwrap(), - Tip::try_from_header(&new_tail.header).unwrap() - ); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); + // Below the window: no record is rebuilt even though the block and its + // spent index are still retained (archive-node behavior). + assert_eq!(batch.get_spent_commitments(&old_commitment).unwrap(), None); + // Inside the window: the record is rebuilt from the retained block. assert_eq!( - batch.get_spent_commitments(&spent_commit).unwrap(), - Some(vec![HashHeight { - hash: new_tail_hash, - height: 2, + batch.get_spent_commitments(&recent_commitment).unwrap(), + Some(vec![SpentCommitmentRecord { + spending_block: HashHeight { + hash: recent_hash, + height: recent_block.header.height, + }, + spent_output: CommitPos { pos: 2, height: 1 }, }]) ); @@ -4904,143 +11727,144 @@ mod tests { } #[test] - fn init_spent_commitment_index_rejects_missing_middle_retained_body() { + fn init_spent_commitment_index_rebuild_rejects_legacy_spent_index() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_rejects_missing_middle_retained_body_{}", + "target/init_spent_commitment_index_rebuild_rejects_legacy_spent_index_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); - let tail = retained_test_block(1, Hash::from_vec(&[1; Hash::LEN]), 1); - let tail_hash = tail.hash(0).unwrap(); - let missing_middle = retained_test_block(2, tail_hash, 2); - let missing_middle_hash = missing_middle.hash(0).unwrap(); - let head = retained_test_block(3, missing_middle_hash, 3); + let genesis = Block::default(0); + let mut block = retained_test_block(1, genesis.hash(0).unwrap(), 51); + block.body.inputs = Inputs::FeaturesAndCommit(vec![ + Input::new(OutputFeatures::Plain, secp.commit_value(51).unwrap()), + Input::new(OutputFeatures::Plain, secp.commit_value(52).unwrap()), + ]); + let block_hash = block.hash(0).unwrap(); { let batch = store.batch_write().unwrap(); - batch.save_block_header(&tail.header).unwrap(); - batch.save_block(&tail).unwrap(); - batch.save_block_header(&missing_middle.header).unwrap(); - batch.save_block_header(&head.header).unwrap(); - batch.save_block(&head).unwrap(); - batch - .save_body_head(&Tip::try_from_header(&head.header).unwrap()) - .unwrap(); + for canonical_block in [&genesis, &block] { + batch.save_block_header(&canonical_block.header).unwrap(); + batch.save_block(canonical_block).unwrap(); + } batch - .save_body_tail(&Tip::try_from_header(&tail.header).unwrap()) + .save_body_head(&Tip::try_from_header(&block.header).unwrap()) .unwrap(); + // Positions-only entry written by a pre-upgrade version. The rebuild + // must fail loudly; migrate_spent_index converts + // these entries before the rebuild runs. batch - .set_retained_spent_commitment_index_complete(false) + .save_spent_index_legacy( + &block_hash, + &[ + CommitPos { pos: 1, height: 0 }, + CommitPos { pos: 2, height: 0 }, + ], + ) .unwrap(); batch.commit().unwrap(); } - let err = Chain::init_spent_commitment_index(&store).unwrap_err(); - assert!(matches!(err, Error::SpentCommitmentIndexIncomplete)); - assert_eq!( - store.batch_read().unwrap().tail().unwrap(), - Tip::try_from_header(&tail.header).unwrap() - ); - assert_retained_spent_commitment_index_incomplete(&store); + let err = Chain::init_spent_commitment_index(&store, None).unwrap_err(); + assert!(matches!(err, Error::StoreErr(..))); + let batch = store.batch_read().unwrap(); + assert!(!batch.is_spent_commitment_record_index_complete().unwrap()); + drop(batch); drop(store); let _ = fs::remove_dir_all(&chain_dir); } #[test] - fn init_spent_commitment_index_rejects_missing_body_tail_on_non_fresh_chain() { + fn replay_attack_check_fails_when_spent_commitment_index_incomplete() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_rejects_missing_body_tail_on_non_fresh_chain_{}", + "target/replay_attack_check_fails_when_spent_commitment_index_incomplete_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); - let store = ChainStore::new(0, &chain_dir).unwrap(); - let mut block = Block::default(0); - block.header.height = 7; - let tip = Tip::try_from_header(&block.header).unwrap(); + let chain = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + global::get_genesis_block(&secp, 0).unwrap(), + pow::verify_size, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap(); { + let store = chain.get_store_for_tests(); let batch = store.batch_write().unwrap(); - batch.save_block_header(&block.header).unwrap(); - batch.save_block(&block).unwrap(); - batch.save_body_head(&tip).unwrap(); batch - .set_retained_spent_commitment_index_complete(false) + .set_spent_commitment_record_index_complete(false) .unwrap(); batch.commit().unwrap(); } - let err = Chain::init_spent_commitment_index(&store).unwrap_err(); - assert!(matches!(err, Error::Other(msg) if msg.contains("body tail is missing"))); - assert!(!store - .batch_read() - .unwrap() - .is_retained_spent_commitment_index_complete() - .unwrap()); + let err = chain + .replay_attack_check(&Transaction::empty()) + .unwrap_err(); + assert!(matches!(err, Error::SpentCommitmentIndexIncomplete)); - drop(store); + drop(chain); let _ = fs::remove_dir_all(&chain_dir); } #[test] - fn init_spent_commitment_index_rejects_skipped_retained_body_height() { + fn init_empty_spent_commitment_record_index_clears_stale_entries_without_body_tail() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain_dir = format!( - "target/init_spent_commitment_index_rejects_skipped_retained_body_height_{}", + "target/init_empty_spent_commitment_record_index_clears_stale_entries_without_body_tail_{}", std::process::id() ); let _ = fs::remove_dir_all(&chain_dir); let store = ChainStore::new(0, &chain_dir).unwrap(); + let stale_commit = secp.commit_value(8).unwrap(); + let block = retained_test_block(9, Hash::from_vec(&[1; Hash::LEN]), 1); + let tip = Tip::try_from_header(&block.header).unwrap(); - let tail = retained_test_block(1, Hash::from_vec(&[1; Hash::LEN]), 1); - let tail_hash = tail.hash(0).unwrap(); - let head = retained_test_block(3, tail_hash, 2); - save_retained_test_blocks(&store, &head, &tail, &[&tail, &head]); - - let err = Chain::init_spent_commitment_index(&store).unwrap_err(); - match err { - Error::Other(msg) => assert!( - msg.contains("expected height 2, found 1"), - "unexpected error: {}", - msg - ), - other => panic!("unexpected error: {:?}", other), + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&block.header).unwrap(); + batch.save_body_head(&tip).unwrap(); + batch + .save_spent_commitments( + &stale_commit, + test_spent_commitment_record(Hash::from_vec(&[9; Hash::LEN]), 99), + ) + .unwrap(); + batch + .set_spent_commitment_record_index_complete(false) + .unwrap(); + batch.commit().unwrap(); } - assert_retained_spent_commitment_index_incomplete(&store); - - drop(store); - let _ = fs::remove_dir_all(&chain_dir); - } - - #[test] - fn init_spent_commitment_index_rejects_non_descending_retained_body_height() { - global::set_local_chain_type(global::ChainTypes::AutomatedTesting); - global::set_local_nrd_enabled(false); - let chain_dir = format!( - "target/init_spent_commitment_index_rejects_non_descending_retained_body_height_{}", - std::process::id() - ); - let _ = fs::remove_dir_all(&chain_dir); - let store = ChainStore::new(0, &chain_dir).unwrap(); - let tail = retained_test_block(1, Hash::from_vec(&[1; Hash::LEN]), 1); - let tail_hash = tail.hash(0).unwrap(); - let same_height_prev = retained_test_block(3, tail_hash, 2); - let same_height_prev_hash = same_height_prev.hash(0).unwrap(); - let head = retained_test_block(3, same_height_prev_hash, 3); - save_retained_test_blocks(&store, &head, &tail, &[&tail, &same_height_prev, &head]); + Chain::init_empty_spent_commitment_record_index(&store).unwrap(); - let err = Chain::init_spent_commitment_index(&store).unwrap_err(); - assert!(matches!(err, Error::Other(msg) if msg.contains("expected height 2, found 3"))); - assert_retained_spent_commitment_index_incomplete(&store); + let batch = store.batch_read().unwrap(); + assert!(batch.tail().is_err()); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); + assert!(batch + .get_spent_commitments(&stale_commit) + .unwrap() + .is_none()); + drop(batch); drop(store); let _ = fs::remove_dir_all(&chain_dir); } @@ -5088,6 +11912,64 @@ mod tests { assert_eq!(orphans.len(), 1); } + #[test] + fn orphan_pool_caps_source_peers_on_first_insert() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + let pool = OrphanBlockPool::new(Arc::new(PibdParams::new())); + let block = Block::default(0); + let hash = block.hash(0).unwrap(); + let source_peers = (0..MAX_ORPHAN_SOURCE_PEERS + 2) + .map(|idx| format!("peer-{}", idx)) + .collect(); + + pool.add( + 0, + Orphan { + block, + opts: Options::NONE, + source_peers, + added: Instant::now(), + }, + ) + .unwrap(); + + let orphan = pool.get_orphan(&hash).unwrap(); + assert_eq!(orphan.source_peers.len(), MAX_ORPHAN_SOURCE_PEERS); + } + + #[test] + fn orphan_pool_caps_source_peers_for_duplicate_block() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + let pool = OrphanBlockPool::new(Arc::new(PibdParams::new())); + let block = Block::default(0); + let hash = block.hash(0).unwrap(); + + for idx in 0..MAX_ORPHAN_SOURCE_PEERS + 2 { + let source_peer = format!("peer-{}", idx); + pool.add( + 0, + Orphan { + block: block.clone(), + opts: Options::NONE, + source_peers: std::iter::once(source_peer).collect(), + added: Instant::now(), + }, + ) + .unwrap(); + } + + let orphan = pool.get_orphan(&hash).unwrap(); + assert_eq!(orphan.source_peers.len(), MAX_ORPHAN_SOURCE_PEERS); + for idx in 0..MAX_ORPHAN_SOURCE_PEERS { + assert!(orphan.source_peers.contains(&format!("peer-{}", idx))); + } + assert!(!orphan + .source_peers + .contains(&format!("peer-{}", MAX_ORPHAN_SOURCE_PEERS))); + } + #[test] fn orphan_pool_rejects_conflicting_body_for_same_header_hash() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); @@ -5142,6 +12024,135 @@ mod tests { assert!(orphan.block.body.outputs.is_empty()); } + #[test] + fn orphan_pool_rejects_conflicting_input_features_for_same_header_hash() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + let pool = OrphanBlockPool::new(Arc::new(PibdParams::new())); + let mut block = Block::default(0); + block.body.inputs = Inputs::FeaturesAndCommit(vec![test_input(OutputFeatures::Plain)]); + let hash = block.hash(0).unwrap(); + + let mut first_peers = HashSet::new(); + first_peers.insert("127.0.0.1:3414".to_string()); + pool.add( + 0, + Orphan { + block, + opts: Options::NONE, + source_peers: first_peers, + added: Instant::now(), + }, + ) + .unwrap(); + + // Same header and same input commitment, but different input features. + // Full-data serialization at the local protocol version converts + // feature-bearing inputs into commit-only wrappers and would treat + // this body as a duplicate, merging the honest sender into the + // poisoned entry's source_peers. When the poisoned body later fails + // validation as bad data, every merged source peer is reported via + // block_rejected and banned. + let mut conflicting_block = Block::default(0); + conflicting_block.body.inputs = + Inputs::FeaturesAndCommit(vec![test_input(OutputFeatures::Coinbase)]); + + let mut second_peers = HashSet::new(); + second_peers.insert("127.0.0.2:3414".to_string()); + let err = pool + .add( + 0, + Orphan { + block: conflicting_block, + opts: Options::NONE, + source_peers: second_peers, + added: Instant::now(), + }, + ) + .unwrap_err(); + + match err { + Error::Unfit(msg) => { + assert_eq!(msg, "conflicting orphan body for header"); + } + e => panic!("expected conflicting orphan body error, got {:?}", e), + } + + let orphan = pool.get_orphan(&hash).unwrap(); + assert_eq!(orphan.source_peers.len(), 1); + assert!(orphan.source_peers.contains("127.0.0.1:3414")); + match &orphan.block.body.inputs { + Inputs::FeaturesAndCommit(inputs) => { + assert_eq!(inputs.len(), 1); + assert!(inputs[0].is_plain()); + } + _ => panic!("expected feature-bearing inputs"), + } + } + + #[test] + fn orphan_pool_rejects_mismatched_inputs_variant_for_same_header_hash() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + let pool = OrphanBlockPool::new(Arc::new(PibdParams::new())); + let mut block = Block::default(0); + block.body.inputs = Inputs::CommitOnly(vec![CommitWrapper::from( + test_input(OutputFeatures::Plain).commitment(), + )]); + let hash = block.hash(0).unwrap(); + + let mut first_peers = HashSet::new(); + first_peers.insert("127.0.0.1:3414".to_string()); + pool.add( + 0, + Orphan { + block, + opts: Options::NONE, + source_peers: first_peers, + added: Instant::now(), + }, + ) + .unwrap(); + + // Same header and same input commitment, but the feature-bearing + // variant. Commit-only and feature-bearing inputs must never be + // deduplicated against each other: the lossy protocol-versioned + // serialization erases the variant distinction, opening the same + // source-peer misattribution vector as an input-features mismatch. + let mut conflicting_block = Block::default(0); + conflicting_block.body.inputs = + Inputs::FeaturesAndCommit(vec![test_input(OutputFeatures::Plain)]); + + let mut second_peers = HashSet::new(); + second_peers.insert("127.0.0.2:3414".to_string()); + let err = pool + .add( + 0, + Orphan { + block: conflicting_block, + opts: Options::NONE, + source_peers: second_peers, + added: Instant::now(), + }, + ) + .unwrap_err(); + + match err { + Error::Unfit(msg) => { + assert_eq!(msg, "conflicting orphan body for header"); + } + e => panic!("expected conflicting orphan body error, got {:?}", e), + } + + let orphan = pool.get_orphan(&hash).unwrap(); + assert_eq!(orphan.source_peers.len(), 1); + assert!(orphan.source_peers.contains("127.0.0.1:3414")); + match &orphan.block.body.inputs { + Inputs::CommitOnly(inputs) => assert_eq!(inputs.len(), 1), + _ => panic!("expected commit-only inputs"), + } + } + #[test] fn compact_eligibility_uses_tail_head_threshold() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); @@ -5152,15 +12163,183 @@ mod tests { let next_compact = tail_height + horizon + horizon / 10; assert_eq!( - Chain::compact_eligibility_for_heights(context_id, tail_height, next_compact - 1), + Chain::compact_eligibility_for_heights(context_id, tail_height, next_compact - 1) + .unwrap(), (false, next_compact) ); assert_eq!( - Chain::compact_eligibility_for_heights(context_id, tail_height, next_compact), + Chain::compact_eligibility_for_heights(context_id, tail_height, next_compact).unwrap(), (true, next_compact) ); } + #[test] + fn compact_eligibility_uses_headers_selected_by_head_and_tail() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!( + "target/compact_eligibility_canonical_tips_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 78); + + let canonical_head = chain.store.head().unwrap(); + let horizon = u64::from(global::cut_through_horizon(0)); + let target_height = canonical_head.height.saturating_sub(horizon); + let canonical_tail_header = chain.get_header_by_height(target_height + 1).unwrap(); + let canonical_tail = Tip::try_from_header(&canonical_tail_header).unwrap(); + assert!(target_height < canonical_tail.height); + + // Corrupt only the redundant cached heights. The selected headers remain + // authoritative. The old raw-height calculation deemed this eligible and + // selected a target below the header selected by BODY_TAIL. + let mut stale_head = canonical_head; + stale_head.height = u64::MAX; + let mut stale_tail = canonical_tail; + stale_tail.height = 0; + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_head(&stale_head).unwrap(); + batch.save_body_tail(&stale_tail).unwrap(); + batch.commit().unwrap(); + } + + let next_compact = canonical_tail + .height + .saturating_add(horizon.saturating_add(horizon / 10)); + assert_eq!(chain.compact_eligibility().unwrap(), (false, next_compact)); + chain.compact(None, Arc::new(StopState::new())).unwrap(); + assert_eq!(chain.store.head().unwrap(), stale_head); + assert_eq!(chain.store.tail().unwrap(), stale_tail); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn compact_rejects_off_chain_tail_when_ineligible() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!( + "target/compact_ineligible_off_chain_tail_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 4); + let context_id = chain.get_context_id(); + let body_head_header = chain.head_header().unwrap(); + let off_chain_tail_header = recovery_test_header( + body_head_header.height, + chain.genesis().hash(context_id).unwrap(), + 901, + ); + assert_ne!( + off_chain_tail_header.hash(context_id).unwrap(), + body_head_header.hash(context_id).unwrap() + ); + let off_chain_tail = Tip::try_from_header(&off_chain_tail_header).unwrap(); + + // Both selectors are individually canonical and height-ordered, but the + // tail is not the body-chain header at its selected height. Its height makes + // compaction ineligible, so the early-return path must still reject it. + { + let batch = chain.store.batch_write().unwrap(); + batch.save_block_header(&off_chain_tail_header).unwrap(); + batch.save_body_tail(&off_chain_tail).unwrap(); + batch.commit().unwrap(); + } + + let err = chain.compact(None, Arc::new(StopState::new())).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("compact eligibility BODY_TAIL") + && msg.contains("is not on the durable body chain") + )); + assert_eq!(chain.store.tail().unwrap(), off_chain_tail); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn compact_rejects_unusable_target_block_before_marker() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = format!( + "target/compact_target_block_preflight_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = mine_chain(&chain_dir, 78); + let context_id = chain.get_context_id(); + // Automated-testing block processing advances BODY_TAIL with each batch. + // Move it back to genesis so this test exercises an eligible compaction. + let genesis_tail = Tip::try_from_header(&chain.genesis()).unwrap(); + { + let batch = chain.store.batch_write().unwrap(); + batch.save_body_tail(&genesis_tail).unwrap(); + batch.commit().unwrap(); + } + let original_tail = chain.store.tail().unwrap(); + let head = chain.store.head().unwrap(); + let horizon = u64::from(global::cut_through_horizon(context_id)); + let target_height = head.height.saturating_sub(horizon); + let target_header = chain.get_header_by_height(target_height).unwrap(); + let target_hash = target_header.hash(context_id).unwrap(); + let target_block = chain.get_block_for_header(&target_header).unwrap(); + + // Raw loss of the retained full-block record must be detected before a + // durable Compact marker is installed. + { + let batch = chain.store.batch_write().unwrap(); + batch.delete(&mwc_store::to_key(b'b', target_hash)).unwrap(); + batch.commit().unwrap(); + } + let err = chain.compact(None, Arc::new(StopState::new())).unwrap_err(); + assert!(matches!( + err, + Error::StoreErr(NotFoundErr(_), ref context) + if context.contains("compact target BODY_TAIL preflight load full block") + )); + assert_eq!(chain.store.tail().unwrap(), original_tail); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + // Restore the record, then model a same-key record whose complete header + // differs from the separately stored canonical ancestry header. + let mut corrupted_target = target_block.clone(); + corrupted_target.header.height = corrupted_target.header.height.saturating_add(100); + assert_eq!(corrupted_target.hash(context_id).unwrap(), target_hash); + assert_ne!(corrupted_target.header, target_header); + { + let batch = chain.store.batch_write().unwrap(); + batch + .db + .put_ser(&mwc_store::to_key(b'b', target_hash), &corrupted_target) + .unwrap(); + batch.commit().unwrap(); + } + let err = chain.compact(None, Arc::new(StopState::new())).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("compact target BODY_TAIL preflight") + && msg.contains("does not exactly match persisted ancestry header") + )); + assert_eq!(chain.store.tail().unwrap(), original_tail); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + assert!(!chain.requires_init_recovery.load(Ordering::SeqCst)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn combine_positioned_outputs_and_rangeproofs_rejects_position_mismatch() { let outputs = (10, vec![(1, test_output_identifier())]); diff --git a/chain/src/error.rs b/chain/src/error.rs index da43612eaf..102938d90c 100644 --- a/chain/src/error.rs +++ b/chain/src/error.rs @@ -51,6 +51,14 @@ pub enum Error { /// The proof of work is invalid #[error("Invalid PoW")] InvalidPow, + /// The proof context does not match the chain context. + #[error("Block header proof context mismatch: expected {expected}, got {actual}")] + InvalidHeaderContext { + /// Context assigned to the chain store. + expected: u32, + /// Context embedded in the supplied proof. + actual: u32, + }, /// Peer abusively sending us an old block we already have #[error("Old Block")] OldBlock, @@ -139,6 +147,15 @@ pub enum Error { /// Error with the txhashset #[error("TxHashSetErr: {0}")] TxHashSetErr(String), + /// A PMMR sync failed after durable persistence may already have begun. + #[error("{context}: PMMR sync failed after persistence may have begun: {source}")] + PmmrSyncStateUncertain { + /// Sync stage for diagnostics. + context: String, + /// Underlying I/O failure. + #[source] + source: io::Error, + }, /// A readonly or rollback txhashset/header PMMR operation failed to discard changes. #[error("{context}: failed to discard txhashset/header PMMR changes: {discard}")] TxHashSetDiscard { @@ -166,6 +183,18 @@ pub enum Error { /// Tx is not valid due to NRD relative_height restriction. #[error("NRD Relative Height")] NRDRelativeHeight, + /// A body rewind target is older than the locally supported horizon. + #[error( + "Cannot rewind body from head height {head_height} to target height {target_height}: minimum supported height is {minimum_height}" + )] + RewindBeyondHorizon { + /// Height of the authenticated body head. + head_height: u64, + /// Requested rewind target height. + target_height: u64, + /// Oldest height to which this body head may rewind. + minimum_height: u64, + }, /// No chain exists and genesis block is required #[error("Genesis Block Required")] GenesisBlockRequired, @@ -240,6 +269,30 @@ pub enum Error { /// Invalid genesis hash. #[error("Invalid genesis hash")] InvalidGenesisHash, + /// Persisted chain metadata or header ancestry is internally inconsistent. + #[error("Invalid persisted chain state: {0}")] + InvalidPersistedChainState(String), + /// A durable database head requires PMMR entries no longer present in the backend files. + /// + /// Rewind-only reconciliation cannot manufacture the missing entries. Startup + /// must stop with the pending operation marker intact so an operator can reset + /// or resynchronize chain state explicitly. + #[error( + "Automatic PMMR recovery stopped before mutation: {0}. The pending chain-operation marker was retained; reset or resynchronize chain state before restarting" + )] + PmmrRecoveryRequired(String), + /// Durable chain state was committed, but the follow-up recovery failed. + /// + /// The nested error describes a node-local post-commit failure and must not + /// be used to attribute bad data to the peer that supplied the accepted data. + #[error("{context}: chain state was committed, but recovery failed: {source}")] + CommittedRecoveryFailed { + /// Operation whose durable commit preceded the recovery failure. + context: String, + /// Underlying recovery failure, retained for diagnostics. + #[source] + source: Box, + }, /// Desegmenter creation error #[error("Unable to create desegmenter, {0}")] DesegmenterCreationError(String), @@ -255,6 +308,15 @@ pub enum Error { } impl Error { + /// Preserve the post-commit phase when exposing a recovery failure through + /// the public chain error type. + pub(crate) fn committed_recovery_failed(context: impl Into, source: Error) -> Self { + Self::CommittedRecoveryFailed { + context: context.into(), + source: Box::new(source), + } + } + /// Whether the error is due to a block that was intrinsically wrong pub fn is_bad_data(&self) -> bool { match self { @@ -266,6 +328,7 @@ impl Error { | Error::InvalidHash | Error::InvalidScaling | Error::InvalidPow + | Error::InvalidHeaderContext { .. } | Error::OldBlock | Error::InvalidBlockTime | Error::InvalidBlockHeight @@ -312,6 +375,9 @@ impl Error { | Error::Orphan(_) | Error::Keychain(_) | Error::StoreErr(_, _) + | Error::InvalidPersistedChainState(_) + | Error::PmmrRecoveryRequired(_) + | Error::CommittedRecoveryFailed { .. } | Error::FileReadErr(_) | Error::IOErr(_) | Error::SerErr(_) @@ -319,8 +385,10 @@ impl Error { | Error::KernelPosIndexIncomplete | Error::SpentCommitmentIndexIncomplete | Error::TxHashSetErr(_) + | Error::PmmrSyncStateUncertain { .. } | Error::TxHashSetDiscard { .. } | Error::PMMRErr(_) + | Error::RewindBeyondHorizon { .. } | Error::GenesisBlockRequired | Error::Other(_) | Error::ChainRestartRequired @@ -362,6 +430,14 @@ impl Error { ) } + /// Whether a pending chain-operation marker must be retained for recovery. + pub fn requires_chain_recovery(&self) -> bool { + matches!( + self, + Error::PmmrSyncStateUncertain { .. } | Error::CommittedRecoveryFailed { .. } + ) || self.is_txhashset_discard_failure() + } + /// Whether this error represents missing chain data. pub fn is_not_found(&self) -> bool { matches!( @@ -423,11 +499,21 @@ mod tests { fn is_bad_data_returns_false_for_local_internal_errors() { let local_errors = vec![ Error::FileReadErr("append-only file read failed".into()), + Error::PmmrRecoveryRequired("durable HEAD is ahead of output PMMR".into()), Error::ChainRestartRequired, Error::Stopped, Error::Bitmap, Error::KernelPosIndexIncomplete, + Error::RewindBeyondHorizon { + head_height: 100, + target_height: 29, + minimum_height: 30, + }, Error::Other("generic local failure".into()), + Error::PmmrSyncStateUncertain { + context: "header_extending sync".into(), + source: io::Error::new(io::ErrorKind::Other, "forced sync failure"), + }, Error::SyncError("sync state unavailable".into()), Error::AbortingPIBDError, Error::ChainInSyncing("headers are still syncing".into()), @@ -447,6 +533,10 @@ mod tests { fn is_bad_data_returns_true_for_explicit_bad_remote_data() { let bad_data_errors = vec![ Error::InvalidPow, + Error::InvalidHeaderContext { + expected: 7, + actual: 11, + }, Error::InputMismatch(Commitment::from_vec([1; 33].to_vec()).unwrap()), Error::ReplayAttack(Commitment::from_vec([2; 33].to_vec()).unwrap(), 10, 20), Error::InvalidRoot("output root mismatch".into()), @@ -461,6 +551,23 @@ mod tests { } } + #[test] + fn committed_recovery_failure_is_never_bad_peer_data() { + let err = Error::committed_recovery_failed( + "process_block_header committed marker cleanup", + Error::InvalidRoot("forced local recovery mismatch".into()), + ); + + assert!(!err.is_bad_data(), "{:?}", err); + assert!(err.requires_chain_recovery()); + assert!(matches!( + &err, + Error::CommittedRecoveryFailed { context, source } + if context == "process_block_header committed marker cleanup" + && matches!(source.as_ref(), Error::InvalidRoot(_)) + )); + } + #[test] fn known_block_errors_are_classified_separately() { assert!(Error::OldBlock.is_known_block()); @@ -483,6 +590,23 @@ mod tests { assert!(err.is_bad_data(), "{:?}", err); } + #[test] + fn uncertain_pmmr_sync_and_discard_failures_require_chain_recovery() { + let sync_err = Error::PmmrSyncStateUncertain { + context: "header_extending sync".into(), + source: io::Error::new(io::ErrorKind::Other, "forced sync failure"), + }; + assert!(sync_err.requires_chain_recovery()); + + let discard_err = Error::TxHashSetDiscard { + context: "header_extending rollback".into(), + discard: Box::new(Error::TxHashSetErr("forced discard failure".into())), + }; + assert!(discard_err.requires_chain_recovery()); + + assert!(!Error::InvalidRoot("pre-sync validation failure".into()).requires_chain_recovery()); + } + #[test] fn pow_consensus_data_overflow_converts_to_chain_data_overflow() { let err = Error::from(pow::Error::ConsensusError(consensus::Error::DataOverflow( diff --git a/chain/src/lib.rs b/chain/src/lib.rs index c2e7dd4d13..5361cd0801 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -38,9 +38,255 @@ mod tests; // Re-export the base interface -pub use crate::chain::Chain; +pub use crate::chain::{Chain, OutputReadSnapshot}; pub use crate::error::Error; pub use crate::store::ChainStore; pub use crate::types::{ BlockStatus, ChainAdapter, Options, SyncState, SyncStatus, Tip, TxHashsetStateValidationStage, }; + +use mwc_core::core::hash::{Hash, Hashed}; +use mwc_core::core::{Block, BlockHeader}; +use std::collections::HashSet; + +/// Load a persisted header by hash and require the record to hash back to its key. +pub(crate) fn checked_header_by_hash( + context_id: u32, + expected_hash: &Hash, + operation: &str, + load: F, +) -> Result +where + F: FnOnce(&Hash) -> Result, +{ + let header = load(expected_hash) + .map_err(|e| Error::StoreErr(e, format!("{} load header {}", operation, expected_hash)))?; + let loaded_hash = header.hash(context_id)?; + if loaded_hash != *expected_hash { + return Err(Error::InvalidPersistedChainState(format!( + "{} header key/hash mismatch: requested {}, loaded {}", + operation, expected_hash, loaded_hash + ))); + } + Ok(header) +} + +/// Load a persisted full block selected by an already-traversed complete header. +/// +/// Hash serialization identifies the packed PoW proof rather than the complete +/// header. Normal block ingestion makes that a safe identity by validating PoW +/// and the body commitments. State-maintenance paths do not repeat those +/// expensive checks, so a loaded record must retain the exact validated header. +/// +/// This is a persisted-state invariant check, not an assumption that a peer can +/// cheaply produce two different consensus-valid headers with the same hash. +/// A mismatch means the independently stored header and full-block records are +/// inconsistent, for example after corruption or an unvalidated local write. +/// This checks cross-record header identity only; it does not independently +/// revalidate the block body against the header's cumulative MMR commitments. +pub(crate) fn checked_block_for_header( + context_id: u32, + expected: &BlockHeader, + operation: &str, + load: F, +) -> Result +where + F: FnOnce(&Hash) -> Result, +{ + let expected_hash = expected.hash(context_id)?; + let block = load(&expected_hash).map_err(|e| { + Error::StoreErr( + e, + format!("{} load full block {}", operation, expected_hash), + ) + })?; + let loaded_hash = block.header.hash(context_id)?; + if loaded_hash != expected_hash { + return Err(Error::InvalidPersistedChainState(format!( + "{} loaded full block {} from key {}", + operation, loaded_hash, expected_hash + ))); + } + if block.header != *expected { + return Err(Error::InvalidPersistedChainState(format!( + "{} full block {} header at height {} does not exactly match persisted ancestry header at height {}", + operation, expected_hash, block.header.height, expected.height + ))); + } + + Ok(block) +} + +/// Load and validate one step through persisted block-header ancestry. +/// +/// Normal PoW validation binds the complete header to the proof-derived hash. +/// This maintenance helper does not repeat PoW, so it verifies key/hash and +/// structural ancestry invariants while trusting persisted headers. +pub(crate) fn checked_previous_header( + context_id: u32, + current: &BlockHeader, + visited: &mut HashSet, + operation: &str, + load: F, +) -> Result +where + F: FnOnce(&Hash) -> Result, +{ + let current_hash = current.hash(context_id)?; + if !visited.insert(current_hash) { + return Err(Error::InvalidPersistedChainState(format!( + "{} encountered repeated header {} at height {}", + operation, current_hash, current.height + ))); + } + + let expected_height = current.height.checked_sub(1).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} attempted to traverse before genesis header {}", + operation, current_hash + )) + })?; + + if visited.contains(¤t.prev_hash) { + return Err(Error::InvalidPersistedChainState(format!( + "{} encountered header ancestry cycle from {} at height {} to {}", + operation, current_hash, current.height, current.prev_hash + ))); + } + + let previous = load(¤t.prev_hash).map_err(|e| { + Error::StoreErr( + e, + format!( + "{} load previous header {} for {} at height {}", + operation, current.prev_hash, current_hash, current.height + ), + ) + })?; + let previous_hash = previous.hash(context_id)?; + if previous_hash != current.prev_hash { + return Err(Error::InvalidPersistedChainState(format!( + "{} loaded header {} from key {} while traversing {} at height {}", + operation, previous_hash, current.prev_hash, current_hash, current.height + ))); + } + if previous.height != expected_height { + return Err(Error::InvalidPersistedChainState(format!( + "{} expected predecessor {} at height {}, found height {}", + operation, previous_hash, expected_height, previous.height + ))); + } + + Ok(previous) +} + +#[cfg(test)] +mod checked_ancestry_tests { + use super::*; + use mwc_core::global::{self, ChainTypes}; + + fn header(height: u64, proof_nonce: u64) -> BlockHeader { + let mut header = BlockHeader::default(0); + header.height = height; + if let Some(nonce) = header.pow.proof.nonces.last_mut() { + *nonce = proof_nonce; + } + header + } + + #[test] + fn checked_block_for_header_rejects_same_hash_different_header() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let expected = header(1, 1); + let mut altered_block = Block::default(0); + altered_block.header = expected.clone(); + altered_block.header.height = 42; + altered_block.header.prev_hash = Hash::from_vec(&[7; Hash::LEN]); + + // This deliberately models corrupt persisted state, not two + // consensus-valid blocks. No collision search is needed because these + // fields are absent from hash serialization; normal PoW validation would + // reject the altered header. + assert_eq!(altered_block.hash(0).unwrap(), expected.hash(0).unwrap()); + assert_ne!(altered_block.header, expected); + + let err = + checked_block_for_header( + 0, + &expected, + "same-hash test", + |_| Ok(altered_block.clone()), + ) + .unwrap_err(); + + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("does not exactly match persisted ancestry header") + )); + } + + #[test] + fn checked_previous_header_rejects_self_cycle() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let mut current = header(1, 1); + current.prev_hash = current.hash(0).unwrap(); + let mut visited = HashSet::new(); + + let err = checked_previous_header(0, ¤t, &mut visited, "self-cycle test", |_| { + Ok(current.clone()) + }) + .unwrap_err(); + + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) if msg.contains("cycle") + )); + } + + #[test] + fn checked_previous_header_rejects_two_header_cycle() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let mut previous = header(1, 2); + let mut current = header(2, 1); + current.prev_hash = previous.hash(0).unwrap(); + previous.prev_hash = current.hash(0).unwrap(); + let mut visited = HashSet::new(); + + let loaded = + checked_previous_header(0, ¤t, &mut visited, "two-header cycle test", |_| { + Ok(previous.clone()) + }) + .unwrap(); + let err = + checked_previous_header(0, &loaded, &mut visited, "two-header cycle test", |_| { + Ok(current.clone()) + }) + .unwrap_err(); + + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) if msg.contains("cycle") + )); + } + + #[test] + fn checked_previous_header_rejects_height_gap() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let previous = header(1, 2); + let mut current = header(3, 1); + current.prev_hash = previous.hash(0).unwrap(); + let mut visited = HashSet::new(); + + let err = checked_previous_header(0, ¤t, &mut visited, "height-gap test", |_| { + Ok(previous.clone()) + }) + .unwrap_err(); + + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("expected predecessor") && msg.contains("height 2") + )); + } +} diff --git a/chain/src/pipe.rs b/chain/src/pipe.rs index 582e040848..aaa2a1cb72 100644 --- a/chain/src/pipe.rs +++ b/chain/src/pipe.rs @@ -15,10 +15,11 @@ //! Implementation of the chain block acceptance (or refusal) pipeline. +use crate::chain::{blocks_equal_as_v3, bodies_equal_header_committed}; use crate::error::Error; use crate::store; use crate::txhashset; -use crate::types::{CommitPos, Options, Tip}; +use crate::types::{CommitPos, Options, SpentCommitmentRecord, SpentOutput, Tip}; use mwc_core::consensus; use mwc_core::consensus::HeaderDifficultyInfo; use mwc_core::core::hash::{Hash, Hashed}; @@ -28,17 +29,19 @@ use mwc_core::core::{block, Block, BlockHeader, BlockSums, OutputIdentifier, Tra use mwc_core::difficulty_cache::DifficultyCache; use mwc_core::global; use mwc_core::pow; -use mwc_core::ser::{self, ProtocolVersion}; use mwc_crates::crossbeam; use mwc_crates::lazy_static::lazy_static; use mwc_crates::log::{debug, error, info}; use mwc_crates::num_cpus; use mwc_crates::parking_lot::{RwLock, RwLockWriteGuard}; +use mwc_crates::secp::pedersen::Commitment; use mwc_crates::secp::Secp256k1; use mwc_store::Error::NotFoundErr; use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; use std::iter::FromIterator; +use std::panic::{self, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, Ordering}; /// Contextual information required to process a new block and either reject or /// accept it. @@ -114,6 +117,20 @@ pub fn init_invalid_block_hashes(context_id: u32, hashes: HashSet) { .extend(hashes); } +/// Validate that a caller-supplied header belongs to the active chain context. +/// This must run before hashing, serializing, or performing PoW operations on +/// the header because proof packing consults global parameters by context ID. +pub fn validate_header_context_id(context_id: u32, header: &BlockHeader) -> Result<(), Error> { + let actual = header.pow.proof.context_id; + if actual != context_id { + return Err(Error::InvalidHeaderContext { + expected: context_id, + actual, + }); + } + Ok(()) +} + /// Validate the block hash, check if it is banned pub fn validate_header_hash(context_id: u32, hash: &Hash) -> Result<(), Error> { let hashes = INVALID_BLOCK_HASHES.read_recursive(); @@ -145,11 +162,48 @@ fn check_known( Ok(KnownStatus::Unknown) } +/// Return true only when the complete incoming header exactly matches the +/// stored record and cannot improve the current header chain. +/// +/// Hash equality alone is insufficient before PoW validation because header +/// hash mode serializes only the proof. An exact stored header with more work +/// than `header_head` is deliberately reported as unknown so it can be +/// reapplied after a reset or recovery. +pub(crate) fn is_exact_known_header( + context_id: u32, + header: &BlockHeader, + header_head: &Tip, + batch: &store::Batch<'_>, +) -> Result { + let bh = header.hash(context_id)?; + match batch.get_block_header(&bh) { + Ok(existing) => { + if existing != *header { + return Err(Error::Block(block::Error::Other( + "known header hash matches a different header".into(), + ))); + } + Ok(!has_more_work(&existing, header_head)) + } + Err(NotFoundErr(_)) => Ok(false), + Err(e) => Err(Error::StoreErr(e, "pipe get exact known header".to_owned())), + } +} + /// Check the outputs of this block against spent outputs in the LMDB within the -/// cut-through horizon. Older duplicates are accepted consistently, including -/// by archive nodes that still retain their historical spent-commitment entries. +/// replay window (half the cut-through horizon). Older duplicates are accepted +/// consistently, including by archive nodes that still retain their historical +/// spent-commitment entries. +/// +/// This policy must not be treated as proof that a commitment has only one raw +/// output occurrence in locally retained history. Coinbase outputs are excluded +/// below, compaction can lag the configured horizon, and archive nodes retain +/// deeper history. Code that restores or preserves a historical output position +/// must therefore authenticate the position independently rather than relying on +/// this replay window. pub fn check_against_spent_output( tx: &TransactionBody, + replay_tip_height: u64, fork_point_height: Option, local_branch_blocks: Option<&Vec>, header_extension: &txhashset::HeaderExtension<'_>, @@ -162,40 +216,46 @@ pub fn check_against_spent_output( .iter() .filter(|output| !output.is_coinbase()) .map(|output| output.identifier.commit); - let tip = batch - .head() - .map_err(|e| Error::Other(format!("Unable to get a head from batch, {}", e)))?; - // Note, using half of horizon because it covers our need for mwc-wallet. - // We better don't use all cut_through_horizon, because in case of deep reorg, number - // of indexed blocks can be reduced and become less than cut_through_horizon. - // We really don't want index to be incomplete to validate commit existance - let replay_horizon_height = tip - .height + // Use the candidate state immediately before this transaction or block is + // applied. The durable body head can belong to a different branch and is not + // advanced between blocks in a validation series. + // + // Half of the cut-through horizon is the configured replay window. Using the + // full horizon would make deep reorgs more likely to require pruned history. + let replay_horizon_height = replay_tip_height .saturating_sub(u64::from(global::cut_through_horizon(batch.get_context_id())) / 2); - let fork_height = fork_point_height.unwrap_or(tip.height); - //convert the list of local branch bocks header hashes to a hash set for quick search + let fork_height = fork_point_height.unwrap_or(replay_tip_height); + // Convert the local-branch block header hashes to a set for quick lookup. let empty_vec = Vec::new(); let local_branch_blocks_list = local_branch_blocks.unwrap_or(&empty_vec); let local_branch_blocks_set = HashSet::<&Hash>::from_iter(local_branch_blocks_list.iter()); - if !batch.is_retained_spent_commitment_index_complete()? { + // This is deliberately a best-effort check over locally indexed blocks. + // "Complete" means the canonical body window has a trusted baseline; it does + // not promise pre-PIBD history or coverage of old retained forks. Subsequent + // block validation adds canonical and fork records incrementally. Missing + // historical coverage must not trigger chain resets because it can also occur + // during synchronization, recovery, and reorgs. + if !batch.is_spent_commitment_record_index_complete()? { return Err(Error::SpentCommitmentIndexIncomplete); } for commit in output_commits { let commit_hash = batch.get_spent_commitments(&commit)?; // check to see if this commitment is in the spent records in db if let Some(c_hash) = commit_hash { - for hash_val in c_hash { + for record in c_hash { + let hash_val = record.spending_block; let header = batch.get_block_header(&hash_val.hash)?; - if header.height != hash_val.height { + let loaded_hash = header.hash(batch.get_context_id())?; + if loaded_hash != hash_val.hash || header.height != hash_val.height { return Err(Error::TxHashSetErr(format!( - "spent commitment index height mismatch for block {}: index height {}, header height {}", - hash_val.hash, hash_val.height, header.height + "spent commitment index block mismatch for {} at height {}: stored header hashes to {} at height {}", + hash_val.hash, hash_val.height, loaded_hash, header.height ))); } - // Keep the boundary inclusive. Only spends strictly below - // tip - CUT_THROUGH_HORIZON are accepted. + // Keep the boundary inclusive. Only spends strictly below the + // candidate replay tip minus CUT_THROUGH_HORIZON / 2 are accepted. if header.height < replay_horizon_height { continue; } @@ -207,7 +267,11 @@ pub fn check_against_spent_output( "output contains spent commtiment:{:?} from local branch", commit ); - return Err(Error::ReplayAttack(commit, tip.height, hash_val.height)); + return Err(Error::ReplayAttack( + commit, + replay_tip_height, + hash_val.height, + )); } else if header.height <= fork_height { if header_extension .is_on_current_chain(Tip::try_from_header(&header)?, batch)? @@ -216,7 +280,11 @@ pub fn check_against_spent_output( "output contains spent commtiment:{:?} from the main chain", commit ); - return Err(Error::ReplayAttack(commit, tip.height, hash_val.height)); + return Err(Error::ReplayAttack( + commit, + replay_tip_height, + hash_val.height, + )); } } } @@ -268,38 +336,91 @@ fn validate_pow_batch_parallel( headers: &[BlockHeader], ctx: &BlockContext<'_>, ) -> Result<(), Error> { - let skip_pow = ctx.skip_pow(); + validate_pow_batch_parallel_inner(context_id, headers, ctx.pow_verifier, ctx.skip_pow()) +} +fn validate_pow_batch_parallel_inner( + context_id: u32, + headers: &[BlockHeader], + pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + skip_pow: bool, +) -> Result<(), Error> { if skip_pow || headers.len() <= 32 { for header in headers { - validate_pow_only(context_id, header, ctx.pow_verifier, skip_pow)?; + validate_pow_only(context_id, header, pow_verifier, skip_pow)?; } return Ok(()); } let worker_count = num_cpus::get().max(1).min(headers.len()); let chunk_size = (headers.len() + worker_count - 1) / worker_count; - let pow_verifier = ctx.pow_verifier; + // PoW validation is deliberately fail-fast. Once one chunk rejects the + // batch, continuing through otherwise valid chunks gives an untrusted peer + // an avoidable CPU-amplification opportunity. + let cancelled = AtomicBool::new(false); let verify_result = crossbeam::thread::scope(|s| { let mut handles = Vec::with_capacity(worker_count); + let mut first_error = None; + let mut worker_panicked = false; for chunk in headers.chunks(chunk_size) { - handles.push(s.spawn(move |_| { - for header in chunk { - validate_pow_only(context_id, header, pow_verifier, false)?; + let cancelled = &cancelled; + let handle = match s.builder().spawn(move |_| { + let result = panic::catch_unwind(AssertUnwindSafe(|| { + for header in chunk { + if cancelled.load(Ordering::Relaxed) { + return Ok(()); + } + validate_pow_only(context_id, header, pow_verifier, false)?; + } + Ok::<(), Error>(()) + })); + + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => { + cancelled.store(true, Ordering::Relaxed); + Err(e) + } + Err(payload) => { + cancelled.store(true, Ordering::Relaxed); + panic::resume_unwind(payload) + } } - Ok::<(), Error>(()) - })); + }) { + Ok(handle) => handle, + Err(e) => { + cancelled.store(true, Ordering::Relaxed); + first_error = Some(Error::Other(format!( + "failed to spawn header PoW worker: {}", + e + ))); + break; + } + }; + handles.push(handle); } for handle in handles { - let result = handle - .join() - .map_err(|_| Error::Other("header PoW crossbeam runtime failure".into()))?; - result?; + match handle.join() { + Ok(Ok(())) => {} + Ok(Err(e)) => { + if first_error.is_none() { + first_error = Some(e); + } + } + Err(_) => worker_panicked = true, + } } - Ok::<(), Error>(()) + if worker_panicked { + return Err(Error::Other("header PoW crossbeam runtime failure".into())); + } + + match first_error { + Some(e) => Err(e), + None => Ok(()), + } }) .map_err(|_| Error::Other("header PoW crossbeam runtime failure".into()))?; verify_result @@ -412,6 +533,9 @@ pub fn process_blocks_series( let first_block = blocks.first().ok_or(Error::Other( "Invalid process_blocks_series param blocks - it is empty".into(), ))?; + for block in blocks { + validate_header_context_id(context_id, &block.header)?; + } debug!( "pipe: process_blocks_series {} at {}, blocks in series: {}", first_block.hash(context_id)?, @@ -477,8 +601,9 @@ pub fn process_blocks_series( // Treat a known full block as duplicate only after the incoming block has // passed PoW and internal block validation. Before that, the proof-derived // header hash alone is not a safe identity for peer-supplied full blocks. - if let Some(e) = check_known_exact_full_block(context_id, first_block, &head, ctx)?.into_error() - { + // Simple mutations fail those checks; this ordering prevents them from being + // incorrectly accepted as already-known data first. + if let Some(e) = check_known_full_blocks(context_id, blocks, &head, &ctx.batch)?.into_error() { return Err(e); } @@ -490,8 +615,9 @@ pub fn process_blocks_series( let header_pmmr = &mut ctx.header_pmmr; let txhashset = &mut ctx.txhashset; let batch = &mut ctx.batch; + let mut retained_spent_records = Vec::new(); + let mut retained_spent_indexes = Vec::new(); let fork_point = txhashset::extending(header_pmmr, txhashset, batch, |ext, batch| { - *state_may_have_changed = true; let fork_point_local_blocks = rewind_and_apply_fork(context_id, &prev, ext, batch, secp)?; let fork_point = fork_point_local_blocks.0; @@ -525,9 +651,21 @@ pub fn process_blocks_series( // Apply the block to the txhashset state. // Validate the txhashset roots and sizes against the block header. // Block is invalid if there are any discrepencies. - apply_block_to_txhashset(b, ext, batch)?; - - local_branch_blocks.push(b.hash(context_id)?); // appending processed block to the local branch + let block_spent_records = apply_block_to_txhashset(b, ext, batch)?; + let block_hash = b.hash(context_id)?; + retained_spent_indexes.push(( + block_hash, + block_spent_records + .iter() + .map(|(commitment, record)| SpentOutput { + commitment: *commitment, + position: record.spent_output, + }) + .collect::>(), + )); + retained_spent_records.extend(block_spent_records); + + local_branch_blocks.push(block_hash); // appending processed block to the local branch } // The txhashset extension and the later BODY_HEAD update must make the @@ -539,6 +677,14 @@ pub fn process_blocks_series( Ok(fork_point) })?; + // A successful higher-work extension has committed its child batch and + // synchronized the body PMMRs. Any later error requires reconciliation. + // Ordinary pre-sync extension errors have already discarded their provisional + // PMMR changes. Sync-stage and discard failures are explicitly classified as + // recovery-worthy by their error variants. + if series_has_more_work { + *state_may_have_changed = true; + } // Add the validated block to the db. // Note we do this in the outer batch, not the child batch from the extension @@ -547,6 +693,16 @@ pub fn process_blocks_series( for b in blocks { add_block(b, &ctx.batch)?; } + // A losing-fork extension deliberately rolls its child batch back, but the + // fully validated block body is retained. Persist its spent-position cache + // and exact records in the outer batch as well. On a winning branch this is + // idempotent. + for (block_hash, spent_index) in retained_spent_indexes { + ctx.batch.save_spent_index(&block_hash, &spent_index)?; + } + for (commitment, record) in retained_spent_records { + ctx.batch.save_spent_commitments(&commitment, record)?; + } // If we have no "tail" then set it now. match ctx.batch.tail() { @@ -583,8 +739,13 @@ pub fn replay_attack_check( }; if b.header.height > height_limit && global::is_replay_protection_enabled() { + let replay_tip_height = ext.extension.head().height; + if replay_tip_height.checked_add(1) != Some(b.header.height) { + return Err(Error::InvalidBlockHeight); + } check_against_spent_output( &b.body, + replay_tip_height, Some(fork_point_height), Some(local_branch_blocks), ext.header_extension, @@ -607,6 +768,9 @@ pub fn process_block_headers( if headers.is_empty() { return Ok(None); } + for header in headers { + validate_header_context_id(context_id, header)?; + } let last_header = headers.last().ok_or(Error::Other( "process_block_headers internal error, headers param is empty".into(), ))?; @@ -752,36 +916,42 @@ pub fn process_block_header( ctx: &mut BlockContext<'_>, state_may_have_changed: &mut bool, ) -> Result<(), Error> { - // If we have already processed the full block for this header then done. + validate_header_context_id(context_id, header)?; + + // Denylist policy is authoritative even for exact headers already in the + // store. Keep this ahead of every known-header success shortcut. + validate_header_hash(context_id, &header.hash(context_id)?)?; + + // If we have already processed the full block for this header then done, + // unless the header can still improve a rewound header chain. // Note: "already known" in this context is success so subsequent processing can continue. + let head = ctx.batch.head()?; + let header_head = ctx.batch.header_head()?; + if check_known(context_id, header, &head, ctx)?.is_known() + && !has_more_work(header, &header_head) { - let head = ctx.batch.head()?; - if check_known(context_id, header, &head, ctx)?.is_known() { - return Ok(()); - } + return Ok(()); } - // Check this header is not an orphan, we must know about the previous header to continue. - let prev_header = prev_header_store(header, &ctx.batch)?; + // Check this header is not an orphan, we must know about the previous header + // to continue. A missing parent must not bypass standalone PoW validation: + // callers may use the proof-derived header hash as an orphan-cache key, and + // that hash binds the rest of the header only after its PoW is authenticated. + let prev_header = match prev_header_store(header, &ctx.batch) { + Ok(prev_header) => prev_header, + Err(e @ Error::Orphan(_)) => { + validate_pow_only(context_id, header, ctx.pow_verifier, ctx.skip_pow())?; + return Err(e); + } + Err(e) => return Err(e), + }; // If we have not yet seen the full block then check if we have seen this header. // If it does not increase total_difficulty beyond our current header_head // then we can (re)accept this header and process the full block (or request it). // This header is on a fork and we should still accept it as the fork may eventually win. - let header_head = ctx.batch.header_head()?; - match ctx.batch.get_block_header(&header.hash(context_id)?) { - Ok(existing) => { - if existing != *header { - return Err(Error::Block(block::Error::Other( - "known header hash matches a different header".into(), - ))); - } - if !has_more_work(&existing, &header_head) { - return Ok(()); - } - } - Err(NotFoundErr(_)) => {} - Err(e) => return Err(Error::StoreErr(e, "pipe check existing header".to_owned())), + if is_exact_known_header(context_id, header, &header_head, &ctx.batch)? { + return Ok(()); } // We want to validate this individual header before applying it to our header PMMR. @@ -789,21 +959,29 @@ pub fn process_block_header( // Apply the header to the header PMMR, making sure we put the extension in the correct state // based on previous header first. + let improves_header_head = has_more_work(header, &header_head); txhashset::header_extending(&mut ctx.header_pmmr, &mut ctx.batch, |ext, batch| { - *state_may_have_changed = true; rewind_and_apply_header_fork(context_id, &prev_header, ext, batch)?; ext.validate_root(header)?; ext.apply_header(header)?; - if !has_more_work(&header, &header_head) { + if !improves_header_head { ext.force_rollback(); } Ok(()) })?; + // A successful higher-work extension has committed its child batch and + // synchronized the header PMMR. Any later error requires reconciliation. + // Ordinary pre-sync extension errors have already discarded their provisional + // PMMR changes. Sync-stage and discard failures are explicitly classified as + // recovery-worthy by their error variants. + if improves_header_head { + *state_may_have_changed = true; + } // Add this new block header to the db. add_block_header(header, &ctx.batch)?; - if has_more_work(header, &header_head) { + if improves_header_head { update_header_head(&Tip::try_from_header(header)?, &mut ctx.batch)?; } @@ -876,19 +1054,41 @@ fn check_known_exact_full_block( context_id: u32, block: &Block, head: &Tip, - ctx: &BlockContext<'_>, + batch: &store::Batch<'_>, ) -> Result { let bh = block.hash(context_id)?; - let existing = match ctx.batch.get_block(&bh) { + let existing = match batch.get_block(&bh) { Ok(existing) => existing, Err(NotFoundErr(_)) => return Ok(KnownStatus::Unknown), Err(e) => return Err(Error::StoreErr(e, "pipe get known full block".to_owned())), }; + if existing.header != block.header { + return Err(Error::Block(block::Error::Other( + "known block hash matches a different header".into(), + ))); + } - let existing_bytes = ser::ser_vec(context_id, &existing, ProtocolVersion::local())?; - let incoming_bytes = ser::ser_vec(context_id, block, ProtocolVersion::local())?; - if existing_bytes != incoming_bytes { - return Ok(KnownStatus::Unknown); + // Outputs, rangeproofs and kernels are committed by the header's cumulative + // MMR roots. Once both the stored block and candidate have passed internal + // validation, different encodings here prove that the candidate cannot match + // this header (absent a cryptographic hash collision). Classify this as bad + // remote data so the caller can reject the peer instead of silently returning + // the generic duplicate/control-flow error below. + if !bodies_equal_header_committed(context_id, &existing.body, &block.body)? { + return Err(Error::InvalidRoot(format!( + "header-committed body conflicts with stored block {}", + bh + ))); + } + + // This is a trusted-store-versus-candidate comparison, not orphan + // deduplication. Normalize v2 input features only for this known-block + // decision; see blocks_equal_as_v3 for the safety boundary and tradeoff. + if !blocks_equal_as_v3(context_id, &existing, block)? { + return Err(Error::Unfit(format!( + "conflicting full block inputs for existing header {}", + bh + ))); } if bh == head.last_block_h || bh == head.prev_block_h { @@ -904,6 +1104,27 @@ fn check_known_exact_full_block( } } +/// Classify the first block for existing duplicate handling and reject a +/// conflicting stored body anywhere in the series before opening a txhashset +/// extension. Later exact bodies retain their existing reapplication behavior. +fn check_known_full_blocks( + context_id: u32, + blocks: &[Block], + head: &Tip, + batch: &store::Batch<'_>, +) -> Result { + let first = blocks.first().ok_or_else(|| { + Error::InvalidBlocksSeries("cannot check known status for an empty block series".into()) + })?; + let first_status = check_known_exact_full_block(context_id, first, head, batch)?; + for block in blocks.iter().skip(1) { + // Ignore duplicate status for later blocks, matching the existing series + // behavior, but never ignore a conflicting body error. + check_known_exact_full_block(context_id, block, head, batch)?; + } + Ok(first_status) +} + // Find the previous header from the store. // Return an Orphan error if we cannot find the previous header. fn prev_header_store(header: &BlockHeader, batch: &store::Batch<'_>) -> Result { @@ -1022,12 +1243,13 @@ fn apply_block_to_txhashset( block: &Block, ext: &mut txhashset::ExtensionPair<'_>, batch: &store::Batch<'_>, -) -> Result<(), Error> { - ext.extension +) -> Result, Error> { + let spent_records = ext + .extension .apply_block(block, ext.header_extension, batch)?; ext.extension.validate_roots(&block.header)?; ext.extension.validate_sizes(&block.header)?; - Ok(()) + Ok(spent_records) } /// Officially adds the block to our chain (possibly on a losing fork). @@ -1090,24 +1312,82 @@ pub fn rewind_and_apply_header_fork( ext: &mut txhashset::HeaderExtension<'_>, batch: &store::Batch<'_>, ) -> Result<(), Error> { - let mut fork_hashes = vec![]; + rewind_and_apply_header_fork_impl(context_id, header, ext, batch, None) +} + +/// Rewind the header PMMR to an authoritative persisted header during +/// incomplete-operation recovery. +/// +/// Unlike the normal path, membership is compared directly against PMMR data +/// after `authenticate_header` binds every complete persisted header to its +/// proof. The speculative PMMR suffix being discarded may have no BlockHeader +/// records because its enclosing DB transaction never committed. Persisted +/// ancestry is also not re-evaluated against the mutable denylist during this +/// durability repair. +pub(crate) fn rewind_and_apply_header_fork_for_recovery( + context_id: u32, + header: &BlockHeader, + ext: &mut txhashset::HeaderExtension<'_>, + batch: &store::Batch<'_>, + authenticate_header: &dyn Fn(&BlockHeader) -> Result<(), Error>, +) -> Result<(), Error> { + rewind_and_apply_header_fork_impl(context_id, header, ext, batch, Some(authenticate_header)) +} + +fn rewind_and_apply_header_fork_impl( + context_id: u32, + header: &BlockHeader, + ext: &mut txhashset::HeaderExtension<'_>, + batch: &store::Batch<'_>, + recovery_authenticator: Option<&dyn Fn(&BlockHeader) -> Result<(), Error>>, +) -> Result<(), Error> { + let mut fork_headers = vec![]; let mut current = header.clone(); - while current.height > 0 { - let current_tip = Tip::try_from_header(¤t)?; - if ext.is_on_current_chain(current_tip, batch)? { + let mut visited = HashSet::new(); + loop { + // HeaderEntry and the indexed PMMR leaf hash do not commit to every + // BlockHeader field. Recovery must therefore cryptographically bind the + // complete persisted header to its proof before trusting a PMMR match. + if let Some(authenticate_header) = recovery_authenticator { + authenticate_header(¤t)?; + } + + // A header already represented by the current PMMR was accepted earlier + // and is the fork point. Do not re-evaluate it or its ancestors against the + // mutable denylist; only fork headers appended below use the current list. + let on_current_chain = if recovery_authenticator.is_some() { + ext.is_persisted_header_on_current_chain(¤t)? + } else { + ext.is_header_on_current_chain(¤t, batch)? + }; + if on_current_chain { break; } - fork_hashes.push(current_tip.last_block_h); - current = batch.get_previous_header(¤t)?; + if current.height == 0 { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_and_apply_header_fork reached noncanonical genesis {}", + current.hash(context_id)? + ))); + } + fork_headers.push(current.clone()); + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "rewind_and_apply_header_fork", + |hash| batch.get_block_header(hash), + )?; } - fork_hashes.reverse(); + fork_headers.reverse(); let forked_header = current; // Rewind the txhashset state back to the block where we forked from the most work chain. ext.rewind(&forked_header)?; - let invalid_block_hashes = { + let invalid_block_hashes = if recovery_authenticator.is_some() { + HashSet::new() + } else { let invalid_hashes = INVALID_BLOCK_HASHES.read_recursive(); if let Some(blocked_hashes) = invalid_hashes.get(&context_id) { blocked_hashes.clone() @@ -1117,13 +1397,10 @@ pub fn rewind_and_apply_header_fork( }; // Re-apply all headers on this fork. - for h in fork_hashes { - let header = batch - .get_block_header(&h) - .map_err(|e| Error::StoreErr(e, "getting forked headers".to_string()))?; - - // Re-validate every header being re-applied. - // This makes it possible to check all header hashes against the ctx specific "denylist". + for header in fork_headers { + // Recheck the denylist and header-MMR linkage while reapplying. Normal + // operation relies on admission-time PoW validation; recovery repeated PoW + // authentication above before adding this header to fork_headers. let header_hash = header.hash(context_id)?; if invalid_block_hashes.contains(&header_hash) { return Err(Error::Block(block::Error::Other( @@ -1149,41 +1426,128 @@ pub fn rewind_and_apply_fork( ext: &mut txhashset::ExtensionPair<'_>, batch: &store::Batch<'_>, secp: &Secp256k1, +) -> Result<(BlockHeader, Vec), Error> { + rewind_and_apply_fork_impl(context_id, header, ext, batch, secp, None) +} + +/// Reconcile body PMMR state to an authoritative durable body head. +/// +/// The header traversal used as a temporary body-fork index authenticates +/// persisted ancestry but deliberately ignores the mutable denylist. This must +/// remain restricted to incomplete-operation recovery; peer admission and +/// explicit bad-block rewinds use [`rewind_and_apply_fork`]. +pub(crate) fn rewind_and_apply_fork_for_recovery( + context_id: u32, + header: &BlockHeader, + ext: &mut txhashset::ExtensionPair<'_>, + batch: &store::Batch<'_>, + secp: &Secp256k1, + authenticate_header: &dyn Fn(&BlockHeader) -> Result<(), Error>, +) -> Result<(BlockHeader, Vec), Error> { + rewind_and_apply_fork_impl( + context_id, + header, + ext, + batch, + secp, + Some(authenticate_header), + ) +} + +fn rewind_and_apply_fork_impl( + context_id: u32, + header: &BlockHeader, + ext: &mut txhashset::ExtensionPair<'_>, + batch: &store::Batch<'_>, + secp: &Secp256k1, + recovery_authenticator: Option<&dyn Fn(&BlockHeader) -> Result<(), Error>>, ) -> Result<(BlockHeader, Vec), Error> { let extension = &mut ext.extension; let header_extension = &mut ext.header_extension; // Prepare the header MMR. - rewind_and_apply_header_fork(context_id, header, header_extension, batch)?; + if let Some(authenticate_header) = recovery_authenticator { + rewind_and_apply_header_fork_for_recovery( + context_id, + header, + header_extension, + batch, + authenticate_header, + )?; + } else { + rewind_and_apply_header_fork(context_id, header, header_extension, batch)?; + } // Rewind the txhashset extension back to common ancestor based on header MMR. let mut current = batch.head_header()?; - while current.height > 0 - && !header_extension.is_on_current_chain(Tip::try_from_header(¤t)?, batch)? - { - current = batch.get_previous_header(¤t)?; + let mut visited = HashSet::new(); + while !header_extension.is_header_on_current_chain(¤t, batch)? { + if current.height == 0 { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_and_apply_fork body ancestry reached noncanonical genesis {}", + current.hash(context_id)? + ))); + } + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "rewind_and_apply_fork body ancestry", + |hash| batch.get_block_header(hash), + )?; } let fork_point = current; - extension.rewind(&fork_point, batch, header_extension, None)?; // Then apply all full blocks since this common ancestor // to put txhashet extension in a state to accept the new block. - let mut fork_hashes = vec![]; + let mut fork_headers = vec![]; let mut current = header.clone(); + let mut visited = HashSet::new(); while current.height > fork_point.height { - fork_hashes.push(current.hash(context_id)?); - current = batch.get_previous_header(¤t)?; + fork_headers.push(current.clone()); + current = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "rewind_and_apply_fork fork ancestry", + |hash| batch.get_block_header(hash), + )?; + } + if current != fork_point { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_and_apply_fork expected fork point {} at height {}, found {} at height {}", + fork_point.hash(context_id)?, + fork_point.height, + current.hash(context_id)?, + current.height + ))); + } + fork_headers.reverse(); + + // Preflight every full block before rewinding or applying body state. These + // persisted records are not passed through normal PoW validation again, so + // each must carry the exact validated header selected by ancestry traversal. + // A mismatch indicates inconsistent local state, not an accepted peer block. + for expected_header in &fork_headers { + crate::checked_block_for_header( + context_id, + expected_header, + "rewind_and_apply_fork preflight", + |hash| batch.get_block(hash), + )?; } - fork_hashes.reverse(); - for h in &fork_hashes { - let fb = match batch - .get_block(&h) - .map_err(|e| Error::StoreErr(e, "getting forked blocks".to_string())) - { - Ok(fb) => fb, - Err(e) => return Err(e), - }; + extension.rewind(&fork_point, batch, None)?; + + let mut fork_hashes = Vec::with_capacity(fork_headers.len()); + for expected_header in &fork_headers { + let fb = crate::checked_block_for_header( + context_id, + expected_header, + "rewind_and_apply_fork apply", + |hash| batch.get_block(hash), + )?; + fork_hashes.push(expected_header.hash(context_id)?); // Re-verify coinbase maturity along this fork. verify_coinbase_maturity(context_id, &fb, ext, batch)?; @@ -1212,3 +1576,170 @@ fn validate_utxo( .utxo_view(header_extension) .validate_block(block, batch) } + +#[cfg(test)] +mod tests { + use super::*; + use mwc_core::core::{CommitWrapper, Inputs, Output, OutputFeatures}; + use mwc_crates::secp::pedersen::RangeProof; + use mwc_crates::secp::ContextFlag; + use std::cell::Cell; + use std::fs; + use std::sync::atomic::{AtomicBool as TestAtomicBool, AtomicUsize}; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + use std::time::Duration; + + static POW_TEST_BARRIER: Mutex>> = Mutex::new(None); + static POW_TEST_CALLS: AtomicUsize = AtomicUsize::new(0); + static POW_TEST_REJECTED: TestAtomicBool = TestAtomicBool::new(false); + + thread_local! { + static POW_TEST_FIRST_CALL: Cell = const { Cell::new(true) }; + } + + fn reject_first_parallel_pow(_: u32, header: &BlockHeader) -> Result<(), pow::Error> { + POW_TEST_CALLS.fetch_add(1, Ordering::SeqCst); + let first_call = POW_TEST_FIRST_CALL.with(|first| first.replace(false)); + + if first_call { + let barrier = POW_TEST_BARRIER + .lock() + .expect("PoW test barrier lock poisoned") + .as_ref() + .expect("PoW test barrier not initialized") + .clone(); + barrier.wait(); + } + + if header.height == 0 { + POW_TEST_REJECTED.store(true, Ordering::SeqCst); + return Err(pow::Error::Verification( + "forced parallel PoW failure".into(), + )); + } + + if first_call { + while !POW_TEST_REJECTED.load(Ordering::SeqCst) { + thread::yield_now(); + } + // Give the rejecting worker time to publish the production cancellation + // flag before the other workers attempt their next header. + thread::sleep(Duration::from_millis(100)); + } + Ok(()) + } + + #[test] + fn parallel_pow_validation_cancels_remaining_headers_after_error() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let mut headers = vec![BlockHeader::default(0); 512]; + for (height, header) in headers.iter_mut().enumerate() { + header.height = height as u64; + // Secondary PoW classification does not consult per-thread chain + // parameters, so this test isolates worker cancellation behavior. + header.pow.proof.edge_bits = consensus::SECOND_POW_EDGE_BITS; + } + + let worker_count = num_cpus::get().max(1).min(headers.len()); + let chunk_size = (headers.len() + worker_count - 1) / worker_count; + let chunk_count = headers.chunks(chunk_size).count(); + POW_TEST_CALLS.store(0, Ordering::SeqCst); + POW_TEST_REJECTED.store(false, Ordering::SeqCst); + *POW_TEST_BARRIER + .lock() + .expect("PoW test barrier lock poisoned") = Some(Arc::new(Barrier::new(chunk_count))); + + let err = validate_pow_batch_parallel_inner(0, &headers, reject_first_parallel_pow, false) + .unwrap_err(); + + *POW_TEST_BARRIER + .lock() + .expect("PoW test barrier lock poisoned") = None; + assert!(matches!(err, Error::InvalidPow), "{err:?}"); + assert_eq!(POW_TEST_CALLS.load(Ordering::SeqCst), chunk_count); + } + + #[test] + fn known_full_block_preflight_rejects_later_series_conflict() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/known_full_block_preflight_rejects_later_series_conflict_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain_store = store::ChainStore::new(0, &chain_dir).unwrap(); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + let mut first = Block::default(0); + first.header.height = 1; + first.header.pow.proof.nonces[0] = 1; + + let mut stored_second = Block::default(0); + stored_second.header.height = 2; + stored_second.header.pow.proof.nonces[0] = 2; + stored_second.body.inputs = + Inputs::CommitOnly(vec![CommitWrapper::from(secp.commit_value(1).unwrap())]); + let mut conflicting_second = stored_second.clone(); + conflicting_second.body.inputs = + Inputs::CommitOnly(vec![CommitWrapper::from(secp.commit_value(2).unwrap())]); + + let first_hash = first.hash(0).unwrap(); + let second_hash = stored_second.hash(0).unwrap(); + assert_ne!(first_hash, second_hash); + assert_eq!(second_hash, conflicting_second.hash(0).unwrap()); + + let batch = chain_store.batch_write().unwrap(); + batch.save_block_header(&first.header).unwrap(); + batch.save_block(&first).unwrap(); + batch.save_block_header(&stored_second.header).unwrap(); + batch.save_block(&stored_second).unwrap(); + + // Even though the first element is already the current head, preflight + // must inspect the rest of the series before duplicate handling returns. + let mut head = Tip::default(); + head.height = first.header.height; + head.last_block_h = first_hash; + let err = check_known_full_blocks( + 0, + &[first.clone(), conflicting_second.clone()], + &head, + &batch, + ) + .unwrap_err(); + match &err { + Error::Unfit(msg) => assert!(msg.contains("conflicting full block inputs"), "{}", msg), + other => panic!("expected conflicting-body rejection, got {:?}", other), + } + assert!(!err.is_known_block()); + assert!(!err.is_bad_data()); + + // A conflict in outputs, rangeproofs or kernels cannot match the roots in + // the already validated header. Unlike the input-only case above, this is + // unambiguously invalid peer data and must reach peer rejection/scoring. + let mut committed_conflict = stored_second.clone(); + committed_conflict.body.outputs.push(Output::new( + OutputFeatures::Plain, + secp.commit_value(3).unwrap(), + RangeProof::zero(), + )); + let committed_err = + check_known_full_blocks(0, &[first.clone(), committed_conflict], &head, &batch) + .unwrap_err(); + assert!( + matches!(&committed_err, Error::InvalidRoot(msg) if msg.contains("header-committed body conflicts")), + "{:?}", + committed_err + ); + assert!(committed_err.is_bad_data()); + assert!(!committed_err.is_known_block()); + + let persisted = batch.get_block(&second_hash).unwrap(); + assert!(blocks_equal_as_v3(0, &persisted, &stored_second).unwrap()); + assert!(!blocks_equal_as_v3(0, &persisted, &conflicting_second).unwrap()); + + drop(batch); + drop(chain_store); + let _ = fs::remove_dir_all(&chain_dir); + } +} diff --git a/chain/src/store.rs b/chain/src/store.rs index 84ce7bf6d0..eda1d17d92 100644 --- a/chain/src/store.rs +++ b/chain/src/store.rs @@ -16,7 +16,7 @@ //! Implements storage primitives required by the chain use crate::linked_list::MultiIndex; -use crate::types::{CommitPos, HashHeight, KernelPos, Tip}; +use crate::types::{CommitPos, KernelPos, SpentCommitmentRecord, SpentOutput, Tip}; use mwc_core::consensus::{self, HeaderDifficultyInfo}; use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::{Block, BlockHeader, BlockSums, Inputs}; @@ -29,6 +29,7 @@ use mwc_crates::log::debug; use mwc_crates::secp::pedersen::Commitment; use mwc_store::{option_to_not_found, to_key, to_key_u64, Error}; use std::convert::{TryFrom, TryInto}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; const STORE_SUBPATH: &str = "chain"; @@ -49,10 +50,11 @@ pub const NRD_KERNEL_ENTRY_PREFIX: u8 = b'k'; const BLOCK_INPUT_BITMAP_PREFIX: u8 = b'B'; const BLOCK_SUMS_PREFIX: u8 = b'M'; const BLOCK_SPENT_PREFIX: u8 = b'S'; -/// Replay index keyed by output commitment. Values are block hashes/heights of -/// retained full blocks that spent the commitment. On normal nodes this is not -/// an all-history spent-output set because compacted block bodies are pruned -/// and their entries are deleted with them. +/// Replay and spent-occurrence index keyed by output commitment. Values identify +/// both the retained block that spent the commitment and the exact output PMMR +/// occurrence it consumed. On normal nodes this is not an all-history set +/// because compacted block bodies are pruned and their entries are deleted with +/// them. const BLOCK_SPENT_COMMITMENT_PREFIX: u8 = b'C'; /// Prefix for various boolean flags stored in the db. @@ -65,8 +67,30 @@ const BLOCKS_V3_MIGRATED: &str = "blocks_v3_migrated"; const KERNEL_POS_INDEX_COMPLETE: &str = "kernel_pos_index_complete"; /// Boolean flag for output_pos index completeness. const OUTPUT_POS_INDEX_COMPLETE: &str = "output_pos_index_complete"; -/// Boolean flag for spent commitment replay index completeness across retained full blocks. -const RETAINED_SPENT_COMMITMENT_INDEX_COMPLETE: &str = "retained_spent_commitment_index_complete"; +/// Boolean marker that the spent commitment index contains a complete baseline +/// for canonical body blocks within one cut-through horizon of the head. +/// Subsequent validation may add records for locally processed forks. +const SPENT_COMMITMENT_RECORD_INDEX_COMPLETE: &str = "spent_commitment_record_index_complete"; +/// Boolean marker that legacy positions-only per-block spent indexes have been +/// migrated to the exact `SpentOutput` occurrence format. +const SPENT_INDEX_MIGRATED: &str = "spent_index_migrated"; + +/// Compare a trusted stored block with a candidate using the canonical v3 +/// database representation. V3 intentionally normalizes legacy +/// feature-bearing inputs to commitment-only inputs. +pub(crate) fn blocks_equal_as_v3( + context_id: u32, + stored: &Block, + candidate: &Block, +) -> Result { + if stored.header != candidate.header { + return Ok(false); + } + + let version = ProtocolVersion(3); + Ok(ser::ser_vec(context_id, &stored.body, version)? + == ser::ser_vec(context_id, &candidate.body, version)?) +} /// All chain-related database operations pub struct ChainStore { @@ -290,10 +314,98 @@ impl<'a> Batch<'a> { self.db.exists(&to_key(BLOCK_PREFIX, h)) } + /// Whether any persisted block-header records exist. + pub(crate) fn has_any_block_headers(&self) -> Result { + self.has_any_prefixed_records(BLOCK_HEADER_PREFIX) + } + + /// Whether any persisted full-block records exist. + pub(crate) fn has_any_full_blocks(&self) -> Result { + self.has_any_prefixed_records(BLOCK_PREFIX) + } + + fn has_any_prefixed_records(&self, record_prefix: u8) -> Result { + let prefix = to_key(record_prefix, ""); + let mut entries = self.db.iter(&prefix, |_, _| Ok(()))?; + match entries.next() { + Some(entry) => { + entry?; + Ok(true) + } + None => Ok(false), + } + } + + /// Whether any persisted chain records exist that cannot be created by the + /// pre-setup migration of an otherwise fresh database. + /// + /// `BLOCKS_V3_MIGRATED` is deliberately excluded because startup writes it + /// before `setup_head()` even when the chain database has never held a block. + pub(crate) fn has_any_auxiliary_chain_state(&self) -> Result { + for prefix in [ + OUTPUT_POS_PREFIX, + KERNEL_POS_PREFIX, + NRD_KERNEL_LIST_PREFIX, + NRD_KERNEL_ENTRY_PREFIX, + BLOCK_INPUT_BITMAP_PREFIX, + BLOCK_SUMS_PREFIX, + BLOCK_SPENT_PREFIX, + BLOCK_SPENT_COMMITMENT_PREFIX, + CHAIN_MARKER_PREFIX, + ] { + if self.has_any_prefixed_records(prefix)? { + return Ok(true); + } + } + + // Existence, rather than the decoded boolean value, is the freshness + // signal. A persisted false completeness flag still proves that setup or + // recovery previously reached this database. + for flag in [ + KERNEL_POS_INDEX_COMPLETE, + OUTPUT_POS_INDEX_COMPLETE, + SPENT_COMMITMENT_RECORD_INDEX_COMPLETE, + ] { + if self.db.exists(&to_key(BOOL_FLAG_PREFIX, flag))? { + return Ok(true); + } + } + + Ok(false) + } + /// Save the block to the db. /// Note: the block header is not saved to the db here, assumes this has already been done. + /// This is a low-level persistence primitive: it does not validate PoW or the + /// body, so production callers must perform consensus validation first. It + /// enforces exact identity with the separately stored complete header and + /// preserves any canonical full body already stored under the block hash. pub fn save_block(&self, b: &Block) -> Result<(), Error> { let block_hash = b.hash(self.get_context_id())?; + let stored_header = self.get_block_header(&block_hash)?; + if stored_header != b.header { + return Err(Error::OtherErr(format!( + "refusing to save full block {} with a header that differs from the separately stored header", + block_hash + ))); + } + + match self.get_block(&block_hash) { + Ok(existing) if blocks_equal_as_v3(self.get_context_id(), &existing, b)? => { + // Preserve the validated body that first claimed this header. This is + // also the idempotent path for equivalent v2/v3 input encodings. + return Ok(()); + } + Ok(_) => { + return Err(Error::OtherErr(format!( + "refusing to overwrite full block {} with a different body for the same header", + block_hash + ))); + } + Err(e) if e.store_error_is_not_found() => {} + Err(e) => return Err(e), + } + debug!( "save_block: {} at {} ({} -> v{})", block_hash, @@ -305,9 +417,11 @@ impl<'a> Batch<'a> { Ok(()) } - /// We maintain a "spent" index for each full block to allow the output_pos - /// to be easily reverted during rewind. - pub fn save_spent_index(&self, h: &Hash, spent: &[CommitPos]) -> Result<(), Error> { + /// Maintain the exact outputs spent by each full block so `output_pos` can be + /// restored during rewind. Each PMMR position remains paired with the + /// commitment authenticated at that position during block validation; callers + /// must not reconstruct this association from block input order. + pub fn save_spent_index(&self, h: &Hash, spent: &[SpentOutput]) -> Result<(), Error> { self.db .put_ser(&to_key(BLOCK_SPENT_PREFIX, h)[..], &spent.to_vec())?; Ok(()) @@ -319,18 +433,17 @@ impl<'a> Batch<'a> { /// The index contains commitments spent by retained block bodies only. Normal /// nodes delete older full blocks during compaction, and delete these entries /// at the same time, so callers must not treat this as all historical spends. - pub fn save_spent_commitments(&self, spent: &Commitment, hh: HashHeight) -> Result<(), Error> { - let hash_list = self + pub fn save_spent_commitments( + &self, + spent: &Commitment, + record: SpentCommitmentRecord, + ) -> Result<(), Error> { + let records = self .db .get_ser(&to_key(BLOCK_SPENT_COMMITMENT_PREFIX, spent))?; - let mut spent_list; - if let Some(list) = hash_list { - spent_list = list; - } else { - spent_list = Vec::new(); - } + let mut spent_list = records.unwrap_or_default(); - if !Self::append_spent_commitment(&mut spent_list, hh)? { + if !Self::append_spent_commitment(&mut spent_list, record)? { return Ok(()); } self.db.put_ser( @@ -341,20 +454,35 @@ impl<'a> Batch<'a> { } fn append_spent_commitment( - spent_list: &mut Vec, - hh: HashHeight, + spent_list: &mut Vec, + record: SpentCommitmentRecord, ) -> Result { - if spent_list.contains(&hh) { - return Ok(false); + if let Some(existing) = spent_list + .iter() + .find(|existing| existing.spending_block.hash == record.spending_block.hash) + { + if existing == &record { + return Ok(false); + } + return Err(Error::OtherErr(format!( + "conflicting spent commitment records for spending block {}: existing {:?}, new {:?}", + record.spending_block.hash, existing, record + ))); } - if spent_list.len() as u64 >= ser::READ_VEC_SIZE_LIMIT { + let spent_count = u64::try_from(spent_list.len()).map_err(|_| { + Error::DataOverflow(format!( + "spent commitment list length does not fit u64: {}", + spent_list.len() + )) + })?; + if spent_count >= ser::READ_VEC_SIZE_LIMIT { return Err(ser::Error::TooLargeWriteErr(format!( "spent commitment list length exceeds {} entries", ser::READ_VEC_SIZE_LIMIT )) .into()); } - spent_list.push(hh); + spent_list.push(record); Ok(true) } @@ -362,11 +490,11 @@ impl<'a> Batch<'a> { /// /// `None` means no retained indexed block is known to have spent this /// commitment. It is only safe to interpret this as a replay-check miss after - /// `is_retained_spent_commitment_index_complete()` has returned true. + /// `is_spent_commitment_record_index_complete()` has returned true. pub fn get_spent_commitments( &self, spent: &Commitment, - ) -> Result>, Error> { + ) -> Result>, Error> { self.db .get_ser(&to_key(BLOCK_SPENT_COMMITMENT_PREFIX, spent)) } @@ -410,22 +538,32 @@ impl<'a> Batch<'a> { self.set_bool_flag(OUTPUT_POS_INDEX_COMPLETE, complete) } - /// DB flag representing a spent commitment replay index complete for retained full blocks. - /// - /// This marker deliberately says "retained": non-archive nodes cannot prove - /// or rebuild entries for compacted historical blocks whose bodies were - /// pruned below BODY_TAIL. - /// Default to false if flag not present. - pub fn is_retained_spent_commitment_index_complete(&self) -> Result { - self.get_bool_flag(RETAINED_SPENT_COMMITMENT_INDEX_COMPLETE) + /// Whether BLOCK_SPENT_COMMITMENT_PREFIX is complete and all values use + /// `SpentCommitmentRecord`. Production code establishes this at an empty trusted + /// boundary, maintains it through validated UTXO transitions, or rebuilds it + /// from the local per-block spent index. That per-block index is admissible for + /// rebuild because its exact positions were produced by UTXO validation (with + /// migrated commitments additionally resolved from the raw output PMMR), and + /// rebuild rechecks the corresponding canonical block bodies. This marker must + /// not be established from peer-supplied or otherwise unauthenticated positions. + pub fn is_spent_commitment_record_index_complete(&self) -> Result { + self.get_bool_flag(SPENT_COMMITMENT_RECORD_INDEX_COMPLETE) } - /// Set DB flag representing a spent commitment replay index complete for retained full blocks. - pub fn set_retained_spent_commitment_index_complete( - &self, - complete: bool, - ) -> Result<(), Error> { - self.set_bool_flag(RETAINED_SPENT_COMMITMENT_INDEX_COMPLETE, complete) + /// Set the exact spent-occurrence index trust/completeness marker. + pub fn set_spent_commitment_record_index_complete(&self, complete: bool) -> Result<(), Error> { + self.set_bool_flag(SPENT_COMMITMENT_RECORD_INDEX_COMPLETE, complete) + } + + /// Check whether legacy positions-only per-block spent indexes in the active + /// rewind window have been migrated to the exact `SpentOutput` format. + pub fn is_spent_index_migrated(&self) -> Result { + self.get_bool_flag(SPENT_INDEX_MIGRATED) + } + + /// Set the spent index migration marker. + pub fn set_spent_index_migrated(&self, migrated: bool) -> Result<(), Error> { + self.set_bool_flag(SPENT_INDEX_MIGRATED, migrated) } /// Read a named DB boolean flag. @@ -495,9 +633,43 @@ impl<'a> Batch<'a> { } /// Delete a full block. Does not delete any record associated with a block - /// header. + /// header. Verify the full-block key and complete header before trusting its + /// inputs to remove secondary spent-commitment entries. pub fn delete_block(&self, bh: &Hash) -> Result<(), Error> { let block = self.get_block(bh)?; + self.delete_block_with_body(bh, block) + } + + /// Delete a full block if its canonical record still exists. + /// + /// Returns `false` only when the initial full-block lookup is missing. Once + /// the block has been loaded, all subsequent errors (including a missing + /// separately stored header) are propagated as persisted-state failures. + pub fn delete_block_if_exists(&self, bh: &Hash) -> Result { + let block = match self.get_block(bh) { + Ok(block) => block, + Err(e) if e.store_error_is_not_found() => return Ok(false), + Err(e) => return Err(e), + }; + self.delete_block_with_body(bh, block)?; + Ok(true) + } + + fn delete_block_with_body(&self, bh: &Hash, block: Block) -> Result<(), Error> { + let block_hash = block.hash(self.get_context_id())?; + if block_hash != *bh { + return Err(Error::OtherErr(format!( + "refusing to delete full block loaded from key {} with computed hash {}", + bh, block_hash + ))); + } + let stored_header = self.get_block_header(bh)?; + if block.header != stored_header { + return Err(Error::OtherErr(format!( + "refusing to delete full block {} with a header that differs from the separately stored header", + bh + ))); + } let inputs = block.inputs(); match inputs { // Missing records are acceptable during idempotent cleanup. @@ -529,6 +701,20 @@ impl<'a> Batch<'a> { /// Save block header to db. pub fn save_block_header(&self, header: &BlockHeader) -> Result<(), Error> { let hash = header.hash(self.get_context_id())?; + // Defense in depth for local persistence. This is not a cryptographic + // collision check: normal PoW validation is what binds the complete header + // to this proof-derived key. + match self.get_block_header(&hash) { + Ok(stored) if stored != *header => { + return Err(Error::OtherErr(format!( + "refusing to overwrite header key {} with a different complete header", + hash + ))); + } + Ok(_) => return Ok(()), + Err(e) if e.store_error_is_not_found() => {} + Err(e) => return Err(e), + } // Store the header itself indexed by hash. self.db @@ -628,17 +814,16 @@ impl<'a> Batch<'a> { /// Called when a retained full block is deleted during compaction or cleanup. /// This keeps the replay index scoped to locally retained full block bodies. pub fn delete_spent_commitments(&self, spent: &Commitment, hash: &Hash) -> Result<(), Error> { - let hash_list = self.get_spent_commitments(spent)?; - let hash_list_unwrap = hash_list.unwrap_or(vec![]); - let filtered_list: Vec<&HashHeight> = hash_list_unwrap - .iter() - .filter(|hash_height| hash_height.hash != *hash) + let records = self.get_spent_commitments(spent)?.unwrap_or_default(); + let filtered_list: Vec = records + .into_iter() + .filter(|record| record.spending_block.hash != *hash) .collect(); - if filtered_list.len() != 0 { + if !filtered_list.is_empty() { self.db.put_ser( &to_key(BLOCK_SPENT_COMMITMENT_PREFIX, spent)[..], - &filtered_list.to_vec(), + &filtered_list, )?; } else { self.db @@ -706,7 +891,10 @@ impl<'a> Batch<'a> { // Clean up the legacy input bitmap as well. Self::ignore_not_found(self.db.delete(&to_key(BLOCK_INPUT_BITMAP_PREFIX, bh)))?; - self.db.delete(&to_key(BLOCK_SPENT_PREFIX, bh)) + // Tolerate a missing record: the spent index migration deletes + // entries outside the supported rewind window before their blocks + // are cleaned up. + Self::ignore_not_found(self.db.delete(&to_key(BLOCK_SPENT_PREFIX, bh))) } /// Save block_sums for the block. @@ -732,11 +920,11 @@ impl<'a> Batch<'a> { match self.get_spent_index(bh) { Ok(spent) => { let mut bitmap = Bitmap::new(); - for x in spent { - let pos = x.pos.try_into().map_err(|e| { + for spent_output in spent { + let pos = spent_output.position.pos.try_into().map_err(|e| { Error::OtherErr(format!( "Invalid commit pos, spent index value {:?}, {}", - x, e + spent_output, e )) })?; bitmap.add(pos); @@ -771,14 +959,45 @@ impl<'a> Batch<'a> { ) } - /// Get the "spent index" from the db for the specified block. - /// If we need to rewind a block then we use this to "unspend" the spent outputs. - pub fn get_spent_index(&self, bh: &Hash) -> Result, Error> { + /// Get the exact commitment-to-occurrence index for the specified block. + /// If we rewind the block, the positions are used to unspend its inputs. + pub fn get_spent_index(&self, bh: &Hash) -> Result, Error> { option_to_not_found(self.db.get_ser(&to_key(BLOCK_SPENT_PREFIX, bh)), || { format!("spent index: {}", bh) }) } + /// Read a spent index entry written before the exact-occurrence format: + /// positions only, paired positionally with the block's inputs. + pub fn get_spent_index_legacy(&self, bh: &Hash) -> Result, Error> { + option_to_not_found(self.db.get_ser(&to_key(BLOCK_SPENT_PREFIX, bh)), || { + format!("legacy spent index: {}", bh) + }) + } + + /// Write a legacy-format (positions only) spent index entry. Exists so + /// tests can model databases written before the exact-occurrence format. + #[cfg(test)] + pub fn save_spent_index_legacy(&self, h: &Hash, spent: &[CommitPos]) -> Result<(), Error> { + self.db + .put_ser(&to_key(BLOCK_SPENT_PREFIX, h)[..], &spent.to_vec())?; + Ok(()) + } + + /// Iterator over raw per-block spent index keys starting at `start`. + /// + /// Values are deliberately not loaded: the spent index migration reads + /// each entry through the typed accessors after inspecting the record key. + /// The first key is greater than or equal to `start`, allowing the migration + /// to release its read transaction between write chunks. + pub fn spent_index_key_iter_from( + &self, + start: &[u8], + ) -> Result, Error>> + '_, Error> { + let prefix = to_key(BLOCK_SPENT_PREFIX, ""); + self.db.iter_from(&prefix, start, |key, _| Ok(key.to_vec())) + } + /// Commits this batch. If it's a child batch, it will be merged with the /// parent, otherwise the batch is written to db. pub fn commit(self) -> Result<(), Error> { @@ -799,8 +1018,17 @@ impl<'a> Batch<'a> { let key = to_key(BLOCK_PREFIX, ""); let protocol_version = self.db.protocol_version(); let context_id = self.db.get_context_id(); - self.db.iter(&key, move |_, mut v| { - ser::deserialize_strict(&mut v, protocol_version, context_id).map_err(From::from) + self.db.iter(&key, move |raw_key, mut v| { + let block: Block = ser::deserialize_strict(&mut v, protocol_version, context_id)?; + let block_hash = block.hash(context_id)?; + let expected_key = to_key(BLOCK_PREFIX, block_hash); + if raw_key != expected_key.as_slice() { + return Err(Error::OtherErr(format!( + "full block {} is stored under noncanonical key {:?}", + block_hash, raw_key + ))); + } + Ok(block) }) } @@ -1102,6 +1330,54 @@ pub enum PendingChainOperation { /// Header head before the operation started. original_header_head: Tip, }, + /// Chain compaction was interrupted after selecting a durable rewind horizon. + Compact { + /// Body head from which the compact horizon was selected. + original_body_head: Tip, + /// Header head before the operation started. + original_header_head: Tip, + /// Oldest body-chain state that must remain rewindable after compaction. + target_body_tail: Tip, + }, +} + +/// Keeps the in-memory recovery signal consistent with an installed durable +/// pending-operation marker when control leaves an operation unexpectedly. +/// +/// The marker owner must disarm the guard only after either clearing the marker +/// or explicitly setting the recovery signal on a handled failure. In +/// particular, dropping an armed guard during panic unwinding leaves the +/// durable marker intact and makes the next chain access initiate recovery. +#[must_use = "an installed pending chain operation must remain guarded until it is finalized"] +pub(crate) struct PendingChainOperationGuard { + requires_init_recovery: Arc, + armed: bool, +} + +impl PendingChainOperationGuard { + pub(crate) fn new(requires_init_recovery: Arc) -> Self { + Self { + requires_init_recovery, + armed: true, + } + } + + pub(crate) fn disarm(&mut self) { + self.armed = false; + } + + pub(crate) fn require_recovery(&mut self) { + self.requires_init_recovery.store(true, Ordering::SeqCst); + self.disarm(); + } +} + +impl Drop for PendingChainOperationGuard { + fn drop(&mut self) { + if self.armed { + self.requires_init_recovery.store(true, Ordering::SeqCst); + } + } } impl PendingChainOperation { @@ -1112,6 +1388,7 @@ impl PendingChainOperation { PendingChainOperation::ResetToGenesis => ChainOperationKind::ResetToGenesis, PendingChainOperation::ResetChainHead { .. } => ChainOperationKind::ResetChainHead, PendingChainOperation::ReconcileHeads { kind, .. } => *kind, + PendingChainOperation::Compact { .. } => ChainOperationKind::Compact, } } } @@ -1146,6 +1423,16 @@ impl Writeable for PendingChainOperation { original_body_head.write(writer)?; original_header_head.write(writer) } + PendingChainOperation::Compact { + original_body_head, + original_header_head, + target_body_tail, + } => { + writer.write_u8(4)?; + original_body_head.write(writer)?; + original_header_head.write(writer)?; + target_body_tail.write(writer) + } } } } @@ -1197,6 +1484,11 @@ impl Readable for PendingChainOperation { original_header_head, }) } + 4 => Ok(PendingChainOperation::Compact { + original_body_head: Tip::read(reader)?, + original_header_head: Tip::read(reader)?, + target_body_tail: Tip::read(reader)?, + }), x => Err(ser::Error::CorruptedData(format!( "Invalid pending chain operation variant {}", x @@ -1238,8 +1530,10 @@ impl Writeable for BoolFlag { #[cfg(test)] mod tests { use super::*; + use mwc_core::core::{CommitWrapper, Input, OutputFeatures}; use mwc_core::global; use mwc_core::ser::{BinReader, ProtocolVersion}; + use mwc_crates::secp::{ContextFlag, Secp256k1}; use std::fs; fn read_bool_flag(bytes: &[u8]) -> Result { @@ -1248,6 +1542,273 @@ mod tests { BoolFlag::read(&mut reader) } + #[test] + fn block_writes_require_exact_separately_stored_header() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = "target/block_writes_require_exact_separately_stored_header"; + let _ = fs::remove_dir_all(chain_dir); + let store = ChainStore::new(0, chain_dir).unwrap(); + + let mut expected_block = Block::default(0); + expected_block.header.height = 1; + expected_block.header.pow.proof.nonces[0] = 1; + let expected_hash = expected_block.hash(0).unwrap(); + let mut altered_block = expected_block.clone(); + altered_block.header.height = 99; + assert_eq!(altered_block.hash(0).unwrap(), expected_hash); + assert_ne!(altered_block.header, expected_block.header); + + let batch = store.batch_write().unwrap(); + batch.save_block_header(&expected_block.header).unwrap(); + batch.save_block(&expected_block).unwrap(); + match batch.save_block(&altered_block).unwrap_err() { + Error::OtherErr(msg) => assert!(msg.contains("separately stored header"), "{}", msg), + other => panic!("expected full-block header mismatch, got {:?}", other), + } + match batch.save_block_header(&altered_block.header).unwrap_err() { + Error::OtherErr(msg) => assert!(msg.contains("different complete header"), "{}", msg), + other => panic!( + "expected stored-header overwrite rejection, got {:?}", + other + ), + } + assert_eq!( + batch.get_block_header(&expected_hash).unwrap(), + expected_block.header + ); + + // The delete path also consumes body data to clean secondary indexes, so + // model raw corruption and require the same complete-header invariant. + batch + .db + .put_ser(&to_key(BLOCK_PREFIX, expected_hash), &altered_block) + .unwrap(); + match batch.delete_block(&expected_hash).unwrap_err() { + Error::OtherErr(msg) => assert!(msg.contains("separately stored header"), "{}", msg), + other => panic!("expected delete header mismatch, got {:?}", other), + } + + drop(batch); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn block_writes_preserve_existing_canonical_body() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/block_writes_preserve_existing_canonical_body_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + let stored_commit = secp.commit_value(1).unwrap(); + let conflicting_commit = secp.commit_value(2).unwrap(); + let mut stored = Block::default(0); + stored.header.height = 1; + stored.header.pow.proof.nonces[0] = 1; + stored.body.inputs = Inputs::CommitOnly(vec![CommitWrapper::from(stored_commit)]); + + // Feature-bearing legacy inputs and commitment-only v3 inputs are the + // same canonical database body and must remain idempotent. + let mut equivalent = stored.clone(); + equivalent.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, stored_commit)]); + assert!(blocks_equal_as_v3(0, &stored, &equivalent).unwrap()); + + let mut conflicting = stored.clone(); + conflicting.body.inputs = Inputs::CommitOnly(vec![CommitWrapper::from(conflicting_commit)]); + assert_eq!(stored.hash(0).unwrap(), conflicting.hash(0).unwrap()); + assert!(!blocks_equal_as_v3(0, &stored, &conflicting).unwrap()); + + let block_hash = stored.hash(0).unwrap(); + let batch = store.batch_write().unwrap(); + batch.save_block_header(&stored.header).unwrap(); + batch.save_block(&stored).unwrap(); + batch.save_block(&equivalent).unwrap(); + match batch.save_block(&conflicting).unwrap_err() { + Error::OtherErr(msg) => assert!(msg.contains("different body"), "{}", msg), + other => panic!("expected full-block overwrite rejection, got {:?}", other), + } + + let persisted = batch.get_block(&block_hash).unwrap(); + assert!(blocks_equal_as_v3(0, &persisted, &stored).unwrap()); + assert!(!blocks_equal_as_v3(0, &persisted, &conflicting).unwrap()); + + drop(batch); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn delete_block_if_exists_only_ignores_an_initially_missing_block() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/delete_block_if_exists_only_ignores_missing_block_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + + let mut block = Block::default(0); + block.header.height = 1; + block.header.pow.proof.nonces[0] = 1; + let block_hash = block.hash(0).unwrap(); + + { + let batch = store.batch_write().unwrap(); + assert!(!batch.delete_block_if_exists(&block_hash).unwrap()); + batch.save_block_header(&block.header).unwrap(); + batch.save_block(&block).unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_write().unwrap(); + batch.delete_block_header(&block_hash).unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_write().unwrap(); + assert!(batch.block_exists(&block_hash).unwrap()); + match batch.delete_block_if_exists(&block_hash).unwrap_err() { + Error::NotFoundErr(msg) => assert!(msg.contains("BLOCK HEADER"), "{}", msg), + other => panic!("expected missing-header error, got {:?}", other), + } + } + + assert!(store.block_exists(&block_hash).unwrap()); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn delete_block_removes_only_its_spent_commitment_occurrence_record() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/delete_block_removes_only_its_spent_record_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let commitment = secp.commit_value(11).unwrap(); + let mut block = Block::default(0); + block.header.height = 2; + block.header.pow.proof.nonces[0] = 1; + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, commitment)]); + let block_hash = block.hash(0).unwrap(); + let other_hash = Hash::from_vec(&[8; Hash::LEN]); + let block_record = SpentCommitmentRecord { + spending_block: crate::types::HashHeight { + hash: block_hash, + height: 2, + }, + spent_output: CommitPos { pos: 1, height: 0 }, + }; + let other_record = SpentCommitmentRecord { + spending_block: crate::types::HashHeight { + hash: other_hash, + height: 2, + }, + spent_output: CommitPos { pos: 1, height: 0 }, + }; + + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&block.header).unwrap(); + batch.save_block(&block).unwrap(); + batch + .save_spent_index( + &block_hash, + &[SpentOutput { + commitment, + position: block_record.spent_output, + }], + ) + .unwrap(); + batch + .save_spent_commitments(&commitment, block_record) + .unwrap(); + batch + .save_spent_commitments(&commitment, other_record) + .unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_write().unwrap(); + batch.delete_block(&block_hash).unwrap(); + batch.commit().unwrap(); + } + let batch = store.batch_read().unwrap(); + assert_eq!( + batch.get_spent_commitments(&commitment).unwrap(), + Some(vec![other_record]) + ); + assert!(batch.get_spent_index(&block_hash).is_err()); + + drop(batch); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + + #[test] + fn blocks_iter_rejects_noncanonical_full_block_keys() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + + for malformed_key_kind in ["wrong_hash", "extra_suffix"] { + let chain_dir = format!( + "target/blocks_iter_rejects_{}_{}", + malformed_key_kind, + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = ChainStore::new(0, &chain_dir).unwrap(); + + let mut block = Block::default(0); + block.header.height = 1; + block.header.pow.proof.nonces[0] = 1; + let block_hash = block.hash(0).unwrap(); + let mut malformed_key = to_key(BLOCK_PREFIX, block_hash); + match malformed_key_kind { + "wrong_hash" => malformed_key[2] ^= 1, + "extra_suffix" => malformed_key.push(0), + _ => unreachable!(), + } + + { + let batch = store.batch_write().unwrap(); + batch.db.put_ser(&malformed_key, &block).unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + let err = batch + .blocks_iter() + .unwrap() + .next() + .expect("malformed full-block record must be scanned") + .unwrap_err(); + match err { + Error::OtherErr(msg) => { + assert!(msg.contains("noncanonical key"), "{}", msg) + } + other => panic!("expected noncanonical-key error, got {:?}", other), + } + assert!(batch.db.exists(&malformed_key).unwrap()); + } + + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + } + #[test] fn bool_flag_rejects_noncanonical_values() { assert!(!bool::from(read_bool_flag(&[0]).unwrap())); @@ -1387,28 +1948,41 @@ mod tests { } #[test] - fn retained_spent_commitment_index_complete_flag_defaults_false_and_roundtrips() { + fn spent_commitment_record_index_complete_flag_defaults_false_and_roundtrips() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); - let chain_dir = "target/retained_spent_commitment_index_complete_flag_roundtrips"; + let chain_dir = "target/spent_commitment_record_index_complete_flag_roundtrips"; let _ = fs::remove_dir_all(chain_dir); let store = ChainStore::new(0, chain_dir).unwrap(); { let batch = store.batch_read().unwrap(); - assert!(!batch.is_retained_spent_commitment_index_complete().unwrap()); + assert!(!batch.is_spent_commitment_record_index_complete().unwrap()); + } + + { + let batch = store.batch_write().unwrap(); + batch + .set_spent_commitment_record_index_complete(true) + .unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); } { let batch = store.batch_write().unwrap(); batch - .set_retained_spent_commitment_index_complete(true) + .set_spent_commitment_record_index_complete(false) .unwrap(); batch.commit().unwrap(); } { let batch = store.batch_read().unwrap(); - assert!(batch.is_retained_spent_commitment_index_complete().unwrap()); + assert!(!batch.is_spent_commitment_record_index_complete().unwrap()); } drop(store); @@ -1427,10 +2001,10 @@ mod tests { { let batch = store.batch_write().unwrap(); batch - .save_spent_commitments(&first_commit, test_hash_height(1)) + .save_spent_commitments(&first_commit, test_spent_record(1)) .unwrap(); batch - .save_spent_commitments(&second_commit, test_hash_height(2)) + .save_spent_commitments(&second_commit, test_spent_record(2)) .unwrap(); batch.commit().unwrap(); } @@ -1587,27 +2161,35 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } - fn test_hash_height(height: u64) -> HashHeight { - HashHeight { - hash: Hash::from_vec(&height.to_le_bytes()), - height, + fn test_spent_record(height: u64) -> SpentCommitmentRecord { + SpentCommitmentRecord { + spending_block: crate::types::HashHeight { + hash: Hash::from_vec(&height.to_le_bytes()), + height, + }, + spent_output: CommitPos { + pos: height.saturating_mul(2).saturating_add(1), + height, + }, } } #[test] fn spent_commitment_append_rejects_unreadable_list_growth() { - let max_spent_list: Vec = (0..ser::READ_VEC_SIZE_LIMIT) - .map(test_hash_height) + let max_spent_list: Vec = (0..ser::READ_VEC_SIZE_LIMIT) + .map(test_spent_record) .collect(); let mut duplicate_list = max_spent_list.clone(); let mut overflow_list = max_spent_list; - assert!(!Batch::append_spent_commitment(&mut duplicate_list, test_hash_height(0)).unwrap()); + assert!( + !Batch::append_spent_commitment(&mut duplicate_list, test_spent_record(0)).unwrap() + ); assert_eq!(duplicate_list.len(), ser::READ_VEC_SIZE_LIMIT as usize); let err = Batch::append_spent_commitment( &mut overflow_list, - test_hash_height(ser::READ_VEC_SIZE_LIMIT), + test_spent_record(ser::READ_VEC_SIZE_LIMIT), ) .unwrap_err(); match err { @@ -1618,4 +2200,19 @@ mod tests { } assert_eq!(overflow_list.len(), ser::READ_VEC_SIZE_LIMIT as usize); } + + #[test] + fn spent_commitment_append_rejects_conflicting_record_for_same_block() { + let original = test_spent_record(7); + let mut conflicting = original; + conflicting.spent_output.pos = conflicting.spent_output.pos.saturating_add(2); + let mut records = vec![original]; + + let err = Batch::append_spent_commitment(&mut records, conflicting).unwrap_err(); + assert!(matches!( + err, + Error::OtherErr(msg) if msg.contains("conflicting spent commitment records") + )); + assert_eq!(records, vec![original]); + } } diff --git a/chain/src/tests/chain_test_helper.rs b/chain/src/tests/chain_test_helper.rs index 1f2fea03f3..1396abdc3b 100644 --- a/chain/src/tests/chain_test_helper.rs +++ b/chain/src/tests/chain_test_helper.rs @@ -77,9 +77,10 @@ pub fn test_chain_dir(test_name: &str) -> String { } pub fn init_chain(secp: &Secp256k1, dir_name: &str, genesis: Block) -> Chain { + let context_id = genesis.header.pow.proof.context_id; Chain::init( &secp, - 0, + context_id, dir_name.to_string(), Arc::new(NoopAdapter {}), genesis, @@ -88,6 +89,7 @@ pub fn init_chain(secp: &Secp256k1, dir_name: &str, genesis: Block) -> Chain { HashSet::new(), None, None, + false, ) .unwrap() } @@ -237,7 +239,7 @@ where assert_eq!(header.hash(0).unwrap(), bhash); // now check the block itself - let block = chain.get_block(&header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); assert_eq!(block.header.height, n); assert_eq!(block.hash(0).unwrap(), bhash); assert_eq!(block.outputs().len(), 1); diff --git a/chain/src/tests/mine_simple_chain.rs b/chain/src/tests/mine_simple_chain.rs index 7f491b5f11..7a14a3f945 100644 --- a/chain/src/tests/mine_simple_chain.rs +++ b/chain/src/tests/mine_simple_chain.rs @@ -13,12 +13,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +use mwc_chain::store::{ChainOperationKind, PendingChainOperation}; use mwc_chain::types::{CommitPos, KernelPos, NoopAdapter, Tip}; use mwc_chain::Chain; use mwc_chain::{BlockStatus, ChainAdapter, Options}; -use mwc_core::core::hash::Hashed; +use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::{ - block, pmmr, transaction, Block, BlockHeader, KernelFeatures, Output, OutputFeatures, + block, pmmr, transaction, Block, BlockHeader, Inputs, KernelFeatures, Output, OutputFeatures, Transaction, }; use mwc_core::global::ChainTypes; @@ -37,6 +38,8 @@ use mwc_keychain::{ use mwc_util::StopState; use std::collections::HashSet; use std::convert::TryInto; +use std::fs; +use std::path::Path; use std::sync::Arc; use super::chain_test_helper::build::{self, Append}; @@ -87,12 +90,38 @@ fn setup_with_status_adapter( HashSet::new(), None, None, + false, ) .unwrap(); chain } +fn accept_test_pow(_: u32, _: &BlockHeader) -> Result<(), pow::Error> { + Ok(()) +} + +// Some recovery fixtures intentionally persist randomly generated proofs while +// processing blocks with SKIP_POW. Keep their configured recovery verifier +// equally explicit and local to those tests. +fn init_chain_accepting_test_pow(secp: &Secp256k1, dir_name: &str, genesis: Block) -> Chain { + let context_id = genesis.header.pow.proof.context_id; + Chain::init( + secp, + context_id, + dir_name.to_string(), + Arc::new(NoopAdapter {}), + genesis, + accept_test_pow, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap() +} + #[test] fn mine_empty_chain() { let chain_dir = ".mwc.empty"; @@ -111,6 +140,104 @@ fn mine_short_chain() { clean_output_dir(chain_dir); } +#[test] +fn known_block_check_normalizes_nonempty_v2_inputs_to_v3() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + let chain_dir = test_chain_dir("known_block_check_normalizes_nonempty_v2_inputs_to_v3"); + clean_output_dir(&chain_dir); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + { + let chain = init_chain( + &secp, + &chain_dir, + global::get_genesis_block(&secp, 0).unwrap(), + ); + let kc = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let pb = ProofBuilder::new(&secp, &kc).unwrap(); + let mut head = chain.head_header().unwrap(); + + // Mine enough blocks for the height-1 coinbase output to mature. + for key_idx in 1..=3 { + let block = prepare_block_key_idx(&mut secp, &kc, &head, &chain, 1, key_idx); + head = block.header.clone(); + chain + .process_block(&mut secp, block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + } + + let coinbase_key = ExtKeychainPath::new(1, 1, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let output_key = ExtKeychainPath::new(1, 30, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let spend = build::transaction( + 0, + &mut secp, + KernelFeatures::Plain { + fee: 20_000u32.try_into().unwrap(), + }, + &[ + build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, coinbase_key), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 20_000, output_key), + ], + &kc, + &pb, + ) + .unwrap(); + let block = prepare_block_tx_key_idx(&mut secp, &kc, &head, &chain, 1, 4, &[spend]); + let block_header = block.header.clone(); + chain + .process_block(&mut secp, block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + + let stored = chain.get_block_for_header(&block_header).unwrap(); + assert!(matches!( + stored.inputs(), + Inputs::CommitOnly(ref inputs) if !inputs.is_empty() + )); + let legacy = chain.convert_block_v2(&secp, stored).unwrap(); + assert!(matches!( + legacy.inputs(), + Inputs::FeaturesAndCommit(ref inputs) if !inputs.is_empty() + )); + + let result = + chain.process_block(&mut secp, legacy.clone(), Options::SKIP_POW, HashSet::new()); + assert!(matches!( + result, + Err(mwc_chain::Error::Unfit(ref msg)) if msg == "already known in head" + )); + + // Known-block comparison intentionally ignores legacy input features. The + // trusted v3 block remains authoritative and this candidate is discarded. + let mut wrong_feature = legacy; + match &mut wrong_feature.body.inputs { + Inputs::FeaturesAndCommit(inputs) => { + assert_eq!(inputs.len(), 1); + assert!(inputs[0].is_coinbase()); + inputs[0].features = OutputFeatures::Plain; + } + Inputs::CommitOnly(_) => panic!("expected legacy feature-bearing inputs"), + } + let result = + chain.process_block(&mut secp, wrong_feature, Options::SKIP_POW, HashSet::new()); + assert!(matches!( + result, + Err(mwc_chain::Error::Unfit(ref msg)) if msg == "already known in head" + )); + } + + clean_output_dir(&chain_dir); +} + #[test] fn block_height_range_to_pmmr_indices_rejects_reversed_range() { let chain_dir = ".mwc.pmmr_height_range"; @@ -149,6 +276,48 @@ fn unspent_outputs_by_pmmr_index_clamps_reported_highest_index() { clean_output_dir(&chain_dir); } +#[test] +fn merkle_proof_uses_current_output_pmmr_state() { + let chain_dir = test_chain_dir("merkle_proof_uses_current_output_pmmr_state"); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 4); + + let origin_header = chain.get_header_by_height(1).unwrap(); + let origin_block = chain.get_block_for_header(&origin_header).unwrap(); + let output = origin_block.outputs()[0]; + assert!(output.is_coinbase()); + + let current_header = chain.head_header().unwrap(); + assert!(current_header.height > origin_header.height); + let proof = chain.get_merkle_proof(&output).unwrap(); + let pos0 = chain.get_output_pos(&output.commitment()).unwrap(); + assert_eq!(proof.mmr_size, current_header.output_mmr_size); + proof + .verify( + chain.get_context_id(), + current_header.output_root, + &output.identifier(), + pos0, + ) + .unwrap(); + + // The API intentionally does not promise an origin-header proof. Once the + // PMMR has grown, this current-state proof must not be verified against the + // root from the block that created the output. + assert!(proof + .verify( + chain.get_context_id(), + origin_header.output_root, + &output.identifier(), + pos0, + ) + .is_err()); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + drop(chain); + clean_output_dir(&chain_dir); +} + #[test] fn locate_headers_returns_header_pmmr_lookup_error() { let chain_dir = ".mwc.locate_headers_pmmr_lookup_error"; @@ -168,6 +337,30 @@ fn locate_headers_returns_header_pmmr_lookup_error() { clean_output_dir(chain_dir); } +#[test] +fn fork_point_returns_header_pmmr_lookup_error() { + let chain_dir = test_chain_dir("fork_point_pmmr_lookup_error"); + clean_output_dir(&chain_dir); + let chain = mine_chain(&chain_dir, 4); + let head = chain.head().unwrap(); + + { + let header_pmmr = chain.get_header_pmmr_for_test(); + header_pmmr.write().size = 1; + } + + let res = chain.fork_point(); + + assert!(matches!( + res, + Err(mwc_chain::Error::InvalidHeaderHeight(height)) if height == head.height + )); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + drop(chain); + clean_output_dir(&chain_dir); +} + #[test] fn locator_hashes_rejects_height_above_sync_head() { let chain_dir = test_chain_dir("locator_hashes_rejects_height_above_sync_head"); @@ -321,9 +514,40 @@ fn block_height_range_to_pmmr_indices_rejects_body_chain_predecessor_skip() { let res = chain.block_height_range_to_pmmr_indices(3, Some(corrupt_head.height)); assert!(matches!( res, - Err(mwc_chain::Error::Other(msg)) - if msg.contains("body chain header traversal stopped at height 1") - && msg.contains("below requested height 2") + Err(mwc_chain::Error::InvalidPersistedChainState(msg)) + if msg.contains("body_chain_header_at_height_maybe_fast ancestry") + && msg.contains("at height 3, found height 1") + )); + + clean_output_dir(chain_dir); +} + +#[test] +fn block_height_range_to_pmmr_indices_rejects_non_decreasing_body_predecessor() { + let chain_dir = ".mwc.pmmr_height_range_body_prev_non_decreasing"; + clean_output_dir(chain_dir); + let chain = mine_chain(chain_dir, 5); + let context_id = chain.get_context_id(); + let store = chain.get_store_for_tests(); + let original_head = chain.head_header().unwrap(); + let mut corrupt_head = original_head.clone(); + corrupt_head.prev_hash = original_head.hash(context_id).unwrap(); + corrupt_head.pow.proof.nonces[0] += 1; + let corrupt_tip = Tip::try_from_header(&corrupt_head).unwrap(); + + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&corrupt_head).unwrap(); + batch.save_body_head(&corrupt_tip).unwrap(); + batch.commit().unwrap(); + } + + let res = chain.block_height_range_to_pmmr_indices(3, Some(corrupt_head.height)); + assert!(matches!( + res, + Err(mwc_chain::Error::InvalidPersistedChainState(msg)) + if msg.contains("body_chain_header_at_height_maybe_fast ancestry") + && msg.contains("at height 3, found height 4") )); clean_output_dir(chain_dir); @@ -387,7 +611,7 @@ fn reset_pibd_chain_keeps_genesis_output_visible_after_compaction() { let genesis_commit = genesis.outputs()[0].commitment(); { - let chain = init_chain(&secp, &chain_dir, genesis); + let chain = init_chain_accepting_test_pow(&secp, &chain_dir, genesis); let mut head = chain.head_header().unwrap(); let b = prepare_block_key_idx(&mut secp, &keychain, &head, &chain, 2, 2); @@ -495,7 +719,7 @@ fn get_unspent_rebuilds_index_with_stale_height() { let chain = mine_chain(chain_dir, 4); let store = chain.get_store_for_tests(); let block_header = chain.get_header_by_height(1).unwrap(); - let block = chain.get_block(&block_header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&block_header).unwrap(); let commit = block.outputs()[0].commitment(); let original_pos = store.get_output_pos_height(&commit).unwrap().unwrap(); assert_eq!(original_pos.height, 1); @@ -531,13 +755,13 @@ fn get_unspent_rebuild_repairs_all_stale_heights() { let store = chain.get_store_for_tests(); let block_a_header = chain.get_header_by_height(1).unwrap(); - let block_a = chain.get_block(&block_a_header.hash(0).unwrap()).unwrap(); + let block_a = chain.get_block_for_header(&block_a_header).unwrap(); let commit_a = block_a.outputs()[0].commitment(); let original_a = store.get_output_pos_height(&commit_a).unwrap().unwrap(); assert_eq!(original_a.height, 1); let block_b_header = chain.get_header_by_height(2).unwrap(); - let block_b = chain.get_block(&block_b_header.hash(0).unwrap()).unwrap(); + let block_b = chain.get_block_for_header(&block_b_header).unwrap(); let commit_b = block_b.outputs()[0].commitment(); let original_b = store.get_output_pos_height(&commit_b).unwrap().unwrap(); assert_eq!(original_b.height, 2); @@ -586,7 +810,7 @@ fn get_unspent_does_not_rebuild_index_with_missing_entry() { let chain = mine_chain(chain_dir, 4); let store = chain.get_store_for_tests(); let block_header = chain.get_header_by_height(1).unwrap(); - let block = chain.get_block(&block_header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&block_header).unwrap(); let commit = block.outputs()[0].commitment(); let original_pos = store.get_output_pos_height(&commit).unwrap().unwrap(); assert_eq!(original_pos.height, 1); @@ -610,7 +834,7 @@ fn get_header_for_output_rebuilds_index_with_stale_height() { let chain = mine_chain(chain_dir, 4); let store = chain.get_store_for_tests(); let block_header = chain.get_header_by_height(1).unwrap(); - let block = chain.get_block(&block_header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&block_header).unwrap(); let commit = block.outputs()[0].commitment(); let original_pos = store.get_output_pos_height(&commit).unwrap().unwrap(); assert_eq!(original_pos.height, 1); @@ -810,6 +1034,48 @@ fn get_header_for_kernel_index_uses_body_chain_when_header_pmmr_is_on_fork() { clean_output_dir(&chain_dir); } +#[test] +fn fork_point_descends_when_header_head_is_below_body_head() { + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = test_chain_dir("fork_point_shorter_header_head"); + clean_output_dir(&chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, &chain_dir, genesis); + let context_id = chain.get_context_id(); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + let body_block = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + process_block(&mut secp, &chain, &body_block); + + let mut header_fork = prepare_block(&mut secp, &kc, &chain.genesis(), &chain, 10).header; + header_fork.output_mmr_size = body_block.header.output_mmr_size; + process_header(&chain, &header_fork); + + assert_eq!( + chain.head().unwrap(), + Tip::try_from_header(&body_block.header).unwrap() + ); + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&header_fork).unwrap() + ); + assert!(header_fork.height < body_block.header.height); + + let fork_point = chain.fork_point().unwrap(); + assert_eq!( + fork_point.hash(context_id).unwrap(), + chain.genesis().hash(context_id).unwrap() + ); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(&chain_dir); +} + #[test] fn init_output_pos_index_maps_missing_outputs_from_body_chain() { let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); @@ -876,7 +1142,7 @@ fn init_output_pos_index_errors_on_unmapped_missing_output() { clean_output_dir(chain_dir); let chain = mine_chain(chain_dir, 2); let block_header = chain.get_header_by_height(1).unwrap(); - let block = chain.get_block(&block_header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&block_header).unwrap(); let commit = block.outputs()[0].commitment(); let genesis_tip = Tip::try_from_header(&chain.genesis()).unwrap(); let store = chain.get_store_for_tests(); @@ -968,6 +1234,64 @@ fn rewind_bad_block_removes_header_only_chain_state() { clean_output_dir(chain_dir); } +#[test] +fn rewind_bad_block_uses_canonical_header_head_and_invalidates_pibd_state() { + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.rewind_bad_stale_header_head"; + clean_output_dir(chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, chain_dir, genesis); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + let block_b = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + process_header(&chain, &block_b.header); + + let old_segmenter = chain.segmenter().unwrap(); + let old_desegmenter = chain.init_desegmenter(0, Hash::default()).unwrap(); + assert!(old_segmenter.is_current()); + assert!(old_desegmenter.is_current()); + + // Preserve the authoritative selected hash while making the redundant cached + // height lower than the denied header. A raw Tip height check would incorrectly + // classify block_b as being above the current header chain. + let store = chain.get_store_for_tests(); + let mut stale_header_head = Tip::try_from_header(&block_b.header).unwrap(); + stale_header_head.height = 0; + { + let batch = store.batch_write().unwrap(); + batch.save_header_head(&stale_header_head).unwrap(); + batch.commit().unwrap(); + } + assert_eq!(chain.header_head().unwrap(), stale_header_head); + + let bad_hash = block_b.hash(0).unwrap(); + chain + .apply_invalid_blocks(&secp, std::iter::once(bad_hash).collect()) + .unwrap(); + + assert_eq!( + chain.head().unwrap(), + Tip::try_from_header(&block_a.header).unwrap() + ); + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&block_a.header).unwrap() + ); + assert!(chain.get_block_header(&bad_hash).is_err()); + assert!(store.pending_chain_operation().unwrap().is_none()); + assert!(!old_segmenter.is_current()); + assert!(!old_desegmenter.is_current()); + + drop(store); + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + #[test] fn rewind_bad_block_on_header_fork_preserves_body_head() { let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); @@ -1002,6 +1326,7 @@ fn rewind_bad_block_on_header_fork_preserves_body_head() { let bad_hash = block_b.hash(0).unwrap(); let body_hash = block_b_fork.hash(0).unwrap(); + assert!(chain.get_block_for_header(&block_b.header).is_ok()); let mut invalid_blocks = HashSet::new(); invalid_blocks.insert(bad_hash); chain.apply_invalid_blocks(&secp, invalid_blocks).unwrap(); @@ -1015,7 +1340,224 @@ fn rewind_bad_block_on_header_fork_preserves_body_head() { Tip::try_from_header(&block_a.header).unwrap() ); assert!(chain.get_block_header(&bad_hash).is_err()); - assert!(chain.get_block(&body_hash).is_ok()); + assert!(chain.get_block_for_header(&block_b.header).is_err()); + assert!(chain.get_block_for_header(&block_b_fork.header).is_ok()); + + // The retained body fork is already known as a full block, but it still has + // more work than the rewound header head. Reprocessing its header must repair + // HEADER_HEAD and the header PMMR instead of returning through the BODY_HEAD + // known-block shortcut. + process_header(&chain, &block_b_fork.header); + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&block_b_fork.header).unwrap() + ); + { + let header_pmmr = chain.get_header_pmmr_for_test(); + assert_eq!(header_pmmr.read().head_hash().unwrap(), body_hash); + } + chain.validate(&secp, false).unwrap(); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + +#[test] +fn marked_body_recovery_ignores_denylist_on_durable_competing_fork() { + // Most chain tests use context 0 and release its process-global denylist on + // teardown. Use a dedicated context so parallel tests cannot clear this + // test's denylist between recovery and the admission assertion below. + let context_id = 0x5049_4244; + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.recovery_denylisted_body_fork"; + clean_output_dir(chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, context_id).unwrap(); + let chain = init_chain_accepting_test_pow(&secp, chain_dir, genesis); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + + let header_branch = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + let body_branch = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + process_header(&chain, &header_branch.header); + process_header(&chain, &body_branch.header); + process_block(&mut secp, &chain, &body_branch); + process_block(&mut secp, &chain, &header_branch); + + let durable_body_head = Tip::try_from_header(&body_branch.header).unwrap(); + let durable_header_head = Tip::try_from_header(&header_branch.header).unwrap(); + assert_eq!(chain.head().unwrap(), durable_body_head); + assert_eq!(chain.header_head().unwrap(), durable_header_head); + + let denied_body_hash = body_branch.hash(context_id).unwrap(); + mwc_chain::pipe::init_invalid_block_hashes( + chain.get_context_id(), + std::iter::once(denied_body_hash).collect(), + ); + let store = chain.get_store_for_tests(); + let marker = PendingChainOperation::ReconcileHeads { + kind: ChainOperationKind::RewindBadBlock, + original_body_head: durable_body_head.clone(), + original_header_head: durable_header_head.clone(), + }; + store.set_pending_chain_operation(&marker).unwrap(); + + // Encountering the existing marker latches in-process recovery without + // changing either durable head. + let trigger_err = chain.reset_pibd_chain().unwrap_err(); + assert!(matches!( + trigger_err, + mwc_chain::Error::Other(msg) + if msg.contains("pending chain operation requires chain init recovery") + )); + assert_eq!(store.pending_chain_operation().unwrap(), Some(marker)); + + // Recovery must replay the durable body branch as persisted state, even + // though new admission of that same header is denied. + assert_eq!(chain.head().unwrap(), durable_body_head); + assert_eq!(chain.header_head().unwrap(), durable_header_head); + assert!(store.pending_chain_operation().unwrap().is_none()); + let admission_err = chain + .process_block_header(&body_branch.header, Options::SKIP_POW) + .unwrap_err(); + assert!(matches!(admission_err, mwc_chain::Error::InvalidHash)); + + drop(store); + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + +#[test] +fn rewind_bad_block_on_body_fork_preserves_header_head() { + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.rewind_bad_body_fork"; + clean_output_dir(chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, chain_dir, genesis); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + + let block_b = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + let block_b_fork = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + + process_header(&chain, &block_b.header); + process_header(&chain, &block_b_fork.header); + process_block(&mut secp, &chain, &block_b_fork); + process_block(&mut secp, &chain, &block_b); + + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&block_b.header).unwrap() + ); + assert_eq!( + chain.head().unwrap(), + Tip::try_from_header(&block_b_fork.header).unwrap() + ); + + let bad_body_hash = block_b_fork.hash(0).unwrap(); + let mut invalid_blocks = HashSet::new(); + invalid_blocks.insert(bad_body_hash); + chain.apply_invalid_blocks(&secp, invalid_blocks).unwrap(); + + assert_eq!( + chain.head().unwrap(), + Tip::try_from_header(&block_a.header).unwrap() + ); + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&block_b.header).unwrap() + ); + assert!(chain.get_block_for_header(&block_b_fork.header).is_err()); + assert!(chain.get_block_header(&bad_body_hash).is_ok()); + assert!(chain.get_block_for_header(&block_b.header).is_ok()); + chain.validate(&secp, false).unwrap(); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + +#[test] +fn rewind_bad_block_rejects_denylist_key_header_hash_mismatch() { + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.rewind_bad_key_mismatch"; + clean_output_dir(chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, chain_dir, genesis); + let old_body_head = chain.head().unwrap(); + let old_header_head = chain.header_head().unwrap(); + + let denied_key = Hash::from_vec(&[7; Hash::LEN]); + let stored_header = chain.genesis(); + assert_ne!(denied_key, stored_header.hash(0).unwrap()); + let store = chain.get_store_for_tests(); + { + let batch = store.batch_write().unwrap(); + batch + .db + .put_ser(&mwc_store::to_key(b'h', denied_key), &stored_header) + .unwrap(); + batch.commit().unwrap(); + } + + let invalid_blocks = std::iter::once(denied_key).collect(); + let res = chain.rewind_bad_block(&secp, &invalid_blocks); + // `get_block_header` rejects a record that does not hash back to its key + // before `rewind_bad_block` applies its own denylist handling. + assert!(matches!( + res, + Err(mwc_chain::Error::InvalidPersistedChainState(msg)) + if msg.contains("key/hash mismatch") && msg.contains(&denied_key.to_string()) + )); + assert_eq!(chain.head().unwrap(), old_body_head); + assert_eq!(chain.header_head().unwrap(), old_header_head); + assert!(store.pending_chain_operation().unwrap().is_none()); + + drop(store); + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + +#[test] +fn rewind_bad_block_rejects_body_rewind_below_body_tail() { + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.rewind_bad_below_tail"; + clean_output_dir(chain_dir); + let chain = mine_chain(chain_dir, 2); + let old_body_head = chain.head().unwrap(); + let old_header_head = chain.header_head().unwrap(); + let old_body_tail = chain.tail().unwrap(); + assert_eq!(old_body_tail, old_body_head); + + let bad_hash = old_body_head.last_block_h; + let invalid_blocks = std::iter::once(bad_hash).collect(); + let res = chain.rewind_bad_block(&secp, &invalid_blocks); + assert!(matches!( + res, + Err(mwc_chain::Error::Other(msg)) + if msg.contains("below BODY_TAIL") && msg.contains("full chain-state reset") + )); + assert_eq!(chain.head().unwrap(), old_body_head); + assert_eq!(chain.header_head().unwrap(), old_header_head); + assert_eq!(chain.tail().unwrap(), old_body_tail); + let bad_header = chain.get_block_header(&bad_hash).unwrap(); + assert!(chain.get_block_for_header(&bad_header).is_ok()); + assert!(chain + .get_store_for_tests() + .pending_chain_operation() + .unwrap() + .is_none()); + chain.validate(&secp, false).unwrap(); mwc_chain::pipe::release_context_data(chain.get_context_id()); clean_output_dir(chain_dir); @@ -1104,6 +1646,119 @@ fn header_only_validation_rejects_incomplete_body_mmr_sizes() { clean_output_dir(chain_dir); } +#[test] +fn invalid_header_prev_root_clears_pending_operation_marker() { + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = ".mwc.invalid_header_prev_root_marker"; + clean_output_dir(chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, chain_dir, genesis); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + let block_b = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + let old_header_head = Tip::try_from_header(&block_a.header).unwrap(); + + let mut invalid = block_b.header.clone(); + invalid.prev_root = Hash::from_vec(&[42; Hash::LEN]); + assert_ne!(invalid.prev_root, block_b.header.prev_root); + + let err = chain + .process_block_header(&invalid, Options::SKIP_POW) + .unwrap_err(); + assert!(matches!(err, mwc_chain::Error::InvalidRoot(_))); + + // header_extending successfully discarded the provisional rewind, so this + // ordinary validation failure must not retain a recovery marker. + assert!(chain + .get_store_for_tests() + .pending_chain_operation() + .unwrap() + .is_none()); + assert_eq!(chain.header_head().unwrap(), old_header_head); + { + let header_pmmr = chain.get_header_pmmr_for_test(); + assert_eq!( + header_pmmr.read().head_hash().unwrap(), + block_a.hash(0).unwrap() + ); + } + + // The valid header can be applied immediately without a reconcile pass. + process_header(&chain, &block_b.header); + assert_eq!( + chain.header_head().unwrap(), + Tip::try_from_header(&block_b.header).unwrap() + ); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + clean_output_dir(chain_dir); +} + +#[cfg(unix)] +#[test] +fn partial_header_pmmr_sync_retains_pending_operation_marker() { + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = test_chain_dir("partial_header_pmmr_sync_marker"); + clean_output_dir(&chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let kc = ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = init_chain(&secp, &chain_dir, genesis); + + let block_a = prepare_block(&mut secp, &kc, &chain.head_header().unwrap(), &chain, 1); + process_block(&mut secp, &chain, &block_a); + let block_b = prepare_block(&mut secp, &kc, &block_a.header, &chain, 2); + let old_header_head = Tip::try_from_header(&block_a.header).unwrap(); + let store = chain.get_store_for_tests(); + + let header_dir = Path::new(&chain_dir).join("header").join("header_head"); + let hash_path = header_dir.join("pmmr_hash.bin"); + let data_path = header_dir.join("pmmr_data.bin"); + let data_backup_path = header_dir.join("pmmr_data.bin.before_sync_failure"); + let hash_len_before = fs::metadata(&hash_path).unwrap().len(); + + // AppendOnlyFile::flush reopens its path. Replacing only the data-file path + // with a directory lets the preceding hash-file flush complete, then forces + // the data-file flush to fail. Unix permits renaming the currently mapped + // data file while the live PMMR handle continues reading the old inode. + fs::rename(&data_path, &data_backup_path).unwrap(); + fs::create_dir(&data_path).unwrap(); + let res = chain.process_block_header(&block_b.header, Options::SKIP_POW); + let hash_len_after = fs::metadata(&hash_path).unwrap().len(); + + // Restore the path before asserting so a failed assertion cannot leave the + // live backend pointed at a deliberately invalid file type. + fs::remove_dir(&data_path).unwrap(); + fs::rename(&data_backup_path, &data_path).unwrap(); + + assert!(matches!( + res, + Err(mwc_chain::Error::PmmrSyncStateUncertain { context, .. }) + if context == "header_extending sync" + )); + assert!(hash_len_after > hash_len_before); + assert_eq!(store.header_head().unwrap(), old_header_head); + assert!(matches!( + store.pending_chain_operation().unwrap(), + Some(PendingChainOperation::ReconcileHeads { + kind: ChainOperationKind::ProcessHeader, + .. + }) + )); + + mwc_chain::pipe::release_context_data(chain.get_context_id()); + drop(store); + drop(chain); + clean_output_dir(&chain_dir); +} + #[test] fn header_only_validation_rejects_wrong_height_as_bad_data() { let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); @@ -1475,6 +2130,9 @@ fn mine_reorg() { let head = chain.head().unwrap(); assert_eq!(head.height, NUM_BLOCKS_MAIN); assert_eq!(head.hash(0).unwrap(), prev.hash(0).unwrap()); + let old_segmenter = chain.segmenter().unwrap(); + assert!(old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_ok()); // Reorg chain should exceed main chain's total difficulty to be considered let reorg_difficulty = head.total_difficulty.to_num(); @@ -1509,6 +2167,12 @@ fn mine_reorg() { let head = chain.head().unwrap(); assert_eq!(head.height, NUM_BLOCKS_MAIN - REORG_DEPTH + 1); assert_eq!(head.hash(0).unwrap(), reorg_head.hash(0).unwrap()); + + // Ordinary reorgs do not invalidate the PIBD generation. The archive delay + // is the normal reorg safety margin; an exceptional reorg crossing that + // point is handled by the receiver's segment/root validation and a retry. + assert!(old_segmenter.is_current()); + assert!(old_segmenter.bitmap_root().is_ok()); } // Cleanup chain directory @@ -1764,25 +2428,116 @@ fn spend_rewind_spend() { ) .unwrap(); - // Mine two further blocks. This leaves the replay block at height 5, - // where AutomatedTesting still uses header version 2. Replay protection - // is activated by node policy, not by the block version. - for n in 3..5 { - let b = prepare_block(&mut secp, &kc, &head, &chain, n); - head = b.header.clone(); + // Mine two further blocks. This leaves the replay block at height 5, + // where AutomatedTesting still uses header version 2. Replay protection + // is activated by node policy, not by the block version. + for n in 3..5 { + let b = prepare_block(&mut secp, &kc, &head, &chain, n); + head = b.header.clone(); + chain + .process_block( + &mut secp, + b, + Options::SKIP_POW, + std::collections::HashSet::new(), + ) + .unwrap(); + } + + // Make a note of this header as we will rewind back to here later. + let rewind_to = head.clone(); + + let key_id_coinbase = ExtKeychainPath::new(1, 1, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let key_id30 = ExtKeychainPath::new(1, 30, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + + let tx1 = build::transaction( + 0, + &mut secp, + KernelFeatures::Plain { + fee: 20000u32.try_into().unwrap(), + }, + &[ + build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, key_id_coinbase.clone()), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30.clone()), + ], + &kc, + &pb, + ) + .unwrap(); + + let b = prepare_block_tx(&mut secp, &kc, &head, &chain, 6, &[tx1.clone()]); + assert_eq!(b.header.version, block::HeaderVersion(2)); + chain + .process_block( + &mut secp, + b.clone(), + Options::SKIP_POW, + std::collections::HashSet::new(), + ) + .unwrap(); + chain.validate(&secp, false).unwrap(); + + // Now mine a competing block also spending the same coinbase output from earlier. + // Rewind back prior to the tx that spends it to "unspend" it. + { + let b = prepare_block_tx(&mut secp, &kc, &rewind_to, &chain, 6, &[tx1]); + chain + .process_block( + &mut secp, + b.clone(), + Options::SKIP_POW, + std::collections::HashSet::new(), + ) + .unwrap(); + chain.validate(&secp, false).unwrap(); + } + } + + clean_output_dir(chain_dir); +} + +#[test] +fn migrate_spent_index_converts_legacy_entries() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + let chain_dir = test_chain_dir("migrate_spent_index_converts_legacy_entries"); + clean_output_dir(&chain_dir); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + { + let chain = init_chain( + &secp, + &chain_dir, + global::get_genesis_block(&secp, 0).unwrap(), + ); + let kc = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let pb = ProofBuilder::new(&secp, &kc).unwrap(); + let mut head = chain.head_header().unwrap(); + + // Mine a few blocks so the coinbase from key_idx 1 is spendable. + for key_idx in 1..=3 { + let block = + prepare_block_key_idx(&mut secp, &kc, &head, &chain, u64::from(key_idx), key_idx); + head = block.header.clone(); chain .process_block( &mut secp, - b, + block, Options::SKIP_POW, std::collections::HashSet::new(), ) .unwrap(); } - // Make a note of this header as we will rewind back to here later. - let rewind_to = head.clone(); - let key_id_coinbase = ExtKeychainPath::new(1, 1, 0, 0, 0) .unwrap() .to_identifier() @@ -1791,51 +2546,217 @@ fn spend_rewind_spend() { .unwrap() .to_identifier() .unwrap(); - - let tx1 = build::transaction( + let spend = build::transaction( 0, &mut secp, KernelFeatures::Plain { fee: 20000u32.try_into().unwrap(), }, &[ - build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, key_id_coinbase.clone()), - build::output(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30.clone()), + build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, key_id_coinbase), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30), ], &kc, &pb, ) .unwrap(); - - let b = prepare_block_tx(&mut secp, &kc, &head, &chain, 6, &[tx1.clone()]); - assert_eq!(b.header.version, block::HeaderVersion(2)); + let spend_block = prepare_block_tx(&mut secp, &kc, &head, &chain, 4, &[spend]); + let spend_hash = spend_block.hash(0).unwrap(); chain .process_block( &mut secp, - b.clone(), + spend_block, Options::SKIP_POW, std::collections::HashSet::new(), ) .unwrap(); - chain.validate(&secp, false).unwrap(); - // Now mine a competing block also spending the same coinbase output from earlier. - // Rewind back prior to the tx that spends it to "unspend" it. + let store = chain.get_store_for_tests(); + let expected = store + .batch_read() + .unwrap() + .get_spent_index(&spend_hash) + .unwrap(); + assert!(!expected.is_empty()); + + // Model a pre-upgrade database: the same entry in the legacy + // positions-only format, with the migration marker unset. Remove the + // full block as well: migration is driven by the spent-index key and its + // canonical header, and resolves data through the output PMMR directly. { - let b = prepare_block_tx(&mut secp, &kc, &rewind_to, &chain, 6, &[tx1]); + let batch = store.batch_write().unwrap(); + let legacy: Vec = expected.iter().map(|spent| spent.position).collect(); + batch.save_spent_index_legacy(&spend_hash, &legacy).unwrap(); + batch.delete(&mwc_store::to_key(b'b', spend_hash)).unwrap(); + batch.set_spent_index_migrated(false).unwrap(); + batch.commit().unwrap(); + } + // The legacy entry no longer parses in the current format. + assert!(store + .batch_read() + .unwrap() + .get_spent_index(&spend_hash) + .is_err()); + + { + let txhashset = chain.get_txhashset_for_test(); + let txhashset = txhashset.read_recursive(); + Chain::migrate_spent_index(&store, &txhashset, None).unwrap(); + } + + let batch = store.batch_read().unwrap(); + assert!(batch.is_spent_index_migrated().unwrap()); + // The migration must reproduce the exact occurrences recorded when the + // block was applied, resolving commitments from the output MMR data. + assert_eq!(batch.get_spent_index(&spend_hash).unwrap(), expected); + } + + clean_output_dir(&chain_dir); +} + +#[test] +fn migrate_spent_index_deletes_records_outside_active_window() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + let chain_dir = test_chain_dir("migrate_spent_index_deletes_records_outside_active_window"); + clean_output_dir(&chain_dir); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + { + let chain = init_chain( + &secp, + &chain_dir, + global::get_genesis_block(&secp, 0).unwrap(), + ); + let kc = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let mut head = chain.head_header().unwrap(); + + // Mine past the cut-through horizon so early blocks fall out of the + // migration window. + let horizon = u64::from(global::cut_through_horizon(0)); + let mut hashes = vec![head.hash(0).unwrap()]; + for height in 1..=(horizon + 10) { + let block = prepare_block_key_idx(&mut secp, &kc, &head, &chain, height, height as u32); + head = block.header.clone(); chain .process_block( &mut secp, - b.clone(), + block, Options::SKIP_POW, std::collections::HashSet::new(), ) .unwrap(); - chain.validate(&secp, false).unwrap(); + hashes.push(head.hash(0).unwrap()); + } + let window_start = head.height - horizon; + + let store = chain.get_store_for_tests(); + + // Every applied block carries a spent index record before migration. + { + let batch = store.batch_read().unwrap(); + for hash in &hashes { + batch.get_spent_index(hash).unwrap(); + } + } + + // Plant inactive legacy records with positions that cannot be resolved. + // Migration must delete both without trying to read compactable PMMR data. + let old_hash = hashes[1]; + let boundary_hash = hashes[window_start as usize]; + let inactive_legacy = vec![CommitPos { + pos: u64::MAX, + height: 0, + }]; + + // Also plant two records that must be deleted: one selected by HEADER_HEAD + // but absent from the canonical body chain, and one with no header at all. + let (fork_hash, orphan_hash) = { + let batch = store.batch_write().unwrap(); + batch + .save_spent_index_legacy(&old_hash, &inactive_legacy) + .unwrap(); + batch + .save_spent_index_legacy(&boundary_hash, &inactive_legacy) + .unwrap(); + // Extend the body head with a header-only block and make it HEADER_HEAD. + // Migration must still anchor canonicality at the persisted body HEAD. + let body_head_hash = head.hash(0).unwrap(); + let mut fork_header = head.clone(); + fork_header.height += 1; + fork_header.prev_hash = body_head_hash; + fork_header.pow.proof.nonces[0] = fork_header.pow.proof.nonces[0].wrapping_add(1); + let fork_hash = fork_header.hash(0).unwrap(); + assert_ne!(fork_hash, body_head_hash); + batch.save_block_header(&fork_header).unwrap(); + batch + .save_header_head(&Tip::try_from_header(&fork_header).unwrap()) + .unwrap(); + batch + .save_spent_index_legacy(&fork_hash, &[CommitPos { pos: 1, height: 1 }]) + .unwrap(); + let orphan_hash = Hash::from_vec(&[9u8; Hash::LEN]); + batch + .save_spent_index_legacy(&orphan_hash, &[CommitPos { pos: 1, height: 1 }]) + .unwrap(); + batch.set_spent_index_migrated(false).unwrap(); + batch.commit().unwrap(); + (fork_hash, orphan_hash) + }; + + { + let txhashset = chain.get_txhashset_for_test(); + let txhashset = txhashset.read_recursive(); + Chain::migrate_spent_index(&store, &txhashset, None).unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + assert!(batch.is_spent_index_migrated().unwrap()); + for (height, hash) in hashes.iter().enumerate() { + if (height as u64) <= window_start { + let err = batch.get_spent_index_legacy(hash).unwrap_err(); + assert!( + err.store_error_is_not_found(), + "inactive record at height {} survived", + height + ); + } else { + assert!( + batch.get_spent_index(hash).is_ok(), + "active record at height {} was deleted", + height + ); + } + } + // Per-block spent indexes are derived caches. Removing them does not + // remove the retained full blocks, including the boundary block. + assert!(batch.get_block(&old_hash).is_ok()); + assert!(batch.get_block(&boundary_hash).is_ok()); + let fork_err = batch.get_spent_index_legacy(&fork_hash).unwrap_err(); + assert!(fork_err.store_error_is_not_found(), "{:?}", fork_err); + let orphan_err = batch.get_spent_index_legacy(&orphan_hash).unwrap_err(); + assert!(orphan_err.store_error_is_not_found(), "{:?}", orphan_err); + } + + // Rebuilding the active spent-commitment index must also skip the exact + // boundary, whose per-block spent index was deleted above. + { + let batch = store.batch_write().unwrap(); + batch + .set_spent_commitment_record_index_complete(false) + .unwrap(); + batch.commit().unwrap(); } + Chain::init_spent_commitment_index(&store, None).unwrap(); + let batch = store.batch_read().unwrap(); + assert!(batch.is_spent_commitment_record_index_complete().unwrap()); } - clean_output_dir(chain_dir); + clean_output_dir(&chain_dir); } #[test] @@ -1973,6 +2894,161 @@ fn spent_output_replay_within_cut_through_horizon_is_rejected() { clean_output_dir(&chain_dir); } +#[test] +fn replay_cutoff_uses_shorter_higher_work_candidate_tip() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + let chain_dir = test_chain_dir("replay_cutoff_uses_shorter_higher_work_candidate_tip"); + clean_output_dir(&chain_dir); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + { + let chain = init_chain( + &secp, + &chain_dir, + global::get_genesis_block(&secp, 0).unwrap(), + ); + let kc = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let pb = ProofBuilder::new(&secp, &kc).unwrap(); + let mut common_head = chain.head_header().unwrap(); + + for key_idx in 1..=3 { + let block = prepare_block_key_idx( + &mut secp, + &kc, + &common_head, + &chain, + u64::from(key_idx), + key_idx, + ); + common_head = block.header.clone(); + chain + .process_block(&mut secp, block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + } + let fork_head = common_head.clone(); + + // Move the durable body head far enough ahead that deriving the replay + // cutoff from it would skip the candidate spend at height 5. + let mut main_head = common_head; + for height in 4u32..=45 { + let block = prepare_block_key_idx( + &mut secp, + &kc, + &main_head, + &chain, + u64::from(height), + height, + ); + main_head = block.header.clone(); + chain + .process_block(&mut secp, block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + } + assert_eq!(chain.head().unwrap().height, 45); + + let key_id_coinbase_1 = ExtKeychainPath::new(1, 1, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let key_id_coinbase_2 = ExtKeychainPath::new(1, 2, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let key_id30 = ExtKeychainPath::new(1, 30, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + let key_id31 = ExtKeychainPath::new(1, 31, 0, 0, 0) + .unwrap() + .to_identifier() + .unwrap(); + + let create = build::transaction( + 0, + &mut secp, + KernelFeatures::Plain { + fee: 20000u32.try_into().unwrap(), + }, + &[ + build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, key_id_coinbase_1), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30.clone()), + ], + &kc, + &pb, + ) + .unwrap(); + let replayed_commitment = create.outputs()[0].commitment(); + let create_block = + prepare_block_tx_key_idx(&mut secp, &kc, &fork_head, &chain, 1, 300, &[create]); + let create_head = create_block.header.clone(); + chain + .process_block(&mut secp, create_block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + + let spend = build::transaction( + 0, + &mut secp, + KernelFeatures::Plain { + fee: 20000u32.try_into().unwrap(), + }, + &[ + build::input(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30.clone()), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 40000, key_id31), + ], + &kc, + &pb, + ) + .unwrap(); + let spend_block = + prepare_block_tx_key_idx(&mut secp, &kc, &create_head, &chain, 1, 301, &[spend]); + let candidate_tip = spend_block.header.clone(); + chain + .process_block(&mut secp, spend_block, Options::SKIP_POW, HashSet::new()) + .unwrap(); + assert_eq!(candidate_tip.height, 5); + assert_eq!(chain.head().unwrap().height, 45); + + let recreate = build::transaction( + 0, + &mut secp, + KernelFeatures::Plain { + fee: 20000u32.try_into().unwrap(), + }, + &[ + build::coinbase_input(consensus::MWC_FIRST_GROUP_REWARD, key_id_coinbase_2), + build::output(consensus::MWC_FIRST_GROUP_REWARD - 20000, key_id30), + ], + &kc, + &pb, + ) + .unwrap(); + assert_eq!(recreate.outputs()[0].commitment(), replayed_commitment); + + let winning_difficulty = chain.head().unwrap().total_difficulty.to_num(); + let replay = prepare_block_tx_key_idx( + &mut secp, + &kc, + &candidate_tip, + &chain, + winning_difficulty, + 302, + &[recreate], + ); + assert!(replay.header.total_difficulty() > chain.head().unwrap().total_difficulty); + let err = chain + .process_block(&mut secp, replay, Options::SKIP_POW, HashSet::new()) + .unwrap_err(); + assert!(matches!(err, mwc_chain::Error::ReplayAttack(_, 5, 5))); + assert_eq!(chain.head().unwrap().height, 45); + } + + clean_output_dir(&chain_dir); +} + #[test] fn spent_output_replay_below_cut_through_horizon_is_accepted() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -2125,7 +3201,7 @@ fn spent_output_replay_below_cut_through_horizon_is_accepted() { .unwrap(); assert!(retained_spends .iter() - .any(|spent| spent.height == spent_height)); + .any(|spent| spent.spending_block.height == spent_height)); chain.replay_attack_check(&recreate).unwrap(); let replay = prepare_block_tx(&mut secp, &kc, &head, &chain, 1000, &[recreate]); @@ -2220,6 +3296,7 @@ fn spend_in_fork_and_compact() { .unwrap(); let next = prepare_block_tx(&mut secp, &kc, &fork_head, &chain, 7, &[tx1.clone()]); + let main_tx1_spend_hash = next.hash(0).unwrap(); let prev_main = next.header.clone(); chain .process_block( @@ -2274,6 +3351,7 @@ fn spend_in_fork_and_compact() { // mine 2 forked blocks from the first let fork = prepare_block_tx(&mut secp, &kc, &fork_head, &chain, 6, &[tx1.clone()]); + let fork_tx1_spend_hash = fork.hash(0).unwrap(); let prev_fork = fork.header.clone(); chain .process_block( @@ -2297,6 +3375,26 @@ fn spend_in_fork_and_compact() { chain.validate(&secp, false).unwrap(); + // The fork is still losing, but it was fully validated and retained. Its + // per-block positions and exact commitment record must already be durable; + // a later reorg must not depend on scanning output history to recover them. + let spent_commitment = b.outputs()[0].commitment(); + let store = chain.get_store_for_tests(); + let batch = store.batch_read().unwrap(); + assert!(batch.get_spent_index(&fork_tx1_spend_hash).is_ok()); + let records = batch + .get_spent_commitments(&spent_commitment) + .unwrap() + .unwrap(); + assert!(records + .iter() + .any(|record| { record.spending_block.hash == main_tx1_spend_hash })); + assert!(records + .iter() + .any(|record| { record.spending_block.hash == fork_tx1_spend_hash })); + drop(batch); + drop(store); + // check state let head = chain.head_header().unwrap(); assert_eq!(head.height, 5); @@ -2431,6 +3529,78 @@ fn compact_rebuilds_output_pos_when_index_incomplete() { .is_some()); } +#[test] +fn interrupted_compaction_recovers_recorded_body_tail() { + let chain_dir = test_chain_dir("interrupted_compaction_recovers_recorded_body_tail"); + clean_output_dir(&chain_dir); + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let genesis = genesis_block(&mut secp, &keychain); + let restart_genesis = genesis.clone(); + + let expected_tail = { + let chain = init_chain_accepting_test_pow(&secp, &chain_dir, genesis); + let mut head = chain.head_header().unwrap(); + for n in 1..80 { + let next = prepare_block(&mut secp, &keychain, &head, &chain, n); + head = next.header.clone(); + process_block(&mut secp, &chain, &next); + } + + let store = chain.get_store_for_tests(); + let original_body_head = chain.head().unwrap(); + let original_header_head = chain.header_head().unwrap(); + let old_tail = chain.tail().unwrap(); + let horizon_height = original_body_head + .height + .saturating_sub(u64::from(global::cut_through_horizon(0))); + let horizon_header = chain.get_header_by_height(horizon_height).unwrap(); + let target_body_tail = Tip::try_from_header(&horizon_header).unwrap(); + assert!(old_tail.height < target_body_tail.height); + + let marker = PendingChainOperation::Compact { + original_body_head, + original_header_head, + target_body_tail, + }; + store.set_pending_chain_operation(&marker).unwrap(); + + // Model a crash after the PMMR file replacements have been flushed but + // before BODY_TAIL is staged and the enclosing LMDB batch commits. + { + let header_pmmr = chain.get_header_pmmr_for_test(); + let _header_pmmr = header_pmmr.read_recursive(); + let txhashset = chain.get_txhashset_for_test(); + let mut txhashset = txhashset.write(); + let batch = store.batch_write().unwrap(); + txhashset.compact(&horizon_header, &batch).unwrap(); + drop(batch); + } + + assert_eq!(store.tail().unwrap(), old_tail); + assert_eq!(store.pending_chain_operation().unwrap(), Some(marker)); + mwc_chain::pipe::release_context_data(chain.get_context_id()); + drop(store); + drop(chain); + target_body_tail + }; + + let restarted = init_chain_accepting_test_pow(&secp, &chain_dir, restart_genesis); + let store = restarted.get_store_for_tests(); + assert_eq!(restarted.tail().unwrap(), expected_tail); + assert!(store.pending_chain_operation().unwrap().is_none()); + + mwc_chain::pipe::release_context_data(restarted.get_context_id()); + drop(store); + drop(restarted); + clean_output_dir(&chain_dir); +} + /// Test ability to retrieve block headers for a given output #[test] fn output_header_mappings() { @@ -2572,7 +3742,6 @@ where &key_id, switch, proof_commit, - None, )?; // we return the output and the value is subtracted instead of added @@ -2819,7 +3988,8 @@ fn prepare_block_nosum( where K: Keychain, { - let proof_size = global::proofsize(0); + let context_id = prev.pow.proof.context_id; + let proof_size = global::proofsize(context_id); let key_id = ExtKeychainPath::new(1, key_idx, 0, 0, 0) .unwrap() .to_identifier() @@ -2827,7 +3997,7 @@ where let fees = txs.iter().map(|tx| tx.fee().unwrap()).sum(); let reward = libtx::reward::output( - 0, + context_id, kc, &libtx::ProofBuilder::new(secp, kc).unwrap(), &key_id, @@ -2837,13 +4007,20 @@ where secp, ) .unwrap(); - let mut b = match Block::new(0, prev, txs, Difficulty::from_num(diff), reward, secp) { + let mut b = match Block::new( + context_id, + prev, + txs, + Difficulty::from_num(diff), + reward, + secp, + ) { Err(e) => panic!("{:?}", e), Ok(b) => b, }; b.header.timestamp = prev.timestamp + Duration::seconds(60); b.header.pow.total_difficulty = (prev.total_difficulty() + Difficulty::from_num(diff)).unwrap(); - b.header.pow.proof = pow::Proof::random(0, proof_size).unwrap(); + b.header.pow.proof = pow::Proof::random(context_id, proof_size).unwrap(); b } @@ -2865,6 +4042,7 @@ fn actual_diff_iter_output() { HashSet::new(), None, None, + false, ) .unwrap(); let iter = chain.difficulty_iter().unwrap(); diff --git a/chain/src/tests/mod.rs b/chain/src/tests/mod.rs index f72a24a888..bafe802ac2 100644 --- a/chain/src/tests/mod.rs +++ b/chain/src/tests/mod.rs @@ -1,4 +1,4 @@ -mod chain_test_helper; +pub(crate) mod chain_test_helper; mod mine_simple_chain; mod process_block_cut_through; diff --git a/chain/src/tests/process_block_cut_through.rs b/chain/src/tests/process_block_cut_through.rs index 497c0a6d46..9265a79626 100644 --- a/chain/src/tests/process_block_cut_through.rs +++ b/chain/src/tests/process_block_cut_through.rs @@ -144,6 +144,51 @@ fn missing_predecessor_header_returns_orphan() -> Result<(), mwc_chain::Error> { Ok(()) } +#[test] +fn state_invalid_block_clears_pending_operation_marker() -> Result<(), mwc_chain::Error> { + let chain_dir = ".mwc.state_invalid_block_marker"; + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + mwc_util::init_test_logger().unwrap(); + clean_output_dir(chain_dir); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + let keychain = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false)?; + let genesis = genesis_block(&mut secp, &keychain); + let chain = init_chain(&secp, chain_dir, genesis); + + // The header is valid and accepted independently, but the body roots are + // deliberately left invalid. This ensures body processing reaches the + // stateful txhashset extension without committing a new header extension in + // the same pending operation. + let block = build_block(&mut secp, &chain, &keychain, &[], true)?; + chain.process_block_header(&block.header, Options::SKIP_POW)?; + let old_head = chain.head()?; + + let err = chain + .process_block( + &mut secp, + block, + Options::SKIP_POW, + std::collections::HashSet::new(), + ) + .unwrap_err(); + assert!(matches!(err, mwc_chain::Error::InvalidRoot(_))); + + // extending() discarded the provisional PMMR and child-batch changes, so + // this ordinary validation error must neither move HEAD nor retain a marker + // that would force full chain reconciliation before the next operation. + assert_eq!(chain.head()?, old_head); + assert!(chain + .get_store_for_tests() + .pending_chain_operation()? + .is_none()); + + clean_output_dir(chain_dir); + Ok(()) +} + #[test] fn process_block_cut_through() -> Result<(), mwc_chain::Error> { let chain_dir = ".mwc.cut_through"; @@ -283,7 +328,7 @@ fn process_block_cut_through() -> Result<(), mwc_chain::Error> { } #[test] -fn readonly_pmmr_operation_preserves_existing_pending_marker() -> Result<(), mwc_chain::Error> { +fn readonly_pmmr_operation_rejects_existing_pending_marker() -> Result<(), mwc_chain::Error> { let chain_dir = ".mwc.readonly_pmmr_existing_marker"; global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); @@ -300,12 +345,24 @@ fn readonly_pmmr_operation_preserves_existing_pending_marker() -> Result<(), mwc chain .get_store_for_tests() .set_pending_chain_operation(&marker)?; - let _block = build_block(&mut secp, &chain, &keychain, &[], false)?; + let err = build_block(&mut secp, &chain, &keychain, &[], false).unwrap_err(); + assert!(matches!( + err, + mwc_chain::Error::Other(ref msg) + if msg == "pending chain operation requires chain init recovery" + )); + // A non-owner must neither run the readonly PMMR operation nor clear the + // existing marker. The next guarded chain access performs recovery. assert_eq!( chain.get_store_for_tests().pending_chain_operation()?, Some(marker) ); + chain.head()?; + assert!(chain + .get_store_for_tests() + .pending_chain_operation()? + .is_none()); clean_output_dir(chain_dir); Ok(()) diff --git a/chain/src/tests/store_indices.rs b/chain/src/tests/store_indices.rs index 3e840dc3c1..1067b9120a 100644 --- a/chain/src/tests/store_indices.rs +++ b/chain/src/tests/store_indices.rs @@ -40,7 +40,11 @@ fn test_store_indices() { { // Block exists in the db. assert_eq!( - chain.get_block(&block_hash).unwrap().hash(0).unwrap(), + chain + .get_block_for_header(&block_header) + .unwrap() + .hash(0) + .unwrap(), block_hash ); @@ -58,7 +62,7 @@ fn test_store_indices() { } // Check the batch did not commit any changes to the store . - assert!(chain.get_block(&block_hash).is_ok()); + assert!(chain.get_block_for_header(&block_header).is_ok()); } // Cleanup chain directory diff --git a/chain/src/tests/test_block_known.rs b/chain/src/tests/test_block_known.rs index 3258d72b58..966227d612 100644 --- a/chain/src/tests/test_block_known.rs +++ b/chain/src/tests/test_block_known.rs @@ -21,7 +21,7 @@ use mwc_chain::Error; use mwc_chain::Options; use mwc_chain::Tip; use mwc_core::core::hash::{Hash, Hashed}; -use mwc_core::core::{block, BlockHeader}; +use mwc_core::core::{block, BlockHeader, Inputs}; use mwc_core::{genesis, global, pow}; use mwc_crates::chrono::Duration; use mwc_crates::secp::{ContextFlag, Secp256k1}; @@ -50,11 +50,10 @@ fn check_known() { // mine some blocks let (latest, genesis) = { let chain = mine_chain(chain_dir, 3); - let genesis = chain - .get_block(&chain.get_header_by_height(0).unwrap().hash(0).unwrap()) - .unwrap(); - let head = chain.head().unwrap(); - let latest = chain.get_block(&head.last_block_h).unwrap(); + let genesis_header = chain.get_header_by_height(0).unwrap(); + let genesis = chain.get_block_for_header(&genesis_header).unwrap(); + let head_header = chain.head_header().unwrap(); + let latest = chain.get_block_for_header(&head_header).unwrap(); (latest, genesis) }; @@ -68,6 +67,26 @@ fn check_known() { std::collections::HashSet::new(), ); assert!(matches!( res, Err(Error::Unfit(ref s)) if s == "already known in head")); + + // The v3 store reads an inputless block as CommitOnly, while the v2 + // compatibility representation uses FeaturesAndCommit. The representation + // difference must not bypass the known-block fast path. + assert!(matches!( + latest.inputs(), + Inputs::CommitOnly(ref inputs) if inputs.is_empty() + )); + let latest_v2 = chain.convert_block_v2(&secp, latest.clone()).unwrap(); + assert!(matches!( + latest_v2.inputs(), + Inputs::FeaturesAndCommit(ref inputs) if inputs.is_empty() + )); + let res = chain.process_block( + &mut secp, + latest_v2, + mwc_chain::Options::NONE, + std::collections::HashSet::new(), + ); + assert!(matches!( res, Err(Error::Unfit(ref s)) if s == "already known in head")); } // attempt to reprocess genesis block @@ -120,8 +139,8 @@ fn full_block_known_hash_with_different_body_is_not_duplicate() { let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let chain = mine_chain(chain_dir, 2); - let head = chain.head().unwrap(); - let mut block = chain.get_block(&head.last_block_h).unwrap(); + let head = chain.head_header().unwrap(); + let mut block = chain.get_block_for_header(&head).unwrap(); assert!(!block.body.outputs.is_empty()); let original_hash = block.hash(0).unwrap(); @@ -235,7 +254,7 @@ fn reset_to_genesis_restores_genesis_metadata() { let genesis_header = chain.get_header_by_height(0).unwrap(); let genesis_hash = genesis_header.hash(0).unwrap(); let first_header = chain.get_header_by_height(1).unwrap(); - let first_block = chain.get_block(&first_header.hash(0).unwrap()).unwrap(); + let first_block = chain.get_block_for_header(&first_header).unwrap(); { let store = chain.get_store_for_tests(); @@ -243,14 +262,14 @@ fn reset_to_genesis_restores_genesis_metadata() { batch.delete_block(&genesis_hash).unwrap(); batch.commit().unwrap(); } - assert!(chain.get_block(&genesis_hash).is_err()); + assert!(chain.get_block_for_header(&genesis_header).is_err()); assert!(chain.get_block_sums(&genesis_hash).is_err()); chain.reset_chain_head_to_genesis().unwrap(); assert_eq!(chain.head().unwrap().last_block_h, genesis_hash); assert_eq!(chain.tail().unwrap().last_block_h, genesis_hash); - assert!(chain.get_block(&genesis_hash).is_ok()); + assert!(chain.get_block_for_header(&genesis_header).is_ok()); assert!(chain.get_block_sums(&genesis_hash).is_ok()); let head = chain @@ -269,6 +288,75 @@ fn reset_to_genesis_restores_genesis_metadata() { clean_output_dir(chain_dir); } +#[test] +fn reset_to_genesis_rebuilds_full_kernel_pos_index() { + let chain_dir = ".mwc.reset_genesis_kernel_pos_index"; + mwc_util::init_test_logger().unwrap(); + clean_output_dir(chain_dir); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + + let chain = mine_chain(chain_dir, 3); + let genesis_header = chain.get_header_by_height(0).unwrap(); + let genesis = chain.get_block_for_header(&genesis_header).unwrap(); + let old_header = chain.get_header_by_height(2).unwrap(); + let old_block = chain.get_block_for_header(&old_header).unwrap(); + let old_excess = old_block.kernels()[0].excess; + let genesis_excess = genesis.kernels()[0].excess; + + assert_ne!(old_excess, genesis_excess); + assert_eq!( + chain + .get_kernel_height(&old_excess, None, None) + .unwrap() + .unwrap() + .1, + old_header.height + ); + assert!(chain + .get_store_for_tests() + .batch_read() + .unwrap() + .is_kernel_pos_index_complete() + .unwrap()); + + chain.reset_chain_head_to_genesis().unwrap(); + + assert_eq!(chain.head().unwrap().height, 0); + { + let store = chain.get_store_for_tests(); + let batch = store.batch_read().unwrap(); + assert!(batch.is_kernel_pos_index_complete().unwrap()); + assert!(batch.kernel_pos_iter(&old_excess).unwrap().next().is_none()); + } + assert!(chain + .get_kernel_height(&old_excess, None, None) + .unwrap() + .is_none()); + assert_eq!( + chain + .get_kernel_height(&genesis_excess, None, None) + .unwrap() + .unwrap() + .1, + 0 + ); + + drop(chain); + let chain = init_chain(&secp, chain_dir, genesis); + assert!(chain + .get_store_for_tests() + .batch_read() + .unwrap() + .is_kernel_pos_index_complete() + .unwrap()); + assert!(chain + .get_kernel_height(&old_excess, None, None) + .unwrap() + .is_none()); + + clean_output_dir(chain_dir); +} + #[test] fn rejects_genesis_context_id_mismatch() { let chain_dir = ".mwc.genesis_context_mismatch"; @@ -290,6 +378,7 @@ fn rejects_genesis_context_id_mismatch() { HashSet::new(), None, None, + false, ); assert!(matches!(res, Err(Error::InvalidGenesisHash))); assert!(!Path::new(chain_dir).exists()); @@ -308,6 +397,7 @@ fn rejects_genesis_context_id_mismatch() { HashSet::new(), None, None, + false, ) .unwrap(); assert_eq!(chain.head().unwrap().last_block_h, valid_genesis_hash); @@ -339,6 +429,7 @@ fn rejects_genesis_with_invalid_height() { HashSet::new(), None, None, + false, ); assert!(matches!(res, Err(Error::InvalidGenesisHash))); assert!(!Path::new(chain_dir).exists()); @@ -366,6 +457,7 @@ fn rejects_genesis_with_invalid_pow() { HashSet::new(), None, None, + false, ); assert!(matches!(res, Err(Error::InvalidPow))); assert!(!Path::new(chain_dir).exists()); @@ -394,6 +486,7 @@ fn rejects_production_genesis_with_mutated_txhashset_commitments() { HashSet::new(), None, None, + false, ); assert!(matches!(res, Err(Error::InvalidGenesisHash))); @@ -424,6 +517,7 @@ fn rejects_genesis_hash_mismatch_with_existing_chain_data() { HashSet::new(), None, None, + false, ) .unwrap(); assert_eq!(chain.head().unwrap().last_block_h, valid_genesis_hash); @@ -445,6 +539,7 @@ fn rejects_genesis_hash_mismatch_with_existing_chain_data() { HashSet::new(), None, None, + false, ); assert!(matches!(res, Err(Error::InvalidGenesisHash))); @@ -460,6 +555,7 @@ fn rejects_genesis_hash_mismatch_with_existing_chain_data() { HashSet::new(), None, None, + false, ) .unwrap(); assert_eq!(chain.head().unwrap().last_block_h, valid_genesis_hash); diff --git a/chain/src/tests/test_pibd_copy.rs b/chain/src/tests/test_pibd_copy.rs index b1d05e6362..dd1f3a705d 100644 --- a/chain/src/tests/test_pibd_copy.rs +++ b/chain/src/tests/test_pibd_copy.rs @@ -82,6 +82,7 @@ impl SegmenterResponder { HashSet::new(), None, None, + false, ) .unwrap(), ), @@ -162,6 +163,7 @@ impl DesegmenterRequestor { HashSet::new(), None, None, + false, ) .unwrap(), ), @@ -520,6 +522,7 @@ fn test_chain_validation() { HashSet::new(), None, None, + false, ) .unwrap(); @@ -534,6 +537,7 @@ fn test_chain_validation() { HashSet::new(), None, None, + false, ) .unwrap(); @@ -617,7 +621,8 @@ fn test_chain_validation() { blocks_are_done = false; let block_hash = hashes.choose(&mut rng).unwrap(); - let block = src_chain.get_block(block_hash).unwrap(); + let header = src_chain.get_block_header(block_hash).unwrap(); + let block = src_chain.get_block_for_header(&header).unwrap(); debug!( "Request size: {}, requested block {} at height {}", diff --git a/chain/src/txhashset/desegmenter.rs b/chain/src/txhashset/desegmenter.rs index fce3ba0441..10e7826554 100644 --- a/chain/src/txhashset/desegmenter.rs +++ b/chain/src/txhashset/desegmenter.rs @@ -17,7 +17,7 @@ //! segmenter use crate::error::Error; -use crate::store::PendingChainOperation; +use crate::store::{PendingChainOperation, PendingChainOperationGuard}; use crate::txhashset; use crate::txhashset::{BitmapAccumulator, BitmapChunk, TxHashSet}; use crate::types::{SyncStatusUpdateThrottle, Tip, TxHashsetStateValidationStage}; @@ -34,6 +34,7 @@ use mwc_crates::num_cpus; use mwc_crates::parking_lot::RwLock; use mwc_crates::secp::pedersen::RangeProof; use mwc_util::StopState; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -60,6 +61,8 @@ pub struct Desegmenter { genesis: BlockHeader, requires_init_recovery: Arc, + state_generation: Arc, + created_at_state_generation: u64, outputs_bitmap_accumulator: RwLock, // Lock 1 outputs_bitmap_mmr_size: u64, @@ -193,8 +196,14 @@ impl Desegmenter { .map(|x| x / leaves_num) } + /// Whether this PIBD session still describes the current chain state. + pub fn is_current(&self) -> bool { + !self.requires_init_recovery.load(Ordering::SeqCst) + && self.state_generation.load(Ordering::SeqCst) == self.created_at_state_generation + } + fn ensure_robust(&self) -> Result<(), Error> { - if self.requires_init_recovery.load(Ordering::SeqCst) { + if !self.is_current() { return Err(Error::ChainRestartRequired); } Ok(()) @@ -236,10 +245,18 @@ impl Desegmenter { Ok(()) } - fn set_pending_operation(&self, op: &PendingChainOperation) -> Result<(), Error> { + fn set_pending_operation( + &self, + op: &PendingChainOperation, + ) -> Result { self.ensure_header_pmmr_locked_for_marker("desegmenter set_pending_operation")?; + // Callers hold header_pmmr here, so recovery cannot advance the generation + // between this check and the protected PMMR mutation. + self.ensure_robust()?; match self.store.set_pending_chain_operation(op) { - Ok(()) => Ok(()), + Ok(()) => Ok(PendingChainOperationGuard::new( + self.requires_init_recovery.clone(), + )), Err(e) => { self.require_init_recovery(format_args!( "failed to set pending chain operation {:?}: {}", @@ -250,6 +267,26 @@ impl Desegmenter { } } + fn finish_pending_operation( + &self, + failure_context: &str, + res: Result<(), Error>, + mut marker_guard: PendingChainOperationGuard, + ) -> Result<(), Error> { + match res { + Ok(()) => { + self.clear_pending_operation_checked()?; + marker_guard.disarm(); + Ok(()) + } + Err(e) => { + self.require_init_recovery(format_args!("{}: {}", failure_context, e)); + marker_guard.disarm(); + Err(e) + } + } + } + fn clear_pending_operation_checked(&self) -> Result<(), Error> { match self.store.clear_pending_chain_operation() { Ok(()) => Ok(()), @@ -263,8 +300,9 @@ impl Desegmenter { } } - /// Create a new segmenter based on the provided txhashset and the specified block header - pub fn new( + /// Create a desegmenter tied to the chain-state generation from which its + /// archive header, bitmap accumulator, and accepted-segment cursors derive. + pub(crate) fn new_guarded( txhashset: Arc>, header_pmmr: Arc>>, archive_header: BlockHeader, @@ -273,6 +311,8 @@ impl Desegmenter { store: Arc, pibd_params: Arc, requires_init_recovery: Arc, + state_generation: Arc, + created_at_state_generation: u64, ) -> Result { info!( "Creating new desegmenter for bitmap_root_hash {}, height {}", @@ -296,6 +336,8 @@ impl Desegmenter { store, genesis, requires_init_recovery, + state_generation, + created_at_state_generation, outputs_bitmap_accumulator: RwLock::new(BitmapAccumulator::new(context_id)), outputs_bitmap_mmr_size: bitmap_mmr_size, bitmap_segment_cache: RwLock::new(SegmentsCache::new( @@ -337,6 +379,9 @@ impl Desegmenter { /// Whether we have all the segments we need pub fn is_complete(&self) -> bool { + if !self.is_current() { + return false; + } if !self .output_segment_cache .read_recursive() @@ -438,7 +483,7 @@ impl Desegmenter { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_operation(&PendingChainOperation::PibdReset)?; + let marker_guard = self.set_pending_operation(&PendingChainOperation::PibdReset)?; let res = (|| { let mut _batch = self.store.batch_write()?; txhashset::extending(&mut header_pmmr, &mut txhashset, &mut _batch, |ext, _| { @@ -448,16 +493,11 @@ impl Desegmenter { })?; Ok(()) })(); - match res { - Ok(()) => self.clear_pending_operation_checked(), - Err(e) => { - self.require_init_recovery(format_args!( - "check_update_leaf_set_state failed while updating txhashset leaf sets: {}", - e - )); - Err(e) - } - } + self.finish_pending_operation( + "check_update_leaf_set_state failed while updating txhashset leaf sets", + res, + marker_guard, + ) } fn validate_kernel_history_parallel( @@ -487,6 +527,7 @@ impl Desegmenter { let mut first_error = None; let processed = Arc::new(AtomicU64::new(0)); let status_throttle = Arc::new(SyncStatusUpdateThrottle::new()); + let context_id = self.store.get_context_id(); for thr_idx in 0..num_cores { let handle_result = (|| -> Result>, Error> { @@ -498,6 +539,13 @@ impl Desegmenter { .read_recursive() .get_block_header(&start_block_hash)?; let processed = processed.clone(); + let actual_start_block_hash = start_block.hash(context_id)?; + if actual_start_block_hash != start_block_hash { + return Err(Error::InvalidPersistedChainState(format!( + "Desegmenter::validate_complete_state, start block at height {} hashes to {}, expected PMMR-selected hash {}", + start_height, actual_start_block_hash, start_block_hash + ))); + } if start_block.height != start_height { return Err(Error::InvalidSegment(format!( "Desegmenter::validate_complete_state, start_block.height={} start_height={}", @@ -517,10 +565,17 @@ impl Desegmenter { &*txhashset.read_recursive(), |view, batch| { let mut start_block = start_block.clone(); + let mut visited = HashSet::new(); while start_block.height > end_height { view.rewind(&start_block)?; view.validate_root()?; - start_block = batch.get_previous_header(&start_block)?; + start_block = crate::checked_previous_header( + context_id, + &start_block, + &mut visited, + "Desegmenter::validate_kernel_history_parallel", + |hash| batch.get_block_header(hash), + )?; let completed = processed .fetch_add(1, Ordering::Relaxed) .saturating_add(1); @@ -590,9 +645,9 @@ impl Desegmenter { txhashset.roots()?.validate(&self.archive_header)?; } - // Validate full kernel history. - // Check the kernel MMR root for every block header, then check NRD - // relative height rules for the full kernel history. + // Validate full kernel history. Check the kernel MMR root for every block + // header, then use each header's kernel-MMR boundary to enforce contextual + // HeightLocked and NRD rules over the full history. { info!("desegmenter validation: validating kernel history"); self.validate_kernel_history_parallel( @@ -606,6 +661,7 @@ impl Desegmenter { let batch = self.store.batch_write()?; txhashset.verify_kernel_pos_index( &self.genesis, + &self.archive_header, &header_pmmr, &batch, Some(status.clone()), @@ -621,7 +677,7 @@ impl Desegmenter { let mut header_pmmr = self.header_pmmr.write(); self.ensure_archive_header_canonical(&header_pmmr)?; let mut txhashset = self.txhashset.write(); - self.set_pending_operation(&PendingChainOperation::PibdReset)?; + let marker_guard = self.set_pending_operation(&PendingChainOperation::PibdReset)?; let res = (|| { info!("desegmenter validation: rewinding a 2nd time (writeable)"); let mut batch = self.store.batch_write()?; @@ -633,7 +689,6 @@ impl Desegmenter { archive_tip, |ext, batch| { let extension = &mut ext.extension; - let header_extension = &mut ext.header_extension; { let status_throttle = SyncStatusUpdateThrottle::new(); let mut rewind_progress = |current: u64, total: u64| { @@ -654,7 +709,6 @@ impl Desegmenter { extension.rewind( &self.archive_header, batch, - header_extension, Some(&mut rewind_progress), )?; } @@ -711,7 +765,6 @@ impl Desegmenter { // Rebuild our NRD kernel_pos index based on recent kernel history. txhashset.init_recent_kernel_pos_index( - &header_pmmr, &batch, Some(status.clone()), Some(stop_state.clone()), @@ -719,7 +772,7 @@ impl Desegmenter { // The full kernel excess index is rebuilt after this commit in chunks. batch.set_kernel_pos_index_complete(false)?; - batch.set_retained_spent_commitment_index_complete(false)?; + batch.set_spent_commitment_record_index_complete(false)?; // Commit all the changes to the db. batch.commit()?; @@ -731,20 +784,19 @@ impl Desegmenter { Some(stop_state.clone()), )?; info!("desegmenter_validation: rebuilt full kernel_pos index"); - Chain::init_empty_retained_spent_commitment_index(self.store.as_ref())?; + // Replay protection is best effort after PIBD. Start with the empty + // retained-body index and let the following one-by-one block sync build + // it. Replay-index coverage must never decide whether PIBD state or a + // reorganized chain is reset. + Chain::init_empty_spent_commitment_record_index(self.store.as_ref())?; info!("desegmenter_validation: initialized empty spent commitment replay index"); Ok(()) })(); - match res { - Ok(()) => self.clear_pending_operation_checked(), - Err(e) => { - self.require_init_recovery(format_args!( - "validate_complete_state failed while validating and rebuilding txhashset: {}", - e - )); - Err(e) - } - } + self.finish_pending_operation( + "validate_complete_state failed while validating and rebuilding txhashset", + res, + marker_guard, + ) } /// Return list of the next preferred segments the desegmenter needs based on @@ -1021,6 +1073,10 @@ impl Desegmenter { let bitmap_cache_became_complete = { let mut bitmap_segment_cache = self.bitmap_segment_cache.write(); let mut bitmap_accumulator = self.outputs_bitmap_accumulator.write(); + // Bitmap state is private to this Desegmenter and does not share the PMMR + // recovery locks. Recheck after taking its own mutation locks so a + // completed recovery cannot revive and extend an old accumulator. + self.ensure_robust()?; let was_complete = bitmap_segment_cache.is_complete(); let res = bitmap_segment_cache.apply_new_segment( @@ -1054,6 +1110,7 @@ impl Desegmenter { } }; + self.ensure_robust()?; if bitmap_cache_became_complete { if let Err(e) = self.finalize_bitmap_init_segment_caches() { self.require_init_recovery(format_args!( @@ -1064,7 +1121,7 @@ impl Desegmenter { } } - Ok(()) + self.ensure_robust() } /// Adds a output segment @@ -1127,7 +1184,7 @@ impl Desegmenter { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_operation(&PendingChainOperation::PibdReset)?; + let marker_guard = self.set_pending_operation(&PendingChainOperation::PibdReset)?; let res = (|| { let mut batch = self.store.batch_write()?; @@ -1171,16 +1228,11 @@ impl Desegmenter { )?; Ok(()) })(); - return match res { - Ok(()) => self.clear_pending_operation_checked(), - Err(e) => { - self.require_init_recovery(format_args!( - "add_output_segment failed while applying output segment to txhashset: {}", - e - )); - Err(e) - } - }; + return self.finish_pending_operation( + "add_output_segment failed while applying output segment to txhashset", + res, + marker_guard, + ); } } return Err(Error::BitmapNotReady); @@ -1246,7 +1298,7 @@ impl Desegmenter { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_operation(&PendingChainOperation::PibdReset)?; + let marker_guard = self.set_pending_operation(&PendingChainOperation::PibdReset)?; let res = (|| { let mut batch = self.store.batch_write()?; @@ -1289,16 +1341,11 @@ impl Desegmenter { Ok(()) })(); - return match res { - Ok(()) => self.clear_pending_operation_checked(), - Err(e) => { - self.require_init_recovery(format_args!( - "add_rangeproof_segment failed while applying rangeproof segment to txhashset: {}", - e - )); - Err(e) - } - }; + return self.finish_pending_operation( + "add_rangeproof_segment failed while applying rangeproof segment to txhashset", + res, + marker_guard, + ); } } @@ -1365,7 +1412,7 @@ impl Desegmenter { let mut header_pmmr = self.header_pmmr.write(); let mut txhashset = self.txhashset.write(); - self.set_pending_operation(&PendingChainOperation::PibdReset)?; + let marker_guard = self.set_pending_operation(&PendingChainOperation::PibdReset)?; let res = (|| { let mut batch = self.store.batch_write()?; @@ -1403,16 +1450,11 @@ impl Desegmenter { Ok(()) })(); - return match res { - Ok(()) => self.clear_pending_operation_checked(), - Err(e) => { - self.require_init_recovery(format_args!( - "add_kernel_segment failed while applying kernel segment to txhashset: {}", - e - )); - Err(e) - } - }; + return self.finish_pending_operation( + "add_kernel_segment failed while applying kernel segment to txhashset", + res, + marker_guard, + ); } return Err(Error::BitmapNotReady); diff --git a/chain/src/txhashset/headers_desegmenter.rs b/chain/src/txhashset/headers_desegmenter.rs index 89164f7326..5a8cf1a73f 100644 --- a/chain/src/txhashset/headers_desegmenter.rs +++ b/chain/src/txhashset/headers_desegmenter.rs @@ -744,11 +744,45 @@ impl HeadersRecieveCache { debug_assert!(!headers.is_empty()); debug_assert!(headers.len() == HEADERS_PER_BATCH as usize); debug_assert!(headers.first().map(|header| header.height) == Some(*height)); - let ending_height = headers + + // The terminal PIBD response can contain unvalidated padding above the + // archive target. Derive the applicable prefix from the authenticated + // batch start and target before allowing any padding header to influence + // stale detection or chain application. + let applicable_len = self + .archive_header_height + .checked_sub(*height) + .and_then(|remaining| remaining.checked_add(1)) + .ok_or_else(|| { + ( + None, + Error::DataOverflow(format!( + "HeadersRecieveCache::apply_cache, cached batch range {}..{}", + height, self.archive_header_height + )), + ) + })?; + let applicable_len = usize::try_from(applicable_len).map_err(|_| { + ( + None, + Error::DataOverflow(format!( + "HeadersRecieveCache::apply_cache, applicable_len={}", + applicable_len + )), + ) + })?; + let applicable_len = cmp::min(headers.len(), applicable_len); + let mut bhs = headers[..applicable_len].to_vec(); + if bhs.is_empty() { + stale_heights.push(*height); + continue; + } + + let ending_height = bhs .last() .ok_or(( None, - Error::Other("Internal error, header expected to be defined".into()), + Error::Other("Internal error, bhs expected to be defined".into()), ))? .height; if ending_height <= tip_height { @@ -762,28 +796,7 @@ impl HeadersRecieveCache { if *height > next_tip_height { break; } - let mut bhs = headers.clone(); - // The terminal PIBD batch can be full sized even when the archive target - // falls inside it. Keep the applied range bounded by the PIBD target. - // Note, trancating addrees the comment that said that series length/consystency is - // not validated above the header height. - if let Some(idx) = bhs - .iter() - .position(|header| header.height > self.archive_header_height) - { - bhs.truncate(idx); - } - if bhs.is_empty() { - stale_heights.push(*height); - continue; - } - tip_height = bhs - .last() - .ok_or(( - Some(peer.clone()), - Error::Other("Internal error, bhs expected to be defined".into()), - ))? - .height; + tip_height = ending_height; headers_by_peer.push((*height, bhs.clone(), peer.clone())); headers_all.append(&mut bhs); @@ -831,16 +844,10 @@ impl HeadersRecieveCache { match self.chain.sync_block_headers(&hdr, tip, Options::NONE) { Ok(_) => self.remove_cached_header_batches(&[height]), Err(e) => { - let evict_cached_batch = - e.is_bad_data() || matches!(&e, Error::Orphan(_)); + let bad_data = e.is_bad_data(); + let err = if bad_data { (Some(peer), e) } else { (None, e) }; - let err = if e.is_bad_data() || matches!(&e, Error::Orphan(_)) { - (Some(peer), e) - } else { - (None, e) - }; - - if evict_cached_batch { + if bad_data { self.remove_cached_header_batches(&[height]); } return Err(err); @@ -919,6 +926,7 @@ mod tests { HashSet::new(), None, None, + false, ) .unwrap(); @@ -1049,12 +1057,19 @@ mod tests { let (chain, chain_dir) = init_test_chain("apply_cache_evicts_bad_batch_and_keeps_later_unapplied_batches"); let context_id = chain.get_context_id(); + let genesis_hash = chain.genesis().hash(context_id).unwrap(); let headers_per_batch = u64::from(HEADERS_PER_BATCH); let headers_cache = headers_receive_cache(chain.clone(), 2 * headers_per_batch); { let mut main_headers_cache = headers_cache.main_headers_cache.write(); - main_headers_cache.insert(1, (dummy_headers(context_id, 1), "bad-peer".to_string())); + main_headers_cache.insert( + 1, + ( + linked_dummy_headers(context_id, 1, genesis_hash), + "bad-peer".to_string(), + ), + ); main_headers_cache.insert( headers_per_batch + 1, ( @@ -1074,6 +1089,63 @@ mod tests { cleanup_test_chain(&chain, &chain_dir); } + #[test] + fn apply_cache_ignores_unvalidated_terminal_padding_for_stale_detection() { + let (chain, chain_dir) = + init_test_chain("apply_cache_ignores_unvalidated_terminal_padding_for_stale_detection"); + let context_id = chain.get_context_id(); + let genesis_hash = chain.genesis().hash(context_id).unwrap(); + let headers_cache = headers_receive_cache(chain.clone(), 1); + let mut headers = linked_dummy_headers(context_id, 1, genesis_hash); + + // The only applicable header is at height 1. A peer-controlled padding + // header must not make that required prefix look stale against the + // genesis tip at height 0. + headers.last_mut().unwrap().height = 0; + headers_cache + .main_headers_cache + .write() + .insert(1, (headers, "padding-peer".to_string())); + + let res = headers_cache.apply_cache(); + assert!( + matches!(&res, Err((Some(peer), e)) if peer == "padding-peer" && e.is_bad_data()), + "the applicable prefix should reach chain validation: {:?}", + res + ); + + cleanup_test_chain(&chain, &chain_dir); + } + + #[test] + fn apply_cache_retains_orphan_without_peer_attribution() { + let (chain, chain_dir) = + init_test_chain("apply_cache_retains_orphan_without_peer_attribution"); + let context_id = chain.get_context_id(); + let headers_cache = headers_receive_cache(chain.clone(), 1); + + // The missing predecessor makes this a local-context failure. Cache + // admission normally authenticates the predecessor hash, so apply_cache + // must neither blame the serving peer nor discard the retryable batch. + headers_cache + .main_headers_cache + .write() + .insert(1, (dummy_headers(context_id, 1), "honest-peer".to_string())); + + let res = headers_cache.apply_cache(); + assert!( + matches!(&res, Err((None, Error::Orphan(_)))), + "unexpected result: {:?}", + res + ); + assert!(headers_cache + .main_headers_cache + .read_recursive() + .contains_key(&1)); + + cleanup_test_chain(&chain, &chain_dir); + } + #[test] fn next_desired_headers_retries_waiting_before_trailing_cached_run() { let (chain, chain_dir) = diff --git a/chain/src/txhashset/segmenter.rs b/chain/src/txhashset/segmenter.rs index be0dd3c1f6..d85d05967a 100644 --- a/chain/src/txhashset/segmenter.rs +++ b/chain/src/txhashset/segmenter.rs @@ -28,7 +28,9 @@ use mwc_crates::log::debug; use mwc_crates::parking_lot::RwLock; use mwc_crates::secp::pedersen::RangeProof; use std::convert::TryFrom; -use std::{sync::Arc, time::Instant}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Instant; // Accept alternate peer segment layouts, but do not let one request scan an // unbounded sparse prunable PMMR range before the payload limit is reached. @@ -231,6 +233,9 @@ pub struct Segmenter { bitmap_snapshot: Arc, bitmap: Bitmap, header: BlockHeader, + requires_init_recovery: Arc, + state_generation: Arc, + created_at_state_generation: u64, } impl Segmenter { @@ -240,6 +245,28 @@ impl Segmenter { txhashset: Arc>, bitmap_snapshot: BitmapAccumulator, header: BlockHeader, + ) -> Result { + Self::new_guarded( + header_pmmr, + txhashset, + bitmap_snapshot, + header, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicU64::new(0)), + 0, + ) + } + + /// Create a segmenter tied to the chain-state generation that produced + /// its immutable bitmap and header-hash snapshots. + pub(crate) fn new_guarded( + header_pmmr: Arc>>, + txhashset: Arc>, + bitmap_snapshot: BitmapAccumulator, + header: BlockHeader, + requires_init_recovery: Arc, + state_generation: Arc, + created_at_state_generation: u64, ) -> Result { let bitmap = bitmap_snapshot .build_bitmap() @@ -251,9 +278,38 @@ impl Segmenter { bitmap_snapshot: Arc::new(bitmap_snapshot), bitmap, header, + requires_init_recovery, + state_generation, + created_at_state_generation, }) } + /// Whether this instance has been explicitly invalidated by chain recovery + /// or a state-reset operation. + /// + /// Ordinary body reorgs deliberately do not invalidate Segmenters. The PIBD + /// archive point is kept at least `state_sync_threshold` blocks behind HEAD + /// (and rounded down to an archive interval), which is intended to keep it + /// behind normal reorgs. If an exceptional deeper reorg crosses an in-flight + /// archive point, the receiver validates every segment against the PMMR roots + /// committed in the archive header. Mismatched data is rejected and PIBD must + /// restart against the new canonical archive header; invalid state cannot be + /// accepted. + pub(crate) fn is_current(&self) -> bool { + !self.requires_init_recovery.load(Ordering::SeqCst) + && self.state_generation.load(Ordering::SeqCst) == self.created_at_state_generation + } + + fn ensure_current(&self) -> Result<(), Error> { + if self.is_current() { + Ok(()) + } else { + Err(Error::Other( + "PIBD segmenter is stale because chain state changed or recovery is pending".into(), + )) + } + } + /// Header associated with this segmenter instance. /// The bitmap "snapshot" corresponds to rewound state at this header. pub fn header(&self) -> &BlockHeader { @@ -262,22 +318,27 @@ impl Segmenter { /// Root hash for headers Hashes MMR pub fn headers_root(&self) -> Result { + self.ensure_current()?; let header_pmmr = self.header_pmmr.read_recursive(); let pmmr = ReadonlyPMMR::at(&*header_pmmr, header_pmmr.size()); let root = pmmr.root()?; + self.ensure_current()?; Ok(root) } /// The root of the bitmap snapshot PMMR. pub fn bitmap_root(&self) -> Result { + self.ensure_current()?; let pmmr = self.bitmap_snapshot.readonly_pmmr(); let root = pmmr.root()?; + self.ensure_current()?; Ok(root) } /// Create a utxo bitmap segment based on our bitmap "snapshot" and return it with /// the corresponding output root. pub fn bitmap_segment(&self, id: SegmentIdentifier) -> Result, Error> { + self.ensure_current()?; let now = Instant::now(); let bitmap_pmmr = self.bitmap_snapshot.readonly_pmmr(); let segment = Segment::::from_pmmr( @@ -295,11 +356,13 @@ impl Segmenter { segment.proof().size(), now.elapsed().as_millis() ); + self.ensure_current()?; Ok(segment) } /// Create headers segment. pub fn headers_segment(&self, id: SegmentIdentifier) -> Result, Error> { + self.ensure_current()?; let now = Instant::now(); let header_pmmr = self.header_pmmr.read_recursive(); let header_pmmr = ReadonlyPMMR::at(&*header_pmmr, header_pmmr.size()); @@ -318,6 +381,7 @@ impl Segmenter { segment.proof().size(), now.elapsed().as_millis() ); + self.ensure_current()?; Ok(segment) } @@ -326,8 +390,11 @@ impl Segmenter { &self, id: SegmentIdentifier, ) -> Result, Error> { + self.ensure_current()?; let now = Instant::now(); let txhashset = self.txhashset.read_recursive(); + // A writer may have failed while this request waited for txhashset. + self.ensure_current()?; let output_pmmr = txhashset.output_pmmr_at(&self.header); validate_prunable_segment_scan_span("output_segment", id, output_pmmr.unpruned_size())?; let leaf_size = OutputIdentifier::elmt_size() @@ -348,13 +415,17 @@ impl Segmenter { segment.proof().size(), now.elapsed().as_millis() ); + self.ensure_current()?; Ok(segment) } /// Create a kernel segment. pub fn kernel_segment(&self, id: SegmentIdentifier) -> Result, Error> { + self.ensure_current()?; let now = Instant::now(); let txhashset = self.txhashset.read_recursive(); + // A writer may have failed while this request waited for txhashset. + self.ensure_current()?; let kernel_pmmr = txhashset.kernel_pmmr_at(&self.header); let segment = Segment::::from_pmmr( id, @@ -371,13 +442,17 @@ impl Segmenter { segment.proof().size(), now.elapsed().as_millis() ); + self.ensure_current()?; Ok(segment) } /// Create a rangeproof segment. pub fn rangeproof_segment(&self, id: SegmentIdentifier) -> Result, Error> { + self.ensure_current()?; let now = Instant::now(); let txhashset = self.txhashset.read_recursive(); + // A writer may have failed while this request waited for txhashset. + self.ensure_current()?; let pmmr = txhashset.rangeproof_pmmr_at(&self.header); validate_prunable_segment_scan_span("rangeproof_segment", id, pmmr.unpruned_size())?; let segment_size_limit = pibd_params::PIBD_MESSAGE_SIZE_LIMIT * 2; @@ -400,6 +475,7 @@ impl Segmenter { segment.proof().size(), now.elapsed().as_millis() ); + self.ensure_current()?; Ok(segment) } } diff --git a/chain/src/txhashset/txhashset.rs b/chain/src/txhashset/txhashset.rs index 678997e80f..9ccc9634e6 100644 --- a/chain/src/txhashset/txhashset.rs +++ b/chain/src/txhashset/txhashset.rs @@ -21,20 +21,22 @@ use crate::linked_list::{ListIndex, PruneableListIndex, RewindableListIndex}; use crate::store::{self, Batch, ChainStore}; use crate::txhashset::{BitmapAccumulator, RewindableKernelView, UTXOView}; use crate::types::{ - CommitPos, HashHeight, KernelPos, SyncStatusUpdateThrottle, Tip, TxHashSetRoots, - TxHashsetStateValidationStage, TXHASHSET_STATE_VALIDATION_STEPS, + CommitPos, HashHeight, KernelPos, SpentCommitmentRecord, SpentOutput, SyncStatusUpdateThrottle, + Tip, TxHashSetRoots, TxHashsetStateValidationStage, TXHASHSET_STATE_VALIDATION_STEPS, }; use crate::{SyncState, SyncStatus}; use mwc_core::consensus::WEEK_HEIGHT; +use mwc_core::core::block::{verify_kernel_lock_height, verify_nrd_kernel_for_header_version}; use mwc_core::core::committed::{verify_kernel_sums_iter, Error as CommittedError}; use mwc_core::core::hash::{Hash, Hashed, ZERO_HASH}; use mwc_core::core::merkle_proof::MerkleProof; use mwc_core::core::pmmr::{self, Backend, ReadablePMMR, ReadonlyPMMR, RewindablePMMR, PMMR}; use mwc_core::core::{ - Block, BlockHeader, KernelFeatures, Output, OutputIdentifier, Segment, TxKernel, + amount_to_hr_string, Block, BlockHeader, Inputs, KernelFeatures, Output, OutputIdentifier, + Segment, TxKernel, }; use mwc_core::global; -use mwc_core::ser::{self, PMMRable, ProtocolVersion}; +use mwc_core::ser::{self, PMMRIndexHashable, PMMRable, ProtocolVersion}; use mwc_crates::croaring::Bitmap; use mwc_crates::crossbeam; use mwc_crates::crossbeam::thread::ScopedJoinHandle; @@ -47,7 +49,7 @@ use mwc_store::types::VariableSizeMetadataValidation; use mwc_store::Error::NotFoundErr; use mwc_util::{secp_static, StopState}; use std::cmp::Ordering; -use std::collections::VecDeque; +use std::collections::{HashMap, HashSet, VecDeque}; use std::convert::TryFrom; use std::fs; use std::io; @@ -59,11 +61,285 @@ const TXHASHSET_SUBDIR: &str = "txhashset"; const KERNEL_POS_INDEX_REBUILD_CHUNK_SIZE: usize = 10_000; const COMMIT_SUM_BATCH_SIZE: usize = 10_000; const INDEX_REBUILD_LOG_INTERVAL_SECS: u64 = 1; +const PERSISTED_ANCESTRY_LOG_INTERVAL_SECS: u64 = 5; +const KERNEL_SUM_PROGRESS_LOG_INTERVAL_SECS: u64 = 5; const OUTPUT_SUBDIR: &str = "output"; const RANGE_PROOF_SUBDIR: &str = "rangeproof"; const KERNEL_SUBDIR: &str = "kernel"; +/// Authenticate cached spent-output positions against the persisted block body +/// and the raw output/rangeproof PMMR data. The output and rangeproof roots do +/// not commit to prunable leaf membership, so callers must perform this check +/// before a cache is allowed to restore leaves during rewind or preserve them +/// during compaction. Returned entries use the exact spent-record positions and +/// are ordered to match the persisted block inputs. +fn validate_block_spent_positions( + operation: &str, + block: &Block, + previous_header: &BlockHeader, + positions: &[u64], + output_mmr_size: u64, + rproof_mmr_size: u64, + mut output_at: FO, + mut rangeproof_exists_at: FR, + mut spent_record: FS, +) -> Result, Error> +where + FO: FnMut(u64) -> Result, Error>, + FR: FnMut(u64) -> Result, + FS: FnMut(&Commitment) -> Result, +{ + let inputs = block.inputs(); + if positions.len() != inputs.len() { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains {} positions for {} inputs", + operation, + block.header.height, + positions.len(), + inputs.len() + ))); + } + + let mut cached_outputs = HashMap::with_capacity(positions.len()); + let mut unique_positions = HashSet::with_capacity(positions.len()); + for pos1 in positions { + let pos0 = pos1.checked_sub(1).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains zero position", + operation, block.header.height + )) + })?; + if !unique_positions.insert(*pos1) { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains duplicate position {}", + operation, block.header.height, pos1 + ))); + } + if !pmmr::is_leaf(pos0) { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains non-leaf PMMR position {}", + operation, block.header.height, pos1 + ))); + } + if *pos1 > previous_header.output_mmr_size { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains position {} beyond predecessor output MMR size {}", + operation, block.header.height, pos1, previous_header.output_mmr_size + ))); + } + if pos0 >= output_mmr_size || pos0 >= rproof_mmr_size { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains position {} beyond current output/rangeproof PMMR sizes {}/{}", + operation, block.header.height, pos1, output_mmr_size, rproof_mmr_size + ))); + } + + let output = output_at(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} for block at height {} points to missing output data at position {}", + operation, block.header.height, pos1 + )) + })?; + if !rangeproof_exists_at(pos0)? { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} points to missing rangeproof data at position {}", + operation, block.header.height, pos1 + ))); + } + let commitment = output.commitment(); + if let Some((other_pos, _)) = cached_outputs.insert(commitment, (*pos1, output)) { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} points to duplicate output commitment {:?} at positions {} and {}", + operation, block.header.height, commitment, other_pos, pos1 + ))); + } + } + + let mut validate_spent_record = |commitment: &Commitment, + pos1: u64| + -> Result { + let record = spent_record(commitment)?; + if record.spending_block.height != block.header.height { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} has spent commitment record height {} for commitment {:?}", + operation, block.header.height, record.spending_block.height, commitment + ))); + } + if record.spent_output.pos != pos1 { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} records input commitment {:?} at position {}, but the authenticated spent commitment record identifies position {} at height {}", + operation, + block.header.height, + commitment, + pos1, + record.spent_output.pos, + record.spent_output.height + ))); + } + if record.spent_output.height > previous_header.height { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} records input commitment {:?} from future output height {} above predecessor height {}", + operation, + block.header.height, + commitment, + record.spent_output.height, + previous_header.height + ))); + } + // The complete exact-spend index is populated from validated UTXO + // transitions and is authoritative for the output creation height. Preserve + // that authenticated record instead of reconstructing its height by walking + // header ancestry on every use. + Ok(SpentOutput { + commitment: *commitment, + position: record.spent_output, + }) + }; + + let mut authenticated_spent = Vec::with_capacity(positions.len()); + match inputs { + Inputs::CommitOnly(inputs) => { + for input in inputs { + let commitment = input.commitment(); + let (pos1, _) = cached_outputs.remove(&commitment).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} for block at height {} has no output matching input commitment {:?}", + operation, block.header.height, commitment + )) + })?; + authenticated_spent.push(validate_spent_record(&commitment, pos1)?); + } + } + Inputs::FeaturesAndCommit(inputs) => { + for input in inputs { + let commitment = input.commitment(); + let (pos1, output) = cached_outputs.remove(&commitment).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} for block at height {} has no output matching input {:?}", + operation, block.header.height, input + )) + })?; + if output.features != input.features { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} points to output features {:?} for input features {:?} with commitment {:?}", + operation, + block.header.height, + output.features, + input.features, + input.commitment() + ))); + } + authenticated_spent.push(validate_spent_record(&commitment, pos1)?); + } + } + } + + if !cached_outputs.is_empty() { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains unmatched output positions {:?}", + operation, + block.header.height, + cached_outputs + .iter() + .map(|(_, (pos, _))| *pos) + .collect::>() + ))); + } + + Ok(authenticated_spent) +} + +fn checked_bitmap_positions_for_inputs( + operation: &str, + block: &Block, + block_bitmap: &Bitmap, +) -> Result, Error> { + let input_count = u64::try_from(block.inputs().len()).map_err(|_| { + Error::DataOverflow(format!( + "{} input count does not fit u64 for block at height {}", + operation, block.header.height + )) + })?; + let position_count = block_bitmap.cardinality(); + if position_count != input_count { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains {} positions for {} inputs", + operation, block.header.height, position_count, input_count + ))); + } + + Ok(block_bitmap.iter().map(u64::from).collect()) +} + +fn require_spent_commitment_record_index(operation: &str, batch: &Batch<'_>) -> Result<(), Error> { + if !batch.is_spent_commitment_record_index_complete()? { + return Err(Error::InvalidPersistedChainState(format!( + "{} requires a complete exact spent commitment record index", + operation + ))); + } + Ok(()) +} + +fn spent_commitment_record_for_block( + operation: &str, + commitment: &Commitment, + spending_block: HashHeight, + batch: &Batch<'_>, +) -> Result { + let records = batch.get_spent_commitments(commitment)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} has no spent commitment records for input {:?} in block {} at height {}", + operation, commitment, spending_block.hash, spending_block.height + )) + })?; + let mut matching = records + .into_iter() + .filter(|record| record.spending_block.hash == spending_block.hash); + let record = matching.next().ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} has no spent commitment record for input {:?} in block {} at height {}", + operation, commitment, spending_block.hash, spending_block.height + )) + })?; + if matching.next().is_some() { + return Err(Error::InvalidPersistedChainState(format!( + "{} has conflicting spent commitment records for input {:?} in block {} at height {}", + operation, commitment, spending_block.hash, spending_block.height + ))); + } + if record.spending_block != spending_block { + return Err(Error::InvalidPersistedChainState(format!( + "{} spent commitment record for input {:?} identifies block {} at height {}, expected {} at height {}", + operation, + commitment, + record.spending_block.hash, + record.spending_block.height, + spending_block.hash, + spending_block.height + ))); + } + Ok(record) +} + +#[derive(Clone, Copy)] +struct KernelHeaderBoundary { + height: u64, + version: mwc_core::core::HeaderVersion, + kernel_mmr_size: u64, +} + +impl From<&BlockHeader> for KernelHeaderBoundary { + fn from(header: &BlockHeader) -> Self { + Self { + height: header.height, + version: header.version, + kernel_mmr_size: header.kernel_mmr_size, + } + } +} + /// Convenience enum to keep track of hash and leaf insertions when rebuilding an mmr /// from segments #[derive(Eq)] @@ -218,6 +494,72 @@ impl PMMRHandle { } } + /// Authenticate a loaded header against both projections retained by the + /// header PMMR at `height`. + /// + /// `HeaderEntry` and the indexed PMMR leaf hash are stored in separate files. + /// Checking both prevents a stale or corrupted data entry from redirecting a + /// height lookup to another header that happens to exist in the block-header + /// database. + /// + /// This authenticates the two PMMR projections; it deliberately does not + /// reverify the complete header's PoW. Callers used by API reads rely on PoW + /// validation at admission plus persisted-ancestry validation at + /// startup/recovery. Adding Cuckoo verification here would turn inexpensive, + /// attacker-selectable lookups into a CPU-amplification DoS primitive. + pub(crate) fn authenticate_header_at_height( + &self, + height: u64, + header: &BlockHeader, + ) -> Result<(), Error> { + if header.height != height { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR lookup for height {} loaded header at height {}", + height, header.height + ))); + } + + let pos0 = pmmr::insertion_to_pmmr_index(height)?; + if pos0 >= self.size { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR is missing leaf position {} for height {}", + pos0, height + ))); + } + + let header_pmmr = ReadonlyPMMR::at(&self.backend, self.size); + let stored_entry = header_pmmr.get_data(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing data at leaf position {} for height {}", + pos0, height + )) + })?; + let expected_entry = header.as_elmt()?; + if stored_entry != expected_entry { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR data at leaf position {} does not match loaded header at height {}", + pos0, height + ))); + } + + let stored_leaf_hash = header_pmmr.get_hash(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing hash at leaf position {} for height {}", + pos0, height + )) + })?; + let context_id = self.backend.get_context_id(); + let expected_leaf_hash = header.hash_with_index(context_id, pos0)?; + if stored_leaf_hash != expected_leaf_hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR hash at leaf position {} does not authenticate loaded header at height {}", + pos0, height + ))); + } + + Ok(()) + } + /// Get the header hash for the head of the header chain based on current MMR state. /// Find the last leaf pos based on MMR size and return its header hash. pub fn head_hash(&self) -> Result { @@ -289,8 +631,13 @@ impl TxHashSet { let mut kernel_probe_errors: Vec = vec![]; let versions = vec![ProtocolVersion(2), ProtocolVersion(1)]; for version in versions { - // Using Fast validation because of node starting issue. Full validaiton takes too much time, - // so we don't validate all commits and kernels internals + // Open the kernel PMMR with Fast validation for performance reasons. + // Kernels are not prunable, so the data volume is high and keeps + // growing. Full validation would deserialize the entire data file on + // every node start, which takes too much time and is not worth it; + // the Fast structural check (size file covers the data file) is + // sufficient here because these locally maintained, append-only + // files are never compacted. let handle = match PMMRHandle::new( Path::new(&root_dir) .join(TXHASHSET_SUBDIR) @@ -336,18 +683,23 @@ impl TxHashSet { } }; if let Some(kernel) = kernel { - if kernel.verify(context_id, secp).is_ok() { - debug!( - "attempting to open kernel PMMR using {:?} - SUCCESS", - version - ); - maybe_kernel_handle = Some(handle); - break; - } else { - debug!( - "attempting to open kernel PMMR using {:?} - FAIL (verify failed)", - version - ); + match kernel.verify(context_id, secp) { + Ok(()) => { + debug!( + "attempting to open kernel PMMR using {:?} - SUCCESS", + version + ); + maybe_kernel_handle = Some(handle); + break; + } + Err(err) => { + debug!( + "attempting to open kernel PMMR using {:?} - FAIL (kernel verification failed: {})", + version, err + ); + kernel_probe_errors + .push(format!("{}: kernel verification failed: {}", version, err)); + } } } else { debug!( @@ -390,32 +742,40 @@ impl TxHashSet { &self, commit: Commitment, ) -> Result, Error> { - match self.commit_index.get_output_pos_height(&commit) { - Ok(Some(pos1)) => { - let output_pmmr: ReadonlyPMMR<'_, OutputIdentifier, _> = - ReadonlyPMMR::at(&self.output_pmmr_h.backend, self.output_pmmr_h.size); - let pos0 = pos1.pos.checked_sub(1).ok_or_else(|| { - mwc_store::Error::DataOverflow(format!( - "TxHashSet::get_unspent pos1.pos={}", - pos1.pos - )) - })?; - match output_pmmr.get_data(pos0)? { - Some(out) if out.commitment() == commit => Ok(Some((out, pos1))), - Some(out) => Err(Error::TxHashSetErr(format!( - "output_pos index mismatch for commitment {:?}: index points to {:?} at pos {}", - commit, - out.commitment(), - pos1.pos - ))), - None => Err(Error::TxHashSetErr(format!( - "output_pos index points to missing output at pos {} for commitment {:?}", - pos1.pos, commit - ))), - } - } - Ok(None) => Ok(None), - Err(e) => Err(Error::StoreErr(e, "txhashset unspent check".to_string())), + let pos = self + .commit_index + .get_output_pos_height(&commit) + .map_err(|e| Error::StoreErr(e, "txhashset unspent check".to_string()))?; + self.get_unspent_with_position(commit, pos) + } + + /// Check an output-position entry supplied by a caller that owns the + /// corresponding database snapshot. + pub(crate) fn get_unspent_with_position( + &self, + commit: Commitment, + pos: Option, + ) -> Result, Error> { + let Some(pos1) = pos else { + return Ok(None); + }; + let output_pmmr: ReadonlyPMMR<'_, OutputIdentifier, _> = + ReadonlyPMMR::at(&self.output_pmmr_h.backend, self.output_pmmr_h.size); + let pos0 = pos1.pos.checked_sub(1).ok_or_else(|| { + mwc_store::Error::DataOverflow(format!("TxHashSet::get_unspent pos1.pos={}", pos1.pos)) + })?; + match output_pmmr.get_data(pos0)? { + Some(out) if out.commitment() == commit => Ok(Some((out, pos1))), + Some(out) => Err(Error::TxHashSetErr(format!( + "output_pos index mismatch for commitment {:?}: index points to {:?} at pos {}", + commit, + out.commitment(), + pos1.pos + ))), + None => Err(Error::TxHashSetErr(format!( + "output_pos index points to missing output at pos {} for commitment {:?}", + pos1.pos, commit + ))), } } @@ -549,6 +909,67 @@ impl TxHashSet { self.rproof_pmmr_h.size } + /// Validate that every body PMMR can reach the durable recovery target. + /// + /// Each backend performs its own prune-aware check because compacted file + /// lengths cannot be compared directly with the logical sizes in a header. + /// The first missing component is enough to make reconciliation impossible; + /// no PMMR has been mutated when this method returns an error. + pub(crate) fn validate_recovery_rewind_targets( + &self, + head: &Tip, + header: &BlockHeader, + ) -> Result<(), Error> { + self.validate_recovery_rewind_targets_for("durable HEAD", head, header) + } + + /// Validate every body PMMR against a named durable selector. + /// + /// Compaction recovery uses this for BODY_TAIL in addition to the normal + /// HEAD reconciliation preflight above. + pub(crate) fn validate_recovery_rewind_targets_for( + &self, + selector: &str, + tip: &Tip, + header: &BlockHeader, + ) -> Result<(), Error> { + for (component, position, result) in [ + ( + "output", + header.output_mmr_size, + self.output_pmmr_h + .backend + .validate_rewind_target(header.output_mmr_size), + ), + ( + "rangeproof", + header.output_mmr_size, + self.rproof_pmmr_h + .backend + .validate_rewind_target(header.output_mmr_size), + ), + ( + "kernel", + header.kernel_mmr_size, + self.kernel_pmmr_h + .backend + .validate_rewind_target(header.kernel_mmr_size), + ), + ] { + match result { + Ok(()) => {} + Err(pmmr::Error::InvalidState(reason)) => { + return Err(Error::PmmrRecoveryRequired(format!( + "{} {} at height {} requires {} PMMR position {}, but the current backend cannot represent that rewind target: {}", + selector, tip.last_block_h, tip.height, component, position, reason + ))); + } + Err(err) => return Err(err.into()), + } + } + Ok(()) + } + /// Find a kernel with a given excess. Work backwards from `max_index` to `min_index` /// NOTE: this linear search over all kernel history can be VERY expensive /// public API access to this method should be limited @@ -628,10 +1049,17 @@ impl TxHashSet { } } - /// build a new merkle proof for the given output commitment - pub fn merkle_proof(&mut self, commit: Commitment) -> Result { + /// Build a Merkle proof for an unspent output against the current output + /// PMMR state. + /// + /// This deliberately uses `self.output_pmmr_h.size`. Do not overlay an older + /// header size here: compaction guarantees the current peaks and maximal + /// pruned-subtree roots, but it does not preserve every node that happened to + /// be a peak at an earlier size. The returned `mmr_size` tells callers which + /// output-root state must be used for verification. + pub fn merkle_proof(&self, commit: Commitment) -> Result { let pos0 = self.commit_index.get_output_pos(&commit)?; - let output_pmmr = PMMR::at(&mut self.output_pmmr_h.backend, self.output_pmmr_h.size); + let output_pmmr = ReadonlyPMMR::at(&self.output_pmmr_h.backend, self.output_pmmr_h.size); match output_pmmr.get_data(pos0)? { Some(out) if out.commitment() == commit => { output_pmmr.merkle_proof(pos0).map_err(|e| { @@ -662,7 +1090,7 @@ impl TxHashSet { let head_header = batch.head_header()?; - let rewind_rm_pos = input_pos_to_rewind(&horizon_header, &head_header, batch)?; + let rewind_rm_pos = input_pos_to_rewind(self, &horizon_header, &head_header, batch)?; debug!("txhashset: check_compact output mmr backend..."); self.output_pmmr_h @@ -679,31 +1107,190 @@ impl TxHashSet { Ok(()) } + /// Authenticate one block's cached spent positions before compaction relies on + /// them to preserve rewind data. A well-formed but incomplete cache would + /// otherwise make check_compact permanently remove an output and rangeproof + /// that a supported rewind needs to restore. + fn validate_compact_block_input_bitmap( + &self, + block: &Block, + previous_header: &BlockHeader, + block_bitmap: &Bitmap, + batch: &Batch<'_>, + ) -> Result<(), Error> { + require_spent_commitment_record_index("compact input bitmap", batch)?; + let spending_block = HashHeight { + hash: block.hash(batch.get_context_id())?, + height: block.header.height, + }; + let positions = + checked_bitmap_positions_for_inputs("compact input bitmap", block, block_bitmap)?; + validate_block_spent_positions( + "compact input bitmap", + block, + previous_header, + &positions, + self.output_pmmr_h.size, + self.rproof_pmmr_h.size, + |pos0| Ok(self.output_pmmr_h.backend.get_data_from_file(pos0)?), + |pos0| { + Ok(self + .rproof_pmmr_h + .backend + .get_data_from_file(pos0)? + .is_some()) + }, + |commitment| { + spent_commitment_record_for_block( + "compact input bitmap", + commitment, + spending_block, + batch, + ) + }, + )?; + Ok(()) + } + + /// Commitment of the output leaf data retained at the 1-based MMR position + /// `pos1`, read directly from the data file regardless of prune state. + /// Returns None when no data is retained at the position. + pub fn output_commitment_at_pos(&self, pos1: u64) -> Result, Error> { + let pos0 = pos1 + .checked_sub(1) + .ok_or_else(|| Error::Other("output commitment lookup at zero MMR position".into()))?; + let data = self.output_pmmr_h.backend.get_data_from_file(pos0)?; + Ok(data.map(|output| output.commitment())) + } + /// (Re)build the NRD kernel_pos index based on 2 weeks of recent kernel history. pub fn init_recent_kernel_pos_index( &self, - header_pmmr: &PMMRHandle, batch: &Batch<'_>, status: Option>, stop_state: Option>, ) -> Result<(), Error> { + if !global::is_nrd_enabled(self.commit_index.get_context_id()) { + return Ok(()); + } let now = Instant::now(); let head = batch.head()?; + let context_id = self.commit_index.get_context_id(); + let head_header = batch.get_block_header(&head.last_block_h)?; + let head_header_hash = head_header.hash(context_id)?; + if head_header.height != head.height || head_header_hash != head.last_block_h { + return Err(Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index body HEAD {} at {} does not match stored header {} at {}", + head.last_block_h, head.height, head_header_hash, head_header.height + ))); + } + if head_header.kernel_mmr_size != self.kernel_pmmr_h.size { + return Err(Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index body HEAD kernel MMR size {} does not match txhashset size {}", + head_header.kernel_mmr_size, self.kernel_pmmr_h.size + ))); + } + + // Body rewinds are bounded to one cut-through horizon from the current + // head. On production networks that horizon is WEEK_HEIGHT, so retaining + // two weeks covers both the deepest supported rewind and the maximum NRD + // relative height. // Safe: WEEK_HEIGHT is a small fixed consensus constant. let cutoff = head.height.saturating_sub(WEEK_HEIGHT * 2); - let cutoff_hash = header_pmmr.get_header_hash_by_height(cutoff)?; - let cutoff_header = batch.get_block_header(&cutoff_hash)?; + + // HEAD and the kernel PMMR describe the validated body chain. The header + // PMMR may legally be ahead on a different fork, so recover every kernel + // boundary from HEAD's prev_hash ancestry instead of looking it up by + // height in the header PMMR. + let mut current_header = head_header.clone(); + let mut boundaries = Vec::new(); + let mut visited = HashSet::new(); + while current_header.height > cutoff { + Self::check_stop_state(&stop_state)?; + boundaries.push(KernelHeaderBoundary::from(¤t_header)); + + let prev_header = crate::checked_previous_header( + context_id, + ¤t_header, + &mut visited, + "init_recent_kernel_pos_index ancestry", + |hash| batch.get_block_header(hash), + )?; + if prev_header.kernel_mmr_size > current_header.kernel_mmr_size { + return Err(Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index kernel MMR size regression from {} at {} to {} at {}", + prev_header.kernel_mmr_size, + prev_header.height, + current_header.kernel_mmr_size, + current_header.height + ))); + } + current_header = prev_header; + } + if current_header.height != cutoff { + return Err(Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index body ancestry stopped at {}, expected cutoff {}", + current_header.height, cutoff + ))); + } + let cutoff_header = current_header; + boundaries.push(KernelHeaderBoundary::from(&cutoff_header)); + boundaries.reverse(); + + let prev_size = if cutoff_header.height == 0 { + 0 + } else { + let prev_header = crate::checked_previous_header( + context_id, + &cutoff_header, + &mut visited, + "init_recent_kernel_pos_index cutoff ancestry", + |hash| batch.get_block_header(hash), + )?; + if prev_header.kernel_mmr_size > cutoff_header.kernel_mmr_size { + return Err(Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index kernel MMR size regression from {} at {} to {} at {}", + prev_header.kernel_mmr_size, + prev_header.height, + cutoff_header.kernel_mmr_size, + cutoff_header.height + ))); + } + prev_header.kernel_mmr_size + }; + info!( "init_recent_kernel_pos_index: starting recent NRD kernel_pos index rebuild from height {} to {}", cutoff, head.height ); self.verify_kernel_pos_index_with_status( &cutoff_header, - header_pmmr, + &head_header, + prev_size, batch, status, stop_state, true, + |height| { + let offset = height.checked_sub(cutoff).ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::init_recent_kernel_pos_index, height={}, cutoff={}", + height, cutoff + )) + })?; + let idx = usize::try_from(offset).map_err(|_| { + Error::DataOverflow(format!( + "TxHashSet::init_recent_kernel_pos_index, boundary offset={}", + offset + )) + })?; + boundaries.get(idx).copied().ok_or_else(|| { + Error::TxHashSetErr(format!( + "init_recent_kernel_pos_index missing body boundary at height {}", + height + )) + }) + }, )?; info!( "init_recent_kernel_pos_index: finished recent NRD kernel_pos index rebuild, took {}s", @@ -772,24 +1359,28 @@ impl TxHashSet { stop_state: Option>, ) -> Result<(), Error> { let now = Instant::now(); + let context_id = self.commit_index.get_context_id(); + let mut current = store.head_header()?; + if current.kernel_mmr_size != self.kernel_pmmr_h.size { + return Err(Error::TxHashSetErr(format!( + "init_kernel_pos_index_chunked body HEAD kernel MMR size {} does not match txhashset size {}", + current.kernel_mmr_size, self.kernel_pmmr_h.size + ))); + } let total_kernels = pmmr::n_leaves(self.kernel_pmmr_h.size)?; let status_throttle = SyncStatusUpdateThrottle::new(); let mut last_progress_log = Instant::now(); let cleared = Self::clear_kernel_pos_index_chunked(store, &stop_state)?; - let context_id = self.commit_index.get_context_id(); let kernel_pmmr = ReadonlyPMMR::at(&self.kernel_pmmr_h.backend, self.kernel_pmmr_h.size); - let mut current = store.head_header()?; let mut batch = store.batch_write()?; let mut pending = 0usize; - let mut total = 0usize; + let mut total = 0u64; + let mut visited = HashSet::new(); info!( "init_kernel_pos_index_chunked: starting full kernel_pos index rebuild, cleared {} entries, kernel_mmr_size {}, kernels {}, chunk size {}", - cleared, - self.kernel_pmmr_h.size, - total_kernels, - KERNEL_POS_INDEX_REBUILD_CHUNK_SIZE, + cleared, self.kernel_pmmr_h.size, total_kernels, KERNEL_POS_INDEX_REBUILD_CHUNK_SIZE, ); Self::update_kernel_pos_index_build_status( &status, @@ -800,10 +1391,17 @@ impl TxHashSet { ); loop { + Self::check_stop_state(&stop_state)?; let prev_header = if current.height == 0 { None } else { - Some(store.get_previous_header(¤t)?) + Some(crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "init_kernel_pos_index_chunked", + |hash| store.get_block_header(hash), + )?) }; let prev_kernel_mmr_size = prev_header .as_ref() @@ -815,10 +1413,7 @@ impl TxHashSet { current.height, prev_kernel_mmr_size, current.kernel_mmr_size ))); } - if current.kernel_mmr_size > self.kernel_pmmr_h.size { - return Err(Error::InvalidHeaderHeight(current.height)); - } - + let current_hash = current.hash(context_id)?; let start_pos = prev_kernel_mmr_size.checked_add(1).ok_or_else(|| { Error::DataOverflow(format!( "TxHashSet::init_kernel_pos_index_chunked, prev_kernel_mmr_size={}", @@ -838,9 +1433,7 @@ impl TxHashSet { let kernel = kernel_pmmr.get_data(pos0)?.ok_or_else(|| { Error::TxHashSetErr(format!( "init_kernel_pos_index_chunked missing kernel PMMR data at pos {} for header {} at {}", - pos, - current.hash(context_id).unwrap_or(ZERO_HASH), - current.height + pos, current_hash, current.height )) })?; batch.save_kernel_pos( @@ -851,25 +1444,29 @@ impl TxHashSet { }, )?; pending += 1; - total += 1; + total = total.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::init_kernel_pos_index_chunked, total={}", + total + )) + })?; if pending >= KERNEL_POS_INDEX_REBUILD_CHUNK_SIZE { batch.commit()?; Self::check_stop_state(&stop_state)?; batch = store.batch_write()?; pending = 0; - let total_u64 = u64::try_from(total).unwrap_or(u64::MAX); Self::update_kernel_pos_index_build_status( &status, &status_throttle, - total_u64, + total, total_kernels, - total_u64 == total_kernels, + total == total_kernels, ); if Self::should_log_index_rebuild_progress(&mut last_progress_log, false) { info!( "init_kernel_pos_index_chunked: rebuilt {} of {} kernel_pos entries", - total_u64, total_kernels + total, total_kernels ); } } @@ -883,14 +1480,19 @@ impl TxHashSet { } } + if total != total_kernels { + return Err(Error::TxHashSetErr(format!( + "init_kernel_pos_index_chunked rebuilt {} kernel_pos entries, expected {}", + total, total_kernels + ))); + } batch.set_kernel_pos_index_complete(true)?; batch.commit()?; - let total_u64 = u64::try_from(total).unwrap_or(u64::MAX); Self::update_kernel_pos_index_build_status( &status, &status_throttle, - total_u64, + total, total_kernels, true, ); @@ -925,50 +1527,199 @@ impl TxHashSet { Ok(total) } - /// Verify and (re)build the NRD kernel_pos index from the provided header onwards. + /// Verify contextual kernel inclusion rules and (re)build the NRD kernel_pos + /// index over the provided header range. The terminal header anchors the + /// header PMMR ancestry and the kernel PMMR state before any index mutation. pub fn verify_kernel_pos_index( &self, from_header: &BlockHeader, + to_header: &BlockHeader, header_pmmr: &PMMRHandle, batch: &Batch<'_>, status: Option>, stop_state: Option>, ) -> Result<(), Error> { - self.verify_kernel_pos_index_with_status( - from_header, - header_pmmr, - batch, - status, - stop_state, - false, - ) - } + let context_id = self.commit_index.get_context_id(); + let load_pmmr_header = |height: u64| -> Result<(Hash, BlockHeader), Error> { + let pmmr_hash = header_pmmr.get_header_hash_by_height(height)?; + let header = batch.get_block_header(&pmmr_hash)?; + let header_hash = header.hash(context_id)?; + if header.height != height || header_hash != pmmr_hash { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index header PMMR entry {} resolves to persisted header {} at {}", + height, header_hash, header.height + ))); + } + Ok((pmmr_hash, header)) + }; - fn verify_kernel_pos_index_with_status( + // Normal PoW validation binds the complete header to its proof-derived hash. + // This maintenance path does not repeat PoW, so resolve each endpoint + // through the header PMMR and require full persisted-header equality before + // trusting contextual fields. + let (pmmr_from_hash, persisted_from) = load_pmmr_header(from_header.height)?; + if persisted_from != *from_header { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index start header {} at {} does not match persisted header selected by the header PMMR", + pmmr_from_hash, from_header.height + ))); + } + + let (pmmr_to_hash, persisted_to) = load_pmmr_header(to_header.height)?; + if persisted_to != *to_header { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index terminal header {} at {} does not match persisted header selected by the header PMMR", + pmmr_to_hash, to_header.height + ))); + } + + if persisted_from.height > persisted_to.height { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index invalid header range {}..{}", + persisted_from.height, persisted_to.height + ))); + } + + let prev_size = if persisted_from.height == 0 { + 0 + } else { + let prev_height = persisted_from.height.checked_sub(1).ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::verify_kernel_pos_index, from_height={}", + persisted_from.height + )) + })?; + let (pmmr_prev_hash, prev_header) = load_pmmr_header(prev_height)?; + if persisted_from.prev_hash != pmmr_prev_hash { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index start header {} at {} has predecessor {}, but header PMMR ancestry has {} at {}", + pmmr_from_hash, + persisted_from.height, + persisted_from.prev_hash, + pmmr_prev_hash, + prev_height + ))); + } + prev_header.kernel_mmr_size + }; + + // The PMMR fixes a header hash at each height, but it does not by itself + // prove that the persisted headers selected by adjacent leaves link to one + // another. Validate the complete range before + // verify_kernel_pos_index_with_status clears or updates the NRD index. This + // intentionally runs even across kernel-free spans that the boundary + // callback below would never visit. + let mut ancestry_hash = pmmr_from_hash; + let mut ancestry_header = persisted_from.clone(); + while ancestry_header.height < persisted_to.height { + Self::check_stop_state(&stop_state)?; + let next_height = ancestry_header.height.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::verify_kernel_pos_index, ancestry_height={}", + ancestry_header.height + )) + })?; + let (next_hash, next_header) = load_pmmr_header(next_height)?; + if next_header.prev_hash != ancestry_hash { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index disconnected header PMMR ancestry: header {} at {} has predecessor {}, expected {} at {}", + next_hash, + next_header.height, + next_header.prev_hash, + ancestry_hash, + ancestry_header.height + ))); + } + if next_header.kernel_mmr_size < ancestry_header.kernel_mmr_size + || next_header.kernel_mmr_size > persisted_to.kernel_mmr_size + { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index invalid kernel MMR boundary {} at {} after {} at {}", + next_header.kernel_mmr_size, + next_header.height, + ancestry_header.kernel_mmr_size, + ancestry_header.height + ))); + } + ancestry_hash = next_hash; + ancestry_header = next_header; + } + if ancestry_hash != pmmr_to_hash || ancestry_header != persisted_to { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index header PMMR ancestry terminated at {} at {}, expected {} at {}", + ancestry_hash, ancestry_header.height, pmmr_to_hash, persisted_to.height + ))); + } + + self.verify_kernel_pos_index_with_status( + &persisted_from, + &persisted_to, + prev_size, + batch, + status, + stop_state, + false, + |height| { + let (_, header) = load_pmmr_header(height)?; + Ok(KernelHeaderBoundary::from(&header)) + }, + ) + } + + fn verify_kernel_pos_index_with_status( &self, from_header: &BlockHeader, - header_pmmr: &PMMRHandle, + to_header: &BlockHeader, + prev_size: u64, batch: &Batch<'_>, status: Option>, stop_state: Option>, build_status: bool, - ) -> Result<(), Error> { + mut boundary_at_height: F, + ) -> Result<(), Error> + where + F: FnMut(u64) -> Result, + { let context_id = self.commit_index.get_context_id(); - if !global::is_nrd_enabled(context_id) { - return Ok(()); - } let now = Instant::now(); + let from_boundary = KernelHeaderBoundary::from(from_header); + let to_boundary = KernelHeaderBoundary::from(to_header); + if from_boundary.height > to_boundary.height { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index invalid boundary range {}..{}", + from_boundary.height, to_boundary.height + ))); + } + if to_boundary.kernel_mmr_size != self.kernel_pmmr_h.size { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index terminal header kernel MMR size {} does not match txhashset size {}", + to_boundary.kernel_mmr_size, self.kernel_pmmr_h.size + ))); + } + if prev_size > from_boundary.kernel_mmr_size + || from_boundary.kernel_mmr_size > to_boundary.kernel_mmr_size + { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index invalid kernel MMR boundaries: previous {}, start {}, terminal {}", + prev_size, from_boundary.kernel_mmr_size, to_boundary.kernel_mmr_size + ))); + } + + let total = pmmr::n_leaves(self.kernel_pmmr_h.size)? + .checked_sub(pmmr::n_leaves(prev_size)?) + .ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::verify_kernel_pos_index, prev_size={}, kernel_pmmr_size={}", + prev_size, self.kernel_pmmr_h.size + )) + })?; + + // Do not clear the authoritative recent index until the header source and + // terminal kernel PMMR state have passed all preflight consistency checks. let kernel_index = store::nrd_recent_kernel_index(); kernel_index.clear(batch)?; - let prev_size = if from_header.height == 0 { - 0 - } else { - let prev_header = batch.get_previous_header(&from_header)?; - prev_header.kernel_mmr_size - }; - debug!( "verify_kernel_pos_index: header: {} at {}, prev kernel_mmr_size: {}", from_header.hash(context_id)?, @@ -984,16 +1735,8 @@ impl TxHashSet { prev_size )) })?; - let mut current_header = from_header.clone(); + let mut current_header = from_boundary; let mut count = 0u64; - let total = pmmr::n_leaves(self.kernel_pmmr_h.size)? - .checked_sub(pmmr::n_leaves(prev_size)?) - .ok_or_else(|| { - Error::DataOverflow(format!( - "TxHashSet::verify_kernel_pos_index, prev_size={}, kernel_pmmr_size={}", - prev_size, self.kernel_pmmr_h.size - )) - })?; let mut applied = 0u64; let status_throttle = SyncStatusUpdateThrottle::new(); if let Some(ref s) = status { @@ -1018,19 +1761,53 @@ impl TxHashSet { })?; if pmmr::is_leaf(current_pos0) { if let Some(kernel) = kernel_pmmr.get_data(current_pos0)? { + // Kernel MMR sizes are authenticated boundaries for each block. + // Use the caller's already-anchored ancestry to recover the block + // that first included this kernel. + while current_pos > current_header.kernel_mmr_size { + let next_height = + current_header.height.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "TxHashSet::verify_kernel_pos_index, current_header_height={}", + current_header.height + )) + })?; + if next_height > to_boundary.height { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index kernel position {} exceeds terminal boundary {} at {}", + current_pos, to_boundary.kernel_mmr_size, to_boundary.height + ))); + } + let next_header = boundary_at_height(next_height)?; + if next_header.height != next_height { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index expected boundary at {}, got {}", + next_height, next_header.height + ))); + } + if next_header.kernel_mmr_size < current_header.kernel_mmr_size + || next_header.kernel_mmr_size > to_boundary.kernel_mmr_size + { + return Err(Error::TxHashSetErr(format!( + "verify_kernel_pos_index invalid kernel MMR boundary {} at {} after {} at {}", + next_header.kernel_mmr_size, + next_header.height, + current_header.kernel_mmr_size, + current_header.height + ))); + } + current_header = next_header; + } + + verify_kernel_lock_height(&kernel, current_header.height)?; + verify_nrd_kernel_for_header_version( + &kernel, + current_header.version, + context_id, + )?; + match kernel.features { KernelFeatures::NoRecentDuplicate { .. } => { - while current_pos > current_header.kernel_mmr_size { - let hash = header_pmmr.get_header_hash_by_height( - current_header.height.checked_add(1).ok_or_else(|| { - Error::DataOverflow(format!( - "TxHashSet::verify_kernel_pos_index, current_header_height={}", - current_header.height - )) - })?, - )?; - current_header = batch.get_block_header(&hash)?; - } let new_pos = CommitPos { pos: current_pos, height: current_header.height, @@ -1165,11 +1942,19 @@ impl TxHashSet { let mut output_ranges = Vec::new(); let mut current = batch.head_header()?; + let context_id = batch.get_context_id(); + let mut visited = HashSet::new(); loop { let prev_header = if current.height == 0 { None } else { - Some(batch.get_previous_header(¤t)?) + Some(crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "init_output_pos_index", + |hash| batch.get_block_header(hash), + )?) }; let prev_output_mmr_size = prev_header .as_ref() @@ -1430,7 +2215,9 @@ where /// the txhashset and the checking of the current tree roots. /// /// If the closure returns an error, modifications are canceled and the unit -/// of work is abandoned. Otherwise, the unit of work is permanently applied. +/// of work is abandoned. Otherwise, PMMR changes are synced and index changes +/// are merged into the caller's batch. The caller must still commit that outer +/// batch; PMMR files and the database are separate durability domains. pub fn extending<'a, F, T>( header_pmmr: &'a mut PMMRHandle, trees: &'a mut TxHashSet, @@ -1528,7 +2315,10 @@ where ); } if let Err(e) = trees.output_pmmr_h.backend.sync() { - let sync_err: Error = e.into(); + let sync_err = Error::PmmrSyncStateUncertain { + context: "extending output sync".to_owned(), + source: e, + }; return result_with_discard( Err(sync_err), discard_txhashset_backends(trees), @@ -1536,7 +2326,10 @@ where ); } if let Err(e) = trees.rproof_pmmr_h.backend.sync() { - let sync_err: Error = e.into(); + let sync_err = Error::PmmrSyncStateUncertain { + context: "extending rangeproof sync".to_owned(), + source: e, + }; return result_with_discard( Err(sync_err), discard_txhashset_backends(trees), @@ -1544,7 +2337,10 @@ where ); } if let Err(e) = trees.kernel_pmmr_h.backend.sync() { - let sync_err: Error = e.into(); + let sync_err = Error::PmmrSyncStateUncertain { + context: "extending kernel sync".to_owned(), + source: e, + }; return result_with_discard( Err(sync_err), discard_txhashset_backends(trees), @@ -1597,6 +2393,40 @@ pub fn header_extending<'a, F, T>( batch: &'a mut Batch<'_>, inner: F, ) -> Result +where + F: FnOnce(&mut HeaderExtension<'_>, &Batch<'_>) -> Result, +{ + header_extending_with_head(handle, batch, None, inner) +} + +/// Start a header MMR unit of work with an explicit logical head. +/// +/// Normal header extensions derive their head from the PMMR's final leaf and +/// require the corresponding BlockHeader to be visible in the enclosing DB +/// batch. Recovery cannot make that assumption: the PMMR files are synced +/// before the enclosing batch commits, so an interrupted operation can leave a +/// valid speculative PMMR suffix whose header records were rolled back. Passing +/// the durable DB-selected head lets recovery enter the extension and rewind +/// that suffix without first resolving its speculative final leaf through the +/// DB. +pub(crate) fn header_extending_with_explicit_head<'a, F, T>( + handle: &'a mut PMMRHandle, + batch: &'a mut Batch<'_>, + head: Tip, + inner: F, +) -> Result +where + F: FnOnce(&mut HeaderExtension<'_>, &Batch<'_>) -> Result, +{ + header_extending_with_head(handle, batch, Some(head), inner) +} + +fn header_extending_with_head<'a, F, T>( + handle: &'a mut PMMRHandle, + batch: &'a mut Batch<'_>, + explicit_head: Option, + inner: F, +) -> Result where F: FnOnce(&mut HeaderExtension<'_>, &Batch<'_>) -> Result, { @@ -1608,13 +2438,16 @@ where // index saving can be undone let child_batch = batch.child()?; - let head = match handle.head_hash() { - Ok(hash) => { - let header = child_batch.get_block_header(&hash)?; - Tip::try_from_header(&header)? - } - Err(Error::EmptyMMR) => Tip::default(), - Err(err) => return Err(err), + let head = match explicit_head { + Some(head) => head, + None => match handle.head_hash() { + Ok(hash) => { + let header = child_batch.get_block_header(&hash)?; + Tip::try_from_header(&header)? + } + Err(Error::EmptyMMR) => Tip::default(), + Err(err) => return Err(err), + }, }; { @@ -1641,7 +2474,10 @@ where ); } if let Err(e) = handle.backend.sync() { - let sync_err: Error = e.into(); + let sync_err = Error::PmmrSyncStateUncertain { + context: "header_extending sync".to_owned(), + source: e, + }; return result_with_discard( Err(sync_err), handle.backend.discard(), @@ -1727,6 +2563,78 @@ impl<'a> HeaderExtension<'a> { Ok(chain_header.hash(context_id)? == t.hash(context_id)?) } + /// Compare a complete header with the persisted header on the current chain. + /// + /// This persisted-state membership check uses full equality because it does + /// not repeat the PoW validation that originally bound the complete header to + /// its proof-derived hash. + pub fn is_header_on_current_chain( + &self, + header: &BlockHeader, + batch: &Batch<'_>, + ) -> Result { + if header.height > self.head.height { + return Ok(false); + } + Ok(self.get_header_by_height(header.height, batch)? == *header) + } + + /// Compare an authoritative persisted header directly with the PMMR entry at + /// the same height, without resolving the PMMR entry's block hash through the + /// database. + /// + /// Recovery uses this while the PMMR may contain a valid speculative suffix + /// whose enclosing DB transaction was rolled back. A different embedded block + /// hash denotes a fork and is safe to rewind. Once the embedded hash matches, + /// however, both the cached HeaderEntry metadata and the indexed PMMR leaf hash + /// must authenticate the complete authoritative header; a mismatch then is + /// corruption rather than an alternate fork. This method only compares the + /// PMMR projection; its recovery caller must authenticate the complete header + /// first because neither stored value commits to every BlockHeader field. + pub(crate) fn is_persisted_header_on_current_chain( + &self, + header: &BlockHeader, + ) -> Result { + let pos0 = pmmr::insertion_to_pmmr_index(header.height)?; + if pos0 >= self.size() { + return Ok(false); + } + + let stored_entry = self.pmmr.get_data_from_file(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing data at leaf position {} for height {}", + pos0, header.height + )) + })?; + let expected_entry = header.as_elmt()?; + if stored_entry.hash != expected_entry.hash { + return Ok(false); + } + if stored_entry != expected_entry { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR data at leaf position {} does not match authoritative header at height {}", + pos0, header.height + ))); + } + + let stored_hash = self.pmmr.get_from_file(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing hash at leaf position {} for height {}", + pos0, header.height + )) + })?; + let context_id = self.pmmr.get_context_id(); + let expected_hash = header.hash_with_index(context_id, pos0)?; + if stored_hash != expected_hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR hash at leaf position {} does not authenticate authoritative header at height {}", + pos0, header.height + ))); + } + + Ok(true) + } + /// Force the rollback of this extension, no matter the result. pub fn force_rollback(&mut self) { self.rollback = true; @@ -1750,85 +2658,362 @@ impl<'a> HeaderExtension<'a> { /// Note the close relationship between header height and insertion index. pub fn rewind(&mut self, header: &BlockHeader) -> Result<(), Error> { let context_id = self.pmmr.get_context_id(); + let header_hash = header.hash(context_id)?; + let current_head_hash = self.head.hash(context_id)?; + let new_head = Tip::try_from_header(header)?; debug!( "Rewind header extension to {} at {} from {} at {}", - header.hash(context_id)?, - header.height, - self.head.hash(context_id)?, - self.head.height, + header_hash, header.height, current_head_hash, self.head.height, ); - let header_pos = pmmr::insertion_to_pmmr_index(header.height)? - .checked_add(1) - .ok_or_else(|| { - Error::DataOverflow(format!( - "HeaderExtension::rewind, header_height={}", - header.height - )) - })?; + let next_height = header.height.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "HeaderExtension::rewind, header_height={}", + header.height + )) + })?; + let header_pos = pmmr::insertion_to_pmmr_index(next_height)?; self.pmmr.rewind(header_pos, &Bitmap::new()).map_err(|e| { Error::TxHashSetErr(format!("pmmr rewind for pos {}, {}", header_pos, e)) })?; // Update our head to reflect the header we rewound to. - self.head = Tip::try_from_header(header)?; + self.head = new_head; Ok(()) } - /// The size of the header MMR. - pub fn size(&self) -> u64 { - self.pmmr.unpruned_size() - } - - /// The root of the header MMR for convenience. - pub fn root(&self) -> Result { - Ok(self.pmmr.root()?) + /// Verify that the retained header PMMR is an exact projection of the + /// authoritative header ancestry stored in the database. + /// + /// HeaderEntry intentionally stores only a proof-derived block hash plus a + /// small metadata cache. It cannot independently reproduce the PMMR leaf hash, + /// so generic PMMR validation is insufficient for this backend. Validate each + /// complete persisted non-genesis header, including its PoW, before + /// authenticating its database key, ancestry, and PMMR projection. Genesis is + /// validated separately before storage is opened by `Chain::init`. + pub(crate) fn validate_persisted_ancestry( + &self, + header: &BlockHeader, + batch: &Batch<'_>, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), mwc_core::pow::Error>, + ) -> Result<(), Error> { + let started = Instant::now(); + info!( + "validate_persisted_ancestry: started, target height {}, PMMR size {}", + header.height, + self.size() + ); + let result = self.validate_persisted_ancestry_inner(header, batch, pow_verifier, &started); + match &result { + Ok(()) => info!( + "validate_persisted_ancestry: finished successfully in {}s", + started.elapsed().as_secs() + ), + Err(err) => error!( + "validate_persisted_ancestry: stopped with error after {}s: {:?}", + started.elapsed().as_secs(), + err + ), + } + result } - /// Validate the prev_root of the header against the root of the current header MMR. - pub fn validate_root(&self, header: &BlockHeader) -> Result<(), Error> { - // If we are validating the genesis block then we have no prev_root. - // So we are done here. - if header.height == 0 { - return Ok(()); - } - let root = self.root()?; - if root != header.prev_root { - Err(Error::InvalidRoot(format!( - "Unable to validate root, Expected header.prev_root {}, get {}", - header.prev_root, root - ))) - } else { - Ok(()) + fn validate_persisted_ancestry_inner( + &self, + header: &BlockHeader, + batch: &Batch<'_>, + pow_verifier: fn(u32, &BlockHeader) -> Result<(), mwc_core::pow::Error>, + started: &Instant, + ) -> Result<(), Error> { + let context_id = self.pmmr.get_context_id(); + let expected_head = Tip::try_from_header(header)?; + if self.head != expected_head { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR head {:?} does not match authoritative target {:?}", + self.head, expected_head + ))); } - } -} -/// An extension "pair" consisting of a txhashet extension (outputs, rangeproofs, kernels) -/// and the associated header extension. -pub struct ExtensionPair<'a> { - /// The header extension. - pub header_extension: &'a mut HeaderExtension<'a>, - /// The txhashset extension. - pub extension: &'a mut Extension<'a>, -} + let next_height = header.height.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "HeaderExtension::validate_persisted_ancestry, height={}", + header.height + )) + })?; + let expected_size = pmmr::insertion_to_pmmr_index(next_height)?; + if self.size() != expected_size { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR size {} does not match target height {} expected size {}", + self.size(), + header.height, + expected_size + ))); + } -/// Allows the application of new blocks on top of the txhashset in a -/// reversible manner within a unit of work provided by the `extending` -/// function. -pub struct Extension<'a> { - head: Tip, + let leaf_capacity = usize::try_from(next_height).map_err(|_| { + Error::DataOverflow(format!( + "HeaderExtension::validate_persisted_ancestry leaf count, height={}", + header.height + )) + })?; + let mut persisted_leaf_hashes = Vec::with_capacity(leaf_capacity); + let mut current = header.clone(); + let mut expected_current_hash = expected_head.last_block_h; + let mut last_progress_log = Instant::now(); + loop { + crate::pipe::validate_header_context_id(context_id, ¤t).map_err(|e| { + Error::InvalidPersistedChainState(format!( + "persisted header at height {} failed context validation: {}", + current.height, e + )) + })?; + if current.height != 0 + && !current.pow.is_primary(context_id) + && !current.pow.is_secondary() + { + return Err(Error::InvalidPersistedChainState(format!( + "persisted header at height {} has invalid proof edge bits", + current.height + ))); + } + // Chain::init validates genesis separately. In particular, the exact + // hardcoded Mainnet and Floonet genesis identities retain a documented + // compatibility exception for their historical proofs. + if current.height != 0 { + pow_verifier(context_id, ¤t).map_err(|e| { + Error::InvalidPersistedChainState(format!( + "persisted header at height {} failed PoW validation: {}", + current.height, e + )) + })?; + } - output_pmmr: PMMR<'a, OutputIdentifier, PMMRBackend>, - rproof_pmmr: PMMR<'a, RangeProof, PMMRBackend>, - kernel_pmmr: PMMR<'a, TxKernel, PMMRBackend>, - /// Rollback flag. - rollback: bool, - context_id: u32, -} + let pos0 = pmmr::insertion_to_pmmr_index(current.height)?; + let stored_entry = self.pmmr.get_data_from_file(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing data at leaf position {} for height {}", + pos0, current.height + )) + })?; + let expected_entry = current.as_elmt()?; + if expected_entry.hash != expected_current_hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR persisted ancestry loaded header {} from key {} at height {}", + expected_entry.hash, expected_current_hash, current.height + ))); + } + if stored_entry != expected_entry { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR data at leaf position {} does not match authoritative header at height {}", + pos0, current.height + ))); + } -impl<'a> Extension<'a> { + let stored_hash = self.pmmr.get_from_file(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing hash at leaf position {} for height {}", + pos0, current.height + )) + })?; + let expected_hash = current.hash_with_index(context_id, pos0)?; + if stored_hash != expected_hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR hash at leaf position {} does not authenticate authoritative header at height {}", + pos0, current.height + ))); + } + persisted_leaf_hashes.push(stored_hash); + + let completed = next_height.saturating_sub(current.height); + if last_progress_log.elapsed().as_secs() >= PERSISTED_ANCESTRY_LOG_INTERVAL_SECS { + info!( + "validate_persisted_ancestry: header ancestry {}/{} ({}%), current height {}", + completed, + next_height, + completed.saturating_mul(100) / next_height, + current.height + ); + last_progress_log = Instant::now(); + } + + if current.height == 0 { + break; + } + + let expected_height = current.height.checked_sub(1).ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR persisted ancestry attempted to traverse before height {}", + current.height + )) + })?; + let previous_key = current.prev_hash; + let previous = batch.get_block_header(&previous_key).map_err(|e| { + Error::StoreErr( + e, + format!( + "header PMMR persisted ancestry load previous header {} for {} at height {}", + previous_key, expected_current_hash, current.height + ), + ) + })?; + if previous.height != expected_height { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR persisted ancestry expected predecessor {} at height {}, found height {}", + previous_key, expected_height, previous.height + ))); + } + current = previous; + expected_current_hash = previous_key; + } + + info!( + "validate_persisted_ancestry: header ancestry complete; validating {} PMMR positions", + expected_size + ); + + // Header PMMRs are non-prunable, so every retained parent must be + // reproducible directly from its two persisted child hashes. The ancestry + // pass above already loaded every leaf. A postorder stack lets us read each + // parent once instead of rereading both children for every parent. + let mut leaf_hashes = persisted_leaf_hashes.into_iter().rev(); + let mut node_stack: Vec<(u64, Hash)> = Vec::new(); + last_progress_log = Instant::now(); + for pos0 in 0..expected_size { + let height = pmmr::bintree_postorder_height(pos0); + if height == 0 { + let leaf_hash = leaf_hashes.next().ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR has more leaf positions than persisted ancestry at position {}", + pos0 + )) + })?; + node_stack.push((0, leaf_hash)); + } else { + let (right_height, right_hash) = node_stack.pop().ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR parent {} has no right child in postorder traversal", + pos0 + )) + })?; + let (left_height, left_hash) = node_stack.pop().ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR parent {} has no left child in postorder traversal", + pos0 + )) + })?; + let child_height = height.checked_sub(1).ok_or_else(|| { + Error::DataOverflow(format!("header PMMR parent height at position {}", pos0)) + })?; + if left_height != child_height || right_height != child_height { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR parent {} at height {} has child heights {} and {}", + pos0, height, left_height, right_height + ))); + } + let stored_hash = self.pmmr.get_from_file(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "header PMMR is missing parent hash at position {}", + pos0 + )) + })?; + let expected_hash = (left_hash, right_hash).hash_with_index(context_id, pos0)?; + if stored_hash != expected_hash { + return Err(Error::InvalidPersistedChainState(format!( + "header PMMR parent hash at position {} does not match its children", + pos0 + ))); + } + node_stack.push((height, stored_hash)); + } + + if last_progress_log.elapsed().as_secs() >= PERSISTED_ANCESTRY_LOG_INTERVAL_SECS { + let completed = pos0.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "header PMMR validation progress at position {}", + pos0 + )) + })?; + info!( + "validate_persisted_ancestry: PMMR positions {}/{} ({}%), elapsed {}s", + completed, + expected_size, + completed.saturating_mul(100) / expected_size, + started.elapsed().as_secs() + ); + last_progress_log = Instant::now(); + } + } + if leaf_hashes.next().is_some() { + return Err(Error::InvalidPersistedChainState( + "persisted header ancestry contains more leaves than the header PMMR".into(), + )); + } + + Ok(()) + } + + /// The size of the header MMR. + pub fn size(&self) -> u64 { + self.pmmr.unpruned_size() + } + + /// The root of the header MMR for convenience. + pub fn root(&self) -> Result { + Ok(self.pmmr.root()?) + } + + /// Validate the prev_root of the header against the root of the current header MMR. + pub fn validate_root(&self, header: &BlockHeader) -> Result<(), Error> { + // If we are validating the genesis block then we have no prev_root. + // So we are done here. + if header.height == 0 { + return Ok(()); + } + let root = self.root()?; + if root != header.prev_root { + Err(Error::InvalidRoot(format!( + "Unable to validate root, Expected header.prev_root {}, get {}", + header.prev_root, root + ))) + } else { + Ok(()) + } + } +} + +/// An extension "pair" consisting of a txhashet extension (outputs, rangeproofs, kernels) +/// and the associated header extension. +pub struct ExtensionPair<'a> { + /// The header extension. + pub header_extension: &'a mut HeaderExtension<'a>, + /// The txhashset extension. + pub extension: &'a mut Extension<'a>, +} + +#[derive(Debug)] +struct RewindBlockPlan { + block: Block, + previous_header: BlockHeader, + spent_outputs: Vec, + persist_spent_index: bool, +} + +/// Allows the application of new blocks on top of the txhashset in a +/// reversible manner within a unit of work provided by the `extending` +/// function. +pub struct Extension<'a> { + head: Tip, + + output_pmmr: PMMR<'a, OutputIdentifier, PMMRBackend>, + rproof_pmmr: PMMR<'a, RangeProof, PMMRBackend>, + kernel_pmmr: PMMR<'a, TxKernel, PMMRBackend>, + /// Rollback flag. + rollback: bool, + context_id: u32, +} + +impl<'a> Extension<'a> { fn new(context_id: u32, trees: &'a mut TxHashSet, head: Tip) -> Extension<'a> { Extension { head, @@ -1875,14 +3060,15 @@ impl<'a> Extension<'a> { } /// Apply a new block to the current txhashet extension (output, rangeproof, kernel MMRs). - /// Returns a vec of commit_pos representing the pos and height of the outputs spent - /// by this block. + /// Returns the exact spent-commitment records produced by this block. The + /// caller may persist these in an outer batch when a fully validated block is + /// retained on a currently losing fork and this extension is rolled back. pub fn apply_block( &mut self, b: &Block, header_ext: &HeaderExtension<'_>, batch: &Batch<'_>, - ) -> Result<(), Error> { + ) -> Result, Error> { let mut affected_pos = vec![]; // Resolve spent outputs before adding any new outputs from this block. @@ -1890,6 +3076,7 @@ impl<'a> Extension<'a> { let spent = self .utxo_view(header_ext) .validate_inputs(&b.inputs(), batch)?; + let mut spent_records = Vec::with_capacity(spent.len()); let b_hash = b.hash(self.context_id)?; // Apply the output to the output and rangeproof MMRs. @@ -1916,16 +3103,28 @@ impl<'a> Extension<'a> { affected_pos.push(pos.pos); batch.delete_output_pos_height(&out.commitment())?; //save the spent commitments. - let hh = HashHeight { - hash: b_hash, - height: b.header.height.clone(), + let record = SpentCommitmentRecord { + spending_block: HashHeight { + hash: b_hash, + height: b.header.height, + }, + spent_output: *pos, }; - batch.save_spent_commitments(&out.commitment().clone(), hh)?; + batch.save_spent_commitments(&out.commitment(), record)?; + spent_records.push((out.commitment(), record)); } - // Update the spent index with spent pos. - let spent_pos: Vec<_> = spent.into_iter().map(|(_, pos)| pos).collect(); - batch.save_spent_index(&b_hash, &spent_pos)?; + // Preserve the commitment-to-occurrence association established by PMMR + // validation. Input serialization order may change between protocol versions, + // so a position-only vector cannot safely be paired with a reloaded block. + let spent_index: Vec<_> = spent_records + .iter() + .map(|(commitment, record)| SpentOutput { + commitment: *commitment, + position: record.spent_output, + }) + .collect(); + batch.save_spent_index(&b_hash, &spent_index)?; // Apply the kernels to the kernel MMR. // Note: This validates and NRD relative height locks via the "recent" kernel index. @@ -1934,7 +3133,7 @@ impl<'a> Extension<'a> { // Update the head of the extension to reflect the block we just applied. self.head = Tip::try_from_header(&b.header)?; - Ok(()) + Ok(spent_records) } // Prune output and rangeproof PMMRs based on provided pos. @@ -2388,14 +3587,12 @@ impl<'a> Extension<'a> { Ok(bitmap_accumulator) } - /// Rewinds the MMRs to the provided block, rewinding to the last output pos - /// and last kernel pos of that block. If `updated_bitmap` is supplied, the - /// bitmap accumulator will be replaced with its contents + /// Rewinds the MMRs to the provided block's last output and kernel positions. + /// All blocks and spent-position metadata are authenticated before mutation. pub fn rewind( &mut self, header: &BlockHeader, batch: &Batch<'_>, - header_ext: &HeaderExtension<'_>, mut progress: Option<&mut dyn FnMut(u64, u64) -> Result<(), Error>>, ) -> Result<(), Error> { let header_hash = header.hash(self.context_id)?; @@ -2412,6 +3609,27 @@ impl<'a> Extension<'a> { // Rewound output pos will be removed from the MMR. // Rewound input (spent) pos will be added back to the MMR. let head_header = batch.get_block_header(&head_hash)?; + let loaded_head_hash = head_header.hash(self.context_id)?; + if loaded_head_hash != head_hash { + return Err(Error::InvalidPersistedChainState(format!( + "Extension::rewind head header key/hash mismatch: selected {}, header hashes to {}", + head_hash, loaded_head_hash + ))); + } + + // Bound supported body reorgs from the authenticated current head rather + // than BODY_TAIL or retained full blocks. Archive retention must not allow + // a deeper rewind than a pruned node can perform. + let minimum_height = head_header + .height + .saturating_sub(u64::from(global::cut_through_horizon(self.context_id))); + if header.height < minimum_height { + return Err(Error::RewindBeyondHorizon { + head_height: head_header.height, + target_height: header.height, + minimum_height, + }); + } if header.height > head_header.height { return Err(Error::TxHashSetErr(format!( @@ -2421,17 +3639,17 @@ impl<'a> Extension<'a> { } let mut current = head_header; - let mut rewind_hashes = vec![]; + let mut rewind_headers = vec![]; + let mut visited = HashSet::new(); while header.height < current.height { - rewind_hashes.push(current.hash(self.context_id)?); - let prev = batch.get_previous_header(¤t)?; - if prev.height >= current.height { - return Err(Error::TxHashSetErr(format!( - "cannot rewind through non-decreasing header heights {} -> {}", - current.height, prev.height - ))); - } - current = prev; + rewind_headers.push(current.clone()); + current = crate::checked_previous_header( + self.context_id, + ¤t, + &mut visited, + "Extension::rewind ancestry", + |hash| batch.get_block_header(hash), + )?; } let current_hash = current.hash(self.context_id)?; @@ -2448,27 +3666,66 @@ impl<'a> Extension<'a> { ))); } - let rewind_total = u64::try_from(rewind_hashes.len()).map_err(|_| { + let rewind_total = u64::try_from(rewind_headers.len()).map_err(|_| { Error::DataOverflow(format!( - "Extension::rewind, rewind_hashes.len={}", - rewind_hashes.len() + "Extension::rewind, rewind_headers.len={}", + rewind_headers.len() )) })?; + + // Verify every full block and authenticate its spent-position cache before + // the first rewind mutation. PMMR roots do not authenticate prunable leaf + // membership, so accepting an incorrect cache here could manufacture an + // unspent output that later UTXO validation would trust. + // + // `progress` is intentionally not called during this preflight. A cancellation + // request communicated through that callback is therefore observed only at the + // checkpoint below, before the first mutation. The preflight is bounded by the + // cut-through horizon and does not rescan chain ancestry for every block, so + // this delayed progress/cancellation response is an accepted tradeoff. + let mut rewind_blocks = Vec::with_capacity(rewind_headers.len()); + for expected_header in &rewind_headers { + let block = crate::checked_block_for_header( + self.context_id, + expected_header, + "Extension::rewind preflight", + |hash| batch.get_block(hash), + )?; + let previous_header = crate::checked_previous_header( + self.context_id, + &block.header, + &mut HashSet::new(), + "prepare_authenticated_rewind_block predecessor", + |hash| batch.get_block_header(hash), + )?; + rewind_blocks.push((block, previous_header)); + } + let mut rewind_plans = Vec::with_capacity(rewind_blocks.len()); + for (block, previous_header) in rewind_blocks { + rewind_plans.push(self.prepare_authenticated_rewind_block( + block, + previous_header, + batch, + )?); + } + if let Some(ref mut progress) = progress { progress(0, rewind_total)?; } - if rewind_hashes.is_empty() { + if rewind_plans.is_empty() { // Nothing to rewind but we do want to truncate the MMRs at header for consistency. + // An empty restore bitmap cannot recover older leaves removed by an interrupted, + // uncommitted extension. Recovery callers must authenticate the resulting leaf + // membership against independently committed state before accepting it. self.rewind_mmrs_to_pos(header.output_mmr_size, header.kernel_mmr_size, &[])?; if let Some(ref mut progress) = progress { progress(rewind_total, rewind_total)?; } } else { let mut rewound = 0u64; - for hash in rewind_hashes { - let block = batch.get_block(&hash)?; - self.rewind_single_block(&block, batch, header_ext)?; + for plan in rewind_plans { + self.apply_rewind_block(plan, batch)?; rewound = rewound.checked_add(1).ok_or_else(|| { Error::DataOverflow("Extension::rewind, rewound overflow".into()) })?; @@ -2484,70 +3741,198 @@ impl<'a> Extension<'a> { Ok(()) } - // Rewind the MMRs and the output_pos index. - // Returns a vec of "affected_pos" so we can apply the necessary updates to the bitmap - // accumulator in a single pass for all rewound blocks. - fn rewind_single_block( - &mut self, - block: &Block, + fn prepare_authenticated_rewind_block( + &self, + block: Block, + previous_header: BlockHeader, batch: &Batch<'_>, - header_ext: &HeaderExtension<'_>, - ) -> Result<(), Error> { + ) -> Result { let header = &block.header; - let prev_header = batch.get_previous_header(&header)?; let header_hash = header.hash(self.context_id)?; - // The spent index allows us to conveniently "unspend" everything in a block. - let (spent_pos, spent_index): (Vec, Option>) = match batch - .get_spent_index(&header_hash) - { - Ok(spent) => { - let spent_pos = spent.iter().map(|x| x.pos).collect(); - (spent_pos, Some(spent)) - } - Err(e) if e.store_error_is_not_found() => { - warn!( - "rewind_single_block: fallback to legacy input bitmap for block {} at {}", - header_hash, header.height - ); - match batch.get_block_input_bitmap(&header_hash) { - Ok(bitmap) => { - let spent_pos = bitmap.iter().map(|x| x.into()).collect(); - (spent_pos, None) - } - Err(e) if e.store_error_is_not_found() => { - warn!( - "rewind_single_block: fallback to calculating inputs for block {} at {}", - header_hash, header.height - ); - let spent = self - .utxo_view(header_ext) - .validate_inputs(&block.inputs(), batch)?; - let spent_index: Vec<_> = spent.into_iter().map(|(_, pos)| pos).collect(); - let spent_pos = spent_index.iter().map(|pos| pos.pos).collect(); - (spent_pos, Some(spent_index)) - } - Err(e) => { - return Err(Error::StoreErr( - e, - "rewind_single_block get legacy input bitmap".into(), - )); + // The spent index allows us to conveniently "unspend" everything in a + // block, but it is derived state and must be authenticated before use. + let (positions, cached_spent_index, persist_spent_index, operation) = + match batch.get_spent_index(&header_hash) { + Ok(spent) => { + let positions = spent.iter().map(|entry| entry.position.pos).collect(); + (positions, Some(spent), false, "rewind spent index") + } + Err(e) if e.store_error_is_not_found() => { + warn!( + "prepare_authenticated_rewind_block: fallback to legacy input bitmap for block {} at {}", + header_hash, header.height + ); + match batch.get_block_input_bitmap(&header_hash) { + Ok(bitmap) => { + let positions = bitmap.iter().map(u64::from).collect(); + (positions, None, true, "rewind legacy input bitmap") + } + Err(e) if e.store_error_is_not_found() => { + if block.inputs().is_empty() { + (Vec::new(), None, true, "rewind missing empty spent index") + } else { + let msg = format!( + "rewind block {} at height {} has neither a spent index nor a legacy input bitmap", + header_hash, header.height + ); + return Err(Error::InvalidPersistedChainState(msg)); + } + } + Err(e) => { + return Err(Error::StoreErr( + e, + "prepare_authenticated_rewind_block get legacy input bitmap".into(), + )); + } } } + Err(e) => { + return Err(Error::StoreErr( + e, + "prepare_authenticated_rewind_block get spent index".into(), + )); + } + }; + + let spent_outputs = self.authenticate_rewind_spent_index( + operation, + &block, + &previous_header, + &positions, + cached_spent_index.as_deref(), + batch, + )?; + + Ok(RewindBlockPlan { + block, + previous_header, + spent_outputs, + persist_spent_index, + }) + } + + fn authenticate_rewind_spent_index( + &self, + operation: &str, + block: &Block, + previous_header: &BlockHeader, + positions: &[u64], + cached_spent_index: Option<&[SpentOutput]>, + batch: &Batch<'_>, + ) -> Result, Error> { + require_spent_commitment_record_index(operation, batch)?; + let spending_block = HashHeight { + hash: block.hash(self.context_id)?, + height: block.header.height, + }; + let spent_outputs = validate_block_spent_positions( + operation, + block, + previous_header, + positions, + self.output_pmmr.size(), + self.rproof_pmmr.size(), + |pos0| Ok(self.output_pmmr.get_data_from_file(pos0)?), + |pos0| Ok(self.rproof_pmmr.get_data_from_file(pos0)?.is_some()), + |commitment| { + spent_commitment_record_for_block(operation, commitment, spending_block, batch) + }, + )?; + + if let Some(cached) = cached_spent_index { + if cached.len() != spent_outputs.len() { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains {} cached occurrences for {} authenticated inputs", + operation, + block.header.height, + cached.len(), + spent_outputs.len() + ))); } - Err(e) => { - return Err(Error::StoreErr( - e, - "rewind_single_block get spent index".into(), - )); + let mut authenticated_by_commitment = HashMap::with_capacity(spent_outputs.len()); + for expected in &spent_outputs { + if authenticated_by_commitment + .insert(expected.commitment, expected.position) + .is_some() + { + return Err(Error::InvalidPersistedChainState(format!( + "{} authenticates duplicate input commitment {:?} for block at height {}", + operation, expected.commitment, block.header.height + ))); + } } - }; + let mut cached_commitments = HashSet::with_capacity(cached.len()); + for cached in cached { + let commitment = cached.commitment; + let cached_position = cached.position; + if cached_position.height > previous_header.height { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} records output position {} at height {} above predecessor height {}", + operation, + block.header.height, + cached_position.pos, + cached_position.height, + previous_header.height + ))); + } + if !cached_commitments.insert(commitment) { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} contains duplicate cached commitment {:?}", + operation, block.header.height, commitment + ))); + } + let expected = authenticated_by_commitment + .get(&commitment) + .ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "{} for block at height {} caches commitment {:?} that is not an input", + operation, block.header.height, commitment + )) + })?; + if cached_position != *expected { + return Err(Error::InvalidPersistedChainState(format!( + "{} for block at height {} records commitment {:?} at output position {} and height {}, but the authenticated spent commitment record identifies position {} and height {}", + operation, + block.header.height, + commitment, + cached_position.pos, + cached_position.height, + expected.pos, + expected.height + ))); + } + } + } + Ok(spent_outputs) + } + + fn apply_rewind_block( + &mut self, + plan: RewindBlockPlan, + batch: &Batch<'_>, + ) -> Result<(), Error> { + let RewindBlockPlan { + block, + previous_header: prev_header, + spent_outputs, + persist_spent_index, + } = plan; + let header = &block.header; + let header_hash = header.hash(self.context_id)?; + let spent_pos = spent_outputs + .iter() + .map(|entry| entry.position.pos) + .collect::>(); if header.height == 0 { self.rewind_mmrs_to_pos(0, 0, &spent_pos)?; } else { - let prev = batch.get_previous_header(header)?; - self.rewind_mmrs_to_pos(prev.output_mmr_size, prev.kernel_mmr_size, &spent_pos)?; + self.rewind_mmrs_to_pos( + prev_header.output_mmr_size, + prev_header.kernel_mmr_size, + &spent_pos, + )?; } // Remove any entries from the output_pos created by the block being rewound. @@ -2635,15 +4020,9 @@ impl<'a> Extension<'a> { // This is necessary to ensure the output_pos index correctly reflects a // reused output commitment. For example an output at pos 1, spent, reused at pos 2. // The output_pos index should be updated to reflect the old pos 1 when unspent. - let spent_index = match spent_index { - Some(spent) => spent, - None => { - let spent = self.reconstruct_spent_index(&spent_pos, &prev_header, batch)?; - batch.save_spent_index(&header_hash, &spent)?; - spent - } - }; - for pos1 in spent_index { + let mut exact_spent_index = Vec::with_capacity(spent_outputs.len()); + for spent_output in spent_outputs { + let pos1 = spent_output.position; let pos0 = pos1.pos.checked_sub(1).ok_or_else(|| { mwc_store::Error::DataOverflow(format!( "Extension::rewind_single_block pos1.pos={}", @@ -2651,7 +4030,16 @@ impl<'a> Extension<'a> { )) })?; match self.output_pmmr.get_data(pos0)? { - Some(out) => batch.save_output_pos_height(&out.commitment(), pos1)?, + Some(out) => { + if out.commitment() != spent_output.commitment { + return Err(Error::InvalidPersistedChainState(format!( + "rewind_single_block restored output commitment {:?} at position {}, expected {:?}", + out.commitment(), pos1.pos, spent_output.commitment + ))); + } + batch.save_output_pos_height(&spent_output.commitment, pos1)?; + exact_spent_index.push(spent_output); + } None => { return Err(Error::TxHashSetErr(format!( "rewind_single_block missing output PMMR data at pos {} while restoring output_pos for block {} at {}", @@ -2660,127 +4048,50 @@ impl<'a> Extension<'a> { } } } + if persist_spent_index { + batch.save_spent_index(&header_hash, &exact_spent_index)?; + } Ok(()) } - fn reconstruct_spent_index( - &self, + /// Rewinds the MMRs to the provided positions, given the output and + /// kernel pos we want to rewind to. + fn rewind_mmrs_to_pos( + &mut self, + output_pos: u64, + kernel_pos: u64, spent_pos: &[u64], - prev_header: &BlockHeader, - batch: &Batch<'_>, - ) -> Result, Error> { - spent_pos + ) -> Result<(), Error> { + let bitmap: Bitmap = spent_pos .iter() - .map(|pos| { - Ok(CommitPos { - pos: *pos, - height: self.output_height_for_pos(*pos, prev_header, batch)?, + .map(|x| { + u32::try_from(*x).map_err(|_| { + Error::DataOverflow(format!("TxHashSet::rewind_mmrs_to_pos, spent_pos={}", x)) }) }) - .collect() + .collect::>()?; + self.output_pmmr.rewind(output_pos, &bitmap)?; + self.rproof_pmmr.rewind(output_pos, &bitmap)?; + self.kernel_pmmr.rewind(kernel_pos, &Bitmap::new())?; + Ok(()) } - fn output_height_for_pos( - &self, - pos: u64, - header: &BlockHeader, + /// Reset the body MMRs to empty and rebuild them from the hard-coded + /// genesis block. + pub fn rebuild_genesis( + &mut self, + genesis: &Block, + header_ext: &HeaderExtension<'_>, batch: &Batch<'_>, - ) -> Result { - if pos == 0 { - return Err(Error::DataOverflow( - "Extension::output_height_for_pos pos=0".into(), - )); - } - if pos > header.output_mmr_size { - return Err(Error::TxHashSetErr(format!( - "rewind_single_block cannot map output pos {} beyond rewind target output MMR size {}", - pos, header.output_mmr_size - ))); - } - let pos0 = pos.checked_sub(1).ok_or_else(|| { - Error::DataOverflow(format!("Extension::output_height_for_pos pos={}", pos)) - })?; - if !pmmr::is_leaf(pos0) { - return Err(Error::TxHashSetErr(format!( - "rewind_single_block cannot map non-leaf output PMMR pos {} to a block height", - pos - ))); - } - - let mut current = header.clone(); - loop { - let prev = if current.height == 0 { - None - } else { - Some(batch.get_previous_header(¤t)?) - }; - if let Some(prev_header) = &prev { - if prev_header.height >= current.height { - return Err(Error::TxHashSetErr(format!( - "rewind_single_block cannot map output pos {} through non-decreasing header heights {} -> {}", - pos, current.height, prev_header.height - ))); - } - } - let prev_output_mmr_size = prev - .as_ref() - .map(|header| header.output_mmr_size) - .unwrap_or(0); - if prev_output_mmr_size > current.output_mmr_size { - return Err(Error::TxHashSetErr(format!( - "rewind_single_block found output MMR size regression at height {}: previous {}, current {}", - current.height, prev_output_mmr_size, current.output_mmr_size - ))); - } - if pos > prev_output_mmr_size { - return Ok(current.height); - } - match prev { - Some(prev) => current = prev, - None => { - return Err(Error::TxHashSetErr(format!( - "rewind_single_block cannot map output pos {} to a block height", - pos - ))); - } - } - } - } - - /// Rewinds the MMRs to the provided positions, given the output and - /// kernel pos we want to rewind to. - fn rewind_mmrs_to_pos( - &mut self, - output_pos: u64, - kernel_pos: u64, - spent_pos: &[u64], - ) -> Result<(), Error> { - let bitmap: Bitmap = spent_pos - .iter() - .map(|x| { - u32::try_from(*x).map_err(|_| { - Error::DataOverflow(format!("TxHashSet::rewind_mmrs_to_pos, spent_pos={}", x)) - }) - }) - .collect::>()?; - self.output_pmmr.rewind(output_pos, &bitmap)?; - self.rproof_pmmr.rewind(output_pos, &bitmap)?; - self.kernel_pmmr.rewind(kernel_pos, &Bitmap::new())?; - Ok(()) - } - - /// Reset the body MMRs to empty and rebuild them from the hard-coded - /// genesis block. - pub fn rebuild_genesis( - &mut self, - genesis: &Block, - header_ext: &HeaderExtension<'_>, - batch: &Batch<'_>, - ) -> Result<(), Error> { - if genesis.header.height != 0 { - return Err(Error::InvalidGenesisHash); + ) -> Result<(), Error> { + if genesis.header.height != 0 { + return Err(Error::InvalidGenesisHash); } + // The full kernel_pos index is derived from the kernel PMMR. Rewinding + // directly to genesis bypasses the per-block index cleanup, so make any + // surviving entries non-authoritative in the same batch as the reset. + batch.set_kernel_pos_index_complete(false)?; self.rewind_mmrs_to_pos(0, 0, &[])?; for out in genesis.outputs() { match batch.delete_output_pos_height(&out.commitment()) { @@ -2794,7 +4105,7 @@ impl<'a> Extension<'a> { } } } - self.apply_block(genesis, header_ext, batch) + self.apply_block(genesis, header_ext, batch).map(|_| ()) } /// Current root hashes and sums (if applicable) for the Output, range proof @@ -2867,13 +4178,17 @@ impl<'a> Extension<'a> { fn validate_mmrs(&self) -> Result<(), Error> { let now = Instant::now(); + info!("Starting PMMR validation"); // validate all hashes and sums within the trees self.output_pmmr.validate()?; + info!("Finish outputs PMMR validation"); self.rproof_pmmr.validate()?; + info!("Finish rangeproofs PMMR validation"); self.kernel_pmmr.validate()?; + info!("Finish Kernels PMMR validation"); - debug!( - "txhashset: validated the output {}, rproof {}, kernel {} mmrs, took {}s", + info!( + "txhashset: validated PMMR: the output {}, rproof {}, kernel {} mmrs, took {}s", self.output_pmmr.unpruned_size(), self.rproof_pmmr.unpruned_size(), self.kernel_pmmr.unpruned_size(), @@ -2883,6 +4198,163 @@ impl<'a> Extension<'a> { Ok(()) } + fn validate_output_rangeproof_leaf_sets(&self) -> Result<(), Error> { + let mut output_positions = self.output_pmmr.leaf_pos_iter()?; + let mut rangeproof_positions = self.rproof_pmmr.leaf_pos_iter()?; + + loop { + match (output_positions.next(), rangeproof_positions.next()) { + (None, None) => return Ok(()), + (Some(output_pos), Some(rangeproof_pos)) => { + let output_pos = output_pos?; + let rangeproof_pos = rangeproof_pos?; + if output_pos != rangeproof_pos { + return Err(Error::InvalidPersistedChainState(format!( + "output leaf position {} does not match rangeproof leaf position {}", + output_pos, rangeproof_pos + ))); + } + } + (Some(output_pos), None) => { + return Err(Error::InvalidPersistedChainState(format!( + "output leaf position {} has no matching rangeproof leaf", + output_pos? + ))); + } + (None, Some(rangeproof_pos)) => { + return Err(Error::InvalidPersistedChainState(format!( + "rangeproof leaf position {} has no matching output leaf", + rangeproof_pos? + ))); + } + } + } + } + + /// Validate exact UTXO leaf membership against the transactionally committed + /// output-position index. + /// + /// PMMR roots authenticate append history, not the prunable leaf bitmap, and + /// output/rangeproof leaf-set equality only proves that the two bitmaps agree + /// with each other. Recovery therefore needs this independent, bidirectional + /// check before it can accept a leaf set produced by a zero-step rewind. + pub(crate) fn validate_output_pos_index( + &self, + batch: &Batch<'_>, + header: &BlockHeader, + ) -> Result<(), Error> { + let index_complete = batch.is_output_pos_index_complete().map_err(|e| { + Error::StoreErr( + e, + "validate output_pos index completeness during recovery".into(), + ) + })?; + if !index_complete { + return Err(Error::InvalidPersistedChainState( + "cannot authenticate UTXO leaf membership: output_pos index is incomplete".into(), + )); + } + + let mut indexed_outputs = 0u64; + let output_pos_iter = batch + .output_pos_iter() + .map_err(|e| Error::StoreErr(e, "iterate output_pos index during recovery".into()))?; + for entry in output_pos_iter { + let (key, pos1) = entry + .map_err(|e| Error::StoreErr(e, "read output_pos entry during recovery".into()))?; + let pos0 = pos1.pos.checked_sub(1).ok_or_else(|| { + Error::InvalidPersistedChainState( + "output_pos index contains invalid position 0".into(), + ) + })?; + if pos1.pos > header.output_mmr_size || !pmmr::is_leaf(pos0) { + return Err(Error::InvalidPersistedChainState(format!( + "output_pos index contains invalid position {} for recovered output MMR size {}", + pos1.pos, header.output_mmr_size + ))); + } + + let output = self.output_pmmr.get_data(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "committed output_pos entry points to missing UTXO leaf at position {}", + pos1.pos + )) + })?; + if !batch.is_match_output_pos_key(&key, &output.commitment()) { + return Err(Error::InvalidPersistedChainState(format!( + "committed output_pos key does not match output commitment at position {}", + pos1.pos + ))); + } + if self.rproof_pmmr.get_data(pos0)?.is_none() { + return Err(Error::InvalidPersistedChainState(format!( + "committed output_pos entry has no rangeproof leaf at position {}", + pos1.pos + ))); + } + + indexed_outputs = indexed_outputs.checked_add(1).ok_or_else(|| { + Error::DataOverflow("validate_output_pos_index indexed output count".into()) + })?; + } + + let mut output_leaves = 0u64; + for pos0 in self.output_pmmr.leaf_pos_iter()? { + let pos0 = pos0?; + let pos1 = pos0.checked_add(1).ok_or_else(|| { + Error::DataOverflow(format!( + "validate_output_pos_index output position {}", + pos0 + )) + })?; + if pos1 > header.output_mmr_size { + return Err(Error::InvalidPersistedChainState(format!( + "UTXO leaf position {} exceeds recovered output MMR size {}", + pos1, header.output_mmr_size + ))); + } + let output = self.output_pmmr.get_data(pos0)?.ok_or_else(|| { + Error::InvalidPersistedChainState(format!( + "output leaf iterator returned missing UTXO data at position {}", + pos1 + )) + })?; + let indexed_pos = batch + .get_output_pos_height(&output.commitment()) + .map_err(|e| { + Error::StoreErr(e, "look up output_pos entry during recovery".into()) + })?; + match indexed_pos { + Some(indexed_pos) if indexed_pos.pos == pos1 => {} + Some(indexed_pos) => { + return Err(Error::InvalidPersistedChainState(format!( + "UTXO leaf at position {} is indexed at position {}", + pos1, indexed_pos.pos + ))); + } + None => { + return Err(Error::InvalidPersistedChainState(format!( + "UTXO leaf at position {} has no committed output_pos entry", + pos1 + ))); + } + } + + output_leaves = output_leaves.checked_add(1).ok_or_else(|| { + Error::DataOverflow("validate_output_pos_index output leaf count".into()) + })?; + } + + if indexed_outputs != output_leaves { + return Err(Error::InvalidPersistedChainState(format!( + "output_pos index count {} does not match UTXO leaf count {}", + indexed_outputs, output_leaves + ))); + } + + Ok(()) + } + fn update_kernel_sum_progress( status: &Option>, status_throttle: &SyncStatusUpdateThrottle, @@ -2959,39 +4431,76 @@ impl<'a> Extension<'a> { let total_outputs = self.output_pmmr.n_unpruned_leaves()?; let total_kernels = pmmr::n_leaves(self.kernel_pmmr.unpruned_size())?; let total_progress = total_outputs.saturating_add(total_kernels); + info!( + "validate_kernel_sums: started at height {}, outputs {}, kernels {}, total commitments {}", + header.height, total_outputs, total_kernels, total_progress + ); let status_throttle = SyncStatusUpdateThrottle::new(); Self::update_kernel_sum_progress(&status, &status_throttle, 0, total_progress, true); - let overage = header.total_overage(self.context_id, genesis.kernel_mmr_size > 0)?; - verify_kernel_sums_iter( - self.output_commitments_iter()?, - std::iter::empty::>(), - self.kernel_commitments_iter(), - overage, - header.total_kernel_offset(), - COMMIT_SUM_BATCH_SIZE, - num_cpus::get().max(1), - secp, - || Self::check_stop_state(&stop_state), - |completed_items| { - let progress = (completed_items as u64).min(total_progress); - Self::update_kernel_sum_progress( - &status, - &status_throttle, - progress, - total_progress, - progress == total_progress, - ); - Ok(()) - }, - ) - .map(|(utxo_sum, kernel_sum)| { - debug!( - "txhashset: validated total kernel sums, took {}s", - now.elapsed().as_secs(), - ); + let mut last_progress_log = Instant::now(); + let result = (|| { + let overage = header.total_overage(self.context_id, genesis.kernel_mmr_size > 0)?; + verify_kernel_sums_iter( + self.output_commitments_iter()?, + std::iter::empty::>(), + self.kernel_commitments_iter(), + overage, + header.total_kernel_offset(), + COMMIT_SUM_BATCH_SIZE, + num_cpus::get().max(1), + secp, + || Self::check_stop_state(&stop_state), + |completed_items| { + let progress = (completed_items as u64).min(total_progress); + Self::update_kernel_sum_progress( + &status, + &status_throttle, + progress, + total_progress, + progress == total_progress, + ); + if last_progress_log.elapsed().as_secs() + >= KERNEL_SUM_PROGRESS_LOG_INTERVAL_SECS + { + let outputs_done = progress.min(total_outputs); + let kernels_done = + progress.saturating_sub(total_outputs).min(total_kernels); + info!( + "validate_kernel_sums: progress {}/{} ({}%), outputs {}/{}, kernels {}/{}", + progress, + total_progress, + progress.saturating_mul(100) / total_progress.max(1), + outputs_done, + total_outputs, + kernels_done, + total_kernels + ); + last_progress_log = Instant::now(); + } + Ok(()) + }, + ) + })(); - (utxo_sum, kernel_sum) - }) + match &result { + Ok(_) => info!( + "validate_kernel_sums: finished successfully in {}s; total circulating balance {} MWC checked at height {}", + now.elapsed().as_secs(), + amount_to_hr_string( + header + .total_overage(self.context_id, genesis.kernel_mmr_size > 0)? + .unsigned_abs(), + true, + ), + header.height + ), + Err(err) => error!( + "validate_kernel_sums: stopped with error after {}s: {}", + now.elapsed().as_secs(), + err + ), + } + result } /// Validate the txhashset state against the provided block header. @@ -3023,6 +4532,7 @@ impl<'a> Extension<'a> { 2, ); self.validate_sizes(header)?; + self.validate_output_rangeproof_leaf_sets()?; if self.can_skip_genesis_mmr_validation(header) && header.total_kernel_offset().is_zero() { if let Some(status) = &status { @@ -3546,10 +5056,37 @@ pub fn clean_txhashset_folder(root_dir: &PathBuf) -> Result<(), Error> { /// We do this by leveraging the "block_input_bitmap" cache and OR'ing /// the set of bitmaps together for the set of blocks being rewound. fn input_pos_to_rewind( + txhashset: &TxHashSet, block_header: &BlockHeader, head_header: &BlockHeader, batch: &Batch<'_>, ) -> Result { + // Rewinding blocks one by one instead load all rewind positions in the RAM. That allow us save memory (unwind can be up to a WEEK). + walk_input_pos_to_rewind( + block_header, + head_header, + batch, + |current, previous, block_bitmap| { + let block = crate::checked_block_for_header( + batch.get_context_id(), + current, + "compact input bitmap preflight", + |hash| batch.get_block(hash), + )?; + txhashset.validate_compact_block_input_bitmap(&block, previous, block_bitmap, batch) + }, + ) +} + +fn walk_input_pos_to_rewind( + block_header: &BlockHeader, + head_header: &BlockHeader, + batch: &Batch<'_>, + mut validate_block_bitmap: F, +) -> Result +where + F: FnMut(&BlockHeader, &BlockHeader, &Bitmap) -> Result<(), Error>, +{ let mut bitmap = Bitmap::new(); let context_id = batch.get_context_id(); @@ -3564,10 +5101,11 @@ fn input_pos_to_rewind( } let mut current = head_header.clone(); + let mut visited = HashSet::new(); while current.height > block_header.height { let current_hash = current.hash(context_id)?; - match batch.get_block_input_bitmap(¤t_hash) { - Ok(block_bitmap) => bitmap.or_inplace(&block_bitmap), + let block_bitmap = match batch.get_block_input_bitmap(¤t_hash) { + Ok(block_bitmap) => block_bitmap, Err(e) if e.store_error_is_not_found() => { return Err(Error::StoreErr( e, @@ -3583,15 +5121,17 @@ fn input_pos_to_rewind( "input positions to rewind get block input bitmap".to_owned(), )); } - } - let prev = batch.get_previous_header(¤t)?; - if prev.height >= current.height { - return Err(Error::TxHashSetErr(format!( - "input positions to rewind encountered non-descending header ancestry: block {} at height {} has previous header {} at height {}", - current_hash, current.height, current.prev_hash, prev.height - ))); - } - current = prev; + }; + let previous = crate::checked_previous_header( + context_id, + ¤t, + &mut visited, + "input positions to rewind ancestry", + |hash| batch.get_block_header(hash), + )?; + validate_block_bitmap(¤t, &previous, &block_bitmap)?; + bitmap.or_inplace(&block_bitmap); + current = previous; } let current_hash = current.hash(context_id)?; @@ -3675,7 +5215,8 @@ mod tests { use super::*; use mwc_core::core::pmmr::segment::SegmentError; use mwc_core::core::{ - Input, Inputs, OutputFeatures, SegmentIdentifier, SegmentProof, TransactionBody, + block, HeaderVersion, Input, NRDRelativeHeight, OutputFeatures, SegmentIdentifier, + SegmentProof, TransactionBody, }; use mwc_core::global::ChainTypes; use mwc_core::libtx::{reward, ProofBuilder}; @@ -3683,6 +5224,13 @@ mod tests { use mwc_keychain::{ExtKeychain, Keychain}; use std::{fs, io}; + fn spent_cache_entry(commitment: Commitment, pos: u64, height: u64) -> SpentOutput { + SpentOutput { + commitment, + position: CommitPos { pos, height }, + } + } + fn assert_data_overflow(result: Result) { match result { Err(Error::DataOverflow(_)) => {} @@ -3757,58 +5305,815 @@ mod tests { batch.commit().unwrap(); } - fn assert_rewind_target_error(err: Error) { - match err { - Error::TxHashSetErr(msg) => { - assert!(msg.contains("rewind"), "{}", msg); + fn save_empty_body_chain(store: &ChainStore, height: u64) -> Vec { + let mut headers = vec![BlockHeader::default(0)]; + for next_height in 1..=height { + let mut header = BlockHeader::default(0); + header.height = next_height; + header.prev_hash = headers.last().unwrap().hash(0).unwrap(); + header.pow.proof.nonces[0] = next_height; + headers.push(header); + } + + let batch = store.batch_write().unwrap(); + batch + .set_spent_commitment_record_index_complete(true) + .unwrap(); + for header in &headers { + batch.save_block_header(header).unwrap(); + if header.height > 0 { + let mut block = Block::default(0); + block.header = header.clone(); + batch.save_block(&block).unwrap(); + batch + .save_spent_index(&header.hash(0).unwrap(), &[]) + .unwrap(); } - other => panic!("expected rewind target error, got {:?}", other), } + batch.commit().unwrap(); + + headers } #[test] - fn txhashset_replace_swaps_in_new_txhashset() { - let root_dir = PathBuf::from("target/txhashset_replace_swaps_in_new_txhashset"); - let from = root_dir.join("from"); - let to = root_dir.join("to"); - let source_path = from.join(TXHASHSET_SUBDIR); - let destination_path = to.join(TXHASHSET_SUBDIR); + fn init_kernel_pos_index_chunked_rejects_short_head_before_clear() { + let chain_dir = "target/init_kernel_pos_index_chunked_rejects_short_head_before_clear"; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let _ = fs::remove_dir_all(chain_dir); - let _ = fs::remove_dir_all(&root_dir); - fs::create_dir_all(&source_path).unwrap(); - fs::write(source_path.join("state"), b"new").unwrap(); - fs::create_dir_all(&destination_path).unwrap(); - fs::write(destination_path.join("state"), b"old").unwrap(); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let kernel = reward_kernel(&mut secp, 1); + let kernel_mmr_size = { + let mut kernel_pmmr = PMMR::at( + &mut txhashset.kernel_pmmr_h.backend, + txhashset.kernel_pmmr_h.size, + ); + kernel_pmmr.push(&kernel).unwrap(); + kernel_pmmr.size() + }; + txhashset.kernel_pmmr_h.size = kernel_mmr_size; + + let head = BlockHeader::default(0); + save_block_headers(&store, &[&head]); + { + let batch = store.batch_write().unwrap(); + batch + .save_body_head(&Tip::try_from_header(&head).unwrap()) + .unwrap(); + batch + .save_kernel_pos(&kernel.excess(), KernelPos { pos: 1, height: 0 }) + .unwrap(); + batch.set_kernel_pos_index_complete(false).unwrap(); + batch.commit().unwrap(); + } + let err = txhashset + .init_kernel_pos_index_chunked(&store, None, None) + .unwrap_err(); assert!(matches!( - txhashset_replace(from.clone(), to.clone()).unwrap(), - TxHashSetReplaceResult::Replaced + err, + Error::TxHashSetErr(msg) + if msg.contains("body HEAD kernel MMR size") + && msg.contains("does not match txhashset size") )); - assert_eq!( - fs::read(to.join(TXHASHSET_SUBDIR).join("state")).unwrap(), - b"new".to_vec() - ); - assert!(!from.join(TXHASHSET_SUBDIR).try_exists().unwrap()); - assert_eq!(fs::read_dir(&to).unwrap().count(), 1); + let batch = store.batch_read().unwrap(); + assert!(!batch.is_kernel_pos_index_complete().unwrap()); + let entries = batch + .kernel_pos_iter(&kernel.excess()) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries, vec![KernelPos { pos: 1, height: 0 }]); + drop(batch); - let _ = fs::remove_dir_all(&root_dir); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); } - #[test] - fn txhashset_replace_restores_existing_txhashset_on_rename_error() { - let root_dir = - PathBuf::from("target/txhashset_replace_restores_existing_txhashset_on_rename_error"); - let from = root_dir.join("from"); - let to = root_dir.join("to"); - let destination_path = to.join(TXHASHSET_SUBDIR); - - let _ = fs::remove_dir_all(&root_dir); - fs::create_dir_all(&from).unwrap(); - fs::create_dir_all(&destination_path).unwrap(); - fs::write(destination_path.join("state"), b"old").unwrap(); - - let err = txhashset_replace(from, to.clone()).unwrap_err(); + fn verify_test_kernel_history( + chain_dir: &str, + kernels: &[TxKernel], + inclusion_height: u64, + inclusion_version: HeaderVersion, + nrd_enabled: bool, + ) -> Result<(), Error> { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(nrd_enabled); + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let kernel_mmr_size = { + let mut kernel_pmmr = PMMR::at( + &mut txhashset.kernel_pmmr_h.backend, + txhashset.kernel_pmmr_h.size, + ); + for kernel in kernels { + kernel_pmmr.push(kernel).unwrap(); + } + kernel_pmmr.size() + }; + txhashset.kernel_pmmr_h.size = kernel_mmr_size; + + let mut headers = vec![BlockHeader::default(0)]; + for height in 1..=inclusion_height { + let mut header = BlockHeader::default(0); + header.height = height; + header.prev_hash = headers.last().unwrap().hash(0).unwrap(); + header.pow.proof.nonces[0] = height; + if height == inclusion_height { + header.version = inclusion_version; + header.kernel_mmr_size = kernel_mmr_size; + } + headers.push(header); + } + + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + for header in &headers { + pmmr.push(header).unwrap(); + } + pmmr.size() + }; + + { + let batch = store.batch_write().unwrap(); + for header in &headers { + batch.save_block_header(header).unwrap(); + } + batch.commit().unwrap(); + } + + let result = { + let batch = store.batch_write().unwrap(); + txhashset.verify_kernel_pos_index( + &headers[0], + headers.last().unwrap(), + &header_pmmr, + &batch, + None, + None, + ) + }; + + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + result + } + + #[test] + fn recent_kernel_index_uses_body_ancestry_across_header_fork() { + let chain_dir = "target/recent_kernel_index_uses_body_ancestry_across_header_fork"; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(true); + let _ = fs::remove_dir_all(chain_dir); + + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + let kernel_mmr_size = { + let mut kernel_pmmr = PMMR::at( + &mut txhashset.kernel_pmmr_h.backend, + txhashset.kernel_pmmr_h.size, + ); + kernel_pmmr.push(&kernel).unwrap(); + kernel_pmmr.size() + }; + txhashset.kernel_pmmr_h.size = kernel_mmr_size; + + let genesis = BlockHeader::default(0); + let genesis_hash = genesis.hash(0).unwrap(); + let mut body_1 = BlockHeader::default(0); + body_1.height = 1; + body_1.version = HeaderVersion(4); + body_1.prev_hash = genesis_hash; + body_1.pow.proof.nonces[0] = 1; + let mut body_2 = BlockHeader::default(0); + body_2.height = 2; + body_2.version = HeaderVersion(4); + body_2.prev_hash = body_1.hash(0).unwrap(); + body_2.pow.proof.nonces[0] = 2; + body_2.kernel_mmr_size = kernel_mmr_size; + + // The header-only fork reaches the same kernel boundary one block earlier. + // Using these boundaries for the body PMMR would record the kernel at 1 + // instead of 2 and permit a duplicate at height 3. + let mut header_fork_1 = BlockHeader::default(0); + header_fork_1.height = 1; + header_fork_1.version = HeaderVersion(4); + header_fork_1.prev_hash = genesis_hash; + header_fork_1.pow.proof.nonces[0] = 11; + header_fork_1.kernel_mmr_size = kernel_mmr_size; + let mut header_fork_2 = BlockHeader::default(0); + header_fork_2.height = 2; + header_fork_2.version = HeaderVersion(4); + header_fork_2.prev_hash = header_fork_1.hash(0).unwrap(); + header_fork_2.pow.proof.nonces[0] = 12; + header_fork_2.kernel_mmr_size = kernel_mmr_size; + + save_block_headers( + &store, + &[&genesis, &body_1, &body_2, &header_fork_1, &header_fork_2], + ); + { + let batch = store.batch_write().unwrap(); + batch + .save_body_head(&Tip::try_from_header(&body_2).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + for header in [&genesis, &header_fork_1, &header_fork_2] { + pmmr.push(header).unwrap(); + } + pmmr.size() + }; + + let kernel_index = store::nrd_recent_kernel_index(); + { + let batch = store.batch_write().unwrap(); + txhashset + .init_recent_kernel_pos_index(&batch, None, None) + .unwrap(); + let stored = kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap(); + assert_eq!(stored.height, body_2.height); + assert!(matches!( + apply_kernel_rules( + &kernel, + CommitPos { + pos: kernel_mmr_size + 1, + height: 3, + }, + &batch, + ), + Err(Error::NRDRelativeHeight) + )); + batch.commit().unwrap(); + } + + // The full-history PMMR path must reject a foreign terminal header before + // clearing the already-correct recent index. + { + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index(&genesis, &body_2, &header_pmmr, &batch, None, None) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => assert!(msg.contains("terminal header"), "{}", msg), + other => panic!("expected terminal header mismatch, got {:?}", other), + } + assert_eq!( + kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap() + .height, + body_2.height + ); + batch.commit().unwrap(); + } + + // Deliberately model an invalid/corrupt same-key header with forged starting + // boundaries. Normal PoW validation would reject it; this maintenance path + // must reject it before clearing the recent index without repeating PoW. + { + let mut altered_start = header_fork_1.clone(); + altered_start.prev_hash = header_fork_2.hash(0).unwrap(); + assert_eq!( + altered_start.hash(0).unwrap(), + header_fork_1.hash(0).unwrap() + ); + assert_ne!(altered_start, header_fork_1); + + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index( + &altered_start, + &header_fork_2, + &header_pmmr, + &batch, + None, + None, + ) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("start header"), "{}", msg); + assert!(msg.contains("does not match persisted"), "{}", msg); + } + other => panic!("expected complete start header mismatch, got {:?}", other), + } + assert_eq!( + kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap() + .height, + body_2.height + ); + batch.commit().unwrap(); + } + + // The terminal endpoint is subject to the same complete-header check. + { + let mut altered_terminal = header_fork_2.clone(); + altered_terminal.version = HeaderVersion(3); + assert_eq!( + altered_terminal.hash(0).unwrap(), + header_fork_2.hash(0).unwrap() + ); + assert_ne!(altered_terminal, header_fork_2); + + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index( + &genesis, + &altered_terminal, + &header_pmmr, + &batch, + None, + None, + ) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("terminal header"), "{}", msg); + assert!(msg.contains("does not match persisted"), "{}", msg); + } + other => panic!( + "expected complete terminal header mismatch, got {:?}", + other + ), + } + assert_eq!( + kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap() + .height, + body_2.height + ); + batch.commit().unwrap(); + } + + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn verify_kernel_pos_index_rejects_invalid_start_predecessor_before_clear() { + let chain_dir = + "target/verify_kernel_pos_index_rejects_invalid_start_predecessor_before_clear"; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(true); + let _ = fs::remove_dir_all(chain_dir); + + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let genesis = BlockHeader::default(0); + let mut off_pmmr_predecessor = BlockHeader::default(0); + off_pmmr_predecessor.pow.proof.nonces[0] = 9; + + let mut start = BlockHeader::default(0); + start.height = 1; + start.version = HeaderVersion(4); + start.prev_hash = off_pmmr_predecessor.hash(0).unwrap(); + start.pow.proof.nonces[0] = 1; + + let mut terminal = BlockHeader::default(0); + terminal.height = 2; + terminal.version = HeaderVersion(4); + terminal.prev_hash = start.hash(0).unwrap(); + terminal.pow.proof.nonces[0] = 2; + + save_block_headers( + &store, + &[&genesis, &off_pmmr_predecessor, &start, &terminal], + ); + + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + for header in [&genesis, &start, &terminal] { + pmmr.push(header).unwrap(); + } + pmmr.size() + }; + + let kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + let kernel_index = store::nrd_recent_kernel_index(); + let sentinel = CommitPos { pos: 1, height: 42 }; + { + let batch = store.batch_write().unwrap(); + kernel_index + .push_pos(&batch, kernel.excess(), sentinel) + .unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index(&start, &terminal, &header_pmmr, &batch, None, None) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("predecessor"), "{}", msg); + assert!(msg.contains("header PMMR ancestry"), "{}", msg); + } + other => panic!("expected start predecessor mismatch, got {:?}", other), + } + let stored = kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap(); + assert_eq!(stored.pos, sentinel.pos); + assert_eq!(stored.height, sentinel.height); + batch.commit().unwrap(); + } + + // Also reject a PMMR-selected predecessor whose persisted height does not + // match the height implied by the starting endpoint. + let mut wrong_height_predecessor = BlockHeader::default(0); + wrong_height_predecessor.height = 7; + wrong_height_predecessor.pow.proof.nonces[0] = 10; + + let mut wrong_height_start = BlockHeader::default(0); + wrong_height_start.height = 1; + wrong_height_start.version = HeaderVersion(4); + wrong_height_start.prev_hash = wrong_height_predecessor.hash(0).unwrap(); + wrong_height_start.pow.proof.nonces[0] = 3; + + let mut wrong_height_terminal = BlockHeader::default(0); + wrong_height_terminal.height = 2; + wrong_height_terminal.version = HeaderVersion(4); + wrong_height_terminal.prev_hash = wrong_height_start.hash(0).unwrap(); + wrong_height_terminal.pow.proof.nonces[0] = 4; + + save_block_headers( + &store, + &[ + &wrong_height_predecessor, + &wrong_height_start, + &wrong_height_terminal, + ], + ); + let mut wrong_height_header_pmmr = PMMRHandle::::new( + Path::new(chain_dir) + .join("wrong_height_header") + .join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + wrong_height_header_pmmr.size = { + let mut pmmr = PMMR::at( + &mut wrong_height_header_pmmr.backend, + wrong_height_header_pmmr.size, + ); + for header in [ + &wrong_height_predecessor, + &wrong_height_start, + &wrong_height_terminal, + ] { + pmmr.push(header).unwrap(); + } + pmmr.size() + }; + + { + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index( + &wrong_height_start, + &wrong_height_terminal, + &wrong_height_header_pmmr, + &batch, + None, + None, + ) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("header PMMR entry 0"), "{}", msg); + assert!(msg.contains("at 7"), "{}", msg); + } + other => panic!("expected predecessor height mismatch, got {:?}", other), + } + let stored = kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap(); + assert_eq!(stored.pos, sentinel.pos); + assert_eq!(stored.height, sentinel.height); + batch.commit().unwrap(); + } + + drop(wrong_height_header_pmmr); + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn verify_kernel_pos_index_rejects_disconnected_range_before_clear() { + let chain_dir = "target/verify_kernel_pos_index_rejects_disconnected_range_before_clear"; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(true); + let _ = fs::remove_dir_all(chain_dir); + + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let genesis = BlockHeader::default(0); + let genesis_hash = genesis.hash(0).unwrap(); + + let mut pmmr_height_one = BlockHeader::default(0); + pmmr_height_one.height = 1; + pmmr_height_one.version = HeaderVersion(4); + pmmr_height_one.prev_hash = genesis_hash; + pmmr_height_one.pow.proof.nonces[0] = 1; + + let mut off_pmmr_height_one = BlockHeader::default(0); + off_pmmr_height_one.height = 1; + off_pmmr_height_one.version = HeaderVersion(4); + off_pmmr_height_one.prev_hash = genesis_hash; + off_pmmr_height_one.pow.proof.nonces[0] = 9; + + // The terminal is individually selected by the PMMR and is a valid child + // of a persisted header, but not of the preceding PMMR leaf. + let mut terminal = BlockHeader::default(0); + terminal.height = 2; + terminal.version = HeaderVersion(4); + terminal.prev_hash = off_pmmr_height_one.hash(0).unwrap(); + terminal.pow.proof.nonces[0] = 2; + + save_block_headers( + &store, + &[&genesis, &pmmr_height_one, &off_pmmr_height_one, &terminal], + ); + + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + for header in [&genesis, &pmmr_height_one, &terminal] { + pmmr.push(header).unwrap(); + } + pmmr.size() + }; + + let kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + let kernel_index = store::nrd_recent_kernel_index(); + let sentinel = CommitPos { pos: 1, height: 42 }; + { + let batch = store.batch_write().unwrap(); + kernel_index + .push_pos(&batch, kernel.excess(), sentinel) + .unwrap(); + batch.commit().unwrap(); + } + + // Every header has an empty kernel boundary. This ensures ancestry is + // checked independently of the kernel-driven boundary callback. + { + let batch = store.batch_write().unwrap(); + let err = txhashset + .verify_kernel_pos_index(&genesis, &terminal, &header_pmmr, &batch, None, None) + .unwrap_err(); + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("disconnected header PMMR ancestry"), "{}", msg); + } + other => panic!("expected disconnected header ancestry, got {:?}", other), + } + let stored = kernel_index + .peek_pos(&batch, kernel.excess()) + .unwrap() + .unwrap(); + assert_eq!(stored.pos, sentinel.pos); + assert_eq!(stored.height, sentinel.height); + batch.commit().unwrap(); + } + + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn verify_kernel_history_rejects_premature_height_locked_kernel() { + let kernel = TxKernel::with_features(KernelFeatures::HeightLocked { + fee: 1u32.try_into().unwrap(), + lock_height: 2, + }) + .unwrap(); + let err = verify_test_kernel_history( + "target/verify_kernel_history_rejects_premature_height_locked_kernel", + &[kernel], + 1, + HeaderVersion(4), + true, + ) + .unwrap_err(); + + assert!(matches!( + err, + Error::Block(block::Error::KernelLockHeight(2, 1)) + )); + } + + #[test] + fn verify_kernel_history_rejects_nrd_kernel_before_header_v4() { + let kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + let err = verify_test_kernel_history( + "target/verify_kernel_history_rejects_nrd_kernel_before_header_v4", + &[kernel], + 1, + HeaderVersion(3), + true, + ) + .unwrap_err(); + + assert!(matches!(err, Error::Block(block::Error::NRDKernelPreHF3))); + } + + #[test] + fn verify_kernel_history_rejects_nrd_kernel_when_disabled() { + let kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + let err = verify_test_kernel_history( + "target/verify_kernel_history_rejects_nrd_kernel_when_disabled", + &[kernel], + 1, + HeaderVersion(4), + false, + ) + .unwrap_err(); + + match err { + Error::Block(block::Error::NRDKernelNotEnabled) => {} + Error::PMMRErr(err) => { + assert!(err.to_string().contains("NRD is disabled"), "{}", err); + } + other => panic!("expected disabled NRD rejection, got {:?}", other), + } + } + + #[test] + fn verify_kernel_history_accepts_contextually_valid_kernels_after_empty_block() { + let height_locked = TxKernel::with_features(KernelFeatures::HeightLocked { + fee: 1u32.try_into().unwrap(), + lock_height: 2, + }) + .unwrap(); + let nrd = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: 1u32.try_into().unwrap(), + relative_height: NRDRelativeHeight::new(2).unwrap(), + }) + .unwrap(); + + verify_test_kernel_history( + "target/verify_kernel_history_accepts_contextually_valid_kernels_after_empty_block", + &[height_locked, nrd], + 2, + HeaderVersion(4), + true, + ) + .unwrap(); + } + + fn assert_rewind_target_error(err: Error) { + match err { + Error::TxHashSetErr(msg) => { + assert!(msg.contains("rewind"), "{}", msg); + } + other => panic!("expected rewind target error, got {:?}", other), + } + } + + #[test] + fn txhashset_replace_swaps_in_new_txhashset() { + let root_dir = PathBuf::from("target/txhashset_replace_swaps_in_new_txhashset"); + let from = root_dir.join("from"); + let to = root_dir.join("to"); + let source_path = from.join(TXHASHSET_SUBDIR); + let destination_path = to.join(TXHASHSET_SUBDIR); + + let _ = fs::remove_dir_all(&root_dir); + fs::create_dir_all(&source_path).unwrap(); + fs::write(source_path.join("state"), b"new").unwrap(); + fs::create_dir_all(&destination_path).unwrap(); + fs::write(destination_path.join("state"), b"old").unwrap(); + + assert!(matches!( + txhashset_replace(from.clone(), to.clone()).unwrap(), + TxHashSetReplaceResult::Replaced + )); + + assert_eq!( + fs::read(to.join(TXHASHSET_SUBDIR).join("state")).unwrap(), + b"new".to_vec() + ); + assert!(!from.join(TXHASHSET_SUBDIR).try_exists().unwrap()); + assert_eq!(fs::read_dir(&to).unwrap().count(), 1); + + let _ = fs::remove_dir_all(&root_dir); + } + + #[test] + fn txhashset_replace_restores_existing_txhashset_on_rename_error() { + let root_dir = + PathBuf::from("target/txhashset_replace_restores_existing_txhashset_on_rename_error"); + let from = root_dir.join("from"); + let to = root_dir.join("to"); + let destination_path = to.join(TXHASHSET_SUBDIR); + + let _ = fs::remove_dir_all(&root_dir); + fs::create_dir_all(&from).unwrap(); + fs::create_dir_all(&destination_path).unwrap(); + fs::write(destination_path.join("state"), b"old").unwrap(); + + let err = txhashset_replace(from, to.clone()).unwrap_err(); match err { Error::IOErr(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound), other => panic!("expected io error, got {:?}", other), @@ -4021,6 +6326,123 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } + #[test] + fn validate_output_pos_index_accepts_exact_utxo_membership() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = "target/validate_output_pos_index_accepts_exact_utxo_membership"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let mut header = BlockHeader::default(0); + header.output_mmr_size = 1; + let commit = secp.commit_value(1).unwrap(); + let output = OutputIdentifier::new(OutputFeatures::Plain, &commit); + let batch = store.batch_write().unwrap(); + batch + .save_output_pos_height(&commit, CommitPos { pos: 1, height: 0 }) + .unwrap(); + batch.set_output_pos_index_complete(true).unwrap(); + + { + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); + assert_eq!(extension.output_pmmr.push(&output).unwrap(), 0); + assert_eq!(extension.rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + extension + .validate_output_pos_index(&batch, &header) + .unwrap(); + } + + drop(batch); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn validate_output_pos_index_rejects_indexed_output_missing_from_leaf_sets() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = + "target/validate_output_pos_index_rejects_indexed_output_missing_from_leaf_sets"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let mut header = BlockHeader::default(0); + header.output_mmr_size = 1; + let commit = secp.commit_value(1).unwrap(); + let output = OutputIdentifier::new(OutputFeatures::Plain, &commit); + let batch = store.batch_write().unwrap(); + batch + .save_output_pos_height(&commit, CommitPos { pos: 1, height: 0 }) + .unwrap(); + batch.set_output_pos_index_complete(true).unwrap(); + + { + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); + assert_eq!(extension.output_pmmr.push(&output).unwrap(), 0); + assert_eq!(extension.rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + assert!(extension.output_pmmr.prune(0).unwrap()); + assert!(extension.rproof_pmmr.prune(0).unwrap()); + + let err = extension + .validate_output_pos_index(&batch, &header) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("committed output_pos entry points to missing UTXO leaf") + )); + } + + drop(batch); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn validate_output_pos_index_rejects_unindexed_utxo_leaf() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = "target/validate_output_pos_index_rejects_unindexed_utxo_leaf"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let mut header = BlockHeader::default(0); + header.output_mmr_size = 1; + let commit = secp.commit_value(1).unwrap(); + let output = OutputIdentifier::new(OutputFeatures::Plain, &commit); + let batch = store.batch_write().unwrap(); + batch.set_output_pos_index_complete(true).unwrap(); + + { + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); + assert_eq!(extension.output_pmmr.push(&output).unwrap(), 0); + assert_eq!(extension.rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + + let err = extension + .validate_output_pos_index(&batch, &header) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("has no committed output_pos entry") + )); + } + + drop(batch); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + #[test] fn get_output_pos_rejects_stale_output_pos_index_entry() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -4182,6 +6604,122 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } + #[test] + fn current_merkle_proof_survives_unrelated_output_compaction() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = format!( + "target/current_merkle_proof_survives_unrelated_output_compaction_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let store = Arc::new(ChainStore::new(0, &chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = TxHashSet::open(chain_dir.clone(), store.clone(), None, &secp).unwrap(); + + let target = + OutputIdentifier::new(OutputFeatures::Coinbase, &secp.commit_value(1).unwrap()); + let left_sibling = + OutputIdentifier::new(OutputFeatures::Plain, &secp.commit_value(2).unwrap()); + let historical_right = + OutputIdentifier::new(OutputFeatures::Plain, &secp.commit_value(3).unwrap()); + let later = OutputIdentifier::new(OutputFeatures::Plain, &secp.commit_value(4).unwrap()); + + let target_pos0; + let historical_right_pos0; + let historical_size; + let historical_root; + { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + target_pos0 = output_pmmr.push(&target).unwrap(); + assert_eq!(output_pmmr.push(&left_sibling).unwrap(), 1); + historical_right_pos0 = output_pmmr.push(&historical_right).unwrap(); + assert_eq!(historical_right_pos0, 3); + historical_size = output_pmmr.size(); + assert_eq!(historical_size, 4); + historical_root = output_pmmr.root().unwrap(); + } + txhashset.output_pmmr_h.size = historical_size; + { + let batch = store.batch_write().unwrap(); + batch + .save_output_pos_height( + &target.commitment(), + CommitPos { + pos: target_pos0 + 1, + height: 0, + }, + ) + .unwrap(); + batch.commit().unwrap(); + } + + let current_root; + let later_pos0; + { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + later_pos0 = output_pmmr.push(&later).unwrap(); + assert_eq!(later_pos0, 4); + txhashset.output_pmmr_h.size = output_pmmr.size(); + current_root = output_pmmr.root().unwrap(); + } + assert_eq!(txhashset.output_pmmr_h.size, 7); + + // The right peak at pos 3 and the later leaf at pos 4 are spent together. + // Compaction rolls them into their parent at pos 5 and physically removes + // the children. Current-state proofs remain supported because they use + // the retained parent. Proofs against the earlier size/root are + // intentionally outside the API contract. + { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + output_pmmr.prune(historical_right_pos0).unwrap(); + output_pmmr.prune(later_pos0).unwrap(); + } + txhashset.output_pmmr_h.backend.sync().unwrap(); + txhashset + .output_pmmr_h + .backend + .check_compact(txhashset.output_pmmr_h.size, &Bitmap::new()) + .unwrap(); + txhashset.output_pmmr_h.backend.sync().unwrap(); + assert_eq!( + txhashset + .output_pmmr_h + .backend + .get_from_file(historical_right_pos0) + .unwrap(), + None + ); + + let proof = txhashset.merkle_proof(target.commitment()).unwrap(); + assert_eq!(proof.mmr_size, txhashset.output_pmmr_h.size); + proof.verify(0, current_root, &target, target_pos0).unwrap(); + assert!(proof + .verify(0, historical_root, &target, target_pos0) + .is_err()); + + // The compacted backend must continue serving the same current-state proof + // after reopening; no per-header historical peak archive is involved. + drop(txhashset); + drop(store); + let store = Arc::new(ChainStore::new(0, &chain_dir).unwrap()); + let txhashset = TxHashSet::open(chain_dir.clone(), store.clone(), None, &secp).unwrap(); + let reopened_proof = txhashset.merkle_proof(target.commitment()).unwrap(); + assert_eq!(reopened_proof, proof); + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn extension_merkle_proof_rejects_output_pos_index_identifier_mismatch() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -4357,45 +6895,161 @@ mod tests { let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let mut header_pmmr = PMMRHandle::::new( - Path::new(chain_dir).join("header").join("header_head"), - false, - ProtocolVersion(1), - 0, - None, - VariableSizeMetadataValidation::Full, - ) - .unwrap(); + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + + let commit = secp.commit_value(1).unwrap(); + let output = Output::new(OutputFeatures::Plain, commit, RangeProof::zero()); + let input = Input::new(OutputFeatures::Plain, commit); + let kernel = reward_kernel(&mut secp, 1); + let mut header = BlockHeader::default(0); + header.height = 1; + let body = TransactionBody::init( + 0, + Inputs::from([input].as_slice()), + &[output], + &[kernel], + false, + ) + .unwrap(); + let block = Block { header, body }; + + { + let batch = store.batch_write().unwrap(); + let mut extension = Extension::new(0, &mut txhashset, Tip::default()); + let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + let header_ext = HeaderExtension::new(pmmr, Tip::default()); + + let err = extension + .apply_block(&block, &header_ext, &batch) + .unwrap_err(); + match err { + Error::AlreadySpent(c) => assert_eq!(c, commit), + other => panic!("expected missing pre-block input, got {:?}", other), + } + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn extension_rewind_allows_exact_horizon_boundary() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/extension_rewind_allows_exact_horizon_boundary"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let horizon = u64::from(global::cut_through_horizon(0)); + let headers = save_empty_body_chain(&store, horizon); + let target = headers.first().unwrap(); + let head = headers.last().unwrap(); + assert_eq!(target.height, head.height.saturating_sub(horizon)); + + { + let batch = store.batch_read().unwrap(); + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(head).unwrap()); + + extension.rewind(target, &batch, None).unwrap(); + assert_eq!(extension.head(), Tip::try_from_header(target).unwrap()); + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn extension_rewind_rejects_target_below_horizon_before_mutation() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/extension_rewind_rejects_target_below_horizon_before_mutation"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let horizon = u64::from(global::cut_through_horizon(0)); + let headers = save_empty_body_chain(&store, horizon + 1); + let target = headers.first().unwrap(); + let head = headers.last().unwrap(); + let head_tip = Tip::try_from_header(head).unwrap(); + + { + let batch = store.batch_read().unwrap(); + let mut extension = Extension::new(0, &mut txhashset, head_tip.clone()); + let output = + OutputIdentifier::new(OutputFeatures::Plain, &secp.commit_value(1).unwrap()); + extension.output_pmmr.push(&output).unwrap(); + extension.rproof_pmmr.push(&RangeProof::zero()).unwrap(); + let original_sizes = extension.sizes(); + let progress_calls = std::cell::Cell::new(0u64); + let mut progress = |_, _| { + progress_calls.set(progress_calls.get() + 1); + Ok(()) + }; + + let err = extension + .rewind(target, &batch, Some(&mut progress)) + .unwrap_err(); + assert!(matches!( + &err, + Error::RewindBeyondHorizon { + head_height, + target_height, + minimum_height, + } if *head_height == horizon + 1 + && *target_height == 0 + && *minimum_height == 1 + )); + assert!(!err.is_bad_data()); + assert_eq!(progress_calls.get(), 0); + assert_eq!(extension.sizes(), original_sizes); + assert_eq!(extension.head(), head_tip); + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn extension_rewind_allows_genesis_before_chain_reaches_horizon() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/extension_rewind_allows_genesis_before_chain_reaches_horizon"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let commit = secp.commit_value(1).unwrap(); - let output = Output::new(OutputFeatures::Plain, commit, RangeProof::zero()); - let input = Input::new(OutputFeatures::Plain, commit); - let kernel = reward_kernel(&mut secp, 1); - let mut header = BlockHeader::default(0); - header.height = 1; - let body = TransactionBody::init( - 0, - Inputs::from([input].as_slice()), - &[output], - &[kernel], - false, - ) - .unwrap(); - let block = Block { header, body }; + let headers = save_empty_body_chain(&store, 1); + let target = headers.first().unwrap(); + let head = headers.last().unwrap(); + assert!(head.height < u64::from(global::cut_through_horizon(0))); { - let batch = store.batch_write().unwrap(); - let mut extension = Extension::new(0, &mut txhashset, Tip::default()); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); + let batch = store.batch_read().unwrap(); + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(head).unwrap()); - let err = extension - .apply_block(&block, &header_ext, &batch) - .unwrap_err(); - match err { - Error::AlreadySpent(c) => assert_eq!(c, commit), - other => panic!("expected missing pre-block input, got {:?}", other), - } + extension.rewind(target, &batch, None).unwrap(); + assert_eq!(extension.head(), Tip::try_from_header(target).unwrap()); } drop(txhashset); @@ -4412,15 +7066,6 @@ mod tests { let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let mut header_pmmr = PMMRHandle::::new( - Path::new(chain_dir).join("header").join("header_head"), - false, - ProtocolVersion(1), - 0, - None, - VariableSizeMetadataValidation::Full, - ) - .unwrap(); let mut head = BlockHeader::default(0); head.height = 1; @@ -4435,12 +7080,8 @@ mod tests { let batch = store.batch_read().unwrap(); let mut extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&head).unwrap()); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); - let err = extension - .rewind(&target, &batch, &header_ext, None) - .unwrap_err(); + let err = extension.rewind(&target, &batch, None).unwrap_err(); assert_rewind_target_error(err); assert_eq!(extension.head().last_block_h, head_hash); } @@ -4450,6 +7091,78 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } + #[test] + fn extension_rewind_rejects_misindexed_head_header_before_preflight() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/extension_rewind_rejects_misindexed_head_header_before_preflight"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let target = BlockHeader::default(0); + let target_hash = target.hash(0).unwrap(); + let mut selected_head = BlockHeader::default(0); + selected_head.height = 1; + selected_head.prev_hash = target_hash; + selected_head.pow.proof.nonces[0] = 1; + let selected_hash = selected_head.hash(0).unwrap(); + let mut substituted_head = selected_head.clone(); + substituted_head.pow.proof.nonces[0] = 2; + let substituted_hash = substituted_head.hash(0).unwrap(); + assert_ne!(selected_hash, substituted_hash); + + let mut substituted_block = Block::default(0); + substituted_block.header = substituted_head.clone(); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&target).unwrap(); + batch.save_block_header(&selected_head).unwrap(); + batch.save_block_header(&substituted_head).unwrap(); + batch.save_block(&substituted_block).unwrap(); + batch.save_spent_index(&substituted_hash, &[]).unwrap(); + // Bypass the normal key/hash invariant to model a misindexed record. + batch + .db + .put_ser(&mwc_store::to_key(b'h', selected_hash), &substituted_head) + .unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + let mut extension = Extension::new( + 0, + &mut txhashset, + Tip::try_from_header(&selected_head).unwrap(), + ); + let progress_calls = std::cell::Cell::new(0u64); + let mut progress = |_, _| { + progress_calls.set(progress_calls.get() + 1); + Ok(()) + }; + + let err = extension + .rewind(&target, &batch, Some(&mut progress)) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("Extension::rewind head header key/hash mismatch") + && msg.contains(&selected_hash.to_string()) + && msg.contains(&substituted_hash.to_string()) + )); + assert_eq!(progress_calls.get(), 0); + assert_eq!(extension.head().last_block_h, selected_hash); + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + #[test] fn extension_rewind_rejects_same_height_fork_header() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -4459,15 +7172,6 @@ mod tests { let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let mut header_pmmr = PMMRHandle::::new( - Path::new(chain_dir).join("header").join("header_head"), - false, - ProtocolVersion(1), - 0, - None, - VariableSizeMetadataValidation::Full, - ) - .unwrap(); let mut head = BlockHeader::default(0); head.height = 1; @@ -4482,12 +7186,8 @@ mod tests { let batch = store.batch_read().unwrap(); let mut extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&head).unwrap()); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); - let err = extension - .rewind(&fork, &batch, &header_ext, None) - .unwrap_err(); + let err = extension.rewind(&fork, &batch, None).unwrap_err(); assert_rewind_target_error(err); assert_eq!(extension.head().last_block_h, head_hash); } @@ -4506,15 +7206,6 @@ mod tests { let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let mut header_pmmr = PMMRHandle::::new( - Path::new(chain_dir).join("header").join("header_head"), - false, - ProtocolVersion(1), - 0, - None, - VariableSizeMetadataValidation::Full, - ) - .unwrap(); let mut head = BlockHeader::default(0); head.height = 1; @@ -4534,12 +7225,8 @@ mod tests { let commit = secp.commit_value(1).unwrap(); let output = OutputIdentifier::new(mwc_core::core::OutputFeatures::Plain, &commit); assert_eq!(extension.output_pmmr.push(&output).unwrap(), 0); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); - let err = extension - .rewind(&altered, &batch, &header_ext, None) - .unwrap_err(); + let err = extension.rewind(&altered, &batch, None).unwrap_err(); match err { Error::TxHashSetErr(msg) => { assert!(msg.contains("does not match canonical"), "{}", msg); @@ -4550,16 +7237,99 @@ mod tests { assert_eq!(extension.head().last_block_h, head_hash); } - drop(header_pmmr); drop(txhashset); drop(store); let _ = fs::remove_dir_all(chain_dir); } #[test] - fn extension_rewind_rejects_lower_fork_header_before_applying_blocks() { + fn extension_rewind_preflights_same_hash_block_headers_before_mutation() { global::set_local_chain_type(ChainTypes::AutomatedTesting); - let chain_dir = "target/extension_rewind_rejects_lower_fork_header_before_applying_blocks"; + let chain_dir = + "target/extension_rewind_preflights_same_hash_block_headers_before_mutation"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let target = BlockHeader::default(0); + let target_hash = target.hash(0).unwrap(); + + let mut intermediate = BlockHeader::default(0); + intermediate.height = 1; + intermediate.prev_hash = target_hash; + intermediate.pow.proof.nonces[0] = 1; + let intermediate_hash = intermediate.hash(0).unwrap(); + + let mut head = BlockHeader::default(0); + head.height = 2; + head.prev_hash = intermediate_hash; + head.pow.proof.nonces[0] = 2; + let head_hash = head.hash(0).unwrap(); + + let mut altered_intermediate_block = Block::default(0); + altered_intermediate_block.header = intermediate.clone(); + altered_intermediate_block.header.height = 99; + assert_eq!( + altered_intermediate_block.hash(0).unwrap(), + intermediate_hash + ); + assert_ne!(altered_intermediate_block.header, intermediate); + + let mut head_block = Block::default(0); + head_block.header = head.clone(); + { + // Deliberately bypass both normal ingestion and the ChainStore write + // invariant to model raw database corruption under the proof-derived key. + let batch = store.batch_write().unwrap(); + for header in [&target, &intermediate, &head] { + batch.save_block_header(header).unwrap(); + } + batch + .db + .put_ser( + &mwc_store::to_key(b'b', intermediate_hash), + &altered_intermediate_block, + ) + .unwrap(); + batch.save_block(&head_block).unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + let mut extension = + Extension::new(0, &mut txhashset, Tip::try_from_header(&head).unwrap()); + let progress_calls = std::cell::Cell::new(0u64); + let mut progress = |_, _| { + progress_calls.set(progress_calls.get() + 1); + Ok(()) + }; + + let err = extension + .rewind(&target, &batch, Some(&mut progress)) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("Extension::rewind preflight") + && msg.contains("does not exactly match persisted ancestry header") + )); + assert_eq!(progress_calls.get(), 0); + assert_eq!(extension.head().last_block_h, head_hash); + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn rewind_and_apply_fork_rejects_same_hash_block_header_before_body_rewind() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = + "target/rewind_and_apply_fork_rejects_same_hash_block_header_before_body_rewind"; let _ = fs::remove_dir_all(chain_dir); let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); @@ -4575,6 +7345,87 @@ mod tests { ) .unwrap(); + let fork_point = BlockHeader::default(0); + let fork_point_tip = Tip::try_from_header(&fork_point).unwrap(); + let mut fork_header = BlockHeader::default(0); + fork_header.height = 1; + fork_header.prev_hash = fork_point.hash(0).unwrap(); + fork_header.pow.proof.nonces[0] = 1; + + header_pmmr.size = { + let mut pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + pmmr.push(&fork_point).unwrap(); + pmmr.push(&fork_header).unwrap(); + pmmr.size() + }; + + let mut altered_fork_block = Block::default(0); + altered_fork_block.header = fork_header.clone(); + altered_fork_block.header.prev_hash = Hash::from_vec(&[7; Hash::LEN]); + assert_eq!( + altered_fork_block.hash(0).unwrap(), + fork_header.hash(0).unwrap() + ); + assert_ne!(altered_fork_block.header, fork_header); + + { + // Deliberately bypass both normal ingestion and the ChainStore write + // invariant to model raw database corruption under the proof-derived key. + let batch = store.batch_write().unwrap(); + batch.save_block_header(&fork_point).unwrap(); + batch.save_block_header(&fork_header).unwrap(); + batch + .db + .put_ser( + &mwc_store::to_key(b'b', fork_header.hash(0).unwrap()), + &altered_fork_block, + ) + .unwrap(); + batch.save_body_head(&fork_point_tip).unwrap(); + batch + .save_header_head(&Tip::try_from_header(&fork_header).unwrap()) + .unwrap(); + batch.commit().unwrap(); + } + + { + let batch = store.batch_read().unwrap(); + let mut extension = Extension::new(0, &mut txhashset, fork_point_tip); + let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + let mut header_extension = + HeaderExtension::new(pmmr, Tip::try_from_header(&fork_header).unwrap()); + let mut pair = ExtensionPair { + header_extension: &mut header_extension, + extension: &mut extension, + }; + + let err = crate::pipe::rewind_and_apply_fork(0, &fork_header, &mut pair, &batch, &secp) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("rewind_and_apply_fork preflight") + && msg.contains("does not exactly match persisted ancestry header") + )); + assert_eq!(pair.extension.head(), fork_point_tip); + } + + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn extension_rewind_rejects_lower_fork_header_before_applying_blocks() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = "target/extension_rewind_rejects_lower_fork_header_before_applying_blocks"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let mut ancestor = BlockHeader::default(0); ancestor.height = 1; ancestor.pow.proof.nonces[0] = 1; @@ -4596,12 +7447,8 @@ mod tests { let batch = store.batch_read().unwrap(); let mut extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&head).unwrap()); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); - let err = extension - .rewind(&fork, &batch, &header_ext, None) - .unwrap_err(); + let err = extension.rewind(&fork, &batch, None).unwrap_err(); assert_rewind_target_error(err); assert_eq!(extension.head().last_block_h, head_hash); } @@ -4612,37 +7459,35 @@ mod tests { } #[test] - fn rewind_single_block_errors_if_spent_output_data_missing() { + fn rewind_preflight_rejects_spent_cache_count_mismatch() { global::set_local_chain_type(ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); - let chain_dir = "target/rewind_single_block_errors_if_spent_output_data_missing"; + let chain_dir = "target/rewind_preflight_rejects_spent_cache_count_mismatch"; let _ = fs::remove_dir_all(chain_dir); let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let mut header_pmmr = PMMRHandle::::new( - Path::new(chain_dir).join("header").join("header_head"), - false, - ProtocolVersion(1), - 0, - None, - VariableSizeMetadataValidation::Full, - ) - .unwrap(); let prev = BlockHeader::default(0); let prev_hash = prev.hash(0).unwrap(); let mut header = BlockHeader::default(0); header.height = 1; header.prev_hash = prev_hash; + header.pow.proof.nonces[0] = 1; let header_hash = header.hash(0).unwrap(); save_block_headers(&store, &[&prev]); { let batch = store.batch_write().unwrap(); batch - .save_spent_index(&header_hash, &[CommitPos { pos: 1, height: 0 }]) + .set_spent_commitment_record_index_complete(true) + .unwrap(); + batch + .save_spent_index( + &header_hash, + &[spent_cache_entry(secp.commit_value(1).unwrap(), 1, 0)], + ) .unwrap(); batch.commit().unwrap(); } @@ -4652,21 +7497,17 @@ mod tests { { let batch = store.batch_write().unwrap(); - let mut extension = + let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); - let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); - let header_ext = HeaderExtension::new(pmmr, Tip::default()); - let err = extension - .rewind_single_block(&block, &batch, &header_ext) + .prepare_authenticated_rewind_block(block, prev, &batch) .unwrap_err(); - match err { - Error::TxHashSetErr(msg) => { - assert!(msg.contains("missing output PMMR data"), "{}", msg); - assert!(msg.contains("restoring output_pos"), "{}", msg); - } - other => panic!("expected missing output PMMR data error, got {:?}", other), - } + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("rewind spent index") + && msg.contains("contains 1 positions for 0 inputs") + )); } drop(txhashset); @@ -4675,85 +7516,175 @@ mod tests { } #[test] - fn reconstruct_spent_index_maps_output_positions_to_heights() { + fn rewind_preflight_authenticates_spent_index_position_and_height() { global::set_local_chain_type(ChainTypes::AutomatedTesting); - let chain_dir = "target/reconstruct_spent_index_maps_output_positions_to_heights"; + global::set_local_nrd_enabled(false); + let chain_dir = "target/rewind_preflight_authenticates_spent_index_position_and_height"; let _ = fs::remove_dir_all(chain_dir); let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); - let genesis = BlockHeader::default(0); - let genesis_hash = genesis.hash(0).unwrap(); - let mut header_1 = BlockHeader::default(0); - header_1.height = 1; - header_1.prev_hash = genesis_hash; - header_1.output_mmr_size = 1; - header_1.pow.proof.nonces[0] = 1; - let header_1_hash = header_1.hash(0).unwrap(); - let mut header_2 = BlockHeader::default(0); - header_2.height = 2; - header_2.prev_hash = header_1_hash; - header_2.output_mmr_size = 3; - header_2.pow.proof.nonces[0] = 2; - save_block_headers(&store, &[&genesis, &header_1]); - - let batch = store.batch_read().unwrap(); - let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header_2).unwrap()); - assert_eq!( - extension - .reconstruct_spent_index(&[1, 2], &header_2, &batch) - .unwrap(), - vec![ - CommitPos { pos: 1, height: 1 }, - CommitPos { pos: 2, height: 2 }, - ] - ); - - drop(extension); - drop(batch); - drop(txhashset); - drop(store); - let _ = fs::remove_dir_all(chain_dir); - } - - #[test] - fn reconstruct_spent_index_rejects_internal_output_pmmr_node() { - global::set_local_chain_type(ChainTypes::AutomatedTesting); - let chain_dir = "target/reconstruct_spent_index_rejects_internal_output_pmmr_node"; - let _ = fs::remove_dir_all(chain_dir); - let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); - let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); - let mut txhashset = - TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let spent_commit = secp.commit_value(1).unwrap(); + let other_commit = secp.commit_value(2).unwrap(); + let spent_output = OutputIdentifier::new(OutputFeatures::Plain, &spent_commit); + let other_output = OutputIdentifier::new(OutputFeatures::Plain, &other_commit); + let output_size = { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + assert_eq!(output_pmmr.push(&spent_output).unwrap(), 0); + assert_eq!(output_pmmr.push(&other_output).unwrap(), 1); + output_pmmr.size() + }; + txhashset.output_pmmr_h.size = output_size; + let rproof_size = { + let mut rproof_pmmr = PMMR::at( + &mut txhashset.rproof_pmmr_h.backend, + txhashset.rproof_pmmr_h.size, + ); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 1); + rproof_pmmr.size() + }; + txhashset.rproof_pmmr_h.size = rproof_size; + assert_eq!(output_size, rproof_size); + txhashset.output_pmmr_h.backend.sync().unwrap(); + txhashset.rproof_pmmr_h.backend.sync().unwrap(); let genesis = BlockHeader::default(0); let genesis_hash = genesis.hash(0).unwrap(); - let mut header_1 = BlockHeader::default(0); - header_1.height = 1; - header_1.prev_hash = genesis_hash; - header_1.output_mmr_size = 1; - header_1.pow.proof.nonces[0] = 1; - let header_1_hash = header_1.hash(0).unwrap(); - let mut header_2 = BlockHeader::default(0); - header_2.height = 2; - header_2.prev_hash = header_1_hash; - header_2.output_mmr_size = 3; - header_2.pow.proof.nonces[0] = 2; - save_block_headers(&store, &[&genesis, &header_1]); + let mut output_one_header = BlockHeader::default(0); + output_one_header.height = 1; + output_one_header.prev_hash = genesis_hash; + output_one_header.output_mmr_size = 1; + output_one_header.pow.proof.nonces[0] = 1; + let output_one_hash = output_one_header.hash(0).unwrap(); + let mut previous_header = BlockHeader::default(0); + previous_header.height = 2; + previous_header.prev_hash = output_one_hash; + previous_header.output_mmr_size = output_size; + previous_header.pow.proof.nonces[0] = 2; + let previous_hash = previous_header.hash(0).unwrap(); + let mut header = BlockHeader::default(0); + header.height = 3; + header.prev_hash = previous_hash; + header.output_mmr_size = output_size; + header.pow.proof.nonces[0] = 3; + let header_hash = header.hash(0).unwrap(); + let mut block = Block::default(0); + block.header = header.clone(); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, spent_commit)]); + // Only the direct predecessor is needed. Authentication uses the exact spent + // record and must not walk old header ancestry to reconstruct output heights. + save_block_headers(&store, &[&previous_header]); - let batch = store.batch_read().unwrap(); - let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header_2).unwrap()); + let batch = store.batch_write().unwrap(); + let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); + batch + .set_spent_commitment_record_index_complete(true) + .unwrap(); + batch + .save_spent_commitments( + &spent_commit, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: header_hash, + height: header.height, + }, + spent_output: CommitPos { pos: 1, height: 1 }, + }, + ) + .unwrap(); + batch + .save_spent_commitments( + &other_commit, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: header_hash, + height: header.height, + }, + spent_output: CommitPos { pos: 2, height: 2 }, + }, + ) + .unwrap(); + + batch + .save_spent_index(&header_hash, &[spent_cache_entry(spent_commit, 2, 0)]) + .unwrap(); let err = extension - .reconstruct_spent_index(&[3], &header_2, &batch) + .prepare_authenticated_rewind_block(block.clone(), previous_header.clone(), &batch) .unwrap_err(); - match err { - Error::TxHashSetErr(msg) => { - assert!(msg.contains("non-leaf output PMMR pos 3"), "{}", msg); - } - other => panic!("expected non-leaf output PMMR pos error, got {:?}", other), - } + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("has no output matching input") + )); + + batch + .save_spent_index(&header_hash, &[spent_cache_entry(spent_commit, 1, 7)]) + .unwrap(); + let err = extension + .prepare_authenticated_rewind_block(block.clone(), previous_header.clone(), &batch) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("records output position 1 at height 7 above predecessor height 2") + )); + + batch + .save_spent_index(&header_hash, &[spent_cache_entry(spent_commit, 1, 0)]) + .unwrap(); + let err = extension + .prepare_authenticated_rewind_block(block.clone(), previous_header.clone(), &batch) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("records commitment") + && msg.contains("at output position 1 and height 0") + && msg.contains("authenticated spent commitment record identifies position 1 and height 1") + )); + + batch + .save_spent_index(&header_hash, &[spent_cache_entry(spent_commit, 1, 1)]) + .unwrap(); + let plan = extension + .prepare_authenticated_rewind_block(block.clone(), previous_header.clone(), &batch) + .unwrap(); + assert_eq!( + plan.spent_outputs, + vec![spent_cache_entry(spent_commit, 1, 1)] + ); + assert!(!plan.persist_spent_index); + + block.body.inputs = Inputs::FeaturesAndCommit(vec![ + Input::new(OutputFeatures::Plain, spent_commit), + Input::new(OutputFeatures::Plain, other_commit), + ]); + batch + .save_spent_index( + &header_hash, + &[ + spent_cache_entry(spent_commit, 1, 2), + spent_cache_entry(other_commit, 2, 1), + ], + ) + .unwrap(); + let err = extension + .prepare_authenticated_rewind_block(block, previous_header, &batch) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("records commitment") + && msg.contains("at output position 1 and height 2") + && msg.contains("authenticated spent commitment record identifies position 1 and height 1") + )); drop(extension); drop(batch); @@ -4763,42 +7694,103 @@ mod tests { } #[test] - fn reconstruct_spent_index_rejects_output_mmr_size_regression() { + fn rewind_preflight_rejects_older_reused_commitment_occurrence() { global::set_local_chain_type(ChainTypes::AutomatedTesting); - let chain_dir = "target/reconstruct_spent_index_rejects_output_mmr_size_regression"; + global::set_local_nrd_enabled(false); + let chain_dir = "target/rewind_preflight_rejects_older_reused_commitment_occurrence"; let _ = fs::remove_dir_all(chain_dir); let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); let mut txhashset = TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let reused_commit = secp.commit_value(1).unwrap(); + let reused_output = OutputIdentifier::new(OutputFeatures::Plain, &reused_commit); + let output_size = { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + assert_eq!(output_pmmr.push(&reused_output).unwrap(), 0); + assert_eq!(output_pmmr.push(&reused_output).unwrap(), 1); + output_pmmr.size() + }; + txhashset.output_pmmr_h.size = output_size; + let rproof_size = { + let mut rproof_pmmr = PMMR::at( + &mut txhashset.rproof_pmmr_h.backend, + txhashset.rproof_pmmr_h.size, + ); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 1); + rproof_pmmr.size() + }; + txhashset.rproof_pmmr_h.size = rproof_size; + assert_eq!(output_size, rproof_size); + txhashset.output_pmmr_h.backend.sync().unwrap(); + txhashset.rproof_pmmr_h.backend.sync().unwrap(); + let genesis = BlockHeader::default(0); let genesis_hash = genesis.hash(0).unwrap(); - let mut header_1 = BlockHeader::default(0); - header_1.height = 1; - header_1.prev_hash = genesis_hash; - header_1.output_mmr_size = 5; - header_1.pow.proof.nonces[0] = 1; - let header_1_hash = header_1.hash(0).unwrap(); - let mut header_2 = BlockHeader::default(0); - header_2.height = 2; - header_2.prev_hash = header_1_hash; - header_2.output_mmr_size = 3; - header_2.pow.proof.nonces[0] = 2; - save_block_headers(&store, &[&genesis, &header_1]); + let mut previous_header = BlockHeader::default(0); + previous_header.height = 1; + previous_header.prev_hash = genesis_hash; + previous_header.output_mmr_size = output_size; + previous_header.pow.proof.nonces[0] = 1; + let previous_hash = previous_header.hash(0).unwrap(); + let mut header = BlockHeader::default(0); + header.height = 2; + header.prev_hash = previous_hash; + header.output_mmr_size = output_size; + header.pow.proof.nonces[0] = 2; + let header_hash = header.hash(0).unwrap(); + let mut block = Block::default(0); + block.header = header.clone(); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, reused_commit)]); + save_block_headers(&store, &[&genesis, &previous_header]); - let batch = store.batch_read().unwrap(); - let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header_2).unwrap()); + let batch = store.batch_write().unwrap(); + let extension = Extension::new(0, &mut txhashset, Tip::try_from_header(&header).unwrap()); + batch + .set_spent_commitment_record_index_complete(true) + .unwrap(); + batch + .save_spent_commitments( + &reused_commit, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: header_hash, + height: header.height, + }, + spent_output: CommitPos { pos: 2, height: 1 }, + }, + ) + .unwrap(); + batch + .save_spent_index(&header_hash, &[spent_cache_entry(reused_commit, 1, 0)]) + .unwrap(); let err = extension - .reconstruct_spent_index(&[2], &header_2, &batch) + .prepare_authenticated_rewind_block(block.clone(), previous_header.clone(), &batch) .unwrap_err(); - match err { - Error::TxHashSetErr(msg) => { - assert!(msg.contains("output MMR size regression"), "{}", msg); - assert!(msg.contains("previous 5, current 3"), "{}", msg); - } - other => panic!("expected output MMR size regression error, got {:?}", other), - } + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("records input commitment") + && msg.contains("at position 1") + && msg.contains("authenticated spent commitment record identifies position 2") + )); + + batch + .save_spent_index(&header_hash, &[spent_cache_entry(reused_commit, 2, 1)]) + .unwrap(); + let plan = extension + .prepare_authenticated_rewind_block(block, previous_header, &batch) + .unwrap(); + assert_eq!( + plan.spent_outputs, + vec![spent_cache_entry(reused_commit, 2, 1)] + ); drop(extension); drop(batch); @@ -4830,6 +7822,154 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } + #[test] + fn compact_bitmap_rejects_large_cardinality_before_expansion() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let block = Block::default(0); + let mut bitmap = Bitmap::new(); + bitmap.add_range(..=u32::MAX); + assert_eq!(bitmap.cardinality(), 1u64 << 32); + + let err = checked_bitmap_positions_for_inputs("compact input bitmap", &block, &bitmap) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("contains 4294967296 positions for 0 inputs") + )); + } + + #[test] + fn compact_rejects_incomplete_or_incorrect_spent_index_before_rewriting_pmmrs() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let chain_dir = + "target/compact_rejects_incomplete_or_incorrect_spent_index_before_rewriting_pmmrs"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + let spent_commit = secp.commit_value(1).unwrap(); + let other_commit = secp.commit_value(2).unwrap(); + let spent_output = OutputIdentifier::new(OutputFeatures::Plain, &spent_commit); + let other_output = OutputIdentifier::new(OutputFeatures::Plain, &other_commit); + let output_size = { + let mut output_pmmr = PMMR::at( + &mut txhashset.output_pmmr_h.backend, + txhashset.output_pmmr_h.size, + ); + assert_eq!(output_pmmr.push(&spent_output).unwrap(), 0); + assert_eq!(output_pmmr.push(&other_output).unwrap(), 1); + output_pmmr.size() + }; + txhashset.output_pmmr_h.size = output_size; + let rproof_size = { + let mut rproof_pmmr = PMMR::at( + &mut txhashset.rproof_pmmr_h.backend, + txhashset.rproof_pmmr_h.size, + ); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 0); + assert_eq!(rproof_pmmr.push(&RangeProof::zero()).unwrap(), 1); + rproof_pmmr.size() + }; + txhashset.rproof_pmmr_h.size = rproof_size; + assert_eq!(output_size, rproof_size); + txhashset.output_pmmr_h.backend.sync().unwrap(); + txhashset.rproof_pmmr_h.backend.sync().unwrap(); + + let mut horizon = BlockHeader::default(0); + horizon.output_mmr_size = output_size; + let horizon_hash = horizon.hash(0).unwrap(); + let mut head = horizon.clone(); + head.height = 1; + head.prev_hash = horizon_hash; + head.pow.proof.nonces[0] = head.pow.proof.nonces[0].wrapping_add(1); + let head_hash = head.hash(0).unwrap(); + assert_ne!(head_hash, horizon_hash); + + let mut head_block = Block::default(0); + head_block.header = head.clone(); + head_block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, spent_commit)]); + { + let batch = store.batch_write().unwrap(); + batch.save_block_header(&horizon).unwrap(); + batch.save_block_header(&head).unwrap(); + batch.save_block(&head_block).unwrap(); + batch + .set_spent_commitment_record_index_complete(true) + .unwrap(); + batch + .save_spent_commitments( + &spent_commit, + SpentCommitmentRecord { + spending_block: HashHeight { + hash: head_hash, + height: head.height, + }, + spent_output: CommitPos { pos: 1, height: 0 }, + }, + ) + .unwrap(); + batch + .save_body_head(&Tip::try_from_header(&head).unwrap()) + .unwrap(); + // This is syntactically valid cache data, but it omits the block's input. + batch.save_spent_index(&head_hash, &[]).unwrap(); + batch.commit().unwrap(); + } + + let output_data_size = txhashset.output_pmmr_h.backend.data_size().unwrap(); + let rproof_data_size = txhashset.rproof_pmmr_h.backend.data_size().unwrap(); + let batch = store.batch_read().unwrap(); + let err = txhashset.compact(&horizon, &batch).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("contains 0 positions for 1 inputs") + )); + assert_eq!( + txhashset.output_pmmr_h.backend.data_size().unwrap(), + output_data_size + ); + assert_eq!( + txhashset.rproof_pmmr_h.backend.data_size().unwrap(), + rproof_data_size + ); + + drop(batch); + { + let batch = store.batch_write().unwrap(); + // The count is now correct, but position 2 is the other output and does + // not match the input spent by this block. + batch + .save_spent_index(&head_hash, &[spent_cache_entry(spent_commit, 2, 0)]) + .unwrap(); + batch.commit().unwrap(); + } + let batch = store.batch_read().unwrap(); + let err = txhashset.compact(&horizon, &batch).unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(ref msg) + if msg.contains("has no output matching input") + )); + assert_eq!( + txhashset.output_pmmr_h.backend.data_size().unwrap(), + output_data_size + ); + assert_eq!( + txhashset.rproof_pmmr_h.backend.data_size().unwrap(), + rproof_data_size + ); + + drop(batch); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + #[test] fn input_pos_to_rewind_errors_if_block_input_bitmap_missing() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -4843,7 +7983,7 @@ mod tests { head.prev_hash = horizon.hash(0).unwrap(); let batch = store.batch_read().unwrap(); - let err = input_pos_to_rewind(&horizon, &head, &batch).unwrap_err(); + let err = walk_input_pos_to_rewind(&horizon, &head, &batch, |_, _, _| Ok(())).unwrap_err(); match err { Error::StoreErr(store_err, msg) => { assert!(store_err.store_error_is_not_found(), "{:?}", store_err); @@ -4872,6 +8012,7 @@ mod tests { let mut head = BlockHeader::default(0); head.height = 1; head.prev_hash = prev_hash; + head.pow.proof.nonces[0] = 1; let head_hash = head.hash(0).unwrap(); { @@ -4882,10 +8023,15 @@ mod tests { } let batch = store.batch_read().unwrap(); - let err = input_pos_to_rewind(&horizon, &head, &batch).unwrap_err(); + let err = walk_input_pos_to_rewind(&horizon, &head, &batch, |_, _, _| Ok(())).unwrap_err(); match err { - Error::TxHashSetErr(msg) => { - assert!(msg.contains("non-descending header ancestry"), "{}", msg); + Error::InvalidPersistedChainState(msg) => { + assert!( + msg.contains("input positions to rewind ancestry"), + "{}", + msg + ); + assert!(msg.contains("at height 0, found height 1"), "{}", msg); } other => panic!("expected non-descending ancestry error, got {:?}", other), } @@ -4908,7 +8054,7 @@ mod tests { head.pow.proof.nonces[0] = 1; let batch = store.batch_read().unwrap(); - let err = input_pos_to_rewind(&horizon, &head, &batch).unwrap_err(); + let err = walk_input_pos_to_rewind(&horizon, &head, &batch, |_, _, _| Ok(())).unwrap_err(); match err { Error::TxHashSetErr(msg) => { assert!(msg.contains("is not on body chain"), "{}", msg); @@ -4934,7 +8080,7 @@ mod tests { assert_eq!(target.hash(0).unwrap(), head.hash(0).unwrap()); let batch = store.batch_read().unwrap(); - let err = input_pos_to_rewind(&target, &head, &batch).unwrap_err(); + let err = walk_input_pos_to_rewind(&target, &head, &batch, |_, _, _| Ok(())).unwrap_err(); match err { Error::TxHashSetErr(msg) => { assert!(msg.contains("above body chain head"), "{}", msg); @@ -4965,7 +8111,7 @@ mod tests { assert_ne!(target, head); let batch = store.batch_read().unwrap(); - let err = input_pos_to_rewind(&target, &head, &batch).unwrap_err(); + let err = walk_input_pos_to_rewind(&target, &head, &batch, |_, _, _| Ok(())).unwrap_err(); match err { Error::TxHashSetErr(msg) => { assert!(msg.contains("does not match canonical"), "{}", msg); @@ -5267,6 +8413,123 @@ mod tests { let _ = fs::remove_dir_all(chain_dir); } + #[test] + fn validate_rejects_output_rangeproof_leaf_set_divergence() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/validate_rejects_output_rangeproof_leaf_set_divergence"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + + { + let mut extension = Extension::new(0, &mut txhashset, Tip::default()); + let commit = secp.commit_value(1).unwrap(); + let output = OutputIdentifier::new(OutputFeatures::Plain, &commit); + let proof = RangeProof::zero(); + assert_eq!(extension.output_pmmr.push(&output).unwrap(), 0); + assert_eq!(extension.rproof_pmmr.push(&proof).unwrap(), 0); + + let roots = extension.roots().unwrap(); + let mut header = BlockHeader::default(0); + header.output_mmr_size = roots.output_mmr_size; + header.kernel_mmr_size = roots.kernel_mmr_size; + header.output_root = roots.output_root; + header.range_proof_root = roots.rproof_root; + header.kernel_root = roots.kernel_root; + + assert!(extension.rproof_pmmr.prune(0).unwrap()); + assert!(extension.validate_roots(&header).is_ok()); + assert!(extension.validate_sizes(&header).is_ok()); + + let err = extension + .validate(&header, true, None, &header, None, &secp) + .unwrap_err(); + assert!(matches!( + err, + Error::InvalidPersistedChainState(msg) + if msg.contains("no matching rangeproof leaf") + )); + } + + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + + #[test] + fn validate_kernel_sums_rejects_matching_missing_utxo_leaves() { + global::set_local_chain_type(ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let chain_dir = "target/validate_kernel_sums_rejects_matching_missing_utxo_leaves"; + let _ = fs::remove_dir_all(chain_dir); + let store = Arc::new(ChainStore::new(0, chain_dir).unwrap()); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[0; 32], false).unwrap(); + let proof_builder = ProofBuilder::new(&secp, &keychain).unwrap(); + let key_id = ExtKeychain::derive_key_id(1, 1, 0, 0, 0).unwrap(); + let reward = reward::output( + 0, + &keychain, + &proof_builder, + &key_id, + 0, + false, + 0, + &mut secp, + ) + .unwrap(); + let mut genesis = Block::default(0).with_reward(reward.0, reward.1).unwrap(); + let mut txhashset = + TxHashSet::open(chain_dir.to_string(), store.clone(), None, &secp).unwrap(); + let mut header_pmmr = PMMRHandle::::new( + Path::new(chain_dir).join("header").join("header_head"), + false, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + + { + let batch = store.batch_write().unwrap(); + let mut extension = Extension::new(0, &mut txhashset, Tip::default()); + let pmmr = PMMR::at(&mut header_pmmr.backend, header_pmmr.size); + let header_ext = HeaderExtension::new(pmmr, Tip::default()); + extension + .apply_block(&genesis, &header_ext, &batch) + .unwrap(); + let roots = extension.roots().unwrap(); + let sizes = extension.sizes(); + genesis.header.output_mmr_size = sizes.0; + genesis.header.kernel_mmr_size = sizes.2; + genesis.header.output_root = roots.output_root; + genesis.header.range_proof_root = roots.rproof_root; + genesis.header.kernel_root = roots.kernel_root; + + extension + .validate(&genesis.header, true, None, &genesis.header, None, &secp) + .unwrap(); + assert!(extension.output_pmmr.prune(0).unwrap()); + assert!(extension.rproof_pmmr.prune(0).unwrap()); + assert!(extension.validate_roots(&genesis.header).is_ok()); + assert!(extension.validate_sizes(&genesis.header).is_ok()); + + let err = extension + .validate(&genesis.header, true, None, &genesis.header, None, &secp) + .unwrap_err(); + assert!(matches!(err, Error::Committed(_))); + } + + drop(header_pmmr); + drop(txhashset); + drop(store); + let _ = fs::remove_dir_all(chain_dir); + } + #[test] fn apply_segments_reject_non_leaf_positions() { global::set_local_chain_type(ChainTypes::AutomatedTesting); @@ -5679,8 +8942,7 @@ mod tests { |ext, batch| { assert_eq!(ext.extension.head().height, archive_tip.height); assert_eq!(ext.extension.head().last_block_h, archive_tip.last_block_h); - ext.extension - .rewind(&archive_header, batch, ext.header_extension, None) + ext.extension.rewind(&archive_header, batch, None) }, ) .unwrap(); diff --git a/chain/src/txhashset/utxo_view.rs b/chain/src/txhashset/utxo_view.rs index a11d0f7de0..748a1f4f98 100644 --- a/chain/src/txhashset/utxo_view.rs +++ b/chain/src/txhashset/utxo_view.rs @@ -57,13 +57,7 @@ impl<'a> UTXOView<'a> { block: &Block, batch: &Batch<'_>, ) -> Result, Error> { - let mut output_commits = BTreeSet::new(); - for output in block.outputs() { - if !output_commits.insert(output.commitment()) { - return Err(Error::DuplicateCommitment(output.commitment())); - } - self.validate_output(output, batch)?; - } + self.validate_outputs(block.outputs(), batch)?; self.validate_inputs(&block.inputs(), batch) } @@ -75,14 +69,21 @@ impl<'a> UTXOView<'a> { tx: &Transaction, batch: &Batch<'_>, ) -> Result, Error> { + self.validate_outputs(tx.outputs(), batch)?; + self.validate_inputs(&tx.inputs(), batch) + } + + /// Validate candidate outputs against the current UTXO set without checking + /// transaction inputs or kernels. No duplicate output commitments are allowed. + pub fn validate_outputs(&self, outputs: &[Output], batch: &Batch<'_>) -> Result<(), Error> { let mut output_commits = BTreeSet::new(); - for output in tx.outputs() { + for output in outputs { if !output_commits.insert(output.commitment()) { return Err(Error::DuplicateCommitment(output.commitment())); } self.validate_output(output, batch)?; } - self.validate_inputs(&tx.inputs(), batch) + Ok(()) } /// Validate the provided inputs. diff --git a/chain/src/types.rs b/chain/src/types.rs index 81b649375c..b245ca48c0 100644 --- a/chain/src/types.rs +++ b/chain/src/types.rs @@ -25,6 +25,7 @@ use mwc_core::pow::Difficulty; use mwc_core::ser::{self, Readable, Reader, Writeable, Writer}; use mwc_crates::log::{debug, info}; use mwc_crates::parking_lot::{RwLock, RwLockWriteGuard}; +use mwc_crates::secp::pedersen::Commitment; use mwc_crates::secp::Secp256k1; use std::collections::HashSet; use std::convert::TryFrom; @@ -436,6 +437,35 @@ impl Writeable for CommitPos { } } +/// A commitment paired with the exact output occurrence consumed by a block. +/// +/// Unlike `CommitPos`, this is specifically a per-block spent-input cache entry. +/// Keeping the association explicit avoids relying on block input order, which +/// can change when inputs are converted between protocol representations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpentOutput { + /// Commitment of the output consumed by the block. + pub commitment: Commitment, + /// Exact output PMMR position and creation height. + pub position: CommitPos, +} + +impl Readable for SpentOutput { + fn read(reader: &mut R) -> Result { + Ok(SpentOutput { + commitment: Commitment::read(reader)?, + position: CommitPos::read(reader)?, + }) + } +} + +impl Writeable for SpentOutput { + fn write(&self, writer: &mut W) -> Result<(), ser::Error> { + self.commitment.write(writer)?; + self.position.write(writer) + } +} + /// Minimal struct representing a known kernel MMR position and associated block height. #[derive(Clone, Copy, Debug, PartialEq)] pub struct KernelPos { @@ -487,6 +517,37 @@ impl Writeable for HashHeight { } } +/// A retained block's exact spend of one output occurrence. +/// +/// Commitments may be recreated after they are spent and the same commitment +/// may also be spent on competing forks. The spending block therefore +/// identifies the record, while `spent_output` disambiguates the precise PMMR +/// occurrence consumed by that block. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpentCommitmentRecord { + /// Block that spent the output occurrence. + pub spending_block: HashHeight, + /// Exact output PMMR position and creation height consumed by the block. + pub spent_output: CommitPos, +} + +impl Readable for SpentCommitmentRecord { + fn read(reader: &mut R) -> Result { + Ok(SpentCommitmentRecord { + spending_block: HashHeight::read(reader)?, + spent_output: CommitPos::read(reader)?, + }) + } +} + +impl Writeable for SpentCommitmentRecord { + fn write(&self, writer: &mut W) -> Result<(), ser::Error> { + self.spending_block.write(writer)?; + self.spent_output.write(writer)?; + Ok(()) + } +} + /// The tip of a fork. A handle to the fork ancestry from its leaf in the /// blockchain tree. References the max height and the latest and previous /// blocks diff --git a/chain/tests/data_file_integrity.rs b/chain/tests/data_file_integrity.rs index 72a333a136..ad8df1df12 100644 --- a/chain/tests/data_file_integrity.rs +++ b/chain/tests/data_file_integrity.rs @@ -14,7 +14,6 @@ // limitations under the License. use self::chain_test_helper::{clean_output_dir, init_chain, mine_chain}; -use mwc_core::core::hash::Hashed; use mwc_core::core::Block; use mwc_core::global; use mwc_crates::secp::{ContextFlag, Secp256k1}; @@ -37,9 +36,8 @@ fn data_files() { let chain = mine_chain(chain_dir, 4); chain.validate(&secp, false).unwrap(); assert_eq!(chain.head().unwrap().height, 3); - chain - .get_block(&chain.get_header_by_height(0).unwrap().hash(0).unwrap()) - .unwrap() + let header = chain.get_header_by_height(0).unwrap(); + chain.get_block_for_header(&header).unwrap() }; // Now reload the chain from existing data files and check it is valid. diff --git a/chain/tests/nrd_validation_rules.rs b/chain/tests/nrd_validation_rules.rs index 5f14c76f34..2d328e79e3 100644 --- a/chain/tests/nrd_validation_rules.rs +++ b/chain/tests/nrd_validation_rules.rs @@ -30,6 +30,40 @@ use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::{ContextFlag, Secp256k1, SecretKey}; use mwc_keychain::{BlindingFactor, ExtKeychain, ExtKeychainPath, Identifier, Keychain}; use std::convert::TryInto; +use std::sync::{Mutex, MutexGuard}; + +const TEST_CHAIN_CONFIG_CONTEXT_ID: u32 = 0; +static GLOBAL_CHAIN_CONFIG_LOCK: Mutex<()> = Mutex::new(()); + +struct GlobalChainConfigGuard { + context_id: u32, + _lock: MutexGuard<'static, ()>, +} + +impl GlobalChainConfigGuard { + fn automated_testing_with_nrd_enabled() -> Self { + let lock = GLOBAL_CHAIN_CONFIG_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + global::release_context_data(TEST_CHAIN_CONFIG_CONTEXT_ID); + global::init_global_chain_type( + TEST_CHAIN_CONFIG_CONTEXT_ID, + global::ChainTypes::AutomatedTesting, + ) + .unwrap(); + global::init_global_nrd_enabled(TEST_CHAIN_CONFIG_CONTEXT_ID, true).unwrap(); + GlobalChainConfigGuard { + context_id: TEST_CHAIN_CONFIG_CONTEXT_ID, + _lock: lock, + } + } +} + +impl Drop for GlobalChainConfigGuard { + fn drop(&mut self) { + global::release_context_data(self.context_id); + } +} fn build_block( secp: &mut Secp256k1, @@ -107,6 +141,7 @@ where #[test] fn process_block_nrd_validation() -> Result<(), Error> { + let _global_chain_config = GlobalChainConfigGuard::automated_testing_with_nrd_enabled(); global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(true); @@ -263,6 +298,7 @@ fn process_block_nrd_validation() -> Result<(), Error> { std::collections::HashSet::new() ) .is_err()); + assert_eq!(chain.head()?.height, 9); // Block at height 10 is valid if we do not include tx2. let block_valid_10 = build_block(&mut secp, &chain, &keychain, &key_id10, vec![])?; @@ -288,6 +324,7 @@ fn process_block_nrd_validation() -> Result<(), Error> { #[test] fn process_block_nrd_validation_relative_height_1() -> Result<(), Error> { + let _global_chain_config = GlobalChainConfigGuard::automated_testing_with_nrd_enabled(); global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(true); @@ -438,6 +475,7 @@ fn process_block_nrd_validation_relative_height_1() -> Result<(), Error> { #[test] fn process_block_nrd_validation_fork() -> Result<(), Error> { + let _global_chain_config = GlobalChainConfigGuard::automated_testing_with_nrd_enabled(); global::set_local_chain_type(global::ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(true); diff --git a/chain/tests/test_coinbase_maturity.rs b/chain/tests/test_coinbase_maturity.rs index 0f800eabf2..e49352b04f 100644 --- a/chain/tests/test_coinbase_maturity.rs +++ b/chain/tests/test_coinbase_maturity.rs @@ -59,6 +59,7 @@ fn test_coinbase_maturity() { HashSet::new(), None, None, + false, ) .unwrap(); diff --git a/chain/tests/test_get_kernel_height.rs b/chain/tests/test_get_kernel_height.rs index fd399f1f9e..5d3e4b038d 100644 --- a/chain/tests/test_get_kernel_height.rs +++ b/chain/tests/test_get_kernel_height.rs @@ -18,7 +18,6 @@ mod chain_test_helper; use self::chain_test_helper::{clean_output_dir, mine_chain}; use mwc_chain::{ChainStore, Error}; -use mwc_core::core::hash::Hashed; use mwc_util::secp_static; fn assert_data_overflow(result: Result) { @@ -75,7 +74,7 @@ fn test_get_kernel_height() { assert_missing(Some(100), Some(99)); let header = chain.get_header_by_height(2).unwrap(); - let block = chain.get_block(&header.hash(0).unwrap()).unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); let located = chain .get_kernel_height(&block.kernels()[0].excess, None, None) .unwrap() diff --git a/chain/tests/test_header_perf.rs b/chain/tests/test_header_perf.rs index 2a74c6c7d4..d75615d10e 100644 --- a/chain/tests/test_header_perf.rs +++ b/chain/tests/test_header_perf.rs @@ -59,6 +59,7 @@ fn test_header_perf_impl( HashSet::new(), None, None, + false, ) .unwrap(), ); @@ -76,6 +77,7 @@ fn test_header_perf_impl( HashSet::new(), None, None, + false, ) .unwrap(), ); diff --git a/chain/tests/test_pibd_validation.rs b/chain/tests/test_pibd_validation.rs index 71506ca97d..ecb9bc8194 100644 --- a/chain/tests/test_pibd_validation.rs +++ b/chain/tests/test_pibd_validation.rs @@ -60,6 +60,7 @@ fn test_pibd_chain_validation_impl(secp: &Secp256k1, is_test_chain: bool, src_ro HashSet::new(), None, None, + false, ) .unwrap(), ); diff --git a/chain/tests/validate_blockchain.rs b/chain/tests/validate_blockchain.rs index 5a7ea6faf5..1770d7313c 100644 --- a/chain/tests/validate_blockchain.rs +++ b/chain/tests/validate_blockchain.rs @@ -17,7 +17,6 @@ use std::collections::HashSet; use mwc_chain::types::NoopAdapter; use mwc_core::consensus::MWC_BASE; -use mwc_core::core::hash::Hashed; use mwc_core::core::KernelFeatures; use mwc_core::{genesis, global, pow}; use mwc_crates::secp::{ContextFlag, Secp256k1}; @@ -54,6 +53,7 @@ fn test_chain_validation() { HashSet::new(), None, None, + false, ) .unwrap(); @@ -65,7 +65,7 @@ fn test_chain_validation() { info!("Processing block {}", height); } let hdr = src_chain.get_header_by_height(height).unwrap(); - let block = src_chain.get_block(&hdr.hash(0).unwrap()).unwrap(); + let block = src_chain.get_block_for_header(&hdr).unwrap(); for kernel in &block.body.kernels { let fee = match kernel.features { KernelFeatures::Plain { fee } => fee.fee(), diff --git a/config/Cargo.toml b/config/Cargo.toml index f97447ffaf..96732da68a 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_config" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Configuration for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,9 +10,9 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_servers = { path = "../servers", version = "7.0.0" } -mwc_p2p = { path = "../p2p", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_servers = { path = "../servers", version = "7.1.0" } +mwc_p2p = { path = "../p2p", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true diff --git a/core/Cargo.toml b/core/Cargo.toml index bb4283b73a..137f9f8216 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_core" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Chain implementation for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,9 +10,9 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -keychain = { package = "mwc_keychain", path = "../keychain", version = "7.0.0" } -util = { package = "mwc_util", path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +keychain = { package = "mwc_keychain", path = "../keychain", version = "7.1.0" } +util = { package = "mwc_util", path = "../util", version = "7.1.0" } thiserror.workspace = true [features] diff --git a/core/src/core/block.rs b/core/src/core/block.rs index 6259398f78..690b10977b 100644 --- a/core/src/core/block.rs +++ b/core/src/core/block.rs @@ -128,7 +128,7 @@ impl From for Error { /// Header entry for storing in the header MMR. /// Note: we hash the block header itself and maintain the hash in the entry. /// This allows us to lookup the original header from the db as necessary. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct HeaderEntry { /// Hash for the BlockHeader pub hash: Hash, @@ -673,9 +673,10 @@ pub struct Block { impl Hashed for Block { /// The hash of the underlying block. - /// A block is identified by its header hash. The header commits to the - /// block body through the output, range proof, and kernel roots, so two - /// different valid blocks cannot have the same block hash. + /// A block is identified by its header hash. The header commits to output, + /// range proof, and kernel state, but does not commit to individual input + /// identities. Persistence must therefore prevent a different full body from + /// replacing the validated body already stored under this hash. fn hash(&self, context_id: u32) -> Result { self.header.hash(context_id) } @@ -1023,13 +1024,7 @@ impl Block { // Verify any absolute kernel lock heights. fn verify_kernel_lock_heights(&self) -> Result<(), Error> { for k in self.kernels() { - // check we have no kernels with lock_heights greater than current height - // no tx can be included in a block earlier than its lock_height - if let KernelFeatures::HeightLocked { lock_height, .. } = k.features { - if lock_height > self.header.height { - return Err(Error::KernelLockHeight(lock_height, self.header.height)); - } - } + verify_kernel_lock_height(k, self.header.height)?; } Ok(()) } @@ -1038,18 +1033,42 @@ impl Block { // NRD kernels were introduced in HF3 and are not valid for block version < 4. // Blocks prior to HF3 containing any NRD kernel(s) are invalid. fn verify_nrd_kernels_for_header_version(&self, context_id: u32) -> Result<(), Error> { - if self.kernels().iter().any(|k| k.is_nrd()) { - if !global::is_nrd_enabled(context_id) { - return Err(Error::NRDKernelNotEnabled); - } - if self.header.version < HeaderVersion(4) { - return Err(Error::NRDKernelPreHF3); - } + for k in self.kernels() { + verify_nrd_kernel_for_header_version(k, self.header.version, context_id)?; } Ok(()) } } +/// Verify that a kernel's absolute lock height permits inclusion in the +/// specified block height. +pub fn verify_kernel_lock_height(kernel: &TxKernel, header_height: u64) -> Result<(), Error> { + if let KernelFeatures::HeightLocked { lock_height, .. } = kernel.features { + if lock_height > header_height { + return Err(Error::KernelLockHeight(lock_height, header_height)); + } + } + Ok(()) +} + +/// Verify that an NRD kernel is enabled and permitted by the including header +/// version. +pub fn verify_nrd_kernel_for_header_version( + kernel: &TxKernel, + header_version: HeaderVersion, + context_id: u32, +) -> Result<(), Error> { + if kernel.is_nrd() { + if !global::is_nrd_enabled(context_id) { + return Err(Error::NRDKernelNotEnabled); + } + if header_version < HeaderVersion(4) { + return Err(Error::NRDKernelPreHF3); + } + } + Ok(()) +} + impl From for Block { fn from(block: UntrustedBlock) -> Self { block.0 diff --git a/core/src/core/pmmr/backend.rs b/core/src/core/pmmr/backend.rs index f582d2ea45..2c9836f368 100644 --- a/core/src/core/pmmr/backend.rs +++ b/core/src/core/pmmr/backend.rs @@ -96,7 +96,10 @@ pub trait Backend { /// Get hash for peak pos. /// Optimized for reading peak hashes rather than arbitrary pos hashes. - /// Peaks can be assumed to not be compacted. + /// Peaks of the backend's current MMR state can be assumed not to be + /// compacted. A node that was a peak at an earlier MMR size can later be + /// compacted beneath a larger pruned-subtree root; this API does not promise + /// historical-size proof availability. fn get_peak_from_file(&self, pos0: u64) -> Result, Error>; /// Get a Data Element by original insertion position diff --git a/core/src/core/pmmr/pmmr.rs b/core/src/core/pmmr/pmmr.rs index 51195e8924..bf8659378a 100644 --- a/core/src/core/pmmr/pmmr.rs +++ b/core/src/core/pmmr/pmmr.rs @@ -48,7 +48,8 @@ pub trait ReadablePMMR { /// Get the hash for the provided peak pos. /// Optimized for reading peak hashes rather than arbitrary pos hashes. - /// Peaks can be assumed to not be compacted. + /// Peaks of the current MMR state can be assumed not to be compacted. This + /// guarantee does not extend to nodes that were peaks at an earlier size. fn get_peak_from_file(&self, pos: u64) -> Result, Error>; /// Get the data element at provided position in the MMR (ignores the remove log). @@ -926,6 +927,8 @@ pub fn round_up_to_leaf_pos(pos0: u64) -> Result { /// Returns the 0-based pmmr index of 0-based leaf index n pub fn insertion_to_pmmr_index(nleaf0: u64) -> Result { // 2 * nleaf0 - nleaf0.count_ones() as u64 + // Note, we understand that we can calculate it nleaf0 + (nleaf0 - popcount(nleaf0)) + // a little safer. But this implementaiton is much easier to read and understand, so let keep it as it is let ones = u64::from(nleaf0.count_ones()); nleaf0 .checked_mul(2) diff --git a/core/src/core/pmmr/segment.rs b/core/src/core/pmmr/segment.rs index 32246af5be..c5ffa7c4bc 100644 --- a/core/src/core/pmmr/segment.rs +++ b/core/src/core/pmmr/segment.rs @@ -533,24 +533,24 @@ where // Segment validity is defined by payload size, not by an independent // height/range cap. Large sparse bitmap segments are valid protocol data, // so keep segment_size_limit as the only size criterion here. + // Note, we can estimate size (segment_size_limit) after the prune step + // only!!! Temporary segment can be much larger, so we can't apply size limit logic to it. if let Some(bitmap) = bitmap { - // let's try to build the segment and prune it... + // Build a temporary segment copy and prune it before applying the result + // size limit. The temporary representation depends on the serving node's + // local compaction state and is not part of the resulting segment payload. let mut segm_copy_data: VecBackend = VecBackend::new(pmmr.get_context_id()); let mut segm_copy = PMMR::new(&mut segm_copy_data); segm_copy.update_index_offset(segment_first_pos); - // constructin the segment in the memory. + // Construct the temporary segment in memory. let mut prune_pos = Vec::new(); - let mut construction_size = 0usize; for pos0 in segment_first_pos..=segment_last_pos { - check_segment_size_limit(construction_size, segment_size_limit)?; if pmmr::is_leaf(pos0) { let keeping = bitmap_keeps_leaf(pos0, mmr_last_pos, bitmap)?; match pmmr.get_data_from_file(pos0)? { Some(data) => { let data = T::from(data); - construction_size = - add_leaf_payload_size(construction_size, leaf_size)?; segm_copy.push(&data).map_err(|e| { SegmentError::GenericError(format!( "Unable to build a segment, {}", @@ -575,8 +575,6 @@ where )) })?; if pos0_copy >= segm_copy.size() { - construction_size = - add_segment_payload_size(construction_size, SEGMENT_HASH_PAYLOAD_SIZE)?; segm_copy .push_pruned_subtree(hash, pos0_copy) .map_err(|e| { diff --git a/core/src/core/transaction.rs b/core/src/core/transaction.rs index a52ccb4046..3196327fbb 100644 --- a/core/src/core/transaction.rs +++ b/core/src/core/transaction.rs @@ -34,6 +34,7 @@ use mwc_crates::serde::de; use mwc_crates::serde::{self, Deserialize, Deserializer, Serialize, Serializer}; use std::cmp::Ordering; use std::cmp::{max, min}; +use std::collections::HashSet; use std::convert::{TryFrom, TryInto}; use std::fmt; use std::fmt::Display; @@ -1798,6 +1799,29 @@ impl Transaction { Ok(()) } + /// Validate an aggregate assembled exclusively from transactions whose + /// rangeproofs and kernel signatures have already been fully verified. + /// + /// Aggregation preserves individual outputs, rangeproofs, kernels, and kernel + /// signatures, so re-verifying those cryptographic components does not add a + /// new security check. The properties that can change during aggregation are + /// still verified here: features, NRD rules, weight, ordering, uniqueness, + /// cut-through, and the aggregate kernel sums. + /// + /// Do not use this as an admission check for an unvalidated transaction. + pub fn validate_aggregate_from_validated_components( + &self, + context_id: u32, + weighting: Weighting, + secp: &Secp256k1, + ) -> Result<(), Error> { + self.body.verify_features()?; + self.body.verify_nrd_enabled(context_id)?; + self.body.validate_read(context_id, weighting)?; + self.verify_kernel_sums(self.overage()?, self.offset.clone(), secp)?; + Ok(()) + } + /// Can be used to compare txs by their fee/weight ratio, aka feerate. /// Don't use these values for anything else though due to precision multiplier. pub fn fee_rate(&self) -> Result { @@ -2153,6 +2177,42 @@ pub fn aggregate( Ok(tx) } +/// Return the unique candidates whose projected consensus hashes are absent +/// from `removed`. +/// +/// Precomputing both hash sets ensures each component is hashed once during the +/// difference calculation instead of repeatedly scanning and hashing an +/// ever-growing retained-component vector. +fn unique_hash_difference_by_key( + context_id: u32, + candidates: I, + removed: &[T], + key_fn: F, +) -> Result, Error> +where + K: Hashed, + I: IntoIterator, + F: Fn(&T) -> &K, +{ + let mut removed_hashes = HashSet::with_capacity(removed.len()); + for item in removed { + removed_hashes.insert(key_fn(item).hash(context_id)?); + } + + let candidates = candidates.into_iter(); + let candidate_capacity = candidates.size_hint().0; + let mut retained = Vec::with_capacity(candidate_capacity); + let mut retained_hashes = HashSet::with_capacity(candidate_capacity); + for candidate in candidates { + let hash = key_fn(&candidate).hash(context_id)?; + if !removed_hashes.contains(&hash) && retained_hashes.insert(hash) { + retained.push(candidate); + } + } + + Ok(retained) +} + /// Attempt to deaggregate a multi-kernel transaction based on multiple /// transactions pub fn deaggregate( @@ -2161,10 +2221,6 @@ pub fn deaggregate( txs: &[Transaction], secp: &Secp256k1, ) -> Result { - let mut inputs: Vec = vec![]; - let mut outputs: Vec = vec![]; - let mut kernels: Vec = vec![]; - // we will subtract these at the end to give us the overall offset for the // transaction let mut kernel_offsets = vec![]; @@ -2173,29 +2229,20 @@ pub fn deaggregate( let mk_inputs = mk_tx.inputs().into_commit_wrappers(context_id)?; let tx_inputs = tx.inputs().into_commit_wrappers(context_id)?; - for mk_input in mk_inputs { - if !ser::contains_by_hash(context_id, &tx_inputs, &mk_input)? - && !ser::contains_by_hash(context_id, &inputs, &mk_input)? - { - inputs.push(mk_input); - } - } - for mk_output in mk_tx.outputs() { - if !ser::contains_by_hash_key(context_id, tx.outputs(), mk_output, |output| { - &output.identifier - })? && !ser::contains_by_hash_key(context_id, &outputs, mk_output, |output| { - &output.identifier - })? { - outputs.push(*mk_output); - } - } - for mk_kernel in mk_tx.kernels() { - if !ser::contains_by_hash(context_id, tx.kernels(), mk_kernel)? - && !ser::contains_by_hash(context_id, &kernels, mk_kernel)? - { - kernels.push(*mk_kernel); - } - } + let mut inputs = + unique_hash_difference_by_key(context_id, mk_inputs, &tx_inputs, |input| input)?; + let mut outputs = unique_hash_difference_by_key( + context_id, + mk_tx.outputs().iter().copied(), + tx.outputs(), + |output| &output.identifier, + )?; + let mut kernels = unique_hash_difference_by_key( + context_id, + mk_tx.kernels().iter().copied(), + tx.kernels(), + |kernel| kernel, + )?; kernel_offsets.push(tx.offset); @@ -2467,6 +2514,19 @@ impl Writeable for Inputs { impl Inputs { /// Compare input collections by consensus hash ordering/equality. + /// + /// Empty collections are equal across protocol representations because they + /// contain no feature data. Nonempty cross-representation collections remain + /// unequal because commit-only inputs cannot preserve consensus-relevant + /// legacy input features. This is the correct rule for two unvalidated bodies, + /// including orphans: without the parent UTXO state there is no safe way to + /// reconstruct or verify the missing feature. + /// + /// A known-block check is a deliberately different case. It may project a + /// candidate and an already validated stored block to the v3 commit-only + /// representation, provided equality only suppresses the candidate and the + /// trusted stored block remains authoritative. Do not weaken this method to + /// implement that special case. pub fn eq_by_hash(&self, context_id: u32, other: &Self) -> Result { match (self, other) { (Inputs::CommitOnly(lhs), Inputs::CommitOnly(rhs)) => { @@ -2475,7 +2535,10 @@ impl Inputs { (Inputs::FeaturesAndCommit(lhs), Inputs::FeaturesAndCommit(rhs)) => { ser::slices_equal_by_hash(context_id, lhs, rhs) } - _ => Ok(false), + (Inputs::CommitOnly(commits), Inputs::FeaturesAndCommit(inputs)) + | (Inputs::FeaturesAndCommit(inputs), Inputs::CommitOnly(commits)) => { + Ok(commits.is_empty() && inputs.is_empty()) + } } } @@ -2869,6 +2932,74 @@ mod test { use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::{AggSigSignature, ContextFlag, SecretKey}; use std::convert::TryInto; + use std::sync::{ + atomic::{AtomicUsize, Ordering as AtomicOrdering}, + Arc, + }; + + #[derive(Clone, Debug)] + struct CountingHashable { + value: u32, + hash_calls: Arc, + fail: bool, + } + + impl Hashed for CountingHashable { + fn hash(&self, _context_id: u32) -> Result { + self.hash_calls.fetch_add(1, AtomicOrdering::Relaxed); + if self.fail { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "injected hash failure", + )) + } else { + Ok(Hash::from_vec(&self.value.to_be_bytes())) + } + } + } + + #[test] + fn unique_hash_difference_hashes_each_item_once() { + let hash_calls = Arc::new(AtomicUsize::new(0)); + let item = |value| CountingHashable { + value, + hash_calls: Arc::clone(&hash_calls), + fail: false, + }; + let removed = (0..128).map(&item).collect::>(); + let mut candidates = (64..256).map(&item).collect::>(); + candidates.push(item(200)); + let expected_hash_calls = removed.len() + candidates.len(); + + let retained = + unique_hash_difference_by_key(0, candidates, &removed, |candidate| candidate).unwrap(); + + assert_eq!( + retained + .into_iter() + .map(|candidate| candidate.value) + .collect::>(), + (128..256).collect::>(), + ); + assert_eq!( + hash_calls.load(AtomicOrdering::Relaxed), + expected_hash_calls, + ); + } + + #[test] + fn unique_hash_difference_propagates_hash_errors() { + let hash_calls = Arc::new(AtomicUsize::new(0)); + let candidate = CountingHashable { + value: 1, + hash_calls, + fail: true, + }; + + let err = unique_hash_difference_by_key(0, vec![candidate], &[], |candidate| candidate) + .expect_err("candidate hash failure must be returned"); + assert!(matches!(err, Error::IO(_))); + } // For ser/deser signature must be valid. One form floo genesis should work fn get_test_valid_signature(secp: &Secp256k1) -> AggSigSignature { diff --git a/core/src/difficulty_cache.rs b/core/src/difficulty_cache.rs index b306a2a875..66dd91d431 100644 --- a/core/src/difficulty_cache.rs +++ b/core/src/difficulty_cache.rs @@ -221,15 +221,43 @@ where && cached_item.hash.is_some() && cached_item.hash == item.hash => { + // A matching ancestor authenticates cached entries at that + // height and below, but not cached descendants. Any entries + // already observed from the cursor at overlapping heights must + // therefore match the cache before cached descendants can be + // substituted for them. + let observed_overlap_matches = last_n.iter().all(|observed| { + if observed.height < cache_tail_height + || observed.height > cache_head_height + { + return true; + } + + // Safe: observed.height is inside the capped cache span. + let observed_idx = + (observed.height - cache_tail_height) as usize; + matches!( + cache_values.entries.get(observed_idx), + Some(cached_observed) + if cached_observed.height == observed.height + && cached_observed.hash.is_some() + && observed.hash.is_some() + && cached_observed.hash == observed.hash + ) + }); + // Safe: the check above guarantees this subtraction cannot // underflow. base_idx is bounded by max_cache_len + window. let start_idx = base_idx + 1 - needed_block_count; let cache_len = cache_values.entries.len(); let mut cached_last_n = Vec::with_capacity(needed_block_count); - let mut cache_hit_valid = true; + let mut cache_hit_valid = observed_overlap_matches; for idx in (start_idx..=base_idx).rev() { + if !cache_hit_valid { + break; + } let (cached_header, expected_height) = if idx < cache_len { // Safe: idx is inside the capped, contiguous cache span, // so cache_tail_height + idx cannot exceed cache_head_height. @@ -503,6 +531,41 @@ mod tests { assert!(cache_values.is_empty()); } + #[test] + fn cache_hit_rejects_conflicting_observed_fork_entries() { + let needed_block_count = DIFFICULTY_ADJUST_WINDOW as usize + 1; + let fork_start_height = needed_block_count as u64 - 5; + let mut cache_values = DifficultyCache::new(); + cache_values + .entries + .extend((0..needed_block_count as u64).map(header_with_hash)); + + let cursor = (0..needed_block_count as u64) + .rev() + .map(|height| { + if height >= fork_start_height { + header( + height, + Some(Hash::from_vec( + &(height + needed_block_count as u64).to_le_bytes(), + )), + 2_000_000, + 10_000, + ) + } else { + header_with_hash(height) + } + }) + .collect::>(); + let mut expected = cursor.clone(); + expected.reverse(); + + let result = difficulty_data_to_vector(0, cursor, &mut cache_values).unwrap(); + + assert_eq!(result, expected); + assert_eq!(cache_values.iter().cloned().collect::>(), expected); + } + #[test] fn rolling_cache_matches_cursor_calculation_after_push() { let needed_block_count = DIFFICULTY_ADJUST_WINDOW as usize + 1; diff --git a/core/src/libtx/aggsig.rs b/core/src/libtx/aggsig.rs index 2ee880e813..69bbbd883f 100644 --- a/core/src/libtx/aggsig.rs +++ b/core/src/libtx/aggsig.rs @@ -253,7 +253,7 @@ pub fn verify_partial_sig( /// let switch = SwitchCommitmentType::Regular; /// let commit = keychain.commit(&secp, value, &key_id, switch).unwrap(); /// let builder = proof::ProofBuilder::new(&secp, &keychain).unwrap(); -/// let proof = proof::create(&mut secp, &keychain, &builder, value, &key_id, switch, commit, None).unwrap(); +/// let proof = proof::create(&mut secp, &keychain, &builder, value, &key_id, switch, commit).unwrap(); /// let output = Output::new(OutputFeatures::Coinbase, commit, proof); /// let height = 20; /// let over_commit = secp.commit_value(reward(0, fees, height).unwrap()).unwrap(); @@ -321,7 +321,7 @@ where /// let switch = SwitchCommitmentType::Regular; /// let commit = keychain.commit(&secp, value, &key_id, switch).unwrap(); /// let builder = proof::ProofBuilder::new(&secp, &keychain).unwrap(); -/// let proof = proof::create(&mut secp, &keychain, &builder, value, &key_id, switch, commit, None).unwrap(); +/// let proof = proof::create(&mut secp, &keychain, &builder, value, &key_id, switch, commit).unwrap(); /// let output = Output::new(OutputFeatures::Coinbase, commit, proof); /// let height = 20; /// let over_commit = secp.commit_value(reward(0, fees, height).unwrap()).unwrap(); diff --git a/core/src/libtx/build.rs b/core/src/libtx/build.rs index ac3cf40f2c..1cee985f25 100644 --- a/core/src/libtx/build.rs +++ b/core/src/libtx/build.rs @@ -153,7 +153,6 @@ where &key_id, switch, commit, - None, )?; Ok(( diff --git a/core/src/libtx/proof.rs b/core/src/libtx/proof.rs index 5060b24968..3ec6c886d4 100644 --- a/core/src/libtx/proof.rs +++ b/core/src/libtx/proof.rs @@ -26,7 +26,10 @@ use mwc_crates::secp::Secp256k1; use mwc_crates::zeroize::{Zeroize, Zeroizing}; use std::convert::TryFrom; -/// Create a bulletproof +/// Create a bulletproof without variable extra transcript data. +/// +/// Proof builder nonces are deterministic, so supporting independently variable +/// extra data here could reuse proof masks across distinct transcripts. pub fn create( secp: &mut Secp256k1, k: &K, @@ -35,7 +38,6 @@ pub fn create( key_id: &Identifier, switch: SwitchCommitmentType, commit: Commitment, - extra_data: Option>, ) -> Result where K: Keychain, @@ -53,14 +55,16 @@ where } let skey = k.derive_key(secp, amount, key_id, switch)?; let rewind_nonce = b.rewind_nonce(secp, &commit)?; - let private_nonce = b.private_nonce(secp, &commit)?; + let private_nonce = b.private_nonce(secp, &commit, key_id)?; let message = b.proof_message(secp, key_id, switch)?; Ok(secp.bullet_proof( amount, skey, rewind_nonce, private_nonce, - extra_data, + // Do not expose variable extra data unless it is first bound into the + // deterministic private nonce. + None, Some(message), )?) } @@ -113,8 +117,16 @@ pub trait ProofBuild { /// Create a BP nonce that will allow to rewind the derivation path and flags fn rewind_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result; - /// Create a BP nonce that blinds the private key - fn private_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result; + /// Create a BP nonce that blinds the private key. The nonce binds the + /// output identifier so the same commitment can never be re-proven with + /// a different proof message while reusing the same tau1/tau2 masks + /// (such reuse leaks the output blinding factor). + fn private_nonce( + &self, + secp: &Secp256k1, + commit: &Commitment, + key_id: &Identifier, + ) -> Result; /// Create a BP message fn proof_message( @@ -176,6 +188,7 @@ where &self, secp: &Secp256k1, commit: &Commitment, + key_id: Option<&Identifier>, private: bool, ) -> Result { let hash = if private { @@ -183,7 +196,19 @@ where } else { &self.rewind_hash }; - let nonce_bytes = zeroizing_blake2b(32, &commit.0, hash); + let nonce_bytes = match key_id { + // Mix the full identifier bytes (including the anti-replay height + // stored in the unused path word) into the private nonce, so every + // distinct proof message gets distinct tau1/tau2 masks. + Some(id) => { + let id_bytes = id.to_bytes(); + let mut data = Vec::with_capacity(commit.0.len() + id_bytes.len()); + data.extend_from_slice(&commit.0); + data.extend_from_slice(&id_bytes); + zeroizing_blake2b(32, &data, hash) + } + None => zeroizing_blake2b(32, &commit.0, hash), + }; SecretKey::from_slice(secp, nonce_bytes.as_slice()).map_err(|e| { Error::RangeProof(format!( "Unable to extract nonce from commit {:?}, {}", @@ -198,11 +223,16 @@ where K: Keychain, { fn rewind_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result { - self.nonce(secp, commit, false) + self.nonce(secp, commit, None, false) } - fn private_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result { - self.nonce(secp, commit, true) + fn private_nonce( + &self, + secp: &Secp256k1, + commit: &Commitment, + key_id: &Identifier, + ) -> Result { + self.nonce(secp, commit, Some(key_id), true) } /// Message bytes: @@ -307,8 +337,25 @@ where }) } - fn nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result { - let nonce_bytes = zeroizing_blake2b(32, &commit.0, &self.root_hash); + fn nonce( + &self, + secp: &Secp256k1, + commit: &Commitment, + key_id: Option<&Identifier>, + ) -> Result { + let nonce_bytes = match key_id { + // Same binding as ProofBuilder: the private nonce commits to the + // full identifier bytes so proof masks can never be reused across + // different proof messages for the same commitment. + Some(id) => { + let id_bytes = id.to_bytes(); + let mut data = Vec::with_capacity(commit.0.len() + id_bytes.len()); + data.extend_from_slice(&commit.0); + data.extend_from_slice(&id_bytes); + zeroizing_blake2b(32, &data, &self.root_hash) + } + None => zeroizing_blake2b(32, &commit.0, &self.root_hash), + }; SecretKey::from_slice(secp, nonce_bytes.as_slice()).map_err(|e| { Error::RangeProof(format!( "Unable to extract nonce from commit {:?}, {}", @@ -323,14 +370,19 @@ where K: Keychain, { fn rewind_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result { - self.nonce(secp, commit) + self.nonce(secp, commit, None) } - fn private_nonce(&self, secp: &Secp256k1, commit: &Commitment) -> Result { + fn private_nonce( + &self, + secp: &Secp256k1, + commit: &Commitment, + key_id: &Identifier, + ) -> Result { // Legacy proofs used the same nonce for rewind and private nonce. Keep this // behavior for compatibility with old pre-hard-fork outputs that wallets may // still need to scan and rewind; new outputs should use ProofBuilder. - self.nonce(secp, commit) + self.nonce(secp, commit, Some(key_id)) } /// Message bytes: @@ -415,7 +467,12 @@ impl ProofBuild for ViewKey { }) } - fn private_nonce(&self, _secp: &Secp256k1, _commit: &Commitment) -> Result { + fn private_nonce( + &self, + _secp: &Secp256k1, + _commit: &Commitment, + _key_id: &Identifier, + ) -> Result { Err(Error::RangeProof( "ViewKey cannot create private rangeproof nonces".into(), )) @@ -522,10 +579,7 @@ mod tests { ExtKeychain::derive_key_id(3, rng.random(), rng.random(), rng.random(), 0).unwrap(); let switch = SwitchCommitmentType::Regular; let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); assert!(verify(&mut secp, commit, proof, None).is_ok()); let rewind = rewind(&mut secp, &builder, commit, None, proof).unwrap(); assert!(rewind.is_some()); @@ -568,9 +622,7 @@ mod tests { let switch = SwitchCommitmentType::Regular; let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); - let res = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ); + let res = create(&mut secp, &keychain, &builder, amount, &id, switch, commit); assert!(matches!( res, @@ -598,7 +650,6 @@ mod tests { &id, switch, wrong_commit, - None, ); assert!(matches!( @@ -642,10 +693,7 @@ mod tests { let id = ExtKeychain::derive_key_id(3, 1, 2, 3, 0).unwrap(); let switch = SwitchCommitmentType::Regular; let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); let rewind = rewind(&mut secp, &other_builder, commit, None, proof).unwrap(); @@ -767,10 +815,8 @@ mod tests { let commit_a = { let switch = SwitchCommitmentType::Regular; let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = + create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); assert!(verify(&mut secp, commit, proof, None).is_ok()); let rewind = rewind(&mut secp, &builder, commit, None, proof).unwrap(); assert!(rewind.is_some()); @@ -784,10 +830,8 @@ mod tests { let commit_b = { let switch = SwitchCommitmentType::None; let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = + create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); assert!(verify(&mut secp, commit, proof, None).is_ok()); let rewind = rewind(&mut secp, &builder, commit, None, proof).unwrap(); assert!(rewind.is_some()); @@ -828,10 +872,7 @@ mod tests { let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); // Generate proof with ProofBuilder.. - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); // ..and rewind with ViewKey let rewind = rewind(&mut secp, &view_key, commit, None, proof); @@ -871,10 +912,7 @@ mod tests { let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); // Generate proof with ProofBuilder.. - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); // ..and rewind with ViewKey let rewind = rewind(&mut secp, &view_key, commit, None, proof); @@ -914,10 +952,7 @@ mod tests { let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); // Generate proof with ProofBuilder.. - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); // ..and rewind with ViewKey let rewind = rewind(&mut secp, &view_key, commit, None, proof); @@ -964,10 +999,8 @@ mod tests { let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); // Generate proof with ProofBuilder.. - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = + create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); // ..and rewind with child ViewKey let rewind = rewind(&mut secp, &child_view_key, commit, None, proof); @@ -1004,10 +1037,8 @@ mod tests { let commit = keychain.commit(&secp, amount, &id, switch).unwrap(); // Generate proof with ProofBuilder.. - let proof = create( - &mut secp, &keychain, &builder, amount, &id, switch, commit, None, - ) - .unwrap(); + let proof = + create(&mut secp, &keychain, &builder, amount, &id, switch, commit).unwrap(); // ..and rewind with child ViewKey let rewind = rewind(&mut secp, &child_view_key, commit, None, proof); diff --git a/core/src/libtx/reward.rs b/core/src/libtx/reward.rs index c7ee8e825a..67f00e34d3 100644 --- a/core/src/libtx/reward.rs +++ b/core/src/libtx/reward.rs @@ -50,7 +50,7 @@ where trace!("Block reward - Pedersen Commit is: {:?}", commit,); - let proof = proof::create(secp, keychain, builder, value, key_id, switch, commit, None)?; + let proof = proof::create(secp, keychain, builder, value, key_id, switch, commit)?; let output = Output::new(OutputFeatures::Coinbase, commit, proof); diff --git a/core/src/libtx/secp_ser.rs b/core/src/libtx/secp_ser.rs index 38b458c9e3..71597b0386 100644 --- a/core/src/libtx/secp_ser.rs +++ b/core/src/libtx/secp_ser.rs @@ -32,6 +32,29 @@ fn serialize_legacy_compact( sig.serialize_compact(secp) } +fn parse_legacy_compact_hex(input: &str) -> Result<[u8; secp::constants::AGG_SIGNATURE_SIZE], E> +where + E: mwc_crates::serde::de::Error, +{ + let hex = input.trim(); + let hex = hex.strip_prefix("0x").unwrap_or(hex); + const HEX_SIZE: usize = secp::constants::AGG_SIGNATURE_SIZE * 2; + + if hex.len() != HEX_SIZE { + return Err(E::invalid_length(hex.len(), &"128 hex characters")); + } + + let bytes = + from_hex(hex).map_err(|err| E::custom(format!("Fail to parse signature HEX, {}", err)))?; + if bytes.len() != secp::constants::AGG_SIGNATURE_SIZE { + return Err(E::invalid_length(bytes.len(), &"64 bytes")); + } + + let mut compact = [0u8; secp::constants::AGG_SIGNATURE_SIZE]; + compact.copy_from_slice(&bytes); + Ok(compact) +} + /// Serializes a secp PublicKey to and from hex pub mod pubkey_serde { use mwc_crates::secp::key::PublicKey; @@ -89,7 +112,7 @@ pub mod pubkey_serde { pub mod option_sig_serde { use mwc_crates::secp; use mwc_crates::serde::{Deserialize, Deserializer, Serializer}; - use util::{from_hex, secp_static, ToHex}; + use util::{secp_static, ToHex}; /// pub fn serialize( @@ -129,41 +152,23 @@ pub mod option_sig_serde { D: Deserializer<'de>, { Option::::deserialize(deserializer).and_then(|res| match res { - Some(string) => from_hex(&string) - .map_err(|err| { - mwc_crates::serde::de::Error::custom(format!( - "Fail to parse signature HEX {}, {}", - string, err - )) - }) - .and_then(|bytes: Vec| { - if bytes.len() != 64 { - return Err(mwc_crates::serde::de::Error::invalid_length( - bytes.len(), - &"64 bytes", - )); - } - let mut b = [0u8; 64]; - b.copy_from_slice(&bytes[0..64]); - secp_static::with_none( - |err| { - mwc_crates::serde::de::Error::custom(format!( - "Unable create Secp, {}", - err - )) - }, - |secp| { - secp::AggSigSignature::from_compact(secp, &b) - .map(Some) - .map_err(|err| { - mwc_crates::serde::de::Error::custom(format!( - "Fail to decode signature, {}", - err - )) - }) - }, - ) - }), + Some(string) => super::parse_legacy_compact_hex(&string).and_then(|b| { + secp_static::with_none( + |err| { + mwc_crates::serde::de::Error::custom(format!("Unable create Secp, {}", err)) + }, + |secp| { + secp::AggSigSignature::from_compact(secp, &b) + .map(Some) + .map_err(|err| { + mwc_crates::serde::de::Error::custom(format!( + "Fail to decode signature, {}", + err + )) + }) + }, + ) + }), None => Ok(None), }) } @@ -229,7 +234,7 @@ pub mod sig_serde { use mwc_crates::secp; use mwc_crates::serde::de::Error; use mwc_crates::serde::{Deserialize, Deserializer, Serializer}; - use util::{from_hex, secp_static, ToHex}; + use util::{secp_static, ToHex}; /// pub fn serialize(sig: &secp::AggSigSignature, serializer: S) -> Result @@ -256,17 +261,8 @@ pub mod sig_serde { D: Deserializer<'de>, { String::deserialize(deserializer) - .and_then(|string| { - from_hex(&string).map_err(|err| { - Error::custom(format!("Fail to parse signature HEX {}, {}", string, err)) - }) - }) - .and_then(|bytes: Vec| { - if bytes.len() != 64 { - return Err(Error::invalid_length(bytes.len(), &"64 bytes")); - } - let mut b = [0u8; 64]; - b.copy_from_slice(&bytes[0..64]); + .and_then(|string| super::parse_legacy_compact_hex(&string)) + .and_then(|b| { secp_static::with_none( |err| { mwc_crates::serde::de::Error::custom(format!("Unable create Secp, {}", err)) @@ -613,21 +609,31 @@ mod test { #[test] fn rejects_overlong_signature_hex() { + #[allow(dead_code)] + #[derive(Deserialize, Debug)] + #[serde(crate = "serde")] + struct SigTest { + #[serde(with = "sig_serde")] + pub sig: AggSigSignature, + } + let s = SerTest::random(); - let serialized = serde_json::to_string(&s).unwrap(); let sig = sig_serde::serialize(&s.sig, serde_json::value::Serializer).unwrap(); let sig = sig.as_str().unwrap(); let overlong_sig = format!("{}00", sig); - let serialized = serialized.replace(sig, &overlong_sig); + let serialized = format!(r#"{{"sig":"{}"}}"#, overlong_sig); - let res = serde_json::from_str::(&serialized); + let err = serde_json::from_str::(&serialized).unwrap_err(); + let err = err.to_string(); - assert!(res.is_err()); + assert!(err.contains("invalid length 130")); + assert!(err.contains("128 hex characters")); } #[test] - fn rejects_overlong_optional_signature_hex() { - #[derive(Serialize, Deserialize, Debug)] + fn rejects_overlong_malformed_optional_signature_without_echoing_input() { + #[allow(dead_code)] + #[derive(Deserialize, Debug)] #[serde(crate = "serde")] struct OptionSigTest { #[serde(with = "option_sig_serde")] @@ -637,11 +643,38 @@ mod test { let s = SerTest::random(); let sig = sig_serde::serialize(&s.sig, serde_json::value::Serializer).unwrap(); let sig = sig.as_str().unwrap(); - let serialized = format!(r#"{{"sig":"{}00"}}"#, sig); + let overlong_sig = format!("{}zz", sig); + let serialized = format!(r#"{{"sig":"{}"}}"#, overlong_sig); - let res = serde_json::from_str::(&serialized); + let err = serde_json::from_str::(&serialized).unwrap_err(); + let err = err.to_string(); - assert!(res.is_err()); + assert!(err.contains("invalid length 130")); + assert!(err.contains("128 hex characters")); + assert!(!err.contains("invalid symbol")); + assert!(!err.contains(&overlong_sig)); + } + + #[test] + fn accepts_normalized_signature_hex() { + #[derive(Deserialize, Debug)] + #[serde(crate = "serde")] + struct NormalizedSigTest { + #[serde(with = "sig_serde")] + pub sig: AggSigSignature, + #[serde(with = "option_sig_serde")] + pub opt_sig: Option, + } + + let s = SerTest::random(); + let sig = sig_serde::serialize(&s.sig, serde_json::value::Serializer).unwrap(); + let sig = sig.as_str().unwrap(); + let serialized = format!(r#"{{"sig":" 0x{} ","opt_sig":" 0x{} "}}"#, sig, sig); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + + assert_eq!(deserialized.sig, s.sig); + assert_eq!(deserialized.opt_sig, Some(s.sig)); } #[test] diff --git a/core/src/pow/types.rs b/core/src/pow/types.rs index 74d12758ad..fb62187bbb 100644 --- a/core/src/pow/types.rs +++ b/core/src/pow/types.rs @@ -608,6 +608,13 @@ impl Readable for Proof { impl Writeable for Proof { fn write(&self, writer: &mut W) -> Result<(), ser::Error> { + let writer_context_id = writer.get_context_id(); + if self.context_id != writer_context_id { + return Err(ser::Error::CorruptedData(format!( + "Proof context_id {} does not match writer context_id {}", + self.context_id, writer_context_id + ))); + } let nonces = self.pack_nonces()?; if writer.serialization_mode() != ser::SerializationMode::Hash { writer.write_u8(self.edge_bits)?; @@ -691,6 +698,20 @@ mod tests { assert!(matches!(err, ser::Error::CorruptedData(_))); } + #[test] + fn proof_write_rejects_context_mismatch_before_packing() { + global::set_local_chain_type(global::ChainTypes::Mainnet); + let mut proof = Proof::zero(0, global::proofsize(0)); + proof.context_id = u32::MAX; + + let err = write_proof(&proof).unwrap_err(); + assert!(matches!( + err, + ser::Error::CorruptedData(ref msg) + if msg == "Proof context_id 4294967295 does not match writer context_id 0" + )); + } + #[test] fn proof_read_rejects_invalid_edge_bits() { global::set_local_chain_type(global::ChainTypes::Mainnet); diff --git a/core/src/ser.rs b/core/src/ser.rs index 9155b307b9..b03979135e 100644 --- a/core/src/ser.rs +++ b/core/src/ser.rs @@ -1021,6 +1021,10 @@ impl PMMRable for RangeProof { } } +// This compact-codec bridge preserves the legacy aggregate-signature format +// used by mwc-wallet. It is retained for backward compatibility with existing +// wallet data; changing it requires an explicitly versioned wallet migration. +// // The legacy ECDSA compact codec reverses the byte order of each 32-byte // aggregate-signature component. Binary consensus encoding uses canonical // `(R.x || s)` bytes, so convert at the compact API boundary. The operation is diff --git a/core/src/stratum/connections.rs b/core/src/stratum/connections.rs index 54443c14bf..5aeacfa657 100644 --- a/core/src/stratum/connections.rs +++ b/core/src/stratum/connections.rs @@ -377,9 +377,10 @@ impl StratumIpPool { } /// Get IP info info for API - pub fn get_ip_info(&self, ip: &String) -> StratumIpPrintable { - match self.connection_info.read_recursive().get(ip) { - Some(con) => StratumIpPrintable::from_stratum_connection( + pub fn get_ip_info(&self, ip: &str) -> Option { + let connection_info = self.connection_info.read_recursive(); + connection_info.get(ip).map(|con| { + StratumIpPrintable::from_stratum_connection( con, con.is_banned( self.ban_action_limit, @@ -387,9 +388,8 @@ impl StratumIpPool { self.connection_pace_ms, false, ), - ), - None => StratumIpPrintable::from_ip(ip), - } + ) + }) } /// Clean IP from the pool. @@ -445,20 +445,6 @@ impl StratumIpPrintable { failed_requests: stratum_connection.ban_noise.len(), } } - - // Empty for IP - fn from_ip(ip: &String) -> Self { - StratumIpPrintable { - ip: ip.clone(), - ban: false, - last_connect_time_ms: None, - workers: 0, - ok_shares: 0, - ok_logins: 0, - failed_login: 0, - failed_requests: 0, - } - } } #[cfg(test)] @@ -487,7 +473,7 @@ mod tests { pool.delete_worker(&ip).unwrap(); pool.retire_old_events(Duration::from_secs(1)); - let info = pool.get_ip_info(&ip); + let info = pool.get_ip_info(&ip).unwrap(); assert_eq!(info.workers, 0); assert!(info.last_connect_time_ms.is_some()); @@ -501,9 +487,7 @@ mod tests { .clone_from(&(Instant::now() - Duration::from_secs(2))); pool.retire_old_events(Duration::from_secs(1)); - let info = pool.get_ip_info(&ip); - assert_eq!(info.workers, 0); - assert!(info.last_connect_time_ms.is_none()); + assert!(pool.get_ip_info(&ip).is_none()); } #[test] @@ -571,7 +555,7 @@ mod tests { ); pool.report_ok_shares(&ip).unwrap(); - let info = pool.get_ip_info(&ip); + let info = pool.get_ip_info(&ip).unwrap(); assert_eq!(info.workers, 1); assert_eq!(info.ok_shares, 1); @@ -593,7 +577,7 @@ mod tests { pool.report_fail_noise(&ip).unwrap(); } - let info = pool.get_ip_info(&ip); + let info = pool.get_ip_info(&ip).unwrap(); assert_eq!(info.ok_shares, 20); assert_eq!(info.ok_logins, 20); assert_eq!(info.failed_login, 20); diff --git a/core/tests/segment.rs b/core/tests/segment.rs index 28825e673d..34991e3da4 100644 --- a/core/tests/segment.rs +++ b/core/tests/segment.rs @@ -220,21 +220,64 @@ fn segment_from_pmmr_keeps_legacy_size_limit_boundary() { } #[test] -fn segment_from_pmmr_bitmap_bounds_initial_construction_work() { +fn segment_from_pmmr_bitmap_applies_size_limit_after_pruning() { let id = SegmentIdentifier { height: 1, idx: 0 }; let mut backend = pmmr::VecBackend::new(0); let mut mmr = pmmr::PMMR::new(&mut backend); mmr.push(&TestElem([0, 0, 0, 1])).unwrap(); mmr.push(&TestElem([0, 0, 0, 2])).unwrap(); + let mmr = mmr.readonly_pmmr(); + let mmr_size = mmr.unpruned_size(); + let root = mmr.root().unwrap(); + let result_size_limit = 40; + // The two physical leaves require 48 bytes in the temporary copy, but the + // fully pruned result contains one 40-byte hash entry and must be accepted. let bitmap = Bitmap::new(); + let segment = + Segment::::from_pmmr(id, &mmr, Some(&bitmap), 16, result_size_limit).unwrap(); + segment.validate(0, mmr_size, Some(&bitmap), &root).unwrap(); + // Retaining the leaf pair produces a 48-byte result and must still be rejected. + let mut bitmap = Bitmap::new(); + bitmap.add(0); assert!(matches!( - Segment::::from_pmmr(id, &mmr.readonly_pmmr(), Some(&bitmap), 16, 24), + Segment::::from_pmmr(id, &mmr, Some(&bitmap), 16, result_size_limit), Err(SegmentError::SegmentSizeAboveLimit) )); } +#[test] +fn segment_from_pmmr_accepts_sparse_h10_result_at_twice_pibd_limit() { + let id = SegmentIdentifier { height: 10, idx: 0 }; + let mut backend = pmmr::VecBackend::new(0); + let mut mmr = pmmr::PMMR::new(&mut backend); + for i in 0..(1 << 10) { + mmr.push(&TestElem([i / 7, i / 5, i / 3, i])).unwrap(); + } + let mmr = mmr.readonly_pmmr(); + let mmr_size = mmr.unpruned_size(); + let root = mmr.root().unwrap(); + + // Match RangeProof PMMR accounting: 683 bytes per leaf plus its position. + // A dense temporary copy exceeds the 2x PIBD limit, while the sparse + // resulting segment remains comfortably below it. + let rangeproof_leaf_size = 683; + let twice_pibd_limit = 2 * 256 * 1034; + let mut bitmap = Bitmap::new(); + bitmap.add(511); + + let segment = Segment::::from_pmmr( + id, + &mmr, + Some(&bitmap), + rangeproof_leaf_size, + twice_pibd_limit, + ) + .unwrap(); + segment.validate(0, mmr_size, Some(&bitmap), &root).unwrap(); +} + #[test] fn segment_validate_accepts_large_sparse_bitmap_segment() { let id = SegmentIdentifier { height: 12, idx: 0 }; diff --git a/core/tests/transaction.rs b/core/tests/transaction.rs index 94db02a9c4..a48a029bb8 100644 --- a/core/tests/transaction.rs +++ b/core/tests/transaction.rs @@ -234,6 +234,26 @@ fn commit_wrapper_from_input_commitment_only_is_explicit() { assert_eq!(commit.commitment(), input.commitment()); } +#[test] +fn empty_inputs_compare_equal_across_protocol_representations() { + let commit_only = Inputs::CommitOnly(vec![]); + let features_and_commit = Inputs::FeaturesAndCommit(vec![]); + + assert!(commit_only.eq_by_hash(0, &features_and_commit).unwrap()); + assert!(features_and_commit.eq_by_hash(0, &commit_only).unwrap()); +} + +#[test] +fn nonempty_inputs_do_not_compare_equal_across_protocol_representations() { + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let input = Input::new(OutputFeatures::Plain, secp.commit_value(1).unwrap()); + let commit_only = Inputs::CommitOnly(vec![CommitWrapper::from_input_commitment_only(&input)]); + let features_and_commit = Inputs::FeaturesAndCommit(vec![input]); + + assert!(!commit_only.eq_by_hash(0, &features_and_commit).unwrap()); + assert!(!features_and_commit.eq_by_hash(0, &commit_only).unwrap()); +} + #[test] fn commit_wrapper_write_rejects_invalid_commitment() { let invalid_commit = Commitment::from_vec(vec![0; PEDERSEN_COMMITMENT_SIZE]).unwrap(); @@ -345,10 +365,7 @@ fn test_output_ser_deser() { let switch = keychain::SwitchCommitmentType::Regular; let commit = keychain.commit(&secp, 5, &key_id, switch).unwrap(); let builder = ProofBuilder::new(&secp, &keychain).unwrap(); - let proof = proof::create( - &mut secp, &keychain, &builder, 5, &key_id, switch, commit, None, - ) - .unwrap(); + let proof = proof::create(&mut secp, &keychain, &builder, 5, &key_id, switch, commit).unwrap(); let out = Output::new(OutputFeatures::Plain, commit, proof); diff --git a/doc/coinbase_maturity.md b/doc/coinbase_maturity.md index f40db6e388..8028bdb1a2 100644 --- a/doc/coinbase_maturity.md +++ b/doc/coinbase_maturity.md @@ -1,5 +1,16 @@ # The Coinbase Maturity Rule (aka Output Lock Heights) +> **Current implementation note:** Parts of the discussion below describe an +> early design in which an input supplied a Merkle proof against the block that +> created a coinbase output. The node does not use origin-header proofs for +> coinbase maturity. It validates maturity from the current UTXO position and +> the canonical cutoff header. Merkle proofs returned by the API are for the +> current output PMMR state only. This is intentional: compaction retains the +> maximal pruned-subtree roots needed by the current state, not every peak from +> every historical MMR size. A proof must therefore be verified with an output +> root whose MMR size matches the `mmr_size` embedded in the proof, not +> automatically with the output's originating block header. + *Read this in other languages: [Korean](translations/coinbase_maturity_KR.md), [简体中文](translations/coinbase_maturity_ZH-CN).* Coinbase outputs (block rewards & fees) are "locked" and require 1,440 confirmations (i.e 24 hours worth of blocks added to the chain) before they mature sufficiently to be spendable. This is to reduce the risk of later txs being reversed if a chain reorganization occurs. diff --git a/doc/releases/release-7.0.0.md b/doc/releases/release-7.0.0.md index 1dd3dcbcbb..69f9f70707 100644 --- a/doc/releases/release-7.0.0.md +++ b/doc/releases/release-7.0.0.md @@ -1465,6 +1465,7 @@ Declare all common creates for mwc-node and mwc-wallet in this crate. Having all - expose targeted orphan removal and make orphan draining best-effort but fallible, preserving peer attribution for bad child blocks while preventing stale orphan failures from invalidating an already accepted parent. - validate `output_pos` entries against the canonical body chain, rebuild the output position index once when a stale height is detected, and verify output PMMR positions fall inside the block header's output range before returning header data. - route transaction, input, coinbase maturity, replay, validation, root-setting, and Merkle-proof reads through robust read/readonly-marker paths, use checked next-block heights, rewind replay checks to the body head. +- define output Merkle proofs as current-PMMR-state proofs for unspent outputs, remove the origin-header argument and historical-size proof path, and document that callers must match the proof's `mmr_size` to the verification root. - harden PIBD segmenter/desegmenter setup by serializing cache misses under a write lock, validating archive headers against body-chain and header-PMMR state, building header-hash MMR data with the chain context id, returning explicit root mismatch errors, passing recovery state into desegmenters, adding stop/status-aware kernel-history validation. - make compaction stoppable and recovery-marked, chunk historical block deletion, recheck compaction eligibility after lock acquisition, choose the compaction horizon from the canonical body chain rather than the header chain, verify body/header PMMR alignment, rebuild indexes with stop support, run historical cleanup only after the compact batch commits. - make last-output/rangeproof/kernel and output-listing reads return `Result`, cap `last_index` to the local output PMMR size, validate output/rangeproof PMMR position alignment, and resolve block-height output bounds from the body chain with overflow and invalid-range checks. @@ -1654,6 +1655,7 @@ Declare all common creates for mwc-node and mwc-wallet in this crate. Having all - make leaf-set updates and authenticated output/rangeproof segment application fully fallible, validate all supplied segment positions are PMMR leaves, reject future leaf positions instead of silently skipping them, convert bitmap leaf indexes safely, report pruning of already-pruned leaves as invalid segment data. - update kernel application to save the durable kernel-position index, keep NRD rule enforcement tied to the saved commit position, reject hash data in non-prunable kernel segments, require segment leaf positions to match the current kernel PMMR size. - validate Merkle-proof output lookups against context-aware output identity, propagate PMMR proof errors directly, keep snapshot errors explicit, initialize bitmap accumulators with the chain context id and fallible leaf-index iteration. +- keep proof generation on the current output PMMR size and explicitly leave arbitrary historical proofs unsupported because compaction does not retain every historical peak. - rewrite txhashset rewind to prove the target header is on the canonical body chain, report rewind progress, rewind blocks one at a time, prefer stored spent indexes with controlled legacy fallbacks, reconstruct missing spent-index heights when possible, restore output/kernel indexes, rewind NRD state. - add genesis rebuild support, typed root extraction, explicit empty-genesis validation rules, MMR-size validation that only skips truly empty genesis state, typed PMMR validation for output, rangeproof, and kernel trees. - replace the old `Committed`-trait sum path with stop-aware batched `verify_kernel_sums_iter`, add state-validation progress stages, require explicit secp context, support an empty-genesis zero-offset shortcut, keep full rangeproof/kernel-signature verification behind the existing fast-validation flag. @@ -1871,7 +1873,7 @@ Declare all common creates for mwc-node and mwc-wallet in this crate. Having all - update with functions signature changes (no functionality changes). - add `OutputPrintable::deserialize_with_context`, make ordinary serde deserialization fail with a clear context-required error. - require `include_merkle_proof` flag for compact-block rendering. -- update tests for context-aware output deserialization, add coverage for invalid Merkle proofs and invalid commitments, verify short range proofs still parse while oversized proofs are rejected, and add a coinbase Merkle-proof regression that fetches the missing header through a temporary chain. +- update tests for context-aware output deserialization, add coverage for invalid Merkle proofs and invalid commitments, verify short range proofs still parse while oversized proofs are rejected, and add a coinbase Merkle-proof regression confirming that current-state proof generation does not require an origin header. **src/web.rs** - broaden `result_to_response` handling for internal, router, p2p, secp, IO, and chain errors. diff --git a/doc/releases/release-7.1.0.md b/doc/releases/release-7.1.0.md new file mode 100644 index 0000000000..5ddeedabfd --- /dev/null +++ b/doc/releases/release-7.1.0.md @@ -0,0 +1,41 @@ +# Release 7.1.0 + +## Fixed issues + +- Invalidate full blockchain data at the node start, add flag `--skip_start_blockchain_validation` to control this step. +- Address bunch of audit comments about data consistency. +- Range proof private nonce calculation is changes to address possible weakness. Because of the same reason removed not used extra data (to address possible future issue) +- Eliminate possible race conditions during Arti (Tor) stop/restart. +- Limit data volume at get_blocks REST API. +- Limit data for output API if proofs are requested. +- Address block v2 and v3 request and migraiton issues. Address problem that inputs can be sorted differently. +- Address block v2 and v3 compare problems (needed to track malicious node). +- Make reading from multiple DBs transactional (one of data consistency problem). +- Address possible PIBD retry race conditions (ignoring late responses). +- Address bunch of crash in the middle of blockchain issues. Many if such errors should be recoverable now. +- Handle long rewinds with duplicated commitments (realistic case for coinbase outputs). Update spent commitments index to support that functionality. +- Update Secp context creations to address performance issues. For frequent secp operations we can hold only one fast secp instance per thread. The next instances come with performance impact. +- Address possible race conditions for Desegmenter and Segmenter. +- Update how node difficulty cache can handle reorgs. +- Limit rewinds for archive node, so the behaviour will be the same as non archive nodes have. +- Handle better failures during save operations, do the best not to leak any data on failure. +- Address possible problems because of very slow peers. Timeouts now applied with more fair policy. +- Update log files rotation policy to save some HDD space. +- Address comment regarding logging, conventing logs from different libraries. +- Update peers eviction policy. Switch back to pure random. +- Rewind changes to make it more tolerant to posiible data corruption. +- Limit amount of calculations needed to update the transactions in the pool. +- Optimize Tx pool operations. Add indexes so we can deny already accepted or conflicting transactions fast. +- Data size checking for signature serialization. +- Tighten block and headers validation. Checking that all data across all DB matching each other. +- Update getting compact block REST API. Nonce was missing there. +- Make p2p protocol more DDoS proof and stale requests proof. +- Fixed secondary transaction submission problem. Second transaction submit can fluff, even first copy still at dandellion phase. +- Fixed bunch of orphan blocks caching problems. +- Optimize transaction submit workflow, so transaction pool will be more DDoS attack proof. +- Optimize dandelion pool and transaction pool to be able to handle large number of transactions efficiently. +- Update IP based peer tracking, switch to pure IP address without port to track peers. +- Fixed PIBD Segmenter size estimation issue for segments that are partly pruned. +- Handle server create/release race conditions. +- Limit PIBD root hash cache. +- Connect PIDB related requests with active Desegmenter. That allow to ignore late responses from the peers. \ No newline at end of file diff --git a/doc/replay_attack_mitigation.md b/doc/replay_attack_mitigation.md index 29afd2aee7..7e7a1e3890 100644 --- a/doc/replay_attack_mitigation.md +++ b/doc/replay_attack_mitigation.md @@ -46,6 +46,22 @@ iii) add height info when we build the output commitment. During the wallet scan trigger self spend workflow. Of course, user will loose some tx fee for each self-spend. There will be a self spend configuration on the QT wallet, user can opt out if they understand and want to take the risk. +## PIBD and reorganization behavior + +The spent-commitment index is a best-effort replay-mitigation cache, not a +consensus-complete history. PIBD restores the authenticated txhashset state but +does not download the old full block bodies used to reconstruct this cache. The +node therefore initializes an empty retained-body index after PIBD; the normal +one-by-one body sync populates it as each following block is validated. + +Index coverage or temporary readiness must never cause the chain to reset. +During synchronization, recovery, or a reorganization, the locally retained +body window can change temporarily. Treating that condition as proof that chain +state is invalid could reset otherwise healthy nodes at the same time. The node +continues with the history it has and performs replay detection on a best-effort +basis. In particular, a PIBD node may not detect a replay whose earlier spend is +only in pre-snapshot block history; the wallet-side mitigations above remain +part of the overall protection. diff --git a/doc/translations/coinbase_maturity_KR.md b/doc/translations/coinbase_maturity_KR.md index 20071237cc..081c938eb9 100644 --- a/doc/translations/coinbase_maturity_KR.md +++ b/doc/translations/coinbase_maturity_KR.md @@ -1,5 +1,14 @@ # Coinbase 만기 규칙 (A.K.A 출력 ) +> **현재 구현 참고:** 아래 내용 중 일부는 입력이 코인베이스 출력을 만든 +> 블록에 대한 Merkle proof를 제공하던 초기 설계를 설명합니다. 현재 노드는 +> 코인베이스 만기 검증에 생성 블록의 과거 proof를 사용하지 않습니다. 만기는 +> 현재 UTXO 위치와 정규 체인의 cutoff 헤더를 사용해 검증합니다. API가 반환하는 +> Merkle proof는 현재 output PMMR 상태에 대해서만 유효합니다. Compaction은 현재 +> 상태에 필요한 최대 pruned-subtree root만 보존하며 모든 과거 MMR 크기의 peak를 +> 보존하지 않습니다. 따라서 proof는 출력 생성 블록 헤더가 아니라 proof에 포함된 +> `mmr_size`와 일치하는 output root로 검증해야 합니다. + Coinbase 산출물 (블록 보상 및 수수료)은 "잠겨"있고 쓰는 것이 가능하기 전에 1,440개의 충분한 확인 (즉, 체인에 추가 된 이후 24 시간정도의 확인, *블록이 추가된 후 24시간 정도의 확인이 필요하다는 뜻 - 역자 주*)이 필요합니다. 이것은 체인 재구성(chain reorganization)이 발생할 경우 나중에 txs가 되돌려질 위험을 줄이기위한 것입니다. Bitcoin도 비슷한 과정을 거칩니다. Bitcoin은 마이닝 보상을 쓰기 전에 100 회의 확인 (Bitcoin 블록은 매 10 분, Mwc 블록은 매 60초)을 수행합니다. @@ -128,4 +137,4 @@ A pruned node may only store the following (refer to pruning doc) - * 원래 블록의 블록 해시 * [추후에 결정될 것 - Merkle proof 기반 인덱스를 유지할 것인가?] 실행값(commitment)과 기능을 통해 올바른 출력이 현재 사용되지 않은지 확인할 수 있습니다. -블록과 출력값로부터 lock height(있는 경우)를 결정할 수 있습니다. \ No newline at end of file +블록과 출력값로부터 lock height(있는 경우)를 결정할 수 있습니다. diff --git a/doc/translations/coinbase_maturity_ZH-CN.md b/doc/translations/coinbase_maturity_ZH-CN.md index c79173d96f..c8e33e333c 100644 --- a/doc/translations/coinbase_maturity_ZH-CN.md +++ b/doc/translations/coinbase_maturity_ZH-CN.md @@ -1,5 +1,12 @@ # Coinbase 期限规则(亦即"输出锁定高度") +> **当前实现说明:** 下文部分内容描述了早期设计,其中输入会提供针对 +> coinbase 输出创建区块的默克尔证明。当前节点不使用起源区块头证明来验证 +> coinbase 期限,而是根据当前 UTXO 位置和规范链的截止区块头进行验证。API +> 返回的默克尔证明只针对当前 output PMMR 状态。压缩只保留当前状态所需的最大 +> 已修剪子树根,并不保留每个历史 MMR 大小的所有峰。因此,验证证明时必须使用 +> 与证明内 `mmr_size` 相匹配的 output root,而不能直接使用输出的起源区块头。 + *阅读其它语言版本: [English](../coinbase_maturity.md), [Korean](coinbase_maturity_KR.md).* coinbase 输出(区块奖励和手续费)为“锁定”,需要有 1440 个确认(也就是经过约 24 小时在链上加上区块)才能到期花费。这是为了防止之后如果出现链上回滚的情况下,降低撤销之后交易的风险。 diff --git a/keychain/Cargo.toml b/keychain/Cargo.toml index b315b57bb3..1f5a5ab4a0 100644 --- a/keychain/Cargo.toml +++ b/keychain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_keychain" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Chain implementation for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,8 +10,8 @@ workspace = '..' edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true [dev-dependencies] diff --git a/keychain/src/extkey_bip32.rs b/keychain/src/extkey_bip32.rs index 337ad8b6d6..10212a3491 100644 --- a/keychain/src/extkey_bip32.rs +++ b/keychain/src/extkey_bip32.rs @@ -159,9 +159,13 @@ impl BIP32Hasher for BIP32MwcHasher { fn result_sha512(&mut self, output: &mut BIP32HmacOutput) -> Result<(), Error> { let hmac_sha512 = self .hmac_sha512 - .take() + .as_mut() .ok_or_else(|| Error::Generic("sha512 is not initialized".into()))?; hmac_sha512.finalize_into(output); + + // Direct assignment drops the Some payload in place. Do not use take(), + // because moving secret-bearing state can leave the old storage unwiped. + self.hmac_sha512 = None; Ok(()) } fn sha_256(&self, input: &[u8]) -> [u8; 32] { diff --git a/keychain/src/zeroizing_hmac_sha512.rs b/keychain/src/zeroizing_hmac_sha512.rs index b7d7210ee3..a1a6fc89c6 100644 --- a/keychain/src/zeroizing_hmac_sha512.rs +++ b/keychain/src/zeroizing_hmac_sha512.rs @@ -51,7 +51,7 @@ impl ZeroizingSha512 { buffer.digest_blocks(data, |blocks| core.update_blocks(blocks)); } - fn finalize_into(mut self, output: &mut Zeroizing<[u8; HMAC_SHA512_OUTPUT_SIZE]>) { + fn finalize_into(&mut self, output: &mut Zeroizing<[u8; HMAC_SHA512_OUTPUT_SIZE]>) { output.zeroize(); let output: &mut Output = (&mut **output).into(); self.core.finalize_variable_core(&mut self.buffer, output); @@ -108,13 +108,14 @@ impl ZeroizingHmacSha512 { self.inner.update(data); } - /// Consumes the HMAC state and writes the tag directly into zeroizing storage. - pub fn finalize_into(self, output: &mut Zeroizing<[u8; HMAC_SHA512_OUTPUT_SIZE]>) { - let Self { inner, mut outer } = self; + /// Finalizes the HMAC state in place and writes the tag into zeroizing storage. + /// + /// This is a terminal operation; the state should be dropped immediately afterward. + pub fn finalize_into(&mut self, output: &mut Zeroizing<[u8; HMAC_SHA512_OUTPUT_SIZE]>) { let mut inner_hash = Zeroizing::new([0u8; HMAC_SHA512_OUTPUT_SIZE]); - inner.finalize_into(&mut inner_hash); - outer.update(&inner_hash[..]); - outer.finalize_into(output); + self.inner.finalize_into(&mut inner_hash); + self.outer.update(&inner_hash[..]); + self.outer.finalize_into(output); } } diff --git a/mwc_crates/Cargo.toml b/mwc_crates/Cargo.toml index 8e5528fc48..b590f08f5d 100644 --- a/mwc_crates/Cargo.toml +++ b/mwc_crates/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_crates" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Shared third-party dependency facade for mwc-node and mwc-wallet" license = "Apache-2.0" @@ -28,17 +28,17 @@ regex = "1.12" serial_test = "3.4" # Tor / Arti onion-service stack used by `p2p/src/tor/*` and the Tor playground. -arti_client = { package = "arti-client", version = "0.43", default-features = false, features = ["tokio", "rustls", "onion-service-service", "onion-service-client", "pt-client", "keymgr", "ephemeral-keystore", "experimental-api"] } -safelog = "0.8.2" -tor_cell = { package = "tor-cell", version = "0.43.0" } -tor_config = { package = "tor-config", version = "0.43.0" } -tor_error = { package = "tor-error", version = "0.43.0" } -tor_hscrypto = { package = "tor-hscrypto", version = "0.43.0" } -tor_hsservice = { package = "tor-hsservice", version = "0.43.0" } -tor_keymgr = { package = "tor-keymgr", version = "0.43.0" } -tor_llcrypto = { package = "tor-llcrypto", version = "0.43.0" } -tor_proto = { package = "tor-proto", version = "0.43.0", features = ["stream-ctrl"] } -tor_rtcompat = { package = "tor-rtcompat", version = "0.43.0", default-features = false, features = ["tokio", "rustls"] } +arti_client = { package = "arti-client", version = "0.45", default-features = false, features = ["tokio", "rustls", "onion-service-service", "onion-service-client", "pt-client", "keymgr", "ephemeral-keystore", "experimental-api"] } +safelog = "0.9" +tor_cell = { package = "tor-cell", version = "0.45.0" } +tor_config = { package = "tor-config", version = "0.45.0" } +tor_error = { package = "tor-error", version = "0.45.0" } +tor_hscrypto = { package = "tor-hscrypto", version = "0.45.0" } +tor_hsservice = { package = "tor-hsservice", version = "0.45.0" } +tor_keymgr = { package = "tor-keymgr", version = "0.45.0" } +tor_llcrypto = { package = "tor-llcrypto", version = "0.45.0" } +tor_proto = { package = "tor-proto", version = "0.45.0", features = ["stream-ctrl"] } +tor_rtcompat = { package = "tor-rtcompat", version = "0.45.0", default-features = false, features = ["tokio", "rustls"] } tracing = "0.1" tracing_subscriber = { package = "tracing-subscriber", version = "0.3", default-features = false, features = ["registry", "std"] } # Windows build linking fix, need only include the crate, no usage requred diff --git a/mwc_node_lib/Cargo.toml b/mwc_node_lib/Cargo.toml index 94f69e6334..c03597ffbd 100644 --- a/mwc_node_lib/Cargo.toml +++ b/mwc_node_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_node_lib" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -19,10 +19,10 @@ path = "src/generate_headers.rs" [dependencies] safer-ffi.workspace = true -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_node_workflow = { path = "../node_workflow", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_p2p = { path = "../p2p", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } -mwc_servers = { path = "../servers", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_node_workflow = { path = "../node_workflow", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_p2p = { path = "../p2p", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } +mwc_servers = { path = "../servers", version = "7.1.0" } thiserror.workspace = true diff --git a/mwc_node_lib/src/mwc_node_calls.rs b/mwc_node_lib/src/mwc_node_calls.rs index 3d62ab2ce0..d42fe7d3ab 100644 --- a/mwc_node_lib/src/mwc_node_calls.rs +++ b/mwc_node_lib/src/mwc_node_calls.rs @@ -245,6 +245,8 @@ fn process_create_server_request(params: &mut Value) -> Result { // For this case we have to build the config by ourselves let context_id: u32 = get_object_param(params, "context_id")?; let db_root: String = get_object_param(params, "db_root")?; + let skip_start_blockchain_validation: bool = + get_object_param(params, "skip_start_blockchain_validation")?; let mut config = ServerConfig::default(); config.db_root = db_root; @@ -285,6 +287,7 @@ fn process_create_server_request(params: &mut Value) -> Result { context_id, config, Arc::new(mwc_util::StopState::new()), + skip_start_blockchain_validation, ) .map_err(|e| format!("Unable to start the node server, {}", e))?; Ok(json!({})) diff --git a/node_workflow/Cargo.toml b/node_workflow/Cargo.toml index 3490363ec1..96e8f0d4b7 100644 --- a/node_workflow/Cargo.toml +++ b/node_workflow/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_node_workflow" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,11 +10,11 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_api = { path = "../api", version = "7.0.0" } -mwc_chain = { path = "../chain", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_p2p = { path = "../p2p", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } -mwc_servers = { path = "../servers", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_api = { path = "../api", version = "7.1.0" } +mwc_chain = { path = "../chain", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_p2p = { path = "../p2p", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } +mwc_servers = { path = "../servers", version = "7.1.0" } thiserror.workspace = true diff --git a/node_workflow/src/server.rs b/node_workflow/src/server.rs index 0e19ff62ee..9f03b168a0 100644 --- a/node_workflow/src/server.rs +++ b/node_workflow/src/server.rs @@ -23,7 +23,7 @@ use mwc_crates::http_body_util::Full; use mwc_crates::hyper::service::Service; use mwc_crates::lazy_static::lazy_static; use mwc_crates::log::error; -use mwc_crates::parking_lot::RwLock; +use mwc_crates::parking_lot::{Condvar, Mutex, RwLock}; use mwc_crates::secp::{ContextFlag, Secp256k1}; use mwc_p2p::tor::arti; use mwc_p2p::TorConfig; @@ -31,17 +31,190 @@ use mwc_servers::{Server, ServerConfig, ServerStats}; use mwc_util::StopState; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Weak}; lazy_static! { /// Global chain status flags. It is expected that init call will set them first for every needed context /// Note, both node and wallet will need to set it up. Any param can be set once - static ref SERVER_CONTEXT: RwLock< HashMap> = RwLock::new(HashMap::new()); + static ref SERVER_CONTEXT: RwLock> = + RwLock::new(HashMap::new()); + + static ref SERVER_LIFECYCLE_CONTEXT: RwLock>> = + RwLock::new(HashMap::new()); - static ref SERVER_STARTUP_STOP_STATE: RwLock>> = + static ref CALL_ROUTER_CONTEXT: RwLock>> = RwLock::new(HashMap::new()); +} + +/// Identity token for one server instance stored under a reusable context ID. +struct ServerGeneration; + +struct RegisteredServer { + generation: Arc, + server: Server, +} + +impl RegisteredServer { + fn new(server: Server) -> Self { + RegisteredServer { + generation: Arc::new(ServerGeneration), + server, + } + } +} - static ref CALL_ROUTER_CONTEXT: RwLock< HashMap>> = RwLock::new(HashMap::new()); +fn same_server_generation( + current: &Arc, + expected: &Arc, +) -> bool { + Arc::ptr_eq(current, expected) +} + +enum ServerLifecycleState { + Idle, + Starting(Arc), + Releasing, +} + +struct ServerLifecycle { + /// Serializes create/release work for this context without blocking other contexts. + operation: Mutex<()>, + state: Mutex, + release_finished: Condvar, +} + +impl ServerLifecycle { + fn new() -> Self { + ServerLifecycle { + operation: Mutex::new(()), + state: Mutex::new(ServerLifecycleState::Idle), + release_finished: Condvar::new(), + } + } +} + +struct CallRouterEntry { + /// `None` permanently closes this generation after all active readers finish. + router: RwLock>, +} + +impl CallRouterEntry { + fn new(router: Router) -> Self { + CallRouterEntry { + router: RwLock::new(Some(router)), + } + } + + fn close_and_wait(&self) { + self.router.write().take(); + } +} + +enum BeginServerRelease { + Acquired(Option>), + CompletedByConcurrentRelease, +} + +fn server_lifecycle(context_id: u32) -> Arc { + if let Some(lifecycle) = SERVER_LIFECYCLE_CONTEXT + .read_recursive() + .get(&context_id) + .and_then(Weak::upgrade) + { + return lifecycle; + } + + let mut lifecycles = SERVER_LIFECYCLE_CONTEXT.write(); + // Context IDs are normally bounded, but release_server() is deliberately + // idempotent for arbitrary IDs. Prune completed weak entries so invalid calls + // cannot grow this registry indefinitely. + lifecycles.retain(|_, lifecycle| lifecycle.strong_count() > 0); + if let Some(lifecycle) = lifecycles.get(&context_id).and_then(Weak::upgrade) { + return lifecycle; + } + + let lifecycle = Arc::new(ServerLifecycle::new()); + lifecycles.insert(context_id, Arc::downgrade(&lifecycle)); + lifecycle +} + +fn register_server_start( + lifecycle: &ServerLifecycle, + stop_state: Arc, +) -> Result<(), Error> { + let mut state = lifecycle.state.lock(); + match &*state { + ServerLifecycleState::Starting(_) => Err(Error::ContextError( + "Node server is already starting for this context".into(), + )), + ServerLifecycleState::Releasing => Err(Error::ContextError( + "Node server is being released for this context".into(), + )), + ServerLifecycleState::Idle => { + *state = ServerLifecycleState::Starting(stop_state); + Ok(()) + } + } +} + +fn finish_server_start(lifecycle: &ServerLifecycle, stop_state: &Arc) { + let mut state = lifecycle.state.lock(); + let owns_starting_state = matches!( + &*state, + ServerLifecycleState::Starting(current) if Arc::ptr_eq(current, stop_state) + ); + if owns_starting_state { + *state = ServerLifecycleState::Idle; + } +} + +fn begin_server_release(lifecycle: &ServerLifecycle) -> BeginServerRelease { + let mut state = lifecycle.state.lock(); + if matches!(&*state, ServerLifecycleState::Releasing) { + while matches!(&*state, ServerLifecycleState::Releasing) { + lifecycle.release_finished.wait(&mut state); + } + return BeginServerRelease::CompletedByConcurrentRelease; + } + + match &*state { + ServerLifecycleState::Starting(stop_state) => { + let stop_state = stop_state.clone(); + *state = ServerLifecycleState::Releasing; + BeginServerRelease::Acquired(Some(stop_state)) + } + ServerLifecycleState::Idle => { + *state = ServerLifecycleState::Releasing; + BeginServerRelease::Acquired(None) + } + ServerLifecycleState::Releasing => unreachable!("handled above"), + } +} + +fn finish_server_release(lifecycle: &ServerLifecycle) { + let mut state = lifecycle.state.lock(); + if matches!(&*state, ServerLifecycleState::Releasing) { + *state = ServerLifecycleState::Idle; + lifecycle.release_finished.notify_all(); + } +} + +fn replace_call_router(context_id: u32, router: Router) { + let old_entry = CALL_ROUTER_CONTEXT + .write() + .insert(context_id, Arc::new(CallRouterEntry::new(router))); + if let Some(old_entry) = old_entry { + old_entry.close_and_wait(); + } +} + +fn remove_and_drain_call_router(context_id: u32) { + let entry = CALL_ROUTER_CONTEXT.write().remove(&context_id); + if let Some(entry) = entry { + // Existing calls hold a read guard. Taking the router waits for those calls, + // and makes stale Arc clones reject a late attempt to start. + entry.close_and_wait(); + } } /// Stop the server jobs and release the server. @@ -50,25 +223,34 @@ lazy_static! { /// context was already released or was never created, the call still succeeds /// after clearing any remaining per-context router/chain data. pub fn release_server(context_id: u32) { - let startup_stop_state = SERVER_STARTUP_STOP_STATE - .read_recursive() - .get(&context_id) - .cloned(); + let lifecycle = server_lifecycle(context_id); + // Publish the release before touching any per-context data. A replacement + // create_server() cannot start until all cleanup below has completed. + let startup_stop_state = match begin_server_release(&lifecycle) { + BeginServerRelease::Acquired(stop_state) => stop_state, + BeginServerRelease::CompletedByConcurrentRelease => return, + }; if let Some(stop_state) = &startup_stop_state { stop_state.stop(); } + let _operation = lifecycle.operation.lock(); let server = { let mut servers = SERVER_CONTEXT.write(); servers.remove(&context_id) }; - CALL_ROUTER_CONTEXT.write().remove(&context_id); + if let Some(server) = &server { + // Give active API operations a chance to stop before waiting for them. + server.server.stop_state.stop(); + } + remove_and_drain_call_router(context_id); if let Some(server) = server { - server.stop(); + server.server.stop(); mwc_chain::pipe::release_context_data(context_id); } else if startup_stop_state.is_none() { mwc_chain::pipe::release_context_data(context_id); } + finish_server_release(&lifecycle); } /// Tor client needs to be started once, no context_id is requred @@ -97,28 +279,29 @@ pub fn create_server( context_id: u32, config: ServerConfig, stop_state: Arc, + skip_start_blockchain_validation: bool, ) -> Result<(), Error> { - { - let mut startup_states = SERVER_STARTUP_STOP_STATE.write(); - if startup_states.contains_key(&context_id) { - return Err(Error::ContextError( - "Node server is already starting for this context".into(), - )); - } - startup_states.insert(context_id, stop_state.clone()); - } + let lifecycle = server_lifecycle(context_id); + let _operation = lifecycle.operation.lock(); + register_server_start(&lifecycle, stop_state.clone())?; if SERVER_CONTEXT.read_recursive().contains_key(&context_id) { - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); + finish_server_start(&lifecycle, &stop_state); return Err(Error::ContextError( "Node server already created for this context".into(), )); } + if context_id != 0 { + if let Err(e) = crate::context::get_chain_type(context_id) { + finish_server_start(&lifecycle, &stop_state); + return Err(e); + } + } let secp = match Secp256k1::with_caps(ContextFlag::Commit) { Ok(secp) => secp, Err(e) => { - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); + finish_server_start(&lifecycle, &stop_state); return Err(Error::ServerError(format!( "Secp instance creation error, {}", e @@ -126,11 +309,17 @@ pub fn create_server( } }; - let serv = match Server::create_server(&secp, context_id, config, stop_state.clone()) { + let serv = match Server::create_server( + &secp, + context_id, + config, + stop_state.clone(), + skip_start_blockchain_validation, + ) { Ok(serv) => serv, Err(e) => { - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); mwc_chain::pipe::release_context_data(context_id); + finish_server_start(&lifecycle, &stop_state); return Err(Error::ServerError(format!( "Unable to create server, {}", e @@ -139,43 +328,31 @@ pub fn create_server( }; if stop_state.is_stopped() { - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); serv.stop(); mwc_chain::pipe::release_context_data(context_id); + finish_server_start(&lifecycle, &stop_state); return Err(Error::ServerError( "Server start was cancelled during blockchain indexing".into(), )); } - let mut serv = Some(serv); - let inserted = { - let mut servers = SERVER_CONTEXT.write(); - if servers.contains_key(&context_id) { - false - } else { - servers.insert(context_id, serv.take().expect("server is present")); - true - } - }; - - if !inserted { - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); - if let Some(serv) = serv { - serv.stop(); - } - return Err(Error::ContextError( - "Node server already created for this context".into(), - )); - } - - SERVER_STARTUP_STOP_STATE.write().remove(&context_id); + SERVER_CONTEXT + .write() + .insert(context_id, RegisteredServer::new(serv)); if stop_state.is_stopped() { - release_server(context_id); + let serv = SERVER_CONTEXT + .write() + .remove(&context_id) + .expect("server was just inserted for this lifecycle operation"); + serv.server.stop(); + mwc_chain::pipe::release_context_data(context_id); + finish_server_start(&lifecycle, &stop_state); return Err(Error::ServerError( "Server start was cancelled during blockchain indexing".into(), )); } + finish_server_start(&lifecycle, &stop_state); Ok(()) } @@ -184,6 +361,7 @@ pub fn start_stratum(context_id: u32) -> Result<(), Error> { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { Some(serv) => serv + .server .start_stratum() .map_err(|e| Error::ServerError(format!("Unable to start stratum, {}", e)))?, None => { @@ -202,6 +380,7 @@ pub fn start_discover_peers(context_id: u32) -> Result<(), Error> { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { Some(serv) => serv + .server .start_discover_peers() .map_err(|e| Error::ServerError(format!("Unable to start discover peers, {}", e)))?, None => { @@ -219,6 +398,7 @@ pub fn start_sync_monitoring(context_id: u32) -> Result<(), Error> { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { Some(serv) => serv + .server .start_sync_monitoring() .map_err(|e| Error::ServerError(format!("Unable to start sync thread, {}", e)))?, None => { @@ -233,12 +413,15 @@ pub fn start_sync_monitoring(context_id: u32) -> Result<(), Error> { /// Start p2p listening job. Needed for inbound peers connection pub fn start_listen_peers(context_id: u32) -> Result<(), Error> { - let pending_listener = { + let (pending_listener, server_generation) = { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { - Some(serv) => serv.begin_start_listen_peers().map_err(|e| { - Error::ServerError(format!("Unable to start listening for peers, {}", e)) - })?, + Some(serv) => { + let pending_listener = serv.server.begin_start_listen_peers().map_err(|e| { + Error::ServerError(format!("Unable to start listening for peers, {}", e)) + })?; + (pending_listener, serv.generation.clone()) + } None => { return Err(Error::ServerError(format!( "Server not exist for context {}", @@ -251,22 +434,36 @@ pub fn start_listen_peers(context_id: u32) -> Result<(), Error> { let startup_result = pending_listener.wait_for_startup(); let mut servers = SERVER_CONTEXT.write(); + // Context IDs can be reused after release. Only finalize against the exact + // server instance that created this pending listener. match servers.get_mut(&context_id) { - Some(serv) => match startup_result { - Ok(started_listener) => { - serv.finish_start_listen_peers(started_listener) + Some(serv) if same_server_generation(&serv.generation, &server_generation) => { + match startup_result { + Ok(started_listener) => serv + .server + .finish_start_listen_peers(started_listener) .map_err(|e| { Error::ServerError(format!("Unable to start listening for peers, {}", e)) - })? + })?, + Err(e) => { + serv.server.finish_failed_listen_peers_startup(); + return Err(Error::ServerError(format!( + "Unable to start listening for peers, {}", + e + ))); + } } - Err(e) => { - serv.finish_failed_listen_peers_startup(); - return Err(Error::ServerError(format!( - "Unable to start listening for peers, {}", - e - ))); + } + Some(_) => { + drop(servers); + if let Ok(started_listener) = startup_result { + started_listener.wait_for_shutdown(); } - }, + return Err(Error::ServerError(format!( + "Server was replaced while peer listener was starting for context {}", + context_id + ))); + } None => { drop(servers); if let Ok(started_listener) = startup_result { @@ -286,6 +483,7 @@ pub fn start_rest_api(context_id: u32) -> Result<(), Error> { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { Some(serv) => serv + .server .start_rest_api() .map_err(|e| Error::ServerError(format!("Unable to start node rest api, {}", e)))?, None => { @@ -303,6 +501,7 @@ pub fn init_call_api(context_id: u32) -> Result<(), Error> { let servers = SERVER_CONTEXT.read_recursive(); let router = match servers.get(&context_id) { Some(serv) => serv + .server .build_api_router_no_secrets() .map_err(|e| Error::ServerError(format!("Unable to build node call api, {}", e)))?, None => { @@ -312,9 +511,7 @@ pub fn init_call_api(context_id: u32) -> Result<(), Error> { ))); } }; - CALL_ROUTER_CONTEXT - .write() - .insert(context_id, Arc::new(router)); + replace_call_router(context_id, router); Ok(()) } @@ -325,45 +522,45 @@ pub fn process_call( uri: String, body: String, ) -> Result>, Error> { - let router = { + let router_entry = { let routers = CALL_ROUTER_CONTEXT.read_recursive(); routers.get(&context_id).cloned() - }; - - match router { - Some(router) => { - let method = http::Method::from_bytes(method.as_bytes()).map_err(|e| { - Error::ServerError(format!("HTTP request get invalid method {}, {}", method, e)) - })?; - let uri = uri.parse::().map_err(|e| { - Error::ServerError(format!("HTTP request get invalid Uri {}, {}", uri, e)) - })?; - - let request = http::Request::builder() - .method(method) - .uri(uri) - .version(http::Version::HTTP_10) - .body(Bytes::from(body)) - .map_err(|e| Error::ServerError(format!("Unable to build a request, {}", e)))?; - - let res = router.call(request); - let response = futures::executor::block_on(res); - let response = match response { - Ok(response) => response, - Err(e) => { - error!("Unable to process API request, {}", e); - return Err(Error::ServerError(format!( - "Unable to process API request, {}", - e - ))); - } - }; - Ok(response) - } - None => Err(Error::ServerError(format!( - "Call API not exist for context {}", + } + .ok_or_else(|| Error::ServerError(format!("Call API not exist for context {}", context_id)))?; + + let method = http::Method::from_bytes(method.as_bytes()).map_err(|e| { + Error::ServerError(format!("HTTP request get invalid method {}, {}", method, e)) + })?; + let uri = uri + .parse::() + .map_err(|e| Error::ServerError(format!("HTTP request get invalid Uri {}, {}", uri, e)))?; + + let request = http::Request::builder() + .method(method) + .uri(uri) + .version(http::Version::HTTP_10) + .body(Bytes::from(body)) + .map_err(|e| Error::ServerError(format!("Unable to build a request, {}", e)))?; + + // Keep this per-context read guard until the response is complete. Shutdown + // removes the registry entry, then takes the write guard to drain these calls. + let router_guard = router_entry.router.read_recursive(); + let router = router_guard.as_ref().ok_or_else(|| { + Error::ServerError(format!( + "Call API is shutting down for context {}", context_id - ))), + )) + })?; + let response = futures::executor::block_on(router.call(request)); + match response { + Ok(response) => Ok(response), + Err(e) => { + error!("Unable to process API request, {}", e); + Err(Error::ServerError(format!( + "Unable to process API request, {}", + e + ))) + } } } @@ -372,6 +569,7 @@ pub fn start_dandelion(context_id: u32) -> Result<(), Error> { let mut servers = SERVER_CONTEXT.write(); match servers.get_mut(&context_id) { Some(serv) => serv + .server .start_dandelion() .map_err(|e| Error::ServerError(format!("Unable to start dandelion, {}", e)))?, None => { @@ -388,7 +586,7 @@ pub fn start_dandelion(context_id: u32) -> Result<(), Error> { pub fn get_server_stats(context_id: u32) -> Result { match SERVER_CONTEXT.try_read_recursive() { Some(servers) => match servers.get(&context_id) { - Some(serv) => Ok(serv.get_server_stats().map_err(|e| { + Some(serv) => Ok(serv.server.get_server_stats().map_err(|e| { Error::ServerError(format!("Unable to get server stat data, {}", e)) })?), None => Err(Error::ServerError(format!( @@ -399,3 +597,173 @@ pub fn get_server_stats(context_id: u32) -> Result { None => Err(Error::ServerError("Server is busy".into())), } } + +#[cfg(test)] +mod tests { + use super::*; + use mwc_api::{Handler, ResponseFuture}; + use std::sync::{mpsc, Mutex as StdMutex}; + use std::thread; + use std::time::{Duration, Instant}; + + struct BlockingHandler { + started_tx: mpsc::SyncSender<()>, + continue_rx: StdMutex>, + } + + impl Handler for BlockingHandler { + fn get(&self, _req: http::Request) -> ResponseFuture { + self.started_tx + .send(()) + .expect("test must wait for the handler to start"); + self.continue_rx + .lock() + .expect("test continue channel mutex must not be poisoned") + .recv() + .expect("test must allow the handler to finish"); + Box::pin(futures::future::ok(http::Response::new(Full::new( + Bytes::new(), + )))) + } + } + + #[test] + fn listener_start_generation_does_not_match_replacement() { + let original_generation = Arc::new(ServerGeneration); + let pending_generation = original_generation.clone(); + let replacement_generation = Arc::new(ServerGeneration); + + assert!(same_server_generation( + &original_generation, + &pending_generation + )); + assert!(!same_server_generation( + &replacement_generation, + &pending_generation + )); + } + + #[test] + fn release_marker_blocks_replacement_until_cleanup_finishes() { + let context_id = u32::MAX; + let lifecycle = server_lifecycle(context_id); + let starting_stop_state = Arc::new(StopState::new()); + register_server_start(&lifecycle, starting_stop_state.clone()).unwrap(); + + let registered_stop_state = match begin_server_release(&lifecycle) { + BeginServerRelease::Acquired(Some(stop_state)) => stop_state, + _ => panic!("release did not take ownership of the active startup"), + }; + assert!(Arc::ptr_eq(®istered_stop_state, &starting_stop_state)); + registered_stop_state.stop(); + + // Cleanup by the displaced startup must not remove the release marker. + finish_server_start(&lifecycle, &starting_stop_state); + let replacement_stop_state = Arc::new(StopState::new()); + assert!(register_server_start(&lifecycle, replacement_stop_state.clone()).is_err()); + + let concurrent_lifecycle = lifecycle.clone(); + let (waiting_tx, waiting_rx) = mpsc::sync_channel(1); + let (finished_tx, finished_rx) = mpsc::sync_channel(1); + let concurrent_release = thread::spawn(move || { + waiting_tx.send(()).unwrap(); + let result = begin_server_release(&concurrent_lifecycle); + finished_tx + .send(matches!( + result, + BeginServerRelease::CompletedByConcurrentRelease + )) + .unwrap(); + }); + waiting_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(finished_rx + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + finish_server_release(&lifecycle); + assert!(finished_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + concurrent_release.join().unwrap(); + + register_server_start(&lifecycle, replacement_stop_state.clone()).unwrap(); + finish_server_start(&lifecycle, &replacement_stop_state); + } + + #[test] + fn release_waits_for_active_call_and_closes_stale_router_clones() { + let context_id = u32::MAX - 1; + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (continue_tx, continue_rx) = mpsc::sync_channel(1); + let mut router = Router::new(); + router + .add_route( + "/block", + Arc::new(BlockingHandler { + started_tx, + continue_rx: StdMutex::new(continue_rx), + }), + ) + .unwrap(); + replace_call_router(context_id, router); + let stale_entry = CALL_ROUTER_CONTEXT + .read_recursive() + .get(&context_id) + .cloned() + .unwrap(); + + let call_thread = thread::spawn(move || { + process_call(context_id, "GET".into(), "/block".into(), String::new()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let (released_tx, released_rx) = mpsc::sync_channel(1); + let release_thread = thread::spawn(move || { + release_server(context_id); + released_tx.send(()).unwrap(); + }); + + let deadline = Instant::now() + Duration::from_secs(1); + while CALL_ROUTER_CONTEXT + .read_recursive() + .contains_key(&context_id) + { + assert!( + Instant::now() < deadline, + "release did not revoke the router" + ); + thread::yield_now(); + } + assert!(released_rx + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + continue_tx.send(()).unwrap(); + assert!(call_thread.join().unwrap().is_ok()); + released_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + release_thread.join().unwrap(); + assert!(stale_entry.router.read_recursive().is_none()); + } + + #[test] + fn lifecycle_operations_are_scoped_to_one_context() { + let first = server_lifecycle(u32::MAX - 2); + let second = server_lifecycle(u32::MAX - 3); + let _first_operation = first.operation.lock(); + + assert!(second.operation.try_lock().is_some()); + } + + #[test] + fn completed_lifecycle_entries_do_not_stay_alive() { + let context_id = u32::MAX - 4; + let prune_trigger_id = u32::MAX - 5; + let lifecycle = server_lifecycle(context_id); + let lifecycle_weak = Arc::downgrade(&lifecycle); + drop(lifecycle); + + assert!(lifecycle_weak.upgrade().is_none()); + drop(server_lifecycle(prune_trigger_id)); + assert!(!SERVER_LIFECYCLE_CONTEXT + .read_recursive() + .contains_key(&context_id)); + } +} diff --git a/p2p/Cargo.toml b/p2p/Cargo.toml index 2410927426..2ff11ed723 100644 --- a/p2p/Cargo.toml +++ b/p2p/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_p2p" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Chain implementation for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,9 +10,9 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_store = { path = "../store", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } -mwc_chain = { path = "../chain", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_store = { path = "../store", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } +mwc_chain = { path = "../chain", version = "7.1.0" } thiserror.workspace = true diff --git a/p2p/src/conn.rs b/p2p/src/conn.rs index 5eeea49e31..d3e1150555 100644 --- a/p2p/src/conn.rs +++ b/p2p/src/conn.rs @@ -168,6 +168,8 @@ impl StopHandle { } pub fn wait(&mut self) -> Result<(), Error> { + // Keeping the first thread error is sufficient for debugging the failed + // shutdown. Both threads are still joined, so any later error can be dropped. let mut first_error = None; if let Some(reader_thread) = self.reader_thread.take() { if let Err(e) = Self::join_thread(reader_thread) { diff --git a/p2p/src/handshake.rs b/p2p/src/handshake.rs index 840298f9a1..0331e00ddc 100644 --- a/p2p/src/handshake.rs +++ b/p2p/src/handshake.rs @@ -15,7 +15,7 @@ use crate::conn::Tracker; use crate::msg::{ - read_body, read_header, read_message, write_message, Hand, Msg, MsgHeaderWrapper, Shake, Type, + read_body, read_header, write_message, Hand, Msg, MsgHeaderWrapper, Shake, Type, ONION_PROOF_SIGNATURE_LEN, USER_AGENT, }; use crate::peer::Peer; @@ -35,6 +35,7 @@ use mwc_crates::log::{debug, info, trace}; use mwc_crates::parking_lot::RwLock; use mwc_crates::rand::rngs::SysRng; use mwc_crates::rand::TryRng; +use mwc_crates::tor_llcrypto::pk::ed25519::ExpandedKeypair; use mwc_crates::zeroize::Zeroizing; use mwc_util::OnionV3Address; use std::collections::{HashMap, VecDeque}; @@ -104,6 +105,41 @@ fn read_hand_message( } } +fn read_shake_message( + conn: &mut TcpDataStream, + version: ProtocolVersion, + context_id: u32, + timeout: Duration, +) -> Result { + let mut reader = conn.deadline_reader(timeout); + let shake = match read_header(&mut reader, version, context_id) { + Ok(MsgHeaderWrapper::Known(header)) => { + if header.msg_type == Type::Shake { + read_body(&header, &mut reader, version, context_id) + } else { + Err(Error::BadMessage(format!( + "header.msg_type={:?} but expected {:?}", + header.msg_type, + Type::Shake + ))) + } + } + Ok(MsgHeaderWrapper::Unknown(msg_len, tp)) => Err(Error::BadMessage(format!( + "Unknown message of length {} and type {} while expecting {:?}", + msg_len, + tp, + Type::Shake + ))), + Err(err) => Err(err), + }; + shake.map_err(|err| match err { + Error::Serialization(err) => { + bad_handshake(format!("invalid Shake message from peer: {}", err)) + } + err => err, + }) +} + /// Handles the handshake negotiation when two peers connect and decides on /// protocol. pub struct Handshake { @@ -125,7 +161,10 @@ pub struct Handshake { context_id: u32, tracker: Arc, pub onion_address: Option, - onion_expanded_key: Option>, + // Parse once during setup so every outbound handshake does not create another + // temporary copy of the onion identity secret. ExpandedKeypair's secret is + // zeroized when dropped. + onion_signing_key: Result, &'static str>, } impl Handshake { @@ -137,6 +176,11 @@ impl Handshake { onion_address: Option, onion_expanded_key: Option>, ) -> Handshake { + let onion_signing_key = onion_expanded_key + .as_ref() + .map(parse_onion_expanded_key) + .transpose(); + Handshake { nonces: Arc::new(RwLock::new(VecDeque::with_capacity(NONCES_CAP))), addrs: Arc::new(RwLock::new(VecDeque::with_capacity(ADDRS_CAP))), @@ -145,7 +189,7 @@ impl Handshake { ))), genesis, // Accepted risk: config.onion_expanded_key may keep the original onion - // identity key string alive as a duplicate of the parsed Zeroizing key + // identity key string alive as a duplicate of the parsed signing key // below. Keep the full P2PConfig here because handshake validation uses // the peer policy settings from it, and callers rely on the complete // config being preserved across handshake setup. P2PConfig zeroizes this @@ -155,7 +199,7 @@ impl Handshake { context_id, tracker: Arc::new(Tracker::new()), onion_address: onion_address, - onion_expanded_key, + onion_signing_key, } } @@ -351,11 +395,14 @@ impl Handshake { Onion(onion) => onion, Ip(_) => return Ok(None), }; - let expanded_key = self.onion_expanded_key.as_ref().ok_or_else(|| { - Error::TorConfig("onion identity key is required to advertise onion address".into()) - })?; - let keypair = parse_onion_expanded_key(&*expanded_key) - .map_err(|e| Error::TorConfig(format!("invalid onion identity key, {}", e)))?; + let keypair = self + .onion_signing_key + .as_ref() + .map_err(|e| Error::TorConfig(format!("invalid onion identity key, {}", e)))? + .as_ref() + .ok_or_else(|| { + Error::TorConfig("onion identity key is required to advertise onion address".into()) + })?; let sender_onion_addr = OnionV3Address::try_from(sender_onion.as_str()).map_err(|e| { Error::TorConfig(format!( "unable to parse local onion address {}: {}", @@ -513,13 +560,14 @@ impl Handshake { let msg = Msg::new(Type::Hand, hand, self.protocol_version, self.context_id)?; write_message(conn, &vec![msg], self.tracker.clone())?; - let shake: Shake = read_message(conn, self.protocol_version, self.context_id, Type::Shake) - .map_err(|err| match err { - Error::Serialization(err) => { - bad_handshake(format!("invalid Shake message from peer: {}", err)) - } - err => err, - })?; + // Bound the complete Shake response to one deadline. Repeated partial reads + // can otherwise extend a per-read timeout far beyond its intended limit. + let shake = read_shake_message( + conn, + self.protocol_version, + self.context_id, + SHAKE_READ_TIMEOUT, + )?; if shake.genesis != self.genesis { return Err(Error::GenesisMismatch { us: self.genesis, @@ -577,10 +625,15 @@ impl Handshake { // Set explicit timeouts on the tcp stream for hand/shake messages. // Once the peer is up and running we will set new values for these. // We accept an inbound connection, reading a Hand then writing a Shake reply. - let _ = conn.set_read_timeout(HAND_READ_TIMEOUT); - let _ = conn.set_write_timeout(SHAKE_WRITE_TIMEOUT); - - let hand: Hand = read_hand_message(conn, self.protocol_version, self.context_id)?; + conn.set_read_timeout(HAND_READ_TIMEOUT); + conn.set_write_timeout(SHAKE_WRITE_TIMEOUT); + + // A per-read idle timeout is insufficient here because read_exact may make + // repeated partial reads. Bound the complete Hand message to one deadline. + let hand: Hand = { + let mut reader = conn.deadline_reader(HAND_READ_TIMEOUT); + read_hand_message(&mut reader, self.protocol_version, self.context_id)? + }; if hand.genesis != self.genesis { return Err(Error::GenesisMismatch { @@ -735,7 +788,19 @@ fn resolve_peer_addr(hand: &Hand, conn: &TcpDataStream) -> Result resolve_ip_peer_addr(socket_addr, conn.peer_addr()), Onion(_) => match conn.peer_addr() { Err(Error::IpAddressRequestFromTor) => Ok(advertised.clone()), - Ok(PeerAddr::Ip(_)) => Ok(advertised.clone()), + Ok(PeerAddr::Ip(_)) => { + // Accepted policy: resolving an onion identity over a TCP transport + // discards the observed socket IP for peer allow/deny and ban checks. + // Consequently, a source covered by an IP peer-deny rule can still be + // accepted under its onion identity. The socket IP may belong to a + // shared Tor exit or gateway rather than to the peer, so persistently + // treating it as the peer identity would cause collateral blocking and + // undermine Tor connectivity. IP rules are therefore peer-identity + // rules, not firewall-style transport rules; transport-level blocking + // must be enforced separately at the listener or firewall boundary. + // Onion identity authentication is handled by verify_onion_hand_proof. + Ok(advertised.clone()) + } Ok(addr) => Err(bad_handshake(format!( "cannot verify advertised onion sender address {} over non-Tor transport {}", advertised, addr @@ -806,6 +871,8 @@ mod tests { use mwc_core::ser; use mwc_crates::tokio::net::{TcpListener, TcpStream}; use mwc_crates::tor_llcrypto::pk::ed25519::{ExpandedKeypair, Keypair}; + use std::io::Write; + use std::time::Instant; fn onion_from_seed(seed: &[u8; 32]) -> String { format!( @@ -888,6 +955,104 @@ mod tests { } } + #[test] + fn read_shake_message_rejects_unknown_type_without_body_discard() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + mwc_util::init_global_runtime().unwrap(); + let async_rt = mwc_util::global_runtime().unwrap(); + let (client, server) = async_rt.block_on(async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = listener.accept().await.unwrap(); + (client, server) + }); + let mut client = client.into_std().unwrap(); + client.set_nonblocking(false).unwrap(); + let mut conn = TcpDataStream::from_tcp(server); + let mut header = ser::ser_vec( + 0, + &MsgHeader::new(0, Type::Ping, 10), + ProtocolVersion::local(), + ) + .unwrap(); + header[2] = 255; + client.write_all(&header).unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + + match read_shake_message( + &mut conn, + ProtocolVersion::local(), + 0, + Duration::from_secs(1), + ) { + Err(Error::BadMessage(message)) => { + assert!(message.contains("while expecting Shake"), "{}", message); + } + Ok(_) => panic!("expected BadMessage, got decoded Shake"), + Err(err) => panic!("expected BadMessage, got {:?}", err), + } + } + + #[test] + fn outbound_shake_read_has_total_deadline() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + mwc_util::init_global_runtime().unwrap(); + let async_rt = mwc_util::global_runtime().unwrap(); + let (client, server) = async_rt.block_on(async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = listener.accept().await.unwrap(); + (client, server) + }); + let mut client = client.into_std().unwrap(); + client.set_nonblocking(false).unwrap(); + let mut conn = TcpDataStream::from_tcp(server); + conn.set_read_timeout(Duration::from_secs(2)); + let header = ser::ser_vec( + 0, + &MsgHeader::new(0, Type::Shake, 0), + ProtocolVersion::local(), + ) + .unwrap(); + + let writer = std::thread::spawn(move || { + for byte in header.into_iter().take(3) { + if client.write_all(&[byte]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(30)); + } + }); + + let start = Instant::now(); + let err = match read_shake_message( + &mut conn, + ProtocolVersion::local(), + 0, + Duration::from_millis(50), + ) { + Err(err) => err, + Ok(_) => panic!("expected the Shake read to time out"), + }; + let elapsed = start.elapsed(); + drop(conn); + writer.join().unwrap(); + + match err { + Error::Connection(err) => { + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + } + err => panic!("expected connection timeout, got {:?}", err), + } + assert!( + elapsed < Duration::from_secs(1), + "total deadline was not enforced: {:?}", + elapsed + ); + } + #[test] fn resolve_ip_peer_addr_uses_transport_ip_and_advertised_port() { let advertised: SocketAddr = "198.51.100.25:3414".parse().unwrap(); diff --git a/p2p/src/peer.rs b/p2p/src/peer.rs index 24194f3c7d..1b63e71a9a 100644 --- a/p2p/src/peer.rs +++ b/p2p/src/peer.rs @@ -15,6 +15,7 @@ use crate::serv::Server; use mwc_crates::parking_lot::{Condvar, Mutex, RwLock}; +use std::collections::VecDeque; use std::fmt; use std::num::NonZeroUsize; use std::path::PathBuf; @@ -51,6 +52,8 @@ use mwc_crates::secp::Secp256k1; const MAX_TRACK_SIZE: usize = 2500; // Currently mac income peers limit is 256, the tracking must be much larger const MAX_PEER_MSG_PER_MIN: u64 = 1000; +const HEADER_REQUEST_TIMEOUT: Duration = + Duration::from_secs(mwc_chain::pibd_params::PIBD_REQUESTS_TIMEOUT_SECS as u64); #[cfg(not(test))] const PEER_STARTING_WAIT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(test)] @@ -508,7 +511,16 @@ impl Peer { /// Sends a request for block headers from the provided block locator pub fn send_header_request(&self, locator: Vec) -> Result<(), Error> { - self.send(&Locator { hashes: locator }, msg::Type::GetHeaders) + let request_id = self.tracking_adapter.next_request_id(); + let mut header_requests = self.tracking_adapter.header_requests.lock(); + header_requests.register(request_id, locator.clone(), Instant::now()); + + if let Err(e) = self.send(&Locator { hashes: locator }, msg::Type::GetHeaders) { + header_requests.rollback(request_id); + return Err(e); + } + + Ok(()) } pub fn send_tx_request(&self, h: Hash) -> Result<(), Error> { @@ -729,6 +741,7 @@ struct TrackingAdapter { adapter: Arc, received: Arc>>, requested: Arc>>, + header_requests: Arc>, next_request_id: Arc, context_id: u32, } @@ -745,6 +758,153 @@ struct RequestRestore { previous: Option, } +#[derive(Debug)] +struct HeaderRequestEntry { + id: u64, + locator: Vec, + requested_at: Instant, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HeaderResponseState { + Idle, + Receiving { expected_prev_hash: Hash }, + Rejecting, +} + +#[derive(Debug)] +struct HeaderRequestTracker { + // Header responses are produced in request order by the peer protocol. Keep + // the full locator because a fork response can start after any common hash, + // not necessarily after the newest (first) locator hash. + pending: VecDeque, + // The codec delivers one Headers message in 32-header fragments. This state + // makes all fragments share one request ticket and prevents rejected trailing + // fragments from being mistaken for a new response. + response_state: HeaderResponseState, +} + +impl HeaderRequestTracker { + fn new() -> Self { + Self { + pending: VecDeque::new(), + response_state: HeaderResponseState::Idle, + } + } + + fn register(&mut self, id: u64, locator: Vec, now: Instant) { + // Higher-level scheduling keeps at most three GetHeaders requests outstanding + // per peer. Responses consume their entries in wire order, and expired entries + // are removed here before a new request is registered. MAX_TRACK_SIZE is thus + // only a defensive memory bound for behavior outside that scheduling contract; + // normal operation never evicts a live request at this point. + self.prune_expired(now); + if self.pending.len() >= MAX_TRACK_SIZE { + self.pending.pop_front(); + } + self.pending.push_back(HeaderRequestEntry { + id, + locator, + requested_at: now, + }); + } + + fn rollback(&mut self, id: u64) { + if let Some(position) = self.pending.iter().position(|entry| entry.id == id) { + self.pending.remove(position); + } + } + + fn prune_expired(&mut self, now: Instant) { + while self.pending.front().map_or(false, |entry| { + now.saturating_duration_since(entry.requested_at) >= HEADER_REQUEST_TIMEOUT + }) { + self.pending.pop_front(); + } + } + + fn admit_headers( + &mut self, + headers: &[core::BlockHeader], + remaining: u64, + context_id: u32, + now: Instant, + ) -> Result { + self.prune_expired(now); + + let first = match headers.first() { + Some(first) => first, + None => { + self.response_state = if remaining == 0 { + HeaderResponseState::Idle + } else { + HeaderResponseState::Rejecting + }; + return Ok(false); + } + }; + + match self.response_state { + HeaderResponseState::Rejecting => { + if remaining == 0 { + self.response_state = HeaderResponseState::Idle; + } + Ok(false) + } + HeaderResponseState::Receiving { expected_prev_hash } => { + if first.prev_hash != expected_prev_hash { + self.reject_through_end(remaining); + return Ok(false); + } + self.accept_fragment(headers, remaining, context_id) + } + HeaderResponseState::Idle => { + let matches_oldest_request = self + .pending + .front() + .map_or(false, |entry| entry.locator.contains(&first.prev_hash)); + if !matches_oldest_request { + self.reject_through_end(remaining); + return Ok(false); + } + + self.pending.pop_front(); + self.accept_fragment(headers, remaining, context_id) + } + } + } + + fn accept_fragment( + &mut self, + headers: &[core::BlockHeader], + remaining: u64, + context_id: u32, + ) -> Result { + if remaining == 0 { + self.response_state = HeaderResponseState::Idle; + return Ok(true); + } + + // Reject later fragments if hashing the accepted boundary header fails. + self.response_state = HeaderResponseState::Rejecting; + let last = match headers.last() { + Some(last) => last, + None => return Ok(false), + }; + let expected_prev_hash = last.hash(context_id)?; + self.response_state = HeaderResponseState::Receiving { expected_prev_hash }; + Ok(true) + } + + fn reject_through_end(&mut self, remaining: u64) { + self.response_state = if remaining == 0 { + HeaderResponseState::Idle + } else { + HeaderResponseState::Rejecting + }; + } +} + impl TrackingAdapter { fn new(context_id: u32, adapter: Arc) -> TrackingAdapter { // unwrap safe because build from positive constant @@ -753,6 +913,7 @@ impl TrackingAdapter { adapter, received: Arc::new(RwLock::new(LruCache::new(track_size))), requested: Arc::new(RwLock::new(LruCache::new(track_size))), + header_requests: Arc::new(Mutex::new(HeaderRequestTracker::new())), next_request_id: Arc::new(AtomicU64::new(0)), context_id, } @@ -906,6 +1067,20 @@ impl ChainAdapter for TrackingAdapter { remaining: u64, peer_info: &PeerInfo, ) -> Result<(), mwc_chain::Error> { + let admitted = self.header_requests.lock().admit_headers( + bh, + remaining, + self.context_id, + Instant::now(), + )?; + if !admitted { + debug!( + "Ignoring unsolicited or mismatched headers response from {}", + peer_info.addr + ); + return Ok(()); + } + // Batch headers are only expected on the sync response path. // These headers are normally far from the tip, so we do not track // them as "already seen" for broadcast suppression. @@ -1596,7 +1771,7 @@ mod tests { } #[test] - fn clean_peers_preserves_dead_ping_peer_when_liveness_deferred() { + fn clean_peers_preserves_dead_ping_peer_across_deferred_cleanup_passes() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); struct LivenessGuard(bool); @@ -1619,16 +1794,20 @@ mod tests { ); peers.add_connected(peer).unwrap(); - let summary = peers.clean_peers( - usize::MAX, - usize::MAX, - Capabilities::UNKNOWN, - P2PConfig::default(), - ); + // This exceeds the former five-pass performance-strike threshold. Deferred + // liveness must not accumulate state that eventually evicts the peer. + for _ in 0..6 { + let summary = peers.clean_peers( + usize::MAX, + usize::MAX, + Capabilities::UNKNOWN, + P2PConfig::default(), + ); - assert_eq!(summary.removed_peers, 0); - assert!(peers.get_connected_peer(&addr).is_some()); - assert!(peers.is_known(&addr)); + assert_eq!(summary.removed_peers, 0); + assert!(peers.get_connected_peer(&addr).is_some()); + assert!(peers.is_known(&addr)); + } } #[test] @@ -1701,6 +1880,184 @@ mod tests { assert!(!peers.is_known(&addr)); } + fn header_with_prev_hash(prev_hash: Hash) -> core::BlockHeader { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + let mut header = core::BlockHeader::default(0); + header.prev_hash = prev_hash; + header + } + + #[test] + fn header_response_matches_any_hash_in_requested_locator() { + let now = Instant::now(); + let newest_locator = Hash::from_vec(&[10]); + let lower_fork_locator = Hash::from_vec(&[11]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![newest_locator, lower_fork_locator], now); + + let response = header_with_prev_hash(lower_fork_locator); + + assert!(tracker.admit_headers(&[response], 0, 0, now).unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn unsolicited_header_response_is_rejected() { + let now = Instant::now(); + let mut tracker = HeaderRequestTracker::new(); + let response = header_with_prev_hash(Hash::from_vec(&[12])); + + assert!(!tracker.admit_headers(&[response], 0, 0, now).unwrap()); + assert_eq!(tracker.response_state, HeaderResponseState::Idle); + } + + #[test] + fn mismatched_header_response_does_not_consume_request() { + let now = Instant::now(); + let requested_locator = Hash::from_vec(&[13]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![requested_locator], now); + + let mismatched = header_with_prev_hash(Hash::from_vec(&[14])); + assert!(!tracker.admit_headers(&[mismatched], 0, 0, now).unwrap()); + assert_eq!(tracker.pending.len(), 1); + + let matched = header_with_prev_hash(requested_locator); + assert!(tracker.admit_headers(&[matched], 0, 0, now).unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn fragmented_header_response_uses_one_request_ticket() { + let now = Instant::now(); + let first_locator = Hash::from_vec(&[15]); + let second_locator = Hash::from_vec(&[16]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![first_locator], now); + tracker.register(2, vec![second_locator], now); + + let first_fragment = header_with_prev_hash(first_locator); + let continuation_anchor = first_fragment.hash(0).unwrap(); + assert!(tracker.admit_headers(&[first_fragment], 1, 0, now).unwrap()); + assert_eq!(tracker.pending.len(), 1); + + let final_fragment = header_with_prev_hash(continuation_anchor); + assert!(tracker.admit_headers(&[final_fragment], 0, 0, now).unwrap()); + assert_eq!(tracker.pending.len(), 1); + + let second_response = header_with_prev_hash(second_locator); + assert!(tracker + .admit_headers(&[second_response], 0, 0, now) + .unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn broken_header_fragment_link_rejects_rest_of_response() { + let now = Instant::now(); + let first_locator = Hash::from_vec(&[23]); + let next_locator = Hash::from_vec(&[24]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![first_locator], now); + tracker.register(2, vec![next_locator], now); + + let first_fragment = header_with_prev_hash(first_locator); + assert!(tracker.admit_headers(&[first_fragment], 2, 0, now).unwrap()); + + let broken_continuation = header_with_prev_hash(Hash::from_vec(&[25])); + assert!(!tracker + .admit_headers(&[broken_continuation], 1, 0, now) + .unwrap()); + assert_eq!(tracker.response_state, HeaderResponseState::Rejecting); + + let trailing_fragment = header_with_prev_hash(next_locator); + assert!(!tracker + .admit_headers(std::slice::from_ref(&trailing_fragment), 0, 0, now) + .unwrap()); + assert_eq!(tracker.pending.len(), 1); + + assert!(tracker + .admit_headers(&[trailing_fragment], 0, 0, now) + .unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn rejected_header_response_discards_all_its_fragments() { + let now = Instant::now(); + let requested_locator = Hash::from_vec(&[17]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![requested_locator], now); + + let unsolicited_first_fragment = header_with_prev_hash(Hash::from_vec(&[18])); + assert!(!tracker + .admit_headers(&[unsolicited_first_fragment], 1, 0, now) + .unwrap()); + assert_eq!(tracker.response_state, HeaderResponseState::Rejecting); + + let matching_but_same_response = header_with_prev_hash(requested_locator); + let matching_fragment = std::slice::from_ref(&matching_but_same_response); + assert!(!tracker.admit_headers(matching_fragment, 0, 0, now).unwrap()); + assert_eq!(tracker.response_state, HeaderResponseState::Idle); + assert_eq!(tracker.pending.len(), 1); + + assert!(tracker + .admit_headers(&[matching_but_same_response], 0, 0, now) + .unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn header_responses_must_follow_request_order() { + let now = Instant::now(); + let first_locator = Hash::from_vec(&[19]); + let second_locator = Hash::from_vec(&[20]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![first_locator], now); + tracker.register(2, vec![second_locator], now); + + let out_of_order = header_with_prev_hash(second_locator); + assert!(!tracker.admit_headers(&[out_of_order], 0, 0, now).unwrap()); + assert_eq!(tracker.pending.len(), 2); + + let first_response = header_with_prev_hash(first_locator); + assert!(tracker.admit_headers(&[first_response], 0, 0, now).unwrap()); + let second_response = header_with_prev_hash(second_locator); + assert!(tracker + .admit_headers(&[second_response], 0, 0, now) + .unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn expired_header_request_does_not_admit_response() { + let now = Instant::now(); + let locator = Hash::from_vec(&[21]); + let mut tracker = HeaderRequestTracker::new(); + tracker.register(1, vec![locator], now); + + let response = header_with_prev_hash(locator); + assert!(!tracker + .admit_headers(&[response], 0, 0, now + HEADER_REQUEST_TIMEOUT) + .unwrap()); + assert!(tracker.pending.is_empty()); + } + + #[test] + fn failed_header_request_send_rolls_back_ticket() { + let peer = test_peer(); + + assert!(peer + .send_header_request(vec![Hash::from_vec(&[22])]) + .is_err()); + assert!(peer + .tracking_adapter + .header_requests + .lock() + .pending + .is_empty()); + } + #[test] fn restore_req_preserves_previous_block_request_opts() { let tracking_adapter = TrackingAdapter::new(0, Arc::new(DummyAdapter {})); diff --git a/p2p/src/peers.rs b/p2p/src/peers.rs index f307d07f3e..e923c9670b 100644 --- a/p2p/src/peers.rs +++ b/p2p/src/peers.rs @@ -129,7 +129,6 @@ pub struct Peers { stopped_peers: Mutex>>, boost_peers_capabilities: RwLock, excluded_peers: Arc>>, - out_peers_failures: Arc>>, advertised_peers: Arc>>, advertised_peer_source_limits: Arc>>, } @@ -148,7 +147,6 @@ impl Peers { time: None, }), excluded_peers: Arc::new(RwLock::new(HashSet::new())), - out_peers_failures: Arc::new(RwLock::new(HashMap::new())), advertised_peers: Arc::new(RwLock::new(HashMap::new())), advertised_peer_source_limits: Arc::new(RwLock::new(HashMap::new())), } @@ -292,7 +290,9 @@ impl Peers { ) -> Result<(), Error> { let peer_data = match self.get_peer(&addr) { Ok(peer) => PeerData { - addr: addr.clone(), + // `addr` may be an inbound transport address with an ephemeral port. + // Preserve the previously verified listening address for known peers. + addr: peer.addr, capabilities: peer.capabilities, user_agent: peer.user_agent, flags: State::Banned, @@ -308,7 +308,9 @@ impl Peers { flags: State::Banned, last_banned: Utc::now().timestamp(), ban_reason, - last_connected: Utc::now().timestamp(), + // A failed first handshake is not a successful connection. Keep this + // peer unverified so it is not advertised after the ban expires. + last_connected: 0, version: mwc_core::ser::ProtocolVersion(1), }, Err(e) => return Err(e), @@ -426,6 +428,9 @@ impl Peers { } for peer in ready_peers { + // Reaping during normal maintenance is best effort. wait_stopped_peer + // already logs unexpected thread errors, and the stopped peer has no + // further recovery action here, so logging is sufficient. let _ = Self::wait_stopped_peer(peer); } } @@ -758,6 +763,10 @@ impl Peers { let mut restored = 0; let mut first_restore_error = None; + // This scan and the updates below are intentionally non-atomic. A concurrent + // state change, including a ban, can be overwritten by the Healthy update. + // Peer state is local connection policy rather than consensus-critical state, + // so this narrow race is accepted to keep the peer-store API simple. for peer in self.all_peer_data(Capabilities::UNKNOWN)? { if peer.flags == State::Defunct && peer.last_connected >= connection_time_limit { if let Err(e) = self.update_state(&peer.addr, State::Healthy) { @@ -810,7 +819,7 @@ impl Peers { let mut summary = PeerCleanupSummary::default(); let liveness_deferred = self.adapter.is_chain_liveness_deferred(); if liveness_deferred { - debug!("clean_peers: skipping dead-ping and stuck-peer eviction while local chain maintenance is active"); + debug!("clean_peers: skipping all liveness-based peer eviction while local chain maintenance is active"); } let preferred_peers = config .peers_preferred @@ -910,75 +919,22 @@ impl Peers { // check here to make sure we don't have too many outgoing connections // Preferred peers are treated preferentially here. - // Also choose outbound peers with lowest total difficulty to drop. - // Reducing outbound connection gradually - let mut excess_outgoing_count = cmp::min( + // Capacity cleanup is independent of peer liveness and remains active while + // local chain maintenance is running. + // Reducing outbound connections gradually. + let excess_outgoing_count = cmp::min( 2, outbound_peers().count().saturating_sub(max_outbound_count), ); - // Filtering out excess and underperforming outbound peers. - // If local chain state cannot be read, use conservative fallbacks so - // this cleanup pass does not evict peers for low performance. - let my_difficulty = match self.adapter.total_difficulty() { - Ok(total_difficulty) => total_difficulty, - Err(e) => { - error!( - "failed to get total difficulty during peer cleanup: {:?}", - e - ); - Difficulty::zero() - } - }; - let my_height = match self.adapter.total_height() { - Ok(total_height) => total_height, - Err(e) => { - error!("failed to get total height during peer cleanup: {:?}", e); - 0 - } - }; - let mut out_peers_failures = self.out_peers_failures.write(); - let mut next_failures = HashMap::new(); - let mut peer_infos: Vec> = outbound_peers() .filter(|x| !preferred_peers.contains(&x.info.addr)) .collect(); - let rm_sz0 = rm.len(); - for peer in &peer_infos { - // If peer 2 blocks behind for 3 check cycyles, we want to exclude it. - // Reason for that: we want outbound peers be high quality. - if peer.info.height() < my_height.saturating_sub(2) - && peer.info.total_difficulty() < my_difficulty - { - let fail_counter = out_peers_failures - .get(&peer.info.addr) - .cloned() - .unwrap_or(0) - .saturating_add(1); - if fail_counter >= 5 { - info!( - "Requesting disconnect for outband peer {:?} because of low performance", - peer.info.addr - ); - rm.push((peer.clone(), CleanupStateUpdate::MarkHealthyOnStop)); - } - next_failures.insert(peer.info.addr.clone(), fail_counter); - } - } - *out_peers_failures = next_failures; - - // rm.len() - rm_sz0 is safe because rm is only grawing since rm_sz0 was assigned to rm.len() - excess_outgoing_count = excess_outgoing_count.saturating_sub(rm.len() - rm_sz0); if excess_outgoing_count > 0 { - let my_base_fee = global::get_accept_fee_base(self.store.get_context_id()); - peer_infos.sort_unstable_by_key(|x| { - if x.info.tx_base_fee < my_base_fee { - x.info.total_difficulty().to_num() / 2 // we don't want to see peers with lower than we are base fee - } else { - x.info.total_difficulty().to_num() - } - }); + // Height, total difficulty, and fee are advertised by the remote peer. + // Do not let those unverified values influence which connection survives. + peer_infos.shuffle(&mut rand::rng()); let mut addrs = peer_infos .into_iter() .map(|x| (x, CleanupStateUpdate::MarkHealthyOnStop)) @@ -1831,6 +1787,13 @@ mod tests { let stored = peers.get_peer(&addr).unwrap(); assert_eq!(stored.flags, State::Banned); assert_eq!(stored.ban_reason, ReasonForBan::BadBlock); + assert_eq!(stored.last_connected, 0); + + peers.unban_peer(&addr).unwrap(); + assert!(!peers + .find_peer_addrs(Capabilities::UNKNOWN) + .unwrap() + .contains(&addr)); } #[test] diff --git a/p2p/src/store.rs b/p2p/src/store.rs index 3fabc00ebd..4790ab8531 100644 --- a/p2p/src/store.rs +++ b/p2p/src/store.rs @@ -507,6 +507,11 @@ impl PeerStore { max_peers ); + // Eviction selection and deletion intentionally use separate transactions. + // A concurrent save can refresh a selected row before this batch deletes it. + // This is a bounded, best-effort peer cache rather than consensus state, and + // once it exceeds max_peers some peer must be evicted anyway. Accept the + // narrow race here to keep cache pruning simple. let batch = self.db.batch_write()?; for peer in to_remove { Self::delete_peer_key_allow_missing(&batch, &peer_key(&peer.addr)[..])?; diff --git a/p2p/src/tor/arti.rs b/p2p/src/tor/arti.rs index b1575d82a0..a3cb1cd089 100644 --- a/p2p/src/tor/arti.rs +++ b/p2p/src/tor/arti.rs @@ -210,6 +210,12 @@ pub fn random_http_probe_url() -> &'static str { } } +#[derive(Clone, Copy)] +struct ArtiOnlineSince { + generation: u64, + since: Instant, +} + lazy_static! { // It is a tor server only running instance, in case of libraries can be shared by multiple nodes and wallets static ref TOR_ARTI_INSTANCE: mwc_crates::parking_lot::RwLock> = mwc_crates::parking_lot::RwLock::new(None); @@ -217,8 +223,10 @@ lazy_static! { static ref TOR_ARTI_INSTANCE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); // Tor service full restart request. Value 0 - not requsted. Otherwise next ArtiCore instance_id static ref TOR_RESTART_REQUEST: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - // Last restarting time (need to understand how long the tor was online without any issue) - static ref TOR_RESTART_TIME: mwc_crates::parking_lot::RwLock> = mwc_crates::parking_lot::RwLock::new(None); + // Start of the currently published Arti generation. Pairing the timestamp + // with its generation prevents a reader from applying an old timestamp to a + // newly published client while a restart is in progress. + static ref TOR_ONLINE_SINCE: mwc_crates::parking_lot::RwLock> = mwc_crates::parking_lot::RwLock::new(None); // Monitoring thread. Only one instance is allowed static ref TOR_MONITORING_THREAD : mwc_crates::parking_lot::RwLock>> = mwc_crates::parking_lot::RwLock::new(None); // Stores monitor thread panics observed outside stop_arti() so synchronous cleanup can report them. @@ -243,16 +251,33 @@ pub fn init_arti_cancelling(context_id: u32) { } } -pub fn init_arti_cancelling_all(context_ids: Vec) { - let mut guard = CANCELLING_ARTI.write(); - +fn rearm_arti_cancelling_tokens( + tokens: &mut HashMap, + context_ids: Vec, +) { for id in context_ids { - if let Some(token) = guard.insert(id, CancellationToken::new()) { - token.cancel(); + // A cancelled entry is the same context registration retained by + // release_arti_cancelling_all(). An absent entry was released while Arti + // restarted, and a live entry belongs to a context initialized (or reused) + // after the snapshot. Neither of those entries may be overwritten. + if let Some(token) = tokens.get_mut(&id) { + if token.is_cancelled() { + *token = CancellationToken::new(); + } } } } +/// Rearm context registrations cancelled by release_arti_cancelling_all(). +/// +/// Missing registrations were released during the restart, while live tokens +/// belong to contexts initialized after the restart snapshot; both are left +/// unchanged. +pub fn init_arti_cancelling_all(context_ids: Vec) { + let mut guard = CANCELLING_ARTI.write(); + rearm_arti_cancelling_tokens(&mut guard, context_ids); +} + pub fn release_arti_cancelling(context_id: u32) { if let Some(token) = CANCELLING_ARTI.write().remove(&context_id) { // cancelling it so cloned instance will stop waiting as well @@ -260,26 +285,38 @@ pub fn release_arti_cancelling(context_id: u32) { } } -// Trigger all cancelling events. Used in cases like arti restart, so all arti users will be dropped. -// Return all context Ids so we could recreate it -pub fn release_arti_cancelling_all() -> Vec { - let mut res: Vec = Vec::new(); - CANCELLING_ARTI.write().retain(|id, token| { - res.push(id.clone()); +fn cancel_arti_context_tokens(tokens: &mut HashMap) -> Vec { + let mut context_ids = Vec::with_capacity(tokens.len()); + for (id, token) in tokens.iter() { + context_ids.push(*id); token.cancel(); - false - }); + } + context_ids +} + +// Trigger all cancelling events. Used in cases like an Arti restart, so all +// Arti users will be dropped. Keep the cancelled entries as restart tombstones: +// release_arti_cancelling() can then remove a context while bootstrap is in +// progress, and init_arti_cancelling() can replace an old context generation. +// Return the context IDs so unchanged registrations can be rearmed. +pub fn release_arti_cancelling_all() -> Vec { + let mut guard = CANCELLING_ARTI.write(); + let context_ids = cancel_arti_context_tokens(&mut guard); - debug_assert!(CANCELLING_ARTI.read_recursive().is_empty()); + debug_assert!(guard.values().all(CancellationToken::is_cancelled)); - res + context_ids } pub fn is_arti_cancelled(context_id: u32) -> bool { // Cancellation tokens are optional context guards; Arti may still run with no // token registered. Treating a missing registration as cancelled is the more // conservative behavior we want for context-scoped operations. - !CANCELLING_ARTI.read_recursive().contains_key(&context_id) + CANCELLING_ARTI + .read_recursive() + .get(&context_id) + .map(CancellationToken::is_cancelled) + .unwrap_or(true) } /// Return a context cancellation token for waiters. @@ -291,6 +328,7 @@ pub fn get_arti_cancell_token(context_id: u32) -> Option { CANCELLING_ARTI .read_recursive() .get(&context_id) + .filter(|token| !token.is_cancelled()) .map(|token| token.child_token()) } @@ -301,6 +339,7 @@ pub fn get_arti_cancell_token(context_id: u32) -> Option { /// that need synchronous cleanup should use stop_arti(). pub fn shutdown_arti() { SHUTDOWN_ARTI.cancel(); + *TOR_ONLINE_SINCE.write() = None; } pub(crate) fn is_shutdown_arti() -> bool { @@ -376,12 +415,24 @@ pub fn is_arti_started() -> bool { is_arti_monitor_running() } -pub fn get_arti_restart_time() -> Option { - if is_arti_healthy() { - TOR_RESTART_TIME.read_recursive().clone() - } else { - None +/// Returns the elapsed time for the current healthy Arti generation. +/// +/// The generation check keeps the timestamp coherent with the instance IDs +/// across concurrent restart publication. `None` is returned while Arti is +/// stopped, restarting, or only partially published. +pub fn get_arti_online_duration() -> Option { + if !is_arti_healthy() { + return None; + } + + let online_since = (*TOR_ONLINE_SINCE.read_recursive())?; + let tor_version = TOR_ARTI_INSTANCE_ID.load(Ordering::SeqCst); + let restart_requested = TOR_RESTART_REQUEST.load(Ordering::SeqCst); + if online_since.generation != tor_version || restart_requested != tor_version { + return None; } + + Some(online_since.since.elapsed()) } pub fn is_arti_healthy() -> bool { @@ -437,6 +488,16 @@ pub fn allocate_arti_object_id() -> u64 { pub fn register_arti_active_object(obj_name: String) -> Result<(), Error> { let mut active_objects = TOR_ACTIVE_OBJECTS.write(); + // Keep the shutdown/restart-state checks and insertion under the same lock + // observed by stop_start_arti's drain. A registration is therefore either + // visible to the drain before it sees an empty set, or rejected after shutdown + // or restart is requested. + if is_shutdown_arti() { + return Err(Error::Interrupted); + } + if is_arti_restarting() { + return Err(Error::TorRestarting); + } if !active_objects.insert(obj_name.clone()) { return Err(Error::Internal(format!( "Duplicate Arti active object registration: {}", @@ -573,9 +634,12 @@ pub fn start_arti( }; let previous_arti = TOR_ARTI_INSTANCE.write().replace(new_arti); + *TOR_ONLINE_SINCE.write() = Some(ArtiOnlineSince { + generation: tor_id, + since: Instant::now(), + }); TOR_ARTI_INSTANCE_ID.store(tor_id, Ordering::SeqCst); let _ = TOR_RESTART_REQUEST.fetch_max(tor_id, Ordering::SeqCst); - *TOR_RESTART_TIME.write() = Some(Instant::now()); let mut monitoring_thread = TOR_MONITORING_THREAD.write(); debug_assert!(monitoring_thread.is_none()); @@ -592,7 +656,7 @@ pub fn start_arti( // instance. Keep TOR_RESTART_REQUEST monotonic so concurrent restart // requests are not erased. TOR_ARTI_INSTANCE_ID.store(previous_instance_id, Ordering::SeqCst); - *TOR_RESTART_TIME.write() = None; + *TOR_ONLINE_SINCE.write() = None; if let Some(new_arti) = new_arti { shutdown_arti_core(new_arti); } @@ -650,14 +714,27 @@ fn stop_start_arti(start_new_client: bool) -> i64 { restart_arti(start_new_client, context_ids) } +/// Stop Arti and synchronously complete monitor and runtime cleanup. +/// +/// This API intentionally requires a regular OS thread that is not currently +/// entered into a Tokio runtime. The cleanup path uses +/// `Runtime::shutdown_timeout` with a nonzero timeout, which Tokio does not +/// permit from an async runtime context. Async code should use +/// [`shutdown_arti`] for signal-only shutdown, or arrange for `stop_arti()` to +/// run on a dedicated non-Tokio thread when synchronous cleanup is required. pub fn stop_arti() -> Result<(), Error> { - let _start_stop_guard = TOR_ARTI_START_STOP_LOCK.lock(); + // Signal first so a concurrent start_arti() can interrupt network-dependent + // bootstrap and release the serialization lock for synchronous cleanup. shutdown_arti(); + let _start_stop_guard = TOR_ARTI_START_STOP_LOCK.lock(); + // is_arti_monitor_running() keeps this lock while publishing a finished + // monitor's panic. Take the handle first so any such publication is complete + // before we consume the stored error below. + let monitoring_thread = TOR_MONITORING_THREAD.write().take(); let mut first_error = TOR_MONITORING_THREAD_ERROR .write() .take() .map(Error::PeerThreadPanic); - let monitoring_thread = TOR_MONITORING_THREAD.write().take(); if let Some(monitoring_thread) = monitoring_thread { if let Some(err_msg) = join_arti_monitor_thread(monitoring_thread) { first_error.get_or_insert(Error::PeerThreadPanic(err_msg)); @@ -739,6 +816,7 @@ fn restart_arti(start_new_client: bool, context_ids: Vec) -> i64 { Some(arti) => { drop(arti.tor_client); drop(guard); + *TOR_ONLINE_SINCE.write() = None; (arti.tor_runtime, arti.config, arti.base_dir) } None => { @@ -784,13 +862,16 @@ fn restart_arti(start_new_client: bool, context_ids: Vec) -> i64 { .load(Ordering::SeqCst) .saturating_add(1); init_arti_cancelling_all(context_ids); + *TOR_ONLINE_SINCE.write() = Some(ArtiOnlineSince { + generation: tor_id, + since: Instant::now(), + }); TOR_ARTI_INSTANCE_ID.store(tor_id, Ordering::SeqCst); // Restart requests are coalesced, not counted. Requests that // arrive while this replacement client is still being published // are treated as satisfied by this start, even if they observed // the freshly incremented instance id. TOR_RESTART_REQUEST.store(tor_id, Ordering::SeqCst); - *TOR_RESTART_TIME.write() = Some(Instant::now()); let now = Utc::now().timestamp(); network_status::update_last_network_reliable_time(now); return expiration_time; @@ -1657,12 +1738,52 @@ fn bridge_cache_key_uses_stable_sha256_digest() { } #[test] -fn active_object_tracking_rejects_duplicate_and_missing_entries() { +fn arti_restart_rearms_only_unchanged_context_registrations() { + let unchanged_id = 1; + let released_id = 2; + let reused_id = 3; + let mut tokens = HashMap::new(); + for id in [unchanged_id, released_id, reused_id] { + tokens.insert(id, CancellationToken::new()); + } + + let unchanged_waiter = tokens[&unchanged_id].child_token(); + let context_ids = cancel_arti_context_tokens(&mut tokens); + assert!(tokens.values().all(CancellationToken::is_cancelled)); + assert!(unchanged_waiter.is_cancelled()); + + // Simulate one context being released and another ID being reused while the + // replacement Arti client is bootstrapping. + tokens.remove(&released_id); + let reused_token = CancellationToken::new(); + tokens.insert(reused_id, reused_token.clone()); + + rearm_arti_cancelling_tokens(&mut tokens, context_ids); + + assert!(!tokens[&unchanged_id].is_cancelled()); + assert!(!tokens.contains_key(&released_id)); + assert_eq!(tokens[&reused_id], reused_token); + assert!(!reused_token.is_cancelled()); +} + +#[test] +fn active_object_tracking_rejects_restart_duplicate_and_missing_entries() { + let current_id = TOR_ARTI_INSTANCE_ID.load(Ordering::SeqCst); + let original_restart_request = TOR_RESTART_REQUEST.load(Ordering::SeqCst); let name = format!("test_arti_active_object_{}", allocate_arti_object_id()); + TOR_RESTART_REQUEST.store(current_id.saturating_add(1), Ordering::SeqCst); + assert!(matches!( + register_arti_active_object(name.clone()), + Err(Error::TorRestarting) + )); + assert!(!TOR_ACTIVE_OBJECTS.read_recursive().contains(&name)); + + TOR_RESTART_REQUEST.store(current_id, Ordering::SeqCst); register_arti_active_object(name.clone()).unwrap(); assert!(register_arti_active_object(name.clone()).is_err()); unregister_arti_active_object(&name).unwrap(); assert!(unregister_arti_active_object(&name).is_err()); + TOR_RESTART_REQUEST.store(original_restart_request, Ordering::SeqCst); } diff --git a/p2p/src/tor/onion_service.rs b/p2p/src/tor/onion_service.rs index 27eed5d02f..502169ee5c 100644 --- a/p2p/src/tor/onion_service.rs +++ b/p2p/src/tor/onion_service.rs @@ -25,12 +25,13 @@ use mwc_crates::log::{error, info, warn}; use mwc_crates::tokio; use mwc_crates::tor_cell::relaycell::msg::Connected; use mwc_crates::tor_hsservice; -use mwc_crates::tor_proto::client::stream::IncomingStreamRequest; +use mwc_crates::tor_proto::stream::IncomingStreamRequest; use mwc_crates::zeroize::Zeroizing; use mwc_util::StopState; use std::any::Any; use std::pin::Pin; -use std::sync::{mpsc, Arc}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -162,6 +163,16 @@ where let incoming_requests_guard = match ArtiRegistrator::new(incoming_requests_object.clone()) { Ok(registrator) => registrator, + Err(Error::TorRestarting) => { + info!( + "Unable to register {} active object because Arti is restarting; retrying", + incoming_requests_object + ); + if let Some(f) = &(*service_status_callback) { + f(false); + }; + continue; + } Err(err) => { error!( "Unable to register {} active object: {}", @@ -171,9 +182,22 @@ where f(false); }; if let Some(failed_service_callback) = &failed_service_callback { - let _ = failed_service_callback(&err); + if failed_service_callback(&err) { + error!( + "listen_onion_service exited because of callback response and error: {}", + err + ); + return Err(err); + } + } + if stop_state.is_stopped() { + break; } - return Err(err); + arti::request_arti_restart(&format!( + "Unable to register {} active object: {}", + incoming_requests_object, err + )); + continue; } }; @@ -181,7 +205,8 @@ where let context_id2 = context_id; let service_name2 = String::from(service_name); let service_status_callback2 = service_status_callback.clone(); - let (monitor_failure_tx, monitor_failure_rx) = mpsc::channel(); + let monitor_stop = Arc::new(AtomicBool::new(false)); + let monitor_stop2 = monitor_stop.clone(); let monitor_thread_name = format!("{}_onion_service_checker_{}", service_name2, context_id2); let monitor_thread_name_for_panic = monitor_thread_name.clone(); @@ -189,7 +214,7 @@ where let monitoring = match thread::Builder::new() .name(monitor_thread_name) - .spawn(move || { + .spawn(move || -> Result<(), Error> { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe( || -> Result<(), Error> { // Guard is needed for @@ -201,7 +226,9 @@ where f(false); }; loop { - if stop_state2.is_stopped() { + if monitor_stop2.load(Ordering::Relaxed) + || stop_state2.is_stopped() + { break; } let need_arti_restart = { @@ -293,6 +320,10 @@ where } }; + if monitor_stop2.load(Ordering::Relaxed) { + break; + } + if need_arti_restart || arti::is_arti_restarting() { arti::request_arti_restart( "Onion service is dead, restarting", @@ -301,7 +332,8 @@ where } for _ in 0..30 { - if stop_state2.is_stopped() + if monitor_stop2.load(Ordering::Relaxed) + || stop_state2.is_stopped() || arti::is_arti_restarting() { break; @@ -316,7 +348,7 @@ where }, )); match result { - Ok(Ok(())) => {} + Ok(Ok(())) => Ok(()), Ok(Err(err)) => { error!( "{} onion_service_checker thread failed: {}", @@ -324,10 +356,10 @@ where ); let err_msg = format!("{}: {}", monitor_thread_name_for_panic, err); - let _ = monitor_failure_tx.send(err_msg); arti::request_arti_restart( "Onion service checker failed, restarting", ); + Err(Error::PeerThreadPanic(err_msg)) } Err(payload) => { let panic_msg = panic_payload_to_string(payload); @@ -337,10 +369,10 @@ where "{} onion_service_checker thread panicked: {}", service_name2, panic_msg ); - let _ = monitor_failure_tx.send(err_msg); arti::request_arti_restart( "Onion service checker panicked, restarting", ); + Err(Error::PeerThreadPanic(err_msg)) } } }) { @@ -375,22 +407,8 @@ where let stop_state = stop_state.clone(); let mut listener_error = None; loop { - match monitor_failure_rx.try_recv() { - Ok(err_msg) => { - let err = Error::PeerThreadPanic(err_msg); - error!("Onion service monitor failed: {}", err); - if let Some(f) = &(*service_status_callback) { - f(false); - }; - if let Some(failed_service_callback) = &failed_service_callback { - if failed_service_callback(&err) { - listener_error = Some(err); - } - } - break; - } - Err(mpsc::TryRecvError::Empty) => {} - Err(mpsc::TryRecvError::Disconnected) => {} + if monitoring.is_finished() { + break; } let request_res = match arti::arti_async_block(async { @@ -514,7 +532,10 @@ where handle_new_peer_callback(stream, None); } Err(err) => { - error!("listen_onion_service accepting stream error: {}", err); + error!( + "listen_onion_service accepting stream error: {}", + err + ); } }, Err(_) => { @@ -547,16 +568,25 @@ where } } + monitor_stop.store(true, Ordering::Relaxed); let thread_id = monitoring.thread().id(); - if let Err(payload) = monitoring.join() { - let panic_msg = panic_payload_to_string(payload); - let err = - Error::PeerThreadPanic(format!("thread {:?}: {}", thread_id, panic_msg)); - error!( - "failed to stop {} onion_service_checker thread {:?}: {}", - service_name, thread_id, panic_msg - ); - if listener_error.is_none() { + let monitor_result = match monitoring.join() { + Ok(result) => result, + Err(payload) => { + let panic_msg = panic_payload_to_string(payload); + error!( + "failed to stop {} onion_service_checker thread {:?}: {}", + service_name, thread_id, panic_msg + ); + Err(Error::PeerThreadPanic(format!( + "thread {:?}: {}", + thread_id, panic_msg + ))) + } + }; + if listener_error.is_none() { + if let Err(err) = monitor_result { + error!("Onion service monitor failed: {}", err); if let Some(f) = &(*service_status_callback) { f(false); }; @@ -566,9 +596,7 @@ where } } if listener_error.is_none() && !stop_state.is_stopped() { - arti::request_arti_restart( - "Onion service checker panicked, restarting", - ); + arti::request_arti_restart("Onion service checker failed, restarting"); } } } @@ -607,6 +635,16 @@ where } thread::sleep(Duration::from_millis(500)); } + // A restart can begin after ArtiCore's pre-launch restart check but + // before start_onion_service checks its cancelled context. That race is + // reported as Interrupted even though the replacement Arti instance can + // be retried normally. + Err(Error::Interrupted) if arti::is_arti_restarting() => { + if stop_state.is_stopped() { + break; + } + thread::sleep(Duration::from_millis(500)); + } Err(e @ Error::TorNotInitialized) => { if stop_state.is_stopped() { break; @@ -617,7 +655,10 @@ where } if let Some(failed_service_callback) = &failed_service_callback { if failed_service_callback(&e) { - error!("listen_onion_service exited because of callback response and error: {}", e); + error!( + "listen_onion_service exited because of callback response and error: {}", + e + ); return Err(e); } } diff --git a/p2p/src/tor/tcp_data_stream.rs b/p2p/src/tor/tcp_data_stream.rs index b571011b8e..e60547eb70 100644 --- a/p2p/src/tor/tcp_data_stream.rs +++ b/p2p/src/tor/tcp_data_stream.rs @@ -26,7 +26,7 @@ use mwc_util::run_global_async_block; use std::io::{ErrorKind, Read, Write}; use std::pin::Pin; use std::task::{Context, Poll}; -use std::time::Duration; +use std::time::{Duration, Instant}; pub enum TcpData { Tcp(TcpStream), @@ -50,6 +50,11 @@ pub struct TcpDataStream { write_timeout: Duration, } +struct DeadlineReader<'a> { + stream: &'a mut TcpDataStream, + deadline: Instant, +} + pub struct TcpDataReadHalfStream { stream: TcpDataReadHalf, read_timeout: Duration, @@ -87,6 +92,35 @@ impl TcpDataStream { self.write_timeout = write_timeout; } + /// Build a reader for one operation with a total time budget. Individual + /// reads still use `read_timeout`, but partial progress cannot extend the + /// operation deadline. + pub(crate) fn deadline_reader(&mut self, timeout: Duration) -> impl Read + '_ { + let now = Instant::now(); + DeadlineReader { + stream: self, + deadline: now.checked_add(timeout).unwrap_or(now), + } + } + + fn read_with_timeout( + &mut self, + buf: &mut [u8], + read_timeout: Duration, + ) -> Result { + let r = match &mut self.stream { + TcpData::Tcp(s) => run_global_async_block(async { + tokio::time::timeout(read_timeout, s.read(buf)).await + }) + .map_err(|e| std::io::Error::new(ErrorKind::Other, e))?, + TcpData::Tor(s) => { + arti_async_block(async { tokio::time::timeout(read_timeout, s.read(buf)).await }) + .map_err(|e| arti_async_block_error(e, "read"))? + } + }; + r.map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "read timeout"))? + } + pub fn is_alive(&mut self) -> bool { match &mut self.stream { TcpData::Tcp(s) => { @@ -242,21 +276,30 @@ impl AsyncWrite for TcpData { /* ---------- std::io::Read ---------- */ impl Read for TcpDataStream { fn read(&mut self, buf: &mut [u8]) -> Result { - let read_timeout = &self.read_timeout; - let r = match &mut self.stream { - TcpData::Tcp(s) => run_global_async_block(async { - tokio::time::timeout(*read_timeout, s.read(buf)).await - }) - .map_err(|e| std::io::Error::new(ErrorKind::Other, e))?, - TcpData::Tor(s) => { - arti_async_block(async { tokio::time::timeout(*read_timeout, s.read(buf)).await }) - .map_err(|e| arti_async_block_error(e, "read"))? - } - }; - r.map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "read timeout"))? + self.read_with_timeout(buf, self.read_timeout) } } +impl Read for DeadlineReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> Result { + let read_timeout = + effective_read_timeout(self.stream.read_timeout, self.deadline, Instant::now())?; + self.stream.read_with_timeout(buf, read_timeout) + } +} + +fn effective_read_timeout( + read_timeout: Duration, + read_deadline: Instant, + now: Instant, +) -> std::io::Result { + let remaining = read_deadline + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| std::io::Error::new(ErrorKind::TimedOut, "read deadline elapsed"))?; + Ok(std::cmp::min(read_timeout, remaining)) +} + /* ---------- std::io::Write ---------- */ impl Write for TcpDataStream { fn write(&mut self, buf: &[u8]) -> Result { @@ -472,6 +515,26 @@ impl Write for TcpDataWriteHalfStream { mod tests { use super::*; + #[test] + fn effective_read_timeout_is_limited_by_absolute_deadline() { + let now = Instant::now(); + let remaining = Duration::from_millis(25); + + assert_eq!( + effective_read_timeout(Duration::from_secs(15), now + remaining, now,).unwrap(), + remaining + ); + } + + #[test] + fn effective_read_timeout_rejects_elapsed_deadline() { + let now = Instant::now(); + let err = effective_read_timeout(Duration::from_secs(15), now, now) + .expect_err("an elapsed total read deadline must fail"); + + assert_eq!(err.kind(), ErrorKind::TimedOut); + } + #[test] fn read_timeout_result_preserves_inner_read_error_kind() { let err = read_timeout_result(Ok(Err(std::io::Error::new( diff --git a/p2p/src/types.rs b/p2p/src/types.rs index fe01c14369..f6aa37ebf0 100644 --- a/p2p/src/types.rs +++ b/p2p/src/types.rs @@ -1077,12 +1077,17 @@ fn last_seen_seconds_ago(peer_last_seen: DateTime) -> u32 { seconds.clamp(0, u32::MAX as i64) as u32 } +/// Process and host status reported by the node foreign API. +/// +/// Host CPU, RAM, and swap fields are sampled together by the node and may be +/// up to five seconds old when returned. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(crate = "serde")] pub struct ProcessStatus { /// How long this process is running pub process_running_time: u64, - /// How long arti is running + /// Seconds since the current healthy Arti generation started, or 0 when + /// Arti is stopped or restarting. pub tor_online_time: u64, /// This host CPU usage, percentage pub host_cpu_usage: f64, diff --git a/pool/Cargo.toml b/pool/Cargo.toml index 93939431d2..54e986a77e 100644 --- a/pool/Cargo.toml +++ b/pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_pool" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Chain implementation for mwc, a simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,14 +10,14 @@ workspace = '..' edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_keychain = { path = "../keychain", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_keychain = { path = "../keychain", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true [dev-dependencies] -mwc_chain = { path = "../chain", version = "7.0.0" } +mwc_chain = { path = "../chain", version = "7.1.0" } [features] default = [] diff --git a/pool/fuzz/fuzz_targets/common.rs b/pool/fuzz/fuzz_targets/common.rs index 0ec46386c4..edebee740a 100644 --- a/pool/fuzz/fuzz_targets/common.rs +++ b/pool/fuzz/fuzz_targets/common.rs @@ -37,7 +37,7 @@ use mwc_core::consensus; use mwc_core::core::hash::Hash; use mwc_core::core::pmmr::{ReadablePMMR, VecBackend, PMMR}; use mwc_core::core::{ - Block, BlockHeader, BlockSums, Inputs, KernelFeatures, OutputIdentifier, Transaction, + Block, BlockHeader, BlockSums, Inputs, KernelFeatures, Output, OutputIdentifier, Transaction, }; use mwc_core::global; use mwc_core::libtx::{build, reward, ProofBuilder}; @@ -172,6 +172,13 @@ impl BlockChain for ChainAdapter { }) } + fn validate_outputs(&self, outputs: &[Output]) -> Result<(), PoolError> { + self.chain.validate_outputs(outputs).map_err(|e| match e { + mwc_chain::Error::DuplicateCommitment(_) => PoolError::DuplicateCommitment, + _ => PoolError::Other(format!("failed to validate outputs, {}", e)), + }) + } + fn validate_inputs(&self, inputs: &Inputs) -> Result, PoolError> { self.chain .validate_inputs(inputs) @@ -385,6 +392,7 @@ impl PoolFuzzer { HashSet::new(), None, None, + false, ) .unwrap() } diff --git a/pool/src/pool.rs b/pool/src/pool.rs index bada0ba1fa..31efb459df 100644 --- a/pool/src/pool.rs +++ b/pool/src/pool.rs @@ -16,34 +16,485 @@ //! Transaction pool implementation. //! Used for both the txpool and stempool layers in the pool. -use crate::types::{BlockChain, PoolEntry, PoolError}; +use crate::types::{BlockChain, PoolEntry, PoolError, TxSource}; use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::id::{ShortId, ShortIdentifiable}; use mwc_core::core::transaction; +#[cfg(all(test, feature = "test-support"))] +use mwc_core::core::Output; use mwc_core::core::{ - Block, BlockHeader, BlockSums, Committed, HeaderVersion, Inputs, OutputIdentifier, Transaction, - TxKernel, Weighting, + Block, BlockHeader, BlockSums, Committed, HeaderVersion, Inputs, OutputFeatures, + OutputIdentifier, Transaction, TxKernel, Weighting, }; use mwc_core::global; use mwc_core::ser; use mwc_crates::indexmap::IndexMap; use mwc_crates::log::{debug, warn}; +use mwc_crates::secp::pedersen::Commitment; use mwc_crates::secp::Secp256k1; use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +// Rebuilding a growing aggregate processes every retained prefix. Keep the +// conservative per-entry reconciliation path strictly bounded so a large pool +// cannot turn one stale entry into quadratic work while the pool write lock is +// held. +const MAX_RECONCILE_FALLBACK_ENTRIES: usize = 512; +const MAX_RECONCILE_FALLBACK_WORK: u128 = 1_000_000; + +#[derive(Clone, Copy)] +struct ReconcileFallbackLimits { + max_entries: usize, + max_work: u128, +} + +const RECONCILE_FALLBACK_LIMITS: ReconcileFallbackLimits = ReconcileFallbackLimits { + max_entries: MAX_RECONCILE_FALLBACK_ENTRIES, + max_work: MAX_RECONCILE_FALLBACK_WORK, +}; + +fn transaction_components(tx: &Transaction) -> u128 { + (tx.body.inputs.len() as u128) + .saturating_add(tx.body.outputs.len() as u128) + .saturating_add(tx.body.kernels.len() as u128) +} + +/// Bounds paths that repeatedly add a transaction to an already growing pool +/// aggregate. Work is measured as the cumulative number of transaction body +/// components visited across all projected aggregate prefixes. +pub(crate) struct ReconcileWorkBudget { + limits: ReconcileFallbackLimits, + attempts: usize, + prefix_components: u128, + work: u128, +} + +/// Exact post-insertion aggregate of a pool whose entries were fully +/// authenticated when admitted. +/// +/// Keep this wrapper crate-private and construct it only at the pool admission +/// boundary. It lets another `Pool` reuse the aggregate without treating an +/// arbitrary `Transaction` as if its rangeproofs and kernel signatures had +/// already been verified. +pub(crate) struct ValidatedPoolAggregate(Transaction); + +/// A pool entry whose rangeproofs and kernel signatures were fully +/// authenticated before the transaction-pool write lock was acquired. +/// +/// The inner entry is private so code cannot manufacture this marker from an +/// arbitrary transaction. The only transformations exposed below either +/// remove already authenticated components during deaggregation or replace +/// input metadata; both recheck every structural property and the resulting +/// kernel sums without repeating rangeproof or signature verification. +#[derive(Clone)] +pub(crate) struct ValidatedPoolEntry { + entry: PoolEntry, + context_id: u32, +} + +impl ValidatedPoolEntry { + /// Fully authenticate a transaction before it crosses the pool write-lock + /// boundary. + pub(crate) fn authenticate( + context_id: u32, + tx: Transaction, + src: TxSource, + secp: &mut Secp256k1, + ) -> Result { + tx.validate(context_id, Weighting::AsTransaction, secp)?; + Ok(ValidatedPoolEntry { + entry: PoolEntry::new(tx, src), + context_id, + }) + } + + fn from_authenticated_entry(context_id: u32, entry: PoolEntry) -> Self { + ValidatedPoolEntry { entry, context_id } + } + + pub(crate) fn transaction(&self) -> &Transaction { + &self.entry.tx + } + + pub(crate) fn pool_entry(&self) -> &PoolEntry { + &self.entry + } + + pub(crate) fn into_pool_entry(self) -> PoolEntry { + self.entry + } + + /// Remove transactions whose authenticated kernels are already represented + /// by this aggregate. `transaction::deaggregate` only removes components from + /// the authenticated transaction; it cannot introduce a new proof or kernel + /// signature. Recheck the properties and kernel sums that can change. + pub(crate) fn deaggregate( + self, + txs: &[Transaction], + secp: &Secp256k1, + ) -> Result { + let tx = transaction::deaggregate(self.context_id, self.entry.tx, txs, secp)?; + tx.validate_aggregate_from_validated_components( + self.context_id, + Weighting::AsTransaction, + secp, + ) + .map_err(PoolError::InvalidTx)?; + Ok(ValidatedPoolEntry::from_authenticated_entry( + self.context_id, + PoolEntry::new(tx, TxSource::Deaggregate), + )) + } + + /// Replace commitment-only inputs with identifiers resolved from authenticated + /// pool indexes and chain state. Outputs, rangeproofs, kernels, and signatures + /// remain unchanged, while structural validity and kernel sums are rechecked. + pub(crate) fn convert_inputs_v2( + self, + spent_pool: &[OutputIdentifier], + spent_utxo: &[OutputIdentifier], + secp: &Secp256k1, + ) -> Result { + let mut inputs = spent_utxo.to_vec(); + inputs.extend_from_slice(spent_pool); + ser::sort_by_hash(self.context_id, &mut inputs) + .map_err(|e| PoolError::Other(format!("convert_tx_v2 input sorting error, {}", e)))?; + + let tx = self.entry.tx; + let tx = Transaction { + body: tx.body.replace_inputs( + self.context_id, + Inputs::from_output_identifiers(self.context_id, inputs.as_slice())?, + )?, + ..tx + }; + tx.validate_aggregate_from_validated_components( + self.context_id, + Weighting::AsTransaction, + secp, + ) + .map_err(PoolError::InvalidTx)?; + + Ok(ValidatedPoolEntry::from_authenticated_entry( + self.context_id, + PoolEntry::new(tx, self.entry.src), + )) + } +} + +/// Component identities for a single pool entry. +/// +/// Build these before mutating the pool so every fallible conversion and hash +/// calculation completes before `entries` and its derived indexes are updated. +struct PoolEntryIndexKeys { + inputs: Vec, + outputs: Vec, + kernels: Vec, + nrd_excesses: Vec, + components: u128, +} + +impl PoolEntryIndexKeys { + fn from_transaction(context_id: u32, tx: &Transaction) -> Result { + let inputs = tx + .inputs() + .into_commit_wrappers(context_id)? + .into_iter() + .map(|input| input.commitment()) + .collect(); + let outputs = tx + .outputs() + .iter() + .map(|output| output.identifier()) + .collect(); + let kernels = tx + .kernels() + .iter() + .map(|kernel| kernel.hash(context_id)) + .collect::, _>>()?; + let nrd_excesses = tx + .kernels() + .iter() + .filter(|kernel| kernel.is_nrd()) + .map(TxKernel::excess) + .collect(); + + Ok(PoolEntryIndexKeys { + inputs, + outputs, + kernels, + nrd_excesses, + components: transaction_components(tx), + }) + } +} + +/// An output retained in the exact pool index. +#[derive(Clone, Copy)] +struct IndexedPoolOutput { + owner: Hash, + features: OutputFeatures, +} + +/// Exact derived indexes for entries currently held by a `Pool`. +/// +/// These indexes reject known conflicts early and locate candidate inputs that +/// spend currently unspent pool outputs. A lookup never authenticates a +/// candidate: standalone cryptographic validation, aggregate validation, and +/// chain-state validation remain authoritative. +/// +/// Security properties: +/// - remote data cannot populate an index until its entry has passed every +/// admission check; +/// - full commitments, NRD excess commitments, and kernel hashes are stored in +/// exact maps, not truncated or probabilistic keys, and memory use grows only +/// with accepted pool data; +/// - `entries` and these maps are mutated together under the pool's exclusive +/// borrow, so there is no independently expiring or asynchronously refreshed +/// cache state; +/// - a hypothetical missing produced-output record makes a pool child fail its +/// chain lookup; it cannot authorize an invalid candidate; +/// - an unexpected stale record cannot cause invalid acceptance because the +/// post-lookup aggregate is still validated against chain state; +/// - removal verifies every expected index owner and output identifier before +/// changing either the entries or the indexes. +/// +/// Keep all mutation behind `Pool::insert_entry`, `Pool::shift_remove_entry`, +/// `Pool::remove_entries`, and `Pool::clear_entries`. Do not persist or evict +/// these records separately from their owning entries. +#[derive(Default)] +struct PoolIndexes { + spent_inputs: HashMap, + produced_outputs: HashMap, + kernels: HashMap, + nrd_excesses: HashMap, + component_count: u128, +} + +impl PoolIndexes { + fn check_conflicts(&self, keys: &PoolEntryIndexKeys) -> Result { + for input in &keys.inputs { + if let Some(owner) = self.spent_inputs.get(input) { + return Err(PoolError::DuplicateKernelOrDuplicateSpent(format!( + "input commitment {:?} is already spent by pool entry {}", + input, owner, + ))); + } + } + + for output in &keys.outputs { + if self.produced_outputs.contains_key(&output.commitment()) { + return Err(PoolError::DuplicateCommitment); + } + } + + for kernel in &keys.kernels { + if let Some(owner) = self.kernels.get(kernel) { + return Err(PoolError::DuplicateKernelOrDuplicateSpent(format!( + "kernel {} is already present in pool entry {}", + kernel, owner, + ))); + } + } + + // NRD uniqueness is defined by public excess, not by the full kernel + // hash. Different valid signatures or features can produce distinct + // kernel hashes for the same excess. + for excess in &keys.nrd_excesses { + if self.nrd_excesses.contains_key(excess) { + return Err(PoolError::NRDKernelRelativeHeight); + } + } + + self.component_count + .checked_add(keys.components) + .ok_or_else(|| PoolError::Other("pool component count overflow".into())) + } + + fn check_owned_by(&self, owner: &Hash, keys: &PoolEntryIndexKeys) -> Result { + for input in &keys.inputs { + if self.spent_inputs.get(input) != Some(owner) { + return Err(PoolError::Other(format!( + "pool spent-input index is inconsistent for entry {}", + owner, + ))); + } + } + for output in &keys.outputs { + let indexed = self.produced_outputs.get(&output.commitment()); + if !indexed.is_some_and(|indexed| { + indexed.owner == *owner && indexed.features == output.features + }) { + return Err(PoolError::Other(format!( + "pool output index is inconsistent for entry {}", + owner, + ))); + } + } + for kernel in &keys.kernels { + if self.kernels.get(kernel) != Some(owner) { + return Err(PoolError::Other(format!( + "pool kernel index is inconsistent for entry {}", + owner, + ))); + } + } + for excess in &keys.nrd_excesses { + if self.nrd_excesses.get(excess) != Some(owner) { + return Err(PoolError::Other(format!( + "pool NRD excess index is inconsistent for entry {}", + owner, + ))); + } + } + self.component_count + .checked_sub(keys.components) + .ok_or_else(|| PoolError::Other("pool component count underflow".into())) + } + + fn insert(&mut self, owner: Hash, keys: &PoolEntryIndexKeys, component_count: u128) { + for input in &keys.inputs { + let previous = self.spent_inputs.insert(*input, owner); + debug_assert!(previous.is_none()); + } + for output in &keys.outputs { + let previous = self.produced_outputs.insert( + output.commitment(), + IndexedPoolOutput { + owner, + features: output.features, + }, + ); + debug_assert!(previous.is_none()); + } + for kernel in &keys.kernels { + let previous = self.kernels.insert(*kernel, owner); + debug_assert!(previous.is_none()); + } + for excess in &keys.nrd_excesses { + let previous = self.nrd_excesses.insert(*excess, owner); + debug_assert!(previous.is_none()); + } + self.component_count = component_count; + } + + fn remove(&mut self, owner: &Hash, keys: &PoolEntryIndexKeys, component_count: u128) { + self.remove_mappings(owner, keys); + self.component_count = component_count; + } + + fn remove_many(&mut self, removals: &[(Hash, PoolEntryIndexKeys)], component_count: u128) { + for (owner, keys) in removals { + self.remove_mappings(owner, keys); + } + self.component_count = component_count; + } + + fn remove_mappings(&mut self, owner: &Hash, keys: &PoolEntryIndexKeys) { + for input in &keys.inputs { + let removed = self.spent_inputs.remove(input); + debug_assert_eq!(removed, Some(*owner)); + } + for output in &keys.outputs { + let removed = self.produced_outputs.remove(&output.commitment()); + debug_assert!(removed.is_some_and(|indexed| indexed.owner == *owner)); + } + for kernel in &keys.kernels { + let removed = self.kernels.remove(kernel); + debug_assert_eq!(removed, Some(*owner)); + } + for excess in &keys.nrd_excesses { + let removed = self.nrd_excesses.remove(excess); + debug_assert_eq!(removed, Some(*owner)); + } + } + + fn clear(&mut self) { + self.spent_inputs.clear(); + self.produced_outputs.clear(); + self.kernels.clear(); + self.nrd_excesses.clear(); + self.component_count = 0; + } + + fn lookup_unspent_output( + &self, + input: &Commitment, + ) -> Result, PoolError> { + if let Some(owner) = self.spent_inputs.get(input) { + return Err(PoolError::DuplicateKernelOrDuplicateSpent(format!( + "input commitment {:?} is already spent by pool entry {}", + input, owner, + ))); + } + + Ok(self + .produced_outputs + .get(input) + .map(|indexed| OutputIdentifier::new(indexed.features, input))) + } +} + +impl ReconcileWorkBudget { + pub(crate) fn new(prefix_components: u128) -> Self { + Self::with_limits(prefix_components, RECONCILE_FALLBACK_LIMITS) + } + + fn with_limits(prefix_components: u128, limits: ReconcileFallbackLimits) -> Self { + ReconcileWorkBudget { + limits, + attempts: 0, + prefix_components, + work: 0, + } + } + + /// Charge one projected add attempt. The accepted prefix is updated + /// separately because a rejected or duplicate transaction does not enlarge it. + pub(crate) fn charge_attempt(&mut self, tx: &Transaction) -> bool { + if self.attempts >= self.limits.max_entries { + return false; + } + + let Some(candidate_components) = self + .prefix_components + .checked_add(transaction_components(tx)) + else { + return false; + }; + let Some(next_work) = self.work.checked_add(candidate_components) else { + return false; + }; + if next_work > self.limits.max_work { + return false; + } + + self.attempts += 1; + self.work = next_work; + true + } + + pub(crate) fn record_accept(&mut self, tx: &Transaction) { + self.prefix_components = self + .prefix_components + .saturating_add(transaction_components(tx)); + } +} + pub struct Pool where B: BlockChain, { /// Entries keyed by the representative kernel hash (first kernel) used for tx-kernel gossip. - /// This is not a full multi-kernel index: lookup is O(1) for the advertised - /// representative kernel, but requests for other kernels in the same tx will - /// miss. A full index should map every kernel hash in a tx to the same pool - /// entry id, so `retrieve_tx_by_kernel_hash` works for any kernel without a - /// full-pool scan. - pub entries: IndexMap, + /// Lookup APIs retain their documented representative-hash semantics. The + /// separate `PoolIndexes::kernels` map covers every kernel for admission + /// conflict detection. + entries: IndexMap, + /// Exact derived indexes used to reject pool conflicts before rebuilding a + /// pool-wide aggregate. They are updated under the same exclusive borrow as + /// `entries` and never replace authoritative transaction validation. + indexes: PoolIndexes, /// The blockchain pub blockchain: Arc, pub name: String, @@ -57,6 +508,7 @@ where pub fn new(context_id: u32, chain: Arc, name: String) -> Self { Pool { entries: IndexMap::new(), + indexes: PoolIndexes::default(), blockchain: chain, name, context_id, @@ -72,6 +524,98 @@ where Ok(kernel.hash(context_id)?) } + /// Reject conflicts that are already known from accepted pool entries. + /// + /// This is deliberately only a negative filter. Returning `Ok(())` does not + /// authenticate `tx`; callers must continue through normal standalone, + /// aggregate, and chain-state validation. + pub(crate) fn check_pool_conflicts(&self, tx: &Transaction) -> Result<(), PoolError> { + let keys = PoolEntryIndexKeys::from_transaction(self.context_id, tx)?; + self.indexes.check_conflicts(&keys).map(|_| ()) + } + + fn insert_entry( + &mut self, + entry_key: Hash, + entry: PoolEntry, + keys: &PoolEntryIndexKeys, + ) -> Result<(), PoolError> { + if self.entries.contains_key(&entry_key) { + return Err(PoolError::DuplicateTx); + } + // Recheck immediately before mutation. All checks are fallible, while the + // following map updates are infallible apart from process-wide allocation + // failure, so callers never observe a partially committed insertion. + let component_count = self.indexes.check_conflicts(keys)?; + + let previous = self.entries.insert(entry_key, entry); + debug_assert!(previous.is_none()); + self.indexes.insert(entry_key, keys, component_count); + Ok(()) + } + + fn shift_remove_entry(&mut self, entry_key: &Hash) -> Result, PoolError> { + let keys = match self.entries.get(entry_key) { + Some(entry) => PoolEntryIndexKeys::from_transaction(self.context_id, &entry.tx)?, + None => return Ok(None), + }; + // Verify the complete derived state before mutating either side. + let component_count = self.indexes.check_owned_by(entry_key, &keys)?; + + let removed = self.entries.shift_remove(entry_key).ok_or_else(|| { + PoolError::Other(format!( + "pool entry {} disappeared during exclusive removal", + entry_key, + )) + })?; + self.indexes.remove(entry_key, &keys, component_count); + Ok(Some(removed)) + } + + /// Remove several entries while preserving insertion order in one linear + /// `IndexMap` pass. Complete every fallible conversion and consistency check + /// before mutating either the entries or their derived indexes. + fn remove_entries(&mut self, entry_keys: &HashSet) -> Result<(), PoolError> { + if entry_keys.is_empty() { + return Ok(()); + } + + let mut removals = Vec::with_capacity(entry_keys.len()); + let mut removed_components = 0u128; + for entry_key in entry_keys { + let entry = self.entries.get(entry_key).ok_or_else(|| { + PoolError::Other(format!( + "pool entry {} disappeared during exclusive bulk removal", + entry_key, + )) + })?; + let keys = PoolEntryIndexKeys::from_transaction(self.context_id, &entry.tx)?; + self.indexes.check_owned_by(entry_key, &keys)?; + removed_components = removed_components + .checked_add(keys.components) + .ok_or_else(|| PoolError::Other("removed component count overflow".into()))?; + removals.push((*entry_key, keys)); + } + + let component_count = self + .indexes + .component_count + .checked_sub(removed_components) + .ok_or_else(|| PoolError::Other("pool component count underflow".into()))?; + + // `IndexMap::retain` preserves the relative order of retained entries and + // rebuilds its positional index once instead of shifting it for every key. + self.entries + .retain(|entry_key, _| !entry_keys.contains(entry_key)); + self.indexes.remove_many(&removals, component_count); + Ok(()) + } + + fn clear_entries(&mut self) { + self.entries.clear(); + self.indexes.clear(); + } + pub fn ordered_entry_refs(&self) -> impl Iterator { self.entries.values() } @@ -120,7 +664,7 @@ where .unwrap_or(false); if remove { - Ok(self.entries.shift_remove(&tx_key)) + self.shift_remove_entry(&tx_key) } else { Ok(None) } @@ -214,6 +758,28 @@ where .collect() } + /// Build the exact aggregate represented by this pool without rechecking + /// rangeproofs or kernel signatures. + /// + /// Every entry can reach `entries` only after full component authentication. + /// Keep that invariant explicit in the return type so this faster path cannot + /// be used with an arbitrary transaction supplied by a caller. + pub(crate) fn validated_pool_aggregate( + &self, + secp: &Secp256k1, + ) -> Result, PoolError> { + let txs = self.all_transactions(); + if txs.is_empty() { + return Ok(None); + } + + Ok(Some(ValidatedPoolAggregate(transaction::aggregate( + self.context_id, + &txs, + secp, + )?))) + } + /// Return a single aggregate tx representing all txs in the pool. /// Takes an optional "extra tx" to include in the aggregation. /// Returns None if there is nothing to aggregate. @@ -248,14 +814,128 @@ where header: &BlockHeader, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - // Combine all the txs from the pool with any extra txs provided. - let mut txs = self.all_transactions(); + self.add_entry_internal(entry, extra_tx, header, secp) + .map(|_| ()) + } + + /// Add an entry authenticated before the transaction-pool write lock was + /// acquired, while accounting for an authenticated aggregate from another + /// pool. Structural, aggregate, and chain-state validation still runs here; + /// only unchanged rangeproof and kernel-signature checks are reused. + pub(crate) fn add_entry_with_validated_extra( + &mut self, + entry: ValidatedPoolEntry, + extra_tx: Option, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result<(), PoolError> { + self.add_prevalidated_entry_internal( + entry, + extra_tx.map(|aggregate| aggregate.0), + header, + secp, + ) + .map(|_| ()) + } - // Quick check to see if we have seen this tx before. - if ser::contains_by_hash(self.context_id, &txs, &entry.tx)? { + /// Add an entry authenticated before the transaction-pool write lock was + /// acquired and return the exact post-insertion pool aggregate. + pub(crate) fn add_entry_with_pool_aggregate( + &mut self, + entry: ValidatedPoolEntry, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { + let aggregate = self.add_prevalidated_entry_internal(entry, None, header, secp)?; + Ok(ValidatedPoolAggregate(aggregate)) + } + + /// Run the cheap, pool-local portion of admission before any component + /// authentication or pool-wide aggregate construction. + fn prepare_entry_for_admission( + &self, + entry: &PoolEntry, + ) -> Result { + if self.contains_tx(&entry.tx)? { return Err(PoolError::DuplicateTx); } + entry.tx.validate_read(self.context_id)?; + let entry_index_keys = PoolEntryIndexKeys::from_transaction(self.context_id, &entry.tx)?; + self.indexes.check_conflicts(&entry_index_keys)?; + Ok(entry_index_keys) + } + + fn add_entry_internal( + &mut self, + entry: PoolEntry, + extra_tx: Option, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { + // Validate cheap structural properties before deriving conflict keys. The + // indexes are needed because aggregate construction discovers duplicate + // inputs, outputs, and kernels only after cloning and sorting every retained + // pool component. Without this precheck, a peer can repeatedly submit + // distinct, individually valid double spends and make every rejection cost + // work proportional to the full pool. + // + // This index check is not an acceptance shortcut. A miss says only that no + // already indexed conflict was found; full component authentication, + // aggregate validation, and chain-state validation below remain mandatory. + let entry_index_keys = self.prepare_entry_for_admission(&entry)?; + + // The aggregate validation below deliberately skips rangeproof and kernel + // signature verification for components already accepted into the pool. + // Fully validate the new component first so add_entry() remains a safe + // admission boundary independent of its callers. + entry + .tx + .validate(self.context_id, Weighting::AsTransaction, secp)?; + self.add_authenticated_entry_internal(entry, extra_tx, entry_index_keys, header, secp) + } + + /// Insert an entry whose individual rangeproofs and kernel signatures have + /// already been authenticated. This is the path used by `TransactionPool` + /// after its off-lock validation. Lower-level entry points continue through + /// `add_entry_internal` and authenticate arbitrary entries themselves. + fn add_prevalidated_entry_internal( + &mut self, + entry: ValidatedPoolEntry, + extra_tx: Option, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { + if entry.context_id != self.context_id { + return Err(PoolError::Other(format!( + "validated entry context {} does not match pool context {}", + entry.context_id, self.context_id, + ))); + } + let entry = entry.into_pool_entry(); + let entry_index_keys = self.prepare_entry_for_admission(&entry)?; + self.add_authenticated_entry_internal(entry, extra_tx, entry_index_keys, header, secp) + } + + fn add_authenticated_entry_internal( + &mut self, + entry: PoolEntry, + extra_tx: Option, + entry_index_keys: PoolEntryIndexKeys, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { + // Reject outputs already present in the chain before cloning, sorting, and + // validating every retained pool component. This is only a negative + // preflight: validate_raw_aggregate_tx() remains authoritative in case chain + // state changes after this lookup. + self.blockchain.validate_outputs(entry.tx.outputs())?; + + // Combine all the txs from the pool with any extra txs provided. + // extra_tx is crate-internal and must be an aggregate produced from an + // already validated pool (the txpool aggregate used by the stempool). + let mut txs = self.all_transactions(); + // Make sure we take extra_tx into consideration here. // When adding to stempool we need to account for current txpool. txs.extend(extra_tx); @@ -272,16 +952,13 @@ where // Validate aggregated tx (existing pool + new tx), ignoring tx weight limits. // Validate against known chain state at the provided header. - self.validate_raw_tx(&agg_tx, header, Weighting::NoLimit, secp)?; + self.validate_raw_aggregate_tx(&agg_tx, header, Weighting::NoLimit, secp)?; // If we get here successfully then we can safely add the entry to the pool. let entry_key = Self::tx_key(self.context_id, &entry.tx)?; - if self.entries.contains_key(&entry_key) { - return Err(PoolError::DuplicateTx); - } self.log_pool_add(&entry, header); - self.entries.insert(entry_key, entry); + self.insert_entry(entry_key, entry, &entry_index_keys)?; - Ok(()) + Ok(agg_tx) } fn log_pool_add(&self, entry: &PoolEntry, header: &BlockHeader) { @@ -308,7 +985,28 @@ where // Validate the tx, conditionally checking against weight limits, // based on weight verification type. tx.validate(self.context_id, weighting, secp)?; + self.validate_raw_tx_state(tx, header, secp) + } + /// Validate an aggregate whose component transactions have already passed + /// full rangeproof and kernel-signature verification. + fn validate_raw_aggregate_tx( + &self, + tx: &Transaction, + header: &BlockHeader, + weighting: Weighting, + secp: &mut Secp256k1, + ) -> Result { + tx.validate_aggregate_from_validated_components(self.context_id, weighting, secp)?; + self.validate_raw_tx_state(tx, header, secp) + } + + fn validate_raw_tx_state( + &self, + tx: &Transaction, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result { // NRD kernels are only valid once HF3/header version 4 rules apply. if tx.kernels().iter().any(|k| k.is_nrd()) { if !global::is_nrd_enabled(self.context_id) { @@ -435,6 +1133,59 @@ where Ok((spent_pool.to_vec(), spent_utxo)) } + /// Locate spends against the exact indexes for this pool and, optionally, + /// another pool. Unlike `locate_spends`, this path accepts no raw extra + /// transaction and therefore does not need to scan or re-authenticate retained + /// entries. + pub(crate) fn locate_spends_from_pools( + &self, + tx: &Transaction, + extra_pool: Option<&Pool>, + ) -> Result<(Vec, Vec), PoolError> { + if let Some(extra_pool) = extra_pool { + if extra_pool.context_id != self.context_id { + return Err(PoolError::Other(format!( + "cannot locate spends across pool contexts {} and {}", + self.context_id, extra_pool.context_id, + ))); + } + } + + let mut inputs = tx.inputs().into_commit_wrappers(self.context_id)?; + ser::sort_by_hash(self.context_id, &mut inputs)?; + match ser::verify_sorted_and_unique_by_hash(self.context_id, &inputs) { + Ok(()) => {} + Err(ser::Error::DuplicateError) => { + return Err(PoolError::InvalidTx(transaction::Error::CutThrough)); + } + Err(e) => return Err(e.into()), + } + + let mut spent_pool = Vec::with_capacity(inputs.len()); + let mut spent_utxo = Vec::with_capacity(inputs.len()); + for input in inputs { + let commitment = input.commitment(); + let local_output = self.indexes.lookup_unspent_output(&commitment)?; + let extra_output = match extra_pool { + Some(extra_pool) => extra_pool.indexes.lookup_unspent_output(&commitment)?, + None => None, + }; + + match (local_output, extra_output) { + (Some(_), Some(_)) => return Err(PoolError::DuplicateCommitment), + (Some(output), None) | (None, Some(output)) => spent_pool.push(output), + (None, None) => spent_utxo.push(input), + } + } + + ser::sort_by_hash(self.context_id, &mut spent_pool)?; + let spent_utxo = self + .blockchain + .validate_inputs(&Inputs::from(spent_utxo.as_slice()))?; + + Ok((spent_pool, spent_utxo)) + } + fn apply_tx_to_block_sums( &self, tx: &Transaction, @@ -469,12 +1220,129 @@ where extra_tx: Option, header: &BlockHeader, secp: &mut Secp256k1, + ) -> Result<(), PoolError> { + self.reconcile_with_limits(extra_tx, header, secp, RECONCILE_FALLBACK_LIMITS) + } + + /// Reconcile against an aggregate returned directly by another pool's + /// admission boundary. + /// + /// The wrapper proves that every component already passed full rangeproof and + /// kernel-signature verification. Reconciliation still checks all structural, + /// aggregate-kernel-sum, and current-chain-state properties; it skips only the + /// redundant cryptographic authentication of unchanged components. + pub(crate) fn reconcile_with_pool_aggregate( + &mut self, + extra_tx: ValidatedPoolAggregate, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result<(), PoolError> { + let existing_entries = self.all_entries(); + if existing_entries.is_empty() { + return Ok(()); + } + + self.reconcile_authenticated_with_limits( + existing_entries, + Some(extra_tx.0), + header, + secp, + RECONCILE_FALLBACK_LIMITS, + ) + } + + fn reconcile_with_limits( + &mut self, + extra_tx: Option, + header: &BlockHeader, + secp: &mut Secp256k1, + fallback_limits: ReconcileFallbackLimits, ) -> Result<(), PoolError> { let existing_entries = self.all_entries(); + if existing_entries.is_empty() { + return Ok(()); + } + + // The retained entries were fully validated when admitted, but extra_tx is + // supplied separately. Authenticate it once before using the aggregate fast + // path so callers cannot smuggle unverified cryptographic components into the + // candidate set. + if let Some(extra_tx) = &extra_tx { + extra_tx.validate(self.context_id, Weighting::NoLimit, secp)?; + } + + self.reconcile_authenticated_with_limits( + existing_entries, + extra_tx, + header, + secp, + fallback_limits, + ) + } + + /// Common reconciliation path. Callers must either authenticate `extra_tx` + /// immediately before entering or obtain it as a `ValidatedPoolAggregate`. + fn reconcile_authenticated_with_limits( + &mut self, + existing_entries: Vec, + extra_tx: Option, + header: &BlockHeader, + secp: &mut Secp256k1, + fallback_limits: ReconcileFallbackLimits, + ) -> Result<(), PoolError> { + // Reconciliation normally retains the complete pool. Validate that aggregate + // once instead of clearing the pool and validating every growing prefix, which + // is quadratic. If the bulk candidate is invalid, fall back to conservative + // per-entry filtering so stale transactions are still evicted individually. + let mut candidate_txs = existing_entries + .iter() + .map(|entry| entry.tx.clone()) + .collect::>(); + candidate_txs.extend(extra_tx.clone()); + let aggregate_result = transaction::aggregate(self.context_id, &candidate_txs, secp) + .map_err(PoolError::from) + .and_then(|aggregate| { + self.validate_raw_aggregate_tx(&aggregate, header, Weighting::NoLimit, secp) + }); + if aggregate_result.is_ok() { + return Ok(()); + } + if let Err(e) = aggregate_result { + debug!( + "reconcile [{}]: bulk validation failed, filtering entries individually: {}", + self.name, e, + ); + } + + // Re-adding entries one at a time rebuilds and validates every growing + // aggregate prefix. Estimate that cumulative work before clearing the pool + // and only use the precise fallback when it is strictly bounded. A false + // eviction is preferable to holding the pool write lock for quadratic work. + let prefix_components = extra_tx.as_ref().map(transaction_components).unwrap_or(0); + let mut fallback_budget = + ReconcileWorkBudget::with_limits(prefix_components, fallback_limits); + let fallback_is_bounded = existing_entries.iter().all(|entry| { + if fallback_budget.charge_attempt(&entry.tx) { + fallback_budget.record_accept(&entry.tx); + true + } else { + false + } + }); + if !fallback_is_bounded { + warn!( + "reconcile [{}]: bulk validation failed; dropping {} entries because individual filtering exceeds the reconciliation work budget", + self.name, + existing_entries.len(), + ); + self.clear_entries(); + return Ok(()); + } + // Logging hashes are diagnostic only; do not let them abort // reconciliation after the pool has been cleared. let header_hash = header.hash(self.context_id).unwrap_or(Hash::default()); - self.entries.clear(); + self.clear_entries(); for x in existing_entries { let tx_hash = x.tx.hash(self.context_id).unwrap_or(Hash::default()); if let Err(e) = self.add_entry(x, extra_tx.clone(), header, secp) { @@ -497,6 +1365,15 @@ where // pool, but reconciliation/validation can clean this up and users can repost // unconfirmed transactions. Avoid overcomplicating pool reconciliation for // this non-critical case. + // + // Resource-bound contract: in normal node operation this pool is populated + // through TransactionPool, whose admission checks cap it at the configured + // PoolConfig::max_pool_size. This is an explicit maintenance operation, not a + // peer-triggered admission path. ReconcileWorkBudget limits are for repeated + // replay/reconciliation work and intentionally do not gate eviction, because + // exhausting such a budget here would leave a full pool unchanged. If eviction + // becomes automatically triggerable by untrusted traffic, add a bounded + // fallback that still removes an entry. pub fn evict_transaction(&mut self, secp: &mut Secp256k1) -> Result<(), PoolError> { let txs = self.bucket_transactions(secp)?; if let Some(evictable_transaction) = txs.last() { @@ -510,7 +1387,7 @@ where } } for key in evict_keys { - self.entries.shift_remove(&key); + self.shift_remove_entry(&key)?; } }; Ok(()) @@ -637,29 +1514,57 @@ where } /// TODO - This is kernel based. How does this interact with NRD? + /// + /// Resolve possible matches through the exact kernel index. Admission calls this + /// while holding the transaction-pool write lock, so work here must depend on + /// the submitted kernel set rather than on every retained pool entry. pub fn find_matching_transactions( &self, kernels: &[TxKernel], ) -> Result, PoolError> { // While the inputs outputs can be cut-through the kernel will stay intact // In order to deaggregate tx we look for tx with the same kernel - let mut found_txs = vec![]; - - // Gather all the kernels of the multi-kernel transaction in one set - let kernel_set = kernels + let kernel_hashes = kernels .iter() .map(|kernel| kernel.hash(self.context_id)) - .collect::, _>>()?; - - // Check each transaction in the pool - for entry in self.entries.values() { - let entry_kernel_set = entry - .tx - .kernels() - .iter() - .map(|kernel| kernel.hash(self.context_id)) - .collect::, _>>()?; - if entry_kernel_set.is_subset(&kernel_set) { + .collect::, _>>()?; + let kernel_set = kernel_hashes.iter().copied().collect::>(); + + // A matching pool entry must own at least one submitted kernel. Deduplicate + // owners and retain pool insertion order without walking unrelated entries. + let mut seen_owners = HashSet::with_capacity(kernel_hashes.len()); + let mut candidate_owners = Vec::new(); + for kernel_hash in &kernel_hashes { + if let Some(owner) = self.indexes.kernels.get(kernel_hash) { + if seen_owners.insert(*owner) { + let position = self.entries.get_index_of(owner).ok_or_else(|| { + PoolError::Other(format!( + "pool kernel index references missing entry {}", + owner, + )) + })?; + candidate_owners.push((position, *owner)); + } + } + } + candidate_owners.sort_unstable_by_key(|(position, _)| *position); + + let mut found_txs = Vec::with_capacity(candidate_owners.len()); + for (_, owner) in candidate_owners { + let entry = self.entries.get(&owner).ok_or_else(|| { + PoolError::Other(format!( + "pool kernel index references missing entry {}", + owner, + )) + })?; + let mut is_subset = true; + for entry_kernel in entry.tx.kernels() { + if !kernel_set.contains(&entry_kernel.hash(self.context_id)?) { + is_subset = false; + break; + } + } + if is_subset { found_txs.push(entry.tx.clone()); } } @@ -683,7 +1588,7 @@ where .iter() .map(|input| input.hash(self.context_id)) .collect::, _>>()?; - let mut evict_keys = Vec::new(); + let mut evict_keys = HashSet::new(); for (key, entry) in &self.entries { let mut kernel_conflict = false; for kernel in entry.tx.kernels() { @@ -703,14 +1608,11 @@ where } } if kernel_conflict || input_conflict { - evict_keys.push(*key); + evict_keys.insert(*key); } } - for key in evict_keys { - self.entries.shift_remove(&key); - } - Ok(()) + self.remove_entries(&evict_keys) } /// Size of the pool. @@ -718,6 +1620,17 @@ where self.entries.len() } + /// Number of input, output, and kernel components currently represented by + /// pool entries. Used to budget operations that repeatedly rebuild aggregates. + pub(crate) fn component_count(&self) -> u128 { + self.indexes.component_count + } + + /// Remove all entries from the pool. + pub fn clear(&mut self) { + self.clear_entries(); + } + /// Number of transaction kernels in the pool. /// This may differ from the size (number of transactions) due to tx aggregation. pub fn kernel_count(&self) -> usize { @@ -765,3 +1678,959 @@ impl Bucket { }) } } + +#[cfg(all(test, feature = "test-support"))] +mod tests { + use super::*; + use crate::transaction_pool::TransactionPool; + use crate::types::{NoopPoolAdapter, PoolConfig, TxSource}; + use mwc_core::core::{KernelFeatures, NRDRelativeHeight}; + use mwc_core::libtx::{aggsig, build, ProofBuilder}; + use mwc_crates::secp::{pedersen::Commitment, ContextFlag}; + use mwc_keychain::{BlindingFactor, ExtKeychain, Keychain}; + use std::convert::TryInto; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + struct AcceptingChain; + + impl BlockChain for AcceptingChain { + fn verify_coinbase_maturity(&self, _inputs: &Inputs) -> Result<(), PoolError> { + Ok(()) + } + + fn verify_tx_lock_height(&self, _tx: &Transaction) -> Result<(), PoolError> { + Ok(()) + } + + fn validate_tx(&self, _tx: &Transaction) -> Result<(), PoolError> { + Ok(()) + } + + fn validate_outputs(&self, _outputs: &[Output]) -> Result<(), PoolError> { + Ok(()) + } + + fn validate_inputs(&self, _inputs: &Inputs) -> Result, PoolError> { + Ok(vec![]) + } + + fn chain_head(&self) -> Result { + Err(PoolError::Other("unused test method".into())) + } + + fn get_block_header(&self, _hash: &Hash) -> Result { + Err(PoolError::Other("unused test method".into())) + } + + fn get_block_sums(&self, _hash: &Hash) -> Result { + Ok(BlockSums::empty()) + } + + fn replay_attack_check(&self, _tx: &Transaction) -> Result<(), PoolError> { + Ok(()) + } + } + + #[derive(Default)] + struct SelectiveChain { + rejected_output: Mutex>, + validate_output_calls: AtomicUsize, + validate_calls: AtomicUsize, + } + + impl SelectiveChain { + fn reject_output(&self, commitment: Commitment) { + *self.rejected_output.lock().unwrap() = Some(commitment); + } + + fn validate_calls(&self) -> usize { + self.validate_calls.load(Ordering::Relaxed) + } + + fn validate_output_calls(&self) -> usize { + self.validate_output_calls.load(Ordering::Relaxed) + } + } + + impl BlockChain for SelectiveChain { + fn verify_coinbase_maturity(&self, _inputs: &Inputs) -> Result<(), PoolError> { + Ok(()) + } + + fn verify_tx_lock_height(&self, _tx: &Transaction) -> Result<(), PoolError> { + Ok(()) + } + + fn validate_tx(&self, tx: &Transaction) -> Result<(), PoolError> { + self.validate_calls.fetch_add(1, Ordering::Relaxed); + let rejected_output = *self.rejected_output.lock().unwrap(); + if rejected_output.is_some_and(|commitment| { + tx.outputs() + .iter() + .any(|output| output.commitment() == commitment) + }) { + return Err(PoolError::DuplicateCommitment); + } + Ok(()) + } + + fn validate_outputs(&self, outputs: &[Output]) -> Result<(), PoolError> { + self.validate_output_calls.fetch_add(1, Ordering::Relaxed); + let rejected_output = *self.rejected_output.lock().unwrap(); + if rejected_output.is_some_and(|commitment| { + outputs + .iter() + .any(|output| output.commitment() == commitment) + }) { + return Err(PoolError::DuplicateCommitment); + } + Ok(()) + } + + fn validate_inputs(&self, _inputs: &Inputs) -> Result, PoolError> { + Ok(vec![]) + } + + fn chain_head(&self) -> Result { + Err(PoolError::Other("unused test method".into())) + } + + fn get_block_header(&self, _hash: &Hash) -> Result { + Err(PoolError::Other("unused test method".into())) + } + + fn get_block_sums(&self, _hash: &Hash) -> Result { + Ok(BlockSums::empty()) + } + + fn replay_attack_check(&self, _tx: &Transaction) -> Result<(), PoolError> { + Ok(()) + } + } + + fn test_transaction( + input_value: u64, + output_value: u64, + key_index: u32, + keychain: &ExtKeychain, + secp: &mut Secp256k1, + ) -> Transaction { + let input_id = ExtKeychain::derive_key_id(1, key_index, 0, 0, 0).unwrap(); + let output_id = ExtKeychain::derive_key_id(1, key_index + 1, 0, 0, 0).unwrap(); + build::transaction( + 0, + secp, + KernelFeatures::Plain { + fee: (input_value - output_value).try_into().unwrap(), + }, + &[ + build::input(input_value, input_id), + build::output(output_value, output_id), + ], + keychain, + &ProofBuilder::new(secp, keychain).unwrap(), + ) + .unwrap() + } + + fn test_nrd_transaction( + input_value: u64, + output_value: u64, + input_key_index: u32, + output_key_index: u32, + relative_height: u64, + excess: &BlindingFactor, + keychain: &ExtKeychain, + secp: &mut Secp256k1, + ) -> Transaction { + let mut kernel = TxKernel::with_features(KernelFeatures::NoRecentDuplicate { + fee: (input_value - output_value).try_into().unwrap(), + relative_height: NRDRelativeHeight::new(relative_height).unwrap(), + }) + .unwrap(); + let msg = kernel.msg_to_sign(0).unwrap(); + let secret_key = excess.secret_key(secp).unwrap(); + kernel.excess = secp.commit(0, secret_key).unwrap(); + let public_key = kernel.excess.to_pubkey(secp).unwrap(); + kernel.excess_sig = aggsig::sign_with_blinding(secp, &msg, excess, &public_key).unwrap(); + kernel.verify(0, secp).unwrap(); + + let input_id = ExtKeychain::derive_key_id(1, input_key_index, 0, 0, 0).unwrap(); + let output_id = ExtKeychain::derive_key_id(1, output_key_index, 0, 0, 0).unwrap(); + build::transaction_with_kernel( + 0, + secp, + &[ + build::input(input_value, input_id), + build::output(output_value, output_id), + ], + kernel, + excess.clone(), + keychain, + &ProofBuilder::new(secp, keychain).unwrap(), + ) + .unwrap() + } + + fn populated_test_pool() -> ( + Pool, + Arc, + Vec, + BlockHeader, + Secp256k1, + ) { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[11u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let chain = Arc::new(SelectiveChain::default()); + let mut pool = Pool::new(0, chain.clone(), "test".into()); + let txs = vec![ + test_transaction(10, 8, 1, &keychain, &mut secp), + test_transaction(11, 9, 3, &keychain, &mut secp), + test_transaction(12, 10, 5, &keychain, &mut secp), + ]; + + for tx in &txs { + pool.add_entry( + PoolEntry::new(tx.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + } + + (pool, chain, txs, header, secp) + } + + fn assert_pool_indexes_consistent(pool: &Pool) { + let mut expected_inputs = HashMap::new(); + let mut expected_outputs = HashMap::new(); + let mut expected_kernels = HashMap::new(); + let mut expected_nrd_excesses = HashMap::new(); + let mut expected_components = 0u128; + + for (entry_key, entry) in &pool.entries { + let keys = PoolEntryIndexKeys::from_transaction(pool.context_id, &entry.tx).unwrap(); + for input in keys.inputs { + assert_eq!(expected_inputs.insert(input, *entry_key), None); + } + for output in keys.outputs { + assert!(expected_outputs + .insert(output.commitment(), (*entry_key, output)) + .is_none()); + } + for kernel in keys.kernels { + assert_eq!(expected_kernels.insert(kernel, *entry_key), None); + } + for excess in keys.nrd_excesses { + assert_eq!(expected_nrd_excesses.insert(excess, *entry_key), None); + } + expected_components = expected_components.checked_add(keys.components).unwrap(); + } + + assert_eq!(pool.indexes.spent_inputs, expected_inputs); + assert_eq!(pool.indexes.produced_outputs.len(), expected_outputs.len()); + for (commitment, (owner, identifier)) in expected_outputs { + let indexed = pool.indexes.produced_outputs.get(&commitment).unwrap(); + assert_eq!(indexed.owner, owner); + assert_eq!(indexed.features, identifier.features); + } + assert_eq!(pool.indexes.kernels, expected_kernels); + assert_eq!(pool.indexes.nrd_excesses, expected_nrd_excesses); + assert_eq!(pool.indexes.component_count, expected_components); + } + + #[test] + fn find_matching_transactions_uses_kernel_index_without_scanning_unrelated_entries() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[29u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + + let first = test_transaction(10, 8, 1, &keychain, &mut secp); + let second = test_transaction(11, 9, 3, &keychain, &mut secp); + let known = transaction::aggregate(0, &[first.clone(), second], &secp).unwrap(); + let unrelated = test_transaction(12, 10, 5, &keychain, &mut secp); + let fresh = test_transaction(13, 11, 7, &keychain, &mut secp); + let complete_candidate = + transaction::aggregate(0, &[known.clone(), fresh.clone()], &secp).unwrap(); + let partial_candidate = transaction::aggregate(0, &[first, fresh], &secp).unwrap(); + + pool.add_entry( + PoolEntry::new(known.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + pool.add_entry( + PoolEntry::new(unrelated.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + // Production mutation paths keep entries and indexes synchronized. Clearing + // this unrelated entry's kernels is a canary: a full-pool scan would inspect + // it and incorrectly treat its empty kernel set as a subset of every request. + let unrelated_key = Pool::::tx_key(0, &unrelated).unwrap(); + pool.entries + .get_mut(&unrelated_key) + .unwrap() + .tx + .body + .kernels + .clear(); + + let found = pool + .find_matching_transactions(complete_candidate.kernels()) + .unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].hash(0).unwrap(), known.hash(0).unwrap()); + + // Sharing only one kernel with an aggregated pool entry is not enough; all + // kernels belonging to that entry must be present before deaggregation. + assert!(pool + .find_matching_transactions(partial_candidate.kernels()) + .unwrap() + .is_empty()); + } + + #[test] + fn reconcile_block_bulk_removal_preserves_order_and_indexes() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[23u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + let mut txs = Vec::new(); + + for index in 0..5u32 { + let input_value = 10 + u64::from(index); + let tx = test_transaction( + input_value, + input_value - 2, + 1 + index * 2, + &keychain, + &mut secp, + ); + pool.add_entry( + PoolEntry::new(tx.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + txs.push(tx); + } + + let expected_entry_keys = [0usize, 2, 4] + .into_iter() + .map(|index| Pool::::tx_key(0, &txs[index]).unwrap()) + .collect::>(); + let mut block = Block::default(0); + block.body.kernels.extend_from_slice(txs[1].kernels()); + block.body.kernels.extend_from_slice(txs[3].kernels()); + + pool.reconcile_block(&block).unwrap(); + + assert_eq!( + pool.entries.keys().copied().collect::>(), + expected_entry_keys + ); + assert!(!pool.contains_tx(&txs[1]).unwrap()); + assert!(!pool.contains_tx(&txs[3]).unwrap()); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn transaction_pool_block_reconcile_reuses_authenticated_components() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[37u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let chain = Arc::new(AcceptingChain); + let mut transaction_pool = TransactionPool::new( + 0, + PoolConfig::default(), + chain.clone(), + Arc::new(NoopPoolAdapter {}), + ); + + let public_tx = test_transaction(10, 8, 1, &keychain, &mut secp); + transaction_pool + .txpool + .add_entry( + PoolEntry::new(public_tx, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + let txpool_aggregate = transaction_pool + .txpool + .validated_pool_aggregate(&secp) + .unwrap(); + let stem_tx = test_transaction(11, 9, 3, &keychain, &mut secp); + let stem_hash = stem_tx.hash(0).unwrap(); + let stem_entry = + ValidatedPoolEntry::authenticate(0, stem_tx, TxSource::Broadcast, &mut secp).unwrap(); + transaction_pool + .stempool + .add_entry_with_validated_extra(stem_entry, txpool_aggregate, &header, &mut secp) + .unwrap(); + + // Production insertion authenticates this proof. Corrupt it afterward as a + // canary: reconciliation must reuse that admission invariant instead of + // repeating rangeproof and kernel-signature verification for every block. + let retained = transaction_pool.txpool.entries.values_mut().next().unwrap(); + retained.tx.body.outputs[0].proof.proof[0] ^= 1; + assert!(retained + .tx + .validate(0, Weighting::AsTransaction, &mut secp) + .is_err()); + assert!(transaction_pool + .txpool + .all_transactions_aggregate(None, &mut secp) + .is_err()); + + let (fluffable_txs, removed) = transaction_pool + .reconcile_stempool_for_fluff(&header, &mut secp) + .unwrap(); + assert_eq!(removed, 0); + assert_eq!(fluffable_txs.len(), 1); + assert_eq!(fluffable_txs[0].hash(0).unwrap(), stem_hash); + + let block = Block { + header, + body: Default::default(), + }; + transaction_pool.reconcile_block(&block, &mut secp); + + assert_eq!(transaction_pool.txpool.size(), 1); + assert_eq!(transaction_pool.stempool.size(), 1); + assert_pool_indexes_consistent(&transaction_pool.txpool); + assert_pool_indexes_consistent(&transaction_pool.stempool); + } + + #[test] + fn add_entry_fully_validates_new_component_before_aggregate_fast_path() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[7u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + + let first = test_transaction(10, 8, 1, &keychain, &mut secp); + pool.add_entry( + PoolEntry::new(first, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + let mut invalid = test_transaction(11, 9, 3, &keychain, &mut secp); + invalid.body.outputs[0].proof.proof[0] ^= 1; + let err = pool + .add_entry( + PoolEntry::new(invalid.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap_err(); + + assert!(matches!(err, PoolError::InvalidTx(_))); + assert_eq!(pool.size(), 1); + + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn prevalidated_entry_reuses_authentication_after_input_conversion() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[41u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + + let tx = test_transaction(10, 8, 1, &keychain, &mut secp); + let mut entry = + ValidatedPoolEntry::authenticate(0, tx, TxSource::Broadcast, &mut secp).unwrap(); + let resolved_inputs = entry + .transaction() + .inputs() + .into_commit_wrappers(0) + .unwrap() + .into_iter() + .map(|input| OutputIdentifier::new(OutputFeatures::Plain, &input.commitment())) + .collect::>(); + + // The private wrapper prevents this mutation in production. Corrupt the + // authenticated proof here as a canary: input conversion and locked pool + // admission must reuse component authentication, while still rechecking + // structural validity, kernel sums, conflicts, and chain state. + entry.entry.tx.body.outputs[0].proof.proof[0] ^= 1; + assert!(entry + .transaction() + .validate(0, Weighting::AsTransaction, &mut secp) + .is_err()); + + let entry = entry + .convert_inputs_v2(&[], &resolved_inputs, &secp) + .unwrap(); + pool.add_entry_with_pool_aggregate(entry, &header, &mut secp) + .unwrap(); + + assert_eq!(pool.size(), 1); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn chain_output_conflict_is_rejected_before_pool_aggregate_construction() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[43u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let chain = Arc::new(SelectiveChain::default()); + let mut pool = Pool::new(0, chain.clone(), "test".into()); + + let retained_tx = test_transaction(10, 8, 1, &keychain, &mut secp); + pool.add_entry( + PoolEntry::new(retained_tx, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + let rejected_tx = test_transaction(12, 10, 3, &keychain, &mut secp); + chain.reject_output(rejected_tx.outputs()[0].commitment()); + let validate_calls = chain.validate_calls(); + let validate_output_calls = chain.validate_output_calls(); + + // Poison the retained transaction with duplicate inputs as an aggregation + // canary. Production code cannot mutate an admitted entry this way. If the + // output preflight moves behind aggregate construction, cut-through will fail + // before the expected chain duplicate-output error is returned. + let original_retained_tx = pool.entries.values().next().unwrap().tx.clone(); + let mut duplicate_inputs = original_retained_tx + .inputs() + .into_commit_wrappers(0) + .unwrap(); + duplicate_inputs.push(duplicate_inputs[0].clone()); + pool.entries.values_mut().next().unwrap().tx.body.inputs = + Inputs::from(duplicate_inputs.as_slice()); + + let entry = + ValidatedPoolEntry::authenticate(0, rejected_tx, TxSource::Broadcast, &mut secp) + .unwrap(); + let err = pool + .add_entry_with_pool_aggregate(entry, &header, &mut secp) + .err() + .expect("chain output conflict must be rejected"); + + pool.entries.values_mut().next().unwrap().tx = original_retained_tx; + + assert!(matches!(err, PoolError::DuplicateCommitment)); + assert_eq!(chain.validate_calls(), validate_calls); + assert_eq!( + chain.validate_output_calls(), + validate_output_calls.checked_add(1).unwrap() + ); + assert_eq!(pool.size(), 1); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn conflicting_input_is_rejected_before_pool_wide_validation() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[13u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let chain = Arc::new(SelectiveChain::default()); + let mut pool = Pool::new(0, chain.clone(), "test".into()); + + let accepted = test_transaction(10, 8, 1, &keychain, &mut secp); + pool.add_entry( + PoolEntry::new(accepted, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + let validation_calls = chain.validate_calls(); + + // Same input commitment, but a different output, fee, kernel, signature, + // proof, and transaction hash. A transaction-hash cache cannot recognize + // this retry, while the spent-input index rejects it without scanning the + // pool. + let conflicting = test_transaction(10, 7, 1, &keychain, &mut secp); + let err = pool + .add_entry( + PoolEntry::new(conflicting, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .err() + .expect("conflicting input must be rejected"); + + assert!(matches!(err, PoolError::DuplicateKernelOrDuplicateSpent(_))); + assert_eq!(chain.validate_calls(), validation_calls); + assert_eq!(pool.size(), 1); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn conflicting_nrd_excess_is_rejected_by_exact_index() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(true); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[31u8; 32], false).unwrap(); + let mut header = global::get_genesis_block(&secp, 0).unwrap().header; + header.version = HeaderVersion(4); + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + let excess = BlindingFactor::rand(&secp).unwrap(); + + let accepted = test_nrd_transaction(10, 8, 1, 2, 2, &excess, &keychain, &mut secp); + let conflicting = test_nrd_transaction(12, 10, 3, 4, 3, &excess, &keychain, &mut secp); + + assert_eq!( + accepted.kernels()[0].excess(), + conflicting.kernels()[0].excess() + ); + assert_ne!( + accepted.kernels()[0].hash(0).unwrap(), + conflicting.kernels()[0].hash(0).unwrap() + ); + + pool.add_entry( + PoolEntry::new(accepted.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + assert_pool_indexes_consistent(&pool); + + // The full kernel hashes differ, but NRD uniqueness is based on excess. + // Reject through the exact index before building a pool-wide aggregate. + assert!(matches!( + pool.check_pool_conflicts(&conflicting), + Err(PoolError::NRDKernelRelativeHeight) + )); + assert!(matches!( + pool.add_entry( + PoolEntry::new(conflicting.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ), + Err(PoolError::NRDKernelRelativeHeight) + )); + assert_eq!(pool.size(), 1); + assert_pool_indexes_consistent(&pool); + + // Removing the owner must remove its NRD excess index record as well. + assert!(pool.remove_tx(&accepted).unwrap().is_some()); + assert!(pool.check_pool_conflicts(&conflicting).is_ok()); + pool.add_entry( + PoolEntry::new(conflicting, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + assert_pool_indexes_consistent(&pool); + pool.clear(); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn pool_child_spend_is_allowed_once_and_removal_updates_indexes() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[15u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + + let parent = test_transaction(10, 8, 1, &keychain, &mut secp); + let child = test_transaction(8, 6, 2, &keychain, &mut secp); + pool.add_entry( + PoolEntry::new(parent, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + pool.add_entry( + PoolEntry::new(child.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + assert_pool_indexes_consistent(&pool); + + let alternate_child = test_transaction(8, 5, 2, &keychain, &mut secp); + assert!(matches!( + pool.add_entry( + PoolEntry::new(alternate_child.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ), + Err(PoolError::DuplicateKernelOrDuplicateSpent(_)) + )); + + assert!(pool.remove_tx(&child).unwrap().is_some()); + assert_pool_indexes_consistent(&pool); + pool.add_entry( + PoolEntry::new(alternate_child, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + assert_pool_indexes_consistent(&pool); + + pool.clear(); + assert!(pool.is_empty()); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn indexed_spend_lookup_tracks_internal_spends_and_removals() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[17u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut pool = Pool::new(0, Arc::new(AcceptingChain), "test".into()); + + let parent = test_transaction(10, 8, 1, &keychain, &mut secp); + let parent_output = parent.outputs()[0].identifier(); + let child = test_transaction(8, 6, 2, &keychain, &mut secp); + let child_output = child.outputs()[0].identifier(); + pool.add_entry( + PoolEntry::new(parent, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + pool.add_entry( + PoolEntry::new(child.clone(), TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + let grandchild = test_transaction(6, 4, 3, &keychain, &mut secp); + let (spent_pool, spent_utxo) = pool.locate_spends_from_pools(&grandchild, None).unwrap(); + assert_eq!(spent_pool.len(), 1); + assert_eq!(spent_pool[0].commitment(), child_output.commitment()); + assert!(spent_utxo.is_empty()); + + let alternate_child = test_transaction(8, 5, 2, &keychain, &mut secp); + assert!(matches!( + pool.locate_spends_from_pools(&alternate_child, None), + Err(PoolError::DuplicateKernelOrDuplicateSpent(_)) + )); + + assert!(pool.remove_tx(&child).unwrap().is_some()); + let (spent_pool, spent_utxo) = pool + .locate_spends_from_pools(&alternate_child, None) + .unwrap(); + assert_eq!(spent_pool.len(), 1); + assert_eq!(spent_pool[0].commitment(), parent_output.commitment()); + assert!(spent_utxo.is_empty()); + assert_pool_indexes_consistent(&pool); + } + + #[test] + fn indexed_spend_lookup_combines_pool_indexes() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[19u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let chain = Arc::new(AcceptingChain); + let mut txpool = Pool::new(0, chain.clone(), "txpool".into()); + let mut stempool = Pool::new(0, chain, "stempool".into()); + + let parent = test_transaction(10, 8, 1, &keychain, &mut secp); + let child = test_transaction(8, 6, 2, &keychain, &mut secp); + let child_output = child.outputs()[0].identifier(); + txpool + .add_entry( + PoolEntry::new(parent, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + let txpool_aggregate = txpool.validated_pool_aggregate(&secp).unwrap(); + let child_entry = + ValidatedPoolEntry::authenticate(0, child, TxSource::Broadcast, &mut secp).unwrap(); + stempool + .add_entry_with_validated_extra(child_entry, txpool_aggregate, &header, &mut secp) + .unwrap(); + + let grandchild = test_transaction(6, 4, 3, &keychain, &mut secp); + let (spent_pool, spent_utxo) = stempool + .locate_spends_from_pools(&grandchild, Some(&txpool)) + .unwrap(); + assert_eq!(spent_pool.len(), 1); + assert_eq!(spent_pool[0].commitment(), child_output.commitment()); + assert!(spent_utxo.is_empty()); + + let alternate_child = test_transaction(8, 5, 2, &keychain, &mut secp); + assert!(matches!( + stempool.locate_spends_from_pools(&alternate_child, Some(&txpool)), + Err(PoolError::DuplicateKernelOrDuplicateSpent(_)) + )); + assert_pool_indexes_consistent(&txpool); + assert_pool_indexes_consistent(&stempool); + } + + #[test] + fn admission_spend_lookup_does_not_scan_or_revalidate_retained_pool_entries() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain = ExtKeychain::from_seed(&secp, &[9u8; 32], false).unwrap(); + let header = global::get_genesis_block(&secp, 0).unwrap().header; + let mut txpool = Pool::new(0, Arc::new(AcceptingChain), "txpool".into()); + let stempool = Pool::new(0, Arc::new(AcceptingChain), "stempool".into()); + + let parent = test_transaction(10, 8, 1, &keychain, &mut secp); + let parent_output = parent.outputs()[0].identifier(); + txpool + .add_entry( + PoolEntry::new(parent, TxSource::Broadcast), + None, + &header, + &mut secp, + ) + .unwrap(); + + // Poison an already admitted proof solely as a verification canary. The + // production API cannot mutate retained entries this way, but a call to + // full transaction validation will now reliably fail and expose a + // regression to the old whole-pool authentication path. + let retained = txpool.entries.values_mut().next().unwrap(); + retained.tx.body.outputs[0].proof.proof[0] ^= 1; + assert!(retained + .tx + .validate(0, Weighting::AsTransaction, &mut secp) + .is_err()); + assert!(txpool.all_transactions_aggregate(None, &mut secp).is_err()); + + // The trusted aggregate builder and admission lookup use the fact that + // pool entries were authenticated at insertion. Neither should recheck + // the retained proof. + assert!(txpool.validated_pool_aggregate(&secp).unwrap().is_some()); + + // Poison retained metadata after checking the aggregate builder. The + // production API cannot create this mismatch, but it is a canary for a + // regression that scans retained entries instead of using their exact index. + let decoy = test_transaction(12, 10, 20, &keychain, &mut secp); + txpool.entries.values_mut().next().unwrap().tx.body.outputs[0].identifier = + decoy.outputs()[0].identifier(); + + // The child spends the originally indexed parent output from the public + // txpool while being admitted to the stempool. + let child = test_transaction(8, 6, 2, &keychain, &mut secp); + let (spent_pool, spent_utxo) = stempool + .locate_spends_from_pools(&child, Some(&txpool)) + .unwrap(); + + assert_eq!(spent_pool.len(), 1); + assert_eq!(spent_pool[0].commitment(), parent_output.commitment()); + assert!(spent_utxo.is_empty()); + } + + #[test] + fn reconcile_filters_a_small_pool_after_bulk_validation_fails() { + let (mut pool, chain, txs, header, mut secp) = populated_test_pool(); + chain.reject_output(txs[1].outputs()[0].commitment()); + + pool.reconcile(None, &header, &mut secp).unwrap(); + + assert_eq!(pool.size(), 2); + assert!(pool.contains_tx(&txs[0]).unwrap()); + assert!(!pool.contains_tx(&txs[1]).unwrap()); + assert!(pool.contains_tx(&txs[2]).unwrap()); + } + + #[test] + fn fluff_reconcile_filters_a_small_stempool_after_bulk_validation_fails() { + let (stempool, chain, txs, header, mut secp) = populated_test_pool(); + let mut transaction_pool = TransactionPool::new( + 0, + PoolConfig::default(), + chain.clone(), + Arc::new(NoopPoolAdapter {}), + ); + transaction_pool.stempool = stempool; + chain.reject_output(txs[1].outputs()[0].commitment()); + + let (fluffable_txs, removed) = transaction_pool + .reconcile_stempool_for_fluff(&header, &mut secp) + .unwrap(); + + assert_eq!(removed, 1); + assert_eq!(fluffable_txs.len(), 2); + assert_eq!( + fluffable_txs + .iter() + .map(|tx| tx.hash(0).unwrap()) + .collect::>(), + vec![txs[0].hash(0).unwrap(), txs[2].hash(0).unwrap()] + ); + assert_pool_indexes_consistent(&transaction_pool.stempool); + } + + #[test] + fn reconcile_drops_an_over_budget_pool_without_individual_fallback() { + let (mut pool, chain, txs, header, mut secp) = populated_test_pool(); + chain.reject_output(txs[1].outputs()[0].commitment()); + let calls_before_reconcile = chain.validate_calls(); + + // Three one-input/one-output/one-kernel transactions have cumulative + // prefix work 3 + 6 + 9 = 18. A test limit of 17 exercises the same + // fail-closed branch used for a production pool above the hard budget. + pool.reconcile_with_limits( + None, + &header, + &mut secp, + ReconcileFallbackLimits { + max_entries: MAX_RECONCILE_FALLBACK_ENTRIES, + max_work: 17, + }, + ) + .unwrap(); + + assert!(pool.is_empty()); + assert_eq!(chain.validate_calls() - calls_before_reconcile, 1); + } +} diff --git a/pool/src/transaction_pool.rs b/pool/src/transaction_pool.rs index 17dd7f4be4..b8d18f8cd9 100644 --- a/pool/src/transaction_pool.rs +++ b/pool/src/transaction_pool.rs @@ -18,21 +18,17 @@ //! resulting tx pool can be added to the current chain state to produce a //! valid chain state. -use crate::pool::Pool; +use crate::pool::{Pool, ReconcileWorkBudget, ValidatedPoolAggregate, ValidatedPoolEntry}; use crate::types::{BlockChain, PoolAdapter, PoolConfig, PoolEntry, PoolError, TxSource}; use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::id::ShortId; -use mwc_core::core::{ - transaction, Block, BlockHeader, HeaderVersion, Inputs, OutputIdentifier, Transaction, - Weighting, -}; +use mwc_core::core::{Block, BlockHeader, HeaderVersion, Inputs, OutputIdentifier, Transaction}; use mwc_core::global; -use mwc_core::ser; use mwc_crates::log::{debug, log_enabled}; use mwc_crates::log::{warn, Level}; -use mwc_crates::parking_lot::RwLock; +use mwc_crates::parking_lot::{Mutex, RwLock}; use mwc_crates::secp::Secp256k1; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; @@ -45,6 +41,39 @@ enum PoolAddOutcome { Fluff(PoolEntry), } +/// Tracks a transaction while it is undergoing the expensive, state-independent +/// cryptographic portion of public pool admission. +/// +/// This is intentionally independent of the pool lock. Holding either side of +/// the pool `RwLock` while validating rangeproofs and kernel signatures would +/// block otherwise unrelated pool work. The key is the full transaction hash, +/// so this suppresses byte-for-byte concurrent submissions without using the +/// private stempool as an externally observable duplicate cache. +struct PendingValidationGuard { + pending: Arc>>, + tx_hash: Hash, +} + +impl PendingValidationGuard { + fn try_acquire( + pending: Arc>>, + tx_hash: Hash, + ) -> Option { + let inserted = pending.lock().insert(tx_hash); + if inserted { + Some(PendingValidationGuard { pending, tx_hash }) + } else { + None + } + } +} + +impl Drop for PendingValidationGuard { + fn drop(&mut self) { + self.pending.lock().remove(&self.tx_hash); + } +} + /// Transaction pool implementation. pub struct TransactionPool where @@ -64,6 +93,9 @@ where /// The pool adapter pub adapter: Arc

, context_id: u32, + /// Full transaction hashes currently undergoing standalone cryptographic + /// admission validation. + pending_validations: Arc>>, } impl TransactionPool @@ -81,6 +113,7 @@ where blockchain: chain, adapter, context_id, + pending_validations: Arc::new(Mutex::new(HashSet::new())), } } @@ -88,16 +121,55 @@ where self.blockchain.chain_head() } + /// Reconcile the private stempool against the current public txpool without + /// re-authenticating rangeproofs and kernel signatures already checked at + /// admission. The pool reconciliation path validates the complete aggregate + /// once and permits individual fallback only within its strict work budget. + fn reconcile_stempool_against_txpool( + &mut self, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result<(), PoolError> { + match self.txpool.validated_pool_aggregate(secp)? { + Some(txpool_tx) => self + .stempool + .reconcile_with_pool_aggregate(txpool_tx, header, secp), + None => self.stempool.reconcile(None, header, secp), + } + } + + /// Reconcile the stempool for Dandelion fluffing and return every retained + /// transaction together with the number of entries evicted during validation. + pub fn reconcile_stempool_for_fluff( + &mut self, + header: &BlockHeader, + secp: &mut Secp256k1, + ) -> Result<(Vec, usize), PoolError> { + let entries_before = self.stempool.size(); + self.reconcile_stempool_against_txpool(header, secp)?; + let fluffable_txs = self.stempool.all_transactions(); + let removed = entries_before + .checked_sub(fluffable_txs.len()) + .ok_or_else(|| { + PoolError::Other(format!( + "stempool grew during exclusive fluff reconciliation, before={} after={}", + entries_before, + fluffable_txs.len(), + )) + })?; + Ok((fluffable_txs, removed)) + } + // Add tx to stempool (passing in all txs from txpool to validate against). fn add_to_stempool( &mut self, - entry: &PoolEntry, + entry: &ValidatedPoolEntry, header: &BlockHeader, - extra_tx: Option, + extra_tx: Option, secp: &mut Secp256k1, ) -> Result<(), PoolError> { self.stempool - .add_entry(entry.clone(), extra_tx, header, secp) + .add_entry_with_validated_extra(entry.clone(), extra_tx, header, secp) } fn add_to_reorg_cache(&mut self, entry: &PoolEntry) { @@ -116,14 +188,15 @@ where // Returns the resulting entry and whether deaggregation changed the tx. fn deaggregate_tx( &self, - entry: PoolEntry, + entry: ValidatedPoolEntry, secp: &Secp256k1, - ) -> Result<(PoolEntry, bool), PoolError> { - if entry.tx.kernels().len() > 1 { - let txs = self.txpool.find_matching_transactions(entry.tx.kernels())?; + ) -> Result<(ValidatedPoolEntry, bool), PoolError> { + if entry.transaction().kernels().len() > 1 { + let txs = self + .txpool + .find_matching_transactions(entry.transaction().kernels())?; if !txs.is_empty() { - let tx = transaction::deaggregate(self.context_id, entry.tx, &txs, secp)?; - return Ok((PoolEntry::new(tx, TxSource::Deaggregate), true)); + return Ok((entry.deaggregate(&txs, secp)?, true)); } } Ok((entry, false)) @@ -131,16 +204,22 @@ where fn add_to_txpool( &mut self, - entry: &PoolEntry, + entry: &ValidatedPoolEntry, header: &BlockHeader, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - self.txpool.add_entry(entry.clone(), None, header, secp)?; + let txpool_agg = self + .txpool + .add_entry_with_pool_aggregate(entry.clone(), header, secp)?; // We now need to reconcile the stempool based on the new state of the txpool. // Some stempool txs may no longer be valid and we need to evict them. - let txpool_agg = self.txpool.all_transactions_aggregate(None, secp)?; - self.stempool.reconcile(txpool_agg, header, secp)?; + // The insertion call returns the exact aggregate it already checked + // before insertion. Every older component was fully verified on admission, so + // rebuilding the aggregate and rechecking every proof and signature here would + // add pool-size-amplified cryptographic work while the pool write lock is held. + self.stempool + .reconcile_with_pool_aggregate(txpool_agg, header, secp)?; Ok(()) } @@ -165,10 +244,11 @@ where /// Validate and submit a transaction through the shared transaction pool. /// - /// This is the public transaction-admission boundary. State-independent - /// cryptographic validation happens before the pool write lock is acquired, - /// pool- and chain-dependent admission happens under the lock, and public - /// fluff relay happens after the lock is released. + /// This is the public transaction-admission boundary. Cheap structural, + /// duplicate, and immutable fee-policy checks happen before state-independent + /// cryptographic validation. Pool- and chain-dependent admission is rechecked + /// under the write lock, and public fluff relay happens after the lock is + /// released. pub fn submit_to_pool( tx_pool: &RwLock, src: TxSource, @@ -177,12 +257,84 @@ where header: &BlockHeader, secp: &mut Secp256k1, ) -> Result<(), PoolError> { - let (context_id, adapter) = { + let (context_id, adapter, pending_validations) = { let tx_pool = tx_pool.read_recursive(); - (tx_pool.context_id, tx_pool.adapter.clone()) + ( + tx_pool.context_id, + tx_pool.adapter.clone(), + tx_pool.pending_validations.clone(), + ) }; - tx.validate(context_id, Weighting::AsTransaction, secp)?; + // Reject malformed structure, invalid ordering, cut-through violations, + // and excessive weight before hashing or consulting admission state. This + // deliberately skips rangeproof and kernel-signature verification. + tx.validate_read(context_id)?; + let tx_hash = tx.hash(context_id)?; + + // Fast read-only admission preflight. Never use private stempool + // membership to reject a public fluff transaction. A repeated stem + // transaction is allowed through so the locked path can preserve the + // documented stem-to-fluff promotion behavior. + { + let tx_pool = tx_pool.read_recursive(); + if tx_pool.context_id != context_id { + return Err(PoolError::Other(format!( + "transaction context {} does not match pool context {}", + context_id, tx_pool.context_id + ))); + } + if tx_pool.txpool.contains_tx(&tx)? { + return Err(PoolError::DuplicateTx); + } + + // Preserve the existing policy order: public duplicates are rejected + // before fee checks, and fee checks precede mutable capacity checks. + Self::verify_fee_policy(context_id, &tx)?; + + let repeated_stem = stem && tx_pool.stempool.contains_tx(&tx)?; + if !repeated_stem { + tx_pool.verify_capacity(stem)?; + } + } + + // Collapse simultaneous byte-for-byte submissions. This closes the race + // where many requests all pass the read-only duplicate check before the + // first request inserts the transaction. + let _pending_guard = PendingValidationGuard::try_acquire(pending_validations, tx_hash) + .ok_or(PoolError::DuplicateTx)?; + + // A transaction may have been accepted between the first read-only check + // and registration in the in-flight set. Avoid cryptographic work in that + // case as well. + { + let tx_pool = tx_pool.read_recursive(); + if tx_pool.context_id != context_id { + return Err(PoolError::Other(format!( + "transaction context {} does not match pool context {}", + context_id, tx_pool.context_id + ))); + } + if tx_pool.txpool.contains_tx(&tx)? { + return Err(PoolError::DuplicateTx); + } + } + + // Deliberate admission-order tradeoff: a structurally and cryptographically + // valid transaction can still reference an unknown or already-spent input, + // because UTXO membership is independent of its proofs, signatures, and + // kernel sums. We nevertheless authenticate the standalone transaction here + // before the pool- and chain-dependent checks in `admit_prevalidated()`. + // Moving those mutable checks ahead of authentication would require tentative + // deaggregation plus a second authoritative check after taking the write lock, + // duplicating state-sensitive logic and increasing race and regression risk. + // Keeping cryptographic work outside the write lock also prevents one slow + // validation from blocking all transaction-pool operations. Unknown or spent + // inputs are still rejected before any pool-wide aggregate is constructed. + // Do not add CPU/load heuristics or admission throttling at this boundary as a + // substitute: that introduces additional state, fairness decisions, and failure + // modes without strengthening transaction validity. + let entry = ValidatedPoolEntry::authenticate(context_id, tx, src, secp)?; let outcome = { let mut tx_pool = tx_pool.write(); @@ -192,7 +344,7 @@ where context_id, tx_pool.context_id ))); } - tx_pool.admit_prevalidated(src, tx, stem, header, secp)? + tx_pool.admit_prevalidated(entry, stem, header, secp)? }; if let PoolAddOutcome::Fluff(entry) = outcome { @@ -207,8 +359,7 @@ where fn admit_prevalidated( &mut self, - src: TxSource, - tx: Transaction, + entry: ValidatedPoolEntry, stem: bool, header: &BlockHeader, secp: &mut Secp256k1, @@ -217,38 +368,43 @@ where // Our stempool is private and we do not want to reveal anything about the txs contained. // If this is a stem tx and is already present in stempool then fluff by adding to txpool. // Otherwise if already present in txpool return a "duplicate tx" error. - if stem && self.stempool.contains_tx(&tx)? { - return self.admit_prevalidated(src, tx, false, header, secp); - } else if self.txpool.contains_tx(&tx)? { + if stem && self.stempool.contains_tx(entry.transaction())? { + return self.admit_prevalidated(entry, false, header, secp); + } else if self.txpool.contains_tx(entry.transaction())? { return Err(PoolError::DuplicateTx); } // Attempt to deaggregate the tx if not stem tx. - let (entry, deaggregated) = if stem { - (PoolEntry::new(tx, src), false) + let entry = if stem { + entry } else { - self.deaggregate_tx(PoolEntry::new(tx, src), secp)? + self.deaggregate_tx(entry, secp)?.0 }; - let ref tx = entry.tx; + let tx = entry.transaction(); // Check this tx is valid based on current header version. // NRD kernels only valid post HF3 and if NRD feature enabled. self.verify_kernel_variants(tx, header)?; // Does this transaction pay the required fees and fit within the pool capacity? - let acceptability = self.is_acceptable(tx, stem); - let mut evict = false; - if !stem && matches!(acceptability.as_ref().err(), Some(PoolError::OverCapacity)) { - evict = true; + self.is_acceptable(tx, stem)?; + + // Reject conflicts through the exact per-pool indexes before spend lookup or + // aggregate construction touches every retained transaction. A peer can vary + // an otherwise valid transaction while repeatedly spending the same input; + // transaction-hash caches do not stop that pattern, but the input commitment + // is stable across all variants. + // + // These checks are negative filters only. A miss does not authorize the + // transaction: all chain-state and aggregate checks below still run. Public + // fluff admission deliberately consults only the public txpool, preserving the + // existing rule that stempool contents are not exposed through duplicate + // responses. Stem admission must account for both pools. + if stem { + self.stempool.check_pool_conflicts(tx)?; + self.txpool.check_pool_conflicts(tx)?; } else { - acceptability?; - } - - // A deaggregated transaction differs from the transaction validated before - // taking the write lock and must be checked again. - if deaggregated { - tx.validate(self.context_id, Weighting::AsTransaction, secp) - .map_err(PoolError::InvalidTx)?; + self.txpool.check_pool_conflicts(tx)?; } // Check the tx lock_time is valid based on current chain state. @@ -256,18 +412,16 @@ where self.blockchain.replay_attack_check(tx)?; - // If stem we want to account for the txpool. - let extra_tx = if stem { - self.txpool.all_transactions_aggregate(None, secp)? - } else { - None - }; - - // Locate outputs being spent from pool and current utxo. + // Locate outputs being spent from the pools and current UTXO using only + // input/output metadata from already admitted entries. In particular, do + // this before building a txpool aggregate: an unknown or already-spent + // input must not make us process every retained proof and signature while + // the transaction-pool write lock is held. let (spent_pool, spent_utxo) = if stem { - self.stempool.locate_spends(tx, extra_tx.clone(), secp) + self.stempool + .locate_spends_from_pools(tx, Some(&self.txpool)) } else { - self.txpool.locate_spends(tx, None, secp) + self.txpool.locate_spends_from_pools(tx, None) }?; // Check coinbase maturity before we go any further. @@ -283,25 +437,44 @@ where // Convert the tx to "v2" compatibility with "features and commit" inputs. let entry = self.convert_tx_v2(entry, &spent_pool, &spent_utxo, secp)?; + // Stempool aggregate validation must account for the public txpool. Its + // entries were fully authenticated when admitted, so preserve that fact in + // the type instead of re-verifying every rangeproof and kernel signature. + // This is intentionally after input lookup so rejected inputs cannot cause + // even the linear aggregate construction work. + let extra_tx = if stem { + self.txpool.validated_pool_aggregate(secp)? + } else { + None + }; + // If this is a stem tx then attempt to add it to stempool. // If the adapter fails to accept the new stem tx then fallback to fluff via txpool. if stem { self.add_to_stempool(&entry, header, extra_tx, secp)?; - if self.adapter.stem_tx_accepted(&entry).is_ok() { + if self.adapter.stem_tx_accepted(entry.pool_entry()).is_ok() { return Ok(PoolAddOutcome::Stemmed); } } - // Add tx to txpool. - self.add_to_txpool(&entry, header, secp)?; - self.add_to_reorg_cache(&entry); - - // Transaction passed all the checks but we have to make space for it - if evict { - self.evict_from_txpool(secp)?; + // Add tx to txpool. If this is a failed stem relay then the entry has + // already been inserted into stempool. Roll that insertion back if the + // fluff fallback also fails so a rejected submission cannot be relayed + // later by the Dandelion monitor. + if let Err(add_err) = self.add_to_txpool(&entry, header, secp) { + if stem { + if let Err(remove_err) = self.stempool.remove_tx(entry.transaction()) { + return Err(PoolError::Other(format!( + "failed to fluff stem transaction: {}; failed to remove it from stempool: {}", + add_err, remove_err + ))); + } + } + return Err(add_err); } + self.add_to_reorg_cache(entry.pool_entry()); - Ok(PoolAddOutcome::Fluff(entry)) + Ok(PoolAddOutcome::Fluff(entry.into_pool_entry())) } /// Convert a transaction for v2 compatibility. @@ -310,35 +483,18 @@ where /// Conversion is done using outputs previously looked up in both the pool and the current utxo. fn convert_tx_v2( &self, - entry: PoolEntry, + entry: ValidatedPoolEntry, spent_pool: &[OutputIdentifier], spent_utxo: &[OutputIdentifier], - secp: &mut Secp256k1, - ) -> Result { - let tx = entry.tx; + secp: &Secp256k1, + ) -> Result { + let tx = entry.transaction(); debug!( "convert_tx_v2: {} ({} -> v2)", tx.hash(self.context_id)?, tx.inputs().version_str(), ); - - let mut inputs = spent_utxo.to_vec(); - inputs.extend_from_slice(spent_pool); - ser::sort_by_hash(self.context_id, &mut inputs) - .map_err(|e| PoolError::Other(format!("convert_tx_v2 input sorting error, {}", e)))?; - - let tx = Transaction { - body: tx.body.replace_inputs( - self.context_id, - Inputs::from_output_identifiers(self.context_id, inputs.as_slice())?, - )?, - ..tx - }; - - // Validate the tx to ensure our converted inputs are correct. - tx.validate(self.context_id, Weighting::AsTransaction, secp)?; - - Ok(PoolEntry::new(tx, entry.src)) + entry.convert_inputs_v2(spent_pool, spent_utxo, secp) } // Evict a transaction from the txpool. @@ -385,6 +541,7 @@ where let mut retained = VecDeque::new(); let mut entries = entries.into_iter(); let mut added = false; + let mut replay_budget = ReconcileWorkBudget::new(self.txpool.component_count()); while let Some(entry) = entries.next() { if self.total_size() >= self.config.max_pool_size { @@ -393,20 +550,52 @@ where break; } + // Cache entries already present in the txpool only need the cheap + // representative-kernel lookup. Do not charge them as projected aggregate + // rebuilds, especially when the existing txpool is already large. + match self.txpool.contains_tx(&entry.tx) { + Ok(true) => { + duplicates += 1; + debug!("reconcile_reorg_cache: skipping duplicate tx {:?}", entry); + retained.push_back(entry); + continue; + } + Ok(false) => {} + Err(e) => { + dropped += 1; + debug!( + "reconcile_reorg_cache: dropping cached tx after duplicate check failed {:?}: {}", + entry, e + ); + continue; + } + } + + if !replay_budget.charge_attempt(&entry.tx) { + let deferred = entries.len().saturating_add(1); + warn!( + "reconcile_reorg_cache: work budget exhausted; deferring {} cached entries", + deferred, + ); + retained.push_back(entry); + retained.extend(entries); + break; + } + // Reorg-cache entries already passed the public txpool admission path // before they were cached. Replay uses the lower-level pool insertion // path so we revalidate the aggregate against the current chain state // without repeating top-level side effects such as deaggregation, // re-caching, or network acceptance callbacks. // - // This intentionally does not re-run fee policy or standalone - // Weighting::AsTransaction validation here. Those checks are stable for - // retained cache entries because accept_fee_base is initialized once per - // node context and max transaction weight is derived from fixed chain - // constants. The context-sensitive checks that can change across blocks - // or reorgs are still re-run by Pool::add_entry(). + // This intentionally does not re-run fee policy here because + // accept_fee_base is initialized once per node context. Pool::add_entry() + // authenticates the cached component and re-runs the context-sensitive + // checks that can change across blocks or reorgs. The work budget above + // bounds the cumulative cost of rebuilding growing aggregate prefixes. match self.txpool.add_entry(entry.clone(), None, header, secp) { Ok(()) => { + replay_budget.record_accept(&entry.tx); added = true; replayed += 1; retained.push_back(entry); @@ -473,8 +662,8 @@ where } fn clear_pool_state(&mut self) { - self.txpool.entries.clear(); - self.stempool.entries.clear(); + self.txpool.clear(); + self.stempool.clear(); } /// Reconcile the transaction pool (both txpool and stempool) against the @@ -491,7 +680,7 @@ where debug!(" reorg_cache tx: {:?}", pe); } - debug!("txpool size: {}", self.txpool.entries.len()); + debug!("txpool size: {}", self.txpool.size()); for pe in self.txpool.all_entries() { debug!(" txpool tx: {:?}", pe); } @@ -503,8 +692,7 @@ where self.txpool.reconcile(None, &block.header, secp)?; self.stempool.reconcile_block(block)?; - let txpool_tx = self.txpool.all_transactions_aggregate(None, secp)?; - self.stempool.reconcile(txpool_tx, &block.header, secp)?; + self.reconcile_stempool_against_txpool(&block.header, secp)?; Ok(()) })(); @@ -525,7 +713,7 @@ where for pe in reorg_cache.iter() { debug!(" reorg_cache tx: {:?}", pe); } - debug!("txpool size: {}", self.txpool.entries.len()); + debug!("txpool size: {}", self.txpool.size()); for pe in self.txpool.all_entries() { debug!(" txpool tx: {:?}", pe); } @@ -555,18 +743,22 @@ where self.txpool.retrieve_transactions(hash, nonce, kern_ids) } - /// Whether the transaction is acceptable to the pool, given both how - /// full the pool is and the transaction weight. - fn is_acceptable(&self, tx: &Transaction, stem: bool) -> Result<(), PoolError> { + /// Check immutable transaction fee policy without consulting mutable pool + /// state. This is safe to run before expensive cryptographic validation. + fn verify_fee_policy(context_id: u32, tx: &Transaction) -> Result<(), PoolError> { // weight for a basic transaction (2 inputs, 2 outputs, 1 kernel) - // (2 * 1) + (2 * 21) + (1 * 3) = 47 // minfees = 47 * 500_000 = 23_500_000 let fee = tx.fee()?; - let accept_fee = tx.accept_fee(self.context_id)?; + let accept_fee = tx.accept_fee(context_id)?; if fee < accept_fee { return Err(PoolError::LowFeeTransaction(fee)); } + Ok(()) + } + /// Check mutable pool and stempool capacity. + fn verify_capacity(&self, stem: bool) -> Result<(), PoolError> { // Treat equality as full because accepting this tx would insert one more entry. if self.total_size() >= self.config.max_pool_size { return Err(PoolError::OverCapacity); @@ -579,6 +771,13 @@ where Ok(()) } + /// Whether the transaction is acceptable to the pool, given both immutable + /// fee policy and mutable pool capacity. + fn is_acceptable(&self, tx: &Transaction, stem: bool) -> Result<(), PoolError> { + Self::verify_fee_policy(self.context_id, tx)?; + self.verify_capacity(stem) + } + /// Get the total size of the pool. /// Note: we only consider the txpool here as stempool is under embargo. pub fn total_size(&self) -> usize { @@ -600,3 +799,20 @@ where self.context_id } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_validation_guard_suppresses_and_releases_identical_hash() { + let pending = Arc::new(Mutex::new(HashSet::new())); + let tx_hash = Hash::default(); + + let first = PendingValidationGuard::try_acquire(pending.clone(), tx_hash).unwrap(); + assert!(PendingValidationGuard::try_acquire(pending.clone(), tx_hash).is_none()); + + drop(first); + assert!(PendingValidationGuard::try_acquire(pending, tx_hash).is_some()); + } +} diff --git a/pool/src/types.rs b/pool/src/types.rs index 9ea7ee816c..0407aefc0b 100644 --- a/pool/src/types.rs +++ b/pool/src/types.rs @@ -21,7 +21,7 @@ use mwc_core::core::block; use mwc_core::core::committed; use mwc_core::core::hash::Hash; use mwc_core::core::transaction::{self, Transaction}; -use mwc_core::core::{BlockHeader, BlockSums, Inputs, OutputIdentifier}; +use mwc_core::core::{BlockHeader, BlockSums, Inputs, Output, OutputIdentifier}; use mwc_core::ser; use mwc_crates::serde::{self, Deserialize, Serialize}; use std::time::Instant; @@ -301,6 +301,13 @@ pub trait BlockChain: Sync + Send { /// Validate a transaction against the current utxo. fn validate_tx(&self, tx: &Transaction) -> Result<(), PoolError>; + /// Reject candidate outputs that already exist in the current UTXO set. + /// + /// This is a cheap negative admission filter used before pool-wide aggregation. + /// A successful result is not authoritative: callers must still validate the + /// final aggregate against chain state because the chain can change afterward. + fn validate_outputs(&self, outputs: &[Output]) -> Result<(), PoolError>; + /// Validate inputs against the current utxo. /// Returns the vec of output identifiers that would be spent /// by these inputs if they can all be successfully spent. diff --git a/pool/tests/block_building.rs b/pool/tests/block_building.rs index a25a877669..f34a4cf982 100644 --- a/pool/tests/block_building.rs +++ b/pool/tests/block_building.rs @@ -16,7 +16,6 @@ pub mod common; use crate::common::*; -use mwc_core::core::hash::Hashed; use mwc_core::global; use mwc_core::ser; use mwc_crates::rand::rngs::SysRng; @@ -126,9 +125,8 @@ fn test_transaction_pool_block_building() -> Result<(), PoolError> { add_block(&mut secp, &chain, &txs, &keychain); // Get full block from head of the chain (block we just processed). - let block = chain - .get_block(&chain.head().unwrap().hash(chain.get_context_id()).unwrap()) - .unwrap(); + let header = chain.head_header().unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); // Check the block contains what we expect. assert_eq!(block.inputs().len(), 4); diff --git a/pool/tests/block_max_weight.rs b/pool/tests/block_max_weight.rs index 8e210aebf5..8e15cefd82 100644 --- a/pool/tests/block_max_weight.rs +++ b/pool/tests/block_max_weight.rs @@ -139,9 +139,8 @@ fn test_block_building_max_weight() { ); add_block(&mut secp, &chain, &txs, &keychain); - let block = chain - .get_block(&chain.head().unwrap().last_block_h) - .unwrap(); + let header = chain.head_header().unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); // Check contents of the block itself (including coinbase reward). assert_eq!(block.inputs().len(), 3); diff --git a/pool/tests/block_reconciliation.rs b/pool/tests/block_reconciliation.rs index a6140b44e8..bdac8ad016 100644 --- a/pool/tests/block_reconciliation.rs +++ b/pool/tests/block_reconciliation.rs @@ -17,7 +17,6 @@ pub mod common; use crate::common::ChainAdapter; use crate::common::*; -use mwc_core::core::hash::Hashed; use mwc_core::global; use mwc_core::ser; use mwc_crates::rand::rngs::SysRng; @@ -138,9 +137,8 @@ fn test_transaction_pool_block_reconciliation() { let block_txs = &[block_tx_1, block_tx_2, block_tx_3, block_tx_4]; add_block(&mut secp, &chain, block_txs, &keychain); - let block = chain - .get_block(&chain.head().unwrap().hash(chain.get_context_id()).unwrap()) - .unwrap(); + let header = chain.head_header().unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); // Check the pool still contains everything we expect at this point. assert_eq!(pool.total_size(), txs_to_add.len()); diff --git a/pool/tests/common.rs b/pool/tests/common.rs index b296e7d805..f80b407e53 100644 --- a/pool/tests/common.rs +++ b/pool/tests/common.rs @@ -21,7 +21,8 @@ use mwc_core::consensus; use mwc_core::core::hash::Hash; use mwc_core::core::pmmr::{ReadablePMMR, VecBackend, PMMR}; use mwc_core::core::{ - Block, BlockHeader, BlockSums, Inputs, KernelFeatures, OutputIdentifier, Transaction, TxKernel, + Block, BlockHeader, BlockSums, Inputs, KernelFeatures, Output, OutputIdentifier, Transaction, + TxKernel, }; use mwc_core::global; use mwc_core::libtx::{reward, ProofBuilder}; @@ -152,6 +153,7 @@ pub fn init_chain(secp: &Secp256k1, dir_name: &str, genesis: Block) -> Chain { HashSet::new(), None, None, + false, ) .unwrap() } @@ -259,6 +261,13 @@ impl BlockChain for ChainAdapter { }) } + fn validate_outputs(&self, outputs: &[Output]) -> Result<(), PoolError> { + self.chain.validate_outputs(outputs).map_err(|e| match e { + mwc_chain::Error::DuplicateCommitment(_) => PoolError::DuplicateCommitment, + _ => PoolError::Other(format!("failed to validate outputs, {}", e)), + }) + } + fn validate_inputs(&self, inputs: &Inputs) -> Result, PoolError> { self.chain .validate_inputs(inputs) diff --git a/pool/tests/nrd_kernel_relative_height.rs b/pool/tests/nrd_kernel_relative_height.rs index 06a080799e..d49f4a3b00 100644 --- a/pool/tests/nrd_kernel_relative_height.rs +++ b/pool/tests/nrd_kernel_relative_height.rs @@ -17,7 +17,6 @@ pub mod common; use crate::common::*; use mwc_core::consensus; -use mwc_core::core::hash::Hashed; use mwc_core::core::{HeaderVersion, KernelFeatures, NRDRelativeHeight, TxKernel}; use mwc_core::global; use mwc_core::libtx::aggsig; @@ -175,9 +174,7 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { // Mine block containing tx1 from the txpool. add_block(&mut secp, &chain, &txs, &keychain); let header = chain.head_header().unwrap(); - let block = chain - .get_block(&header.hash(chain.get_context_id()).unwrap()) - .unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); // Confirm the stempool/txpool is empty after reconciling the new block. pool.reconcile_block(&block, &mut secp); @@ -232,9 +229,7 @@ fn test_nrd_kernel_relative_height() -> Result<(), PoolError> { // Mine block containing tx2 from the txpool. add_block(&mut secp, &chain, &txs, &keychain); let header = chain.head_header().unwrap(); - let block = chain - .get_block(&header.hash(chain.get_context_id()).unwrap()) - .unwrap(); + let block = chain.get_block_for_header(&header).unwrap(); pool.reconcile_block(&block, &mut secp); assert_eq!(pool.total_size(), 0); diff --git a/pool/tests/transaction_pool.rs b/pool/tests/transaction_pool.rs index 64ef873016..2c5037ca96 100644 --- a/pool/tests/transaction_pool.rs +++ b/pool/tests/transaction_pool.rs @@ -25,7 +25,7 @@ use mwc_crates::rand::rngs::SysRng; use mwc_crates::secp::{ContextFlag, Secp256k1, SecretKey}; use mwc_keychain::{ExtKeychain, Keychain}; use mwc_pool::{PoolAdapter, PoolConfig, PoolEntry, PoolError, TransactionPool, TxSource}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; #[derive(Default)] @@ -52,6 +52,25 @@ impl PoolAdapter for RelayLockCheckingAdapter { } } +#[derive(Default)] +struct FailSecondStemAdapter { + stem_attempts: AtomicUsize, +} + +impl PoolAdapter for FailSecondStemAdapter { + fn tx_accepted(&self, _entry: &PoolEntry) -> Result<(), PoolError> { + Ok(()) + } + + fn stem_tx_accepted(&self, _entry: &PoolEntry) -> Result<(), PoolError> { + if self.stem_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(()) + } else { + Err(PoolError::DandelionError) + } + } +} + #[test] fn fluff_relay_runs_after_pool_write_lock_is_released() { mwc_util::init_test_logger().unwrap(); @@ -104,6 +123,71 @@ fn fluff_relay_runs_after_pool_write_lock_is_released() { clean_output_dir(db_root.into()); } +#[test] +fn failed_dependent_stem_fallback_removes_child_from_stempool() { + mwc_util::init_test_logger().unwrap(); + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_accept_fee_base(1).unwrap(); + global::set_local_nrd_enabled(false); + + let mut secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let keychain: ExtKeychain = + ExtKeychain::from_seed(&secp, &SecretKey::new(&secp, &mut SysRng).unwrap().0, false) + .unwrap(); + let db_root = "target/.transaction_pool_failed_stem_fallback"; + clean_output_dir(db_root.into()); + + let genesis = genesis_block(&keychain); + let chain = Arc::new(init_chain(&secp, db_root, genesis)); + add_some_blocks(&mut secp, &chain, 4 * 3, &keychain); + let header = chain.head_header().unwrap(); + let header_1 = chain.get_header_by_height(1).unwrap(); + + let adapter = Arc::new(FailSecondStemAdapter::default()); + let mut pool = TransactionPool::new( + 0, + PoolConfig { + tx_fee_base: mwc_pool::types::default_tx_fee_base(), + reorg_cache_timeout: 1_440, + max_pool_size: 50, + max_stempool_size: 50, + mineable_max_weight: 10_000, + }, + Arc::new(ChainAdapter { + chain: chain.clone(), + }), + adapter, + ); + + let initial_tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500]); + submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); + + let parent = test_transaction(&mut secp, &keychain, vec![500], vec![469]); + submit_to_pool!( + pool, + test_source(), + parent.clone(), + true, + &header, + &mut secp + ) + .unwrap(); + assert!(pool.stempool.contains_tx(&parent).unwrap()); + + let child = test_transaction(&mut secp, &keychain, vec![469], vec![438]); + let err = + submit_to_pool!(pool, test_source(), child.clone(), true, &header, &mut secp).unwrap_err(); + + assert!(!matches!(err, PoolError::DandelionError)); + assert_eq!(pool.txpool.size(), 1); + assert_eq!(pool.stempool.size(), 1); + assert!(pool.stempool.contains_tx(&parent).unwrap()); + assert!(!pool.stempool.contains_tx(&child).unwrap()); + assert!(!pool.txpool.contains_tx(&child).unwrap()); + + clean_output_dir(db_root.into()); +} + /// Test we can add some txs to the pool (both stempool and txpool). #[test] fn test_the_transaction_pool() { @@ -187,12 +271,14 @@ fn test_the_transaction_pool() { } // Test adding the exact same tx multiple times (same kernel signature). - // This will fail for stem=false during tx aggregation due to duplicate - // outputs and duplicate kernels. + // Corrupt the duplicate's proof so the precise DuplicateTx result also proves + // the cheap pool lookup happens before expensive rangeproof validation. { - assert!( - submit_to_pool!(pool, test_source(), tx1.clone(), false, &header, &mut secp).is_err() - ); + let mut duplicate = tx1.clone(); + duplicate.body.outputs[0].proof.proof[0] ^= 1; + let err = + submit_to_pool!(pool, test_source(), duplicate, false, &header, &mut secp).unwrap_err(); + assert!(matches!(err, PoolError::DuplicateTx)); } // Test adding a duplicate tx with the same input and outputs. @@ -407,7 +493,7 @@ fn test_reconcile_reorg_cache_retains_valid_entries() { assert_eq!(pool.txpool.size(), 1); assert_eq!(pool.reorg_cache.read().len(), 1); - pool.txpool.entries.clear(); + pool.txpool.clear(); assert_eq!(pool.txpool.size(), 0); pool.reconcile_reorg_cache(&header, &mut secp); @@ -449,18 +535,34 @@ fn test_transaction_pool_capacity_limits() { let initial_tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_1, vec![500, 600]); - submit_to_pool!(pool, test_source(), initial_tx, false, &header, &mut secp).unwrap(); + submit_to_pool!( + pool, + test_source(), + initial_tx.clone(), + false, + &header, + &mut secp + ) + .unwrap(); assert_eq!(pool.txpool.size(), 1); - let low_fee_tx = test_transaction(&mut secp, &keychain, vec![600], vec![599]); + let mut low_fee_tx = test_transaction(&mut secp, &keychain, vec![600], vec![599]); + // A malformed proof would fail full validation. LowFeeTransaction proves + // immutable fee policy rejects it before rangeproof verification. + low_fee_tx.body.outputs[0].proof.proof[0] ^= 1; let err = submit_to_pool!(pool, test_source(), low_fee_tx, false, &header, &mut secp) .unwrap_err(); assert!(matches!(err, PoolError::LowFeeTransaction(1))); assert_eq!(pool.txpool.size(), 1); - let tx = test_transaction(&mut secp, &keychain, vec![500], vec![469]); - submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); + let mut tx = test_transaction(&mut secp, &keychain, vec![500], vec![469]); + // Capacity is checked before full cryptographic validation and before the + // expensive whole-pool aggregate admission path. + tx.body.outputs[0].proof.proof[0] ^= 1; + let err = submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap_err(); + assert!(matches!(err, PoolError::OverCapacity)); assert_eq!(pool.txpool.size(), 1); + assert!(pool.txpool.contains_tx(&initial_tx).unwrap()); } { @@ -470,7 +572,8 @@ fn test_transaction_pool_capacity_limits() { pool.config.max_pool_size = 0; let tx = test_transaction_spending_coinbase(&mut secp, &keychain, &header_2, vec![700]); - submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap(); + let err = submit_to_pool!(pool, test_source(), tx, false, &header, &mut secp).unwrap_err(); + assert!(matches!(err, PoolError::OverCapacity)); assert_eq!(pool.txpool.size(), 0); } @@ -488,7 +591,9 @@ fn test_transaction_pool_capacity_limits() { submit_to_pool!(pool, test_source(), tx, true, &header, &mut secp).unwrap(); assert_eq!(pool.stempool.size(), 1); - let tx = test_transaction(&mut secp, &keychain, vec![900], vec![869]); + let mut tx = test_transaction(&mut secp, &keychain, vec![900], vec![869]); + // The hard stem-capacity snapshot is also checked before cryptography. + tx.body.outputs[0].proof.proof[0] ^= 1; let err = submit_to_pool!(pool, test_source(), tx, true, &header, &mut secp).unwrap_err(); assert!(matches!(err, PoolError::OverCapacity)); assert_eq!(pool.stempool.size(), 1); diff --git a/servers/Cargo.toml b/servers/Cargo.toml index 18e7e5a56f..9b5b6e92c7 100644 --- a/servers/Cargo.toml +++ b/servers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_servers" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,15 +10,15 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_api = { path = "../api", version = "7.0.0" } -mwc_chain = { path = "../chain", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_keychain = { path = "../keychain", version = "7.0.0" } -mwc_p2p = { path = "../p2p", version = "7.0.0" } -mwc_pool = { path = "../pool", version = "7.0.0" } -mwc_store = { path = "../store", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_api = { path = "../api", version = "7.1.0" } +mwc_chain = { path = "../chain", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_keychain = { path = "../keychain", version = "7.1.0" } +mwc_p2p = { path = "../p2p", version = "7.1.0" } +mwc_pool = { path = "../pool", version = "7.1.0" } +mwc_store = { path = "../store", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true [features] diff --git a/servers/src/common/adapters.rs b/servers/src/common/adapters.rs index bba9e4fea6..89fc02f6c3 100644 --- a/servers/src/common/adapters.rs +++ b/servers/src/common/adapters.rs @@ -32,8 +32,8 @@ use mwc_chain::txhashset::Segmenter; use mwc_core::core::hash::{Hash, Hashed}; use mwc_core::core::transaction::Transaction; use mwc_core::core::{ - BlockHeader, BlockSums, CompactBlock, Inputs, OutputIdentifier, Segment, SegmentIdentifier, - TxKernel, + BlockHeader, BlockSums, CompactBlock, Inputs, Output, OutputIdentifier, Segment, + SegmentIdentifier, TxKernel, }; use mwc_core::pow::Difficulty; use mwc_core::ser::ProtocolVersion; @@ -221,6 +221,13 @@ impl LegacyV2BlockConversionBucket { struct LegacyV2BlockConversionThrottle { // Adapter-wide manager for per-peer legacy conversion buckets. The limit is // keyed by PeerAddr so one legacy peer cannot consume all conversion capacity. + // + // Accepted risk: one attacker can use many IP or Tor identities to obtain + // independent buckets. There is no reliable way to distinguish that Sybil + // behavior from many honest legacy peers. A shared or global limit would let + // an attacker exhaust the common budget and prevent honest peers from syncing, + // so we intentionally keep this as a fairness limit per observed peer identity + // rather than attempting to enforce an aggregate anti-Sybil limit here. // Stale entries are pruned so disconnected legacy peers do not grow this map // indefinitely. peers: RwLock>, @@ -360,6 +367,9 @@ where /// Entries are also pruned by age and capped by count so hostile peers cannot /// grow this in-memory cache without bound. compact_block_reconstruction_cache: RwLock>, + /// Last accepted peer-difficulty notification per peer. Admission is capped at + /// once per half ping interval before any chain or locator reads are performed. + peer_difficulty_request_cache: RwLock>, cached_tip: RwLock<(Difficulty, u64)>, chain_liveness_deferred_until: RwLock>, } @@ -434,7 +444,10 @@ where let tx_hash = tx.hash(self.context_id)?; // For transaction we allow double processing, we want to be sure that TX will be stored in the pool // because there is no recovery plan for transactions. So we want to use natural retry to help us handle failures - if self.processed_transactions.contains(&tx_hash, false) { + // Stem duplicates must reach the pool: a repeated stem transaction breaks a + // Dandelion cycle by promoting the transaction to fluff. A public fluff + // arriving shortly after the stem must likewise not be hidden by this cache. + if !stem && self.processed_transactions.contains(&tx_hash, false) { debug!("transaction_received, cache for {} Rejected", tx_hash); return Ok(true); } else { @@ -458,7 +471,11 @@ where secp, ) { Ok(_) => { - self.processed_transactions.contains(&tx_hash, true); + // Caching a stem transaction would suppress the duplicate event used for + // Dandelion cycle detection and stem-to-fluff promotion. + if !stem { + self.processed_transactions.contains(&tx_hash, true); + } Ok(true) } Err(e) => { @@ -912,7 +929,12 @@ where return Ok(None); } let chain = self.chain()?; - let block = match chain.get_block(&h) { + let header = match chain.get_block_header(&h) { + Ok(header) => header, + Err(e) if e.is_not_found() => return Ok(None), + Err(e) => return Err(e), + }; + let block = match chain.get_block_for_header(&header) { Ok(block) => block, Err(e) if e.is_not_found() => return Ok(None), Err(e) => return Err(e), @@ -1192,9 +1214,24 @@ where return Ok(()); } + // Do not sleep or delay Pong responses. Atomically suppress only the + // expensive chain/locator work until half the expected ping interval passes. + { + let now = Instant::now(); + let cooldown = Duration::from_secs(global::PEER_PING_INTERVAL_SECONDS) / 2; + let mut peers = self.peer_difficulty_request_cache.write(); + peers.retain(|_, reserved_at| { + now.saturating_duration_since(*reserved_at) < cooldown + }); + if peers.contains_key(peer) { + return Ok(()); + } + peers.insert(peer.clone(), now); + } + let chain = self.chain()?; let tip = chain.head()?; - if difficulty > tip.total_difficulty && height > tip.height { + if difficulty > tip.total_difficulty { let tip_height = tip.height; let heights = get_locator_heights(tip_height); let locator = chain.get_locator_hashes(tip, &heights)?; @@ -1230,15 +1267,10 @@ where tx_pool: Arc>>, chain_validation_mode: ChainValidationMode, hooks: Vec>, - ) -> Self { - let cached_tip = match chain.head() { - Ok(tip) => (tip.total_difficulty, tip.height), - Err(e) => { - warn!("NetToChainAdapter: unable to initialize cached tip: {}", e); - (Difficulty::zero(), 0) - } - }; - NetToChainAdapter { + ) -> Result { + let tip = chain.head()?; + let cached_tip = (tip.total_difficulty, tip.height); + Ok(NetToChainAdapter { sync_state, sync_manager, chain: Arc::downgrade(&chain), @@ -1250,9 +1282,10 @@ where processed_transactions: EventCache::new(), legacy_v2_block_conversion_throttle: LegacyV2BlockConversionThrottle::new(), compact_block_reconstruction_cache: RwLock::new(HashMap::new()), + peer_difficulty_request_cache: RwLock::new(HashMap::new()), cached_tip: RwLock::new(cached_tip), chain_liveness_deferred_until: RwLock::new(None), - } + }) } fn current_tip_for_peer_liveness(&self) -> Result<(Difficulty, u64), mwc_chain::Error> { @@ -1684,13 +1717,16 @@ where if source_peers.is_empty() { return; } - if self.sync_state.is_syncing() && matches!(err, mwc_chain::Error::OldBlock) { - // During sync, the same block can arrive from several peers after - // another in-flight batch already advanced the local chain. That is - // stale data, not a bad-block signal. + if err.is_known_block() { + // The same valid block can arrive from several peers or remain in an + // orphan cache after another response stores it. Known-block outcomes + // are stale/redundant data, not a bad-block signal. debug!( - "Skipping peer ban for old block {} from {:?}", - hash, source_peers + "Skipping peer ban for known block {} from {:?}, syncing={}: {}", + hash, + source_peers, + self.sync_state.is_syncing(), + err ); return; } @@ -1995,6 +2031,13 @@ impl mwc_pool::BlockChain for PoolToChainAdapter { .map_err(|e| chain_validation_error_to_pool_error(e, "validate tx")) } + fn validate_outputs(&self, outputs: &[Output]) -> Result<(), mwc_pool::PoolError> { + let chain = self.chain()?; + chain + .validate_outputs(outputs) + .map_err(|e| chain_validation_error_to_pool_error(e, "validate outputs")) + } + fn validate_inputs( &self, inputs: &Inputs, diff --git a/servers/src/mwc/dandelion_monitor.rs b/servers/src/mwc/dandelion_monitor.rs index 898503a4f3..0b389ec5c0 100644 --- a/servers/src/mwc/dandelion_monitor.rs +++ b/servers/src/mwc/dandelion_monitor.rs @@ -15,7 +15,7 @@ use crate::common::adapters::DandelionAdapter; use crate::ServerTxPool; -use mwc_core::core::hash::{Hash, Hashed}; +use mwc_core::core::hash::Hashed; use mwc_core::core::transaction; use mwc_core::global; use mwc_crates::log::{debug, error, info, warn}; @@ -25,7 +25,6 @@ use mwc_pool::{ BlockChain, DandelionConfig, Pool, PoolEntry, PoolError, TransactionPool, TxSource, }; use mwc_util::StopState; -use std::collections::HashSet; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -130,8 +129,7 @@ fn process_fluff_phase( ) -> Result<(), PoolError> { let mut pool = tx_pool.write(); - let all_entries = pool.stempool.all_entries(); - if all_entries.is_empty() { + if pool.stempool.is_empty() { return Ok(()); } @@ -148,34 +146,10 @@ fn process_fluff_phase( let header = pool.chain_head()?; let context_id = pool.get_context_id(); - let fluffable_txs = { - let txpool_tx = pool.txpool.all_transactions_aggregate(None, secp)?; - let txs: Vec<_> = all_entries.iter().map(|x| x.tx.clone()).collect(); - pool.stempool.validate_raw_txs( - &txs, - txpool_tx, - &header, - transaction::Weighting::NoLimit, - secp, - )? - }; - let fluffable_hashes = fluffable_txs - .iter() - .map(|tx| tx.hash(context_id)) - .collect::, _>>()?; - let mut skipped = 0; - for entry in &all_entries { - let tx_hash = entry.tx.hash(context_id)?; - if !fluffable_hashes.contains(&tx_hash) { - if pool.stempool.remove_tx(&entry.tx)?.is_some() { - skipped += 1; - debug!( - "dand_mon: removed skipped stempool tx {} after failed aggregate validation", - tx_hash - ); - } - } - } + // Pool entries were fully authenticated at admission. Reconcile the complete + // aggregate once, using the pool's bounded per-entry fallback only if bulk + // validation fails, instead of repeatedly validating every growing prefix. + let (fluffable_txs, skipped) = pool.reconcile_stempool_for_fluff(&header, secp)?; debug!( "dand_mon: Found {} txs in local stempool to fluff, removed {} skipped txs", @@ -195,6 +169,11 @@ fn process_fluff_phase( fluff_txs.len() ); + // The stempool is a best-effort, non-durable cache. Promoting an earlier batch + // can reconcile and evict entries captured in this snapshot; if a later batch + // fails, deliberately do not restore the failed or unsubmitted entries. Wallets + // are responsible for reposting transactions that remain unconfirmed, and + // retaining/restoring partial snapshots would add state and retry complexity. for tx in fluff_txs { match TransactionPool::submit_to_pool( tx_pool.as_ref(), diff --git a/servers/src/mwc/server.rs b/servers/src/mwc/server.rs index af2b38c48b..6be072cd44 100644 --- a/servers/src/mwc/server.rs +++ b/servers/src/mwc/server.rs @@ -146,6 +146,7 @@ impl Server { context_id: u32, config: ServerConfig, stop_state: Arc, + skip_start_blockchain_validation: bool, ) -> Result { if let Some(ban_window) = config.p2p_config.ban_window { if ban_window <= 0 { @@ -249,6 +250,7 @@ impl Server { invalid_blocks, Some(sync_state.clone()), Some(stop_state.clone()), + skip_start_blockchain_validation, ) .map_err(|e| Error::ServerError(format!("Unable to read blockchain data, {}", e)))?, ); @@ -267,15 +269,20 @@ impl Server { stop_state.clone(), )); - let net_adapter = Arc::new(NetToChainAdapter::new( - context_id, - sync_state.clone(), - shared_chain.clone(), - sync_manager.clone(), - tx_pool.clone(), - config.chain_validation_mode.clone(), - net_hooks, - )); + let net_adapter = Arc::new( + NetToChainAdapter::new( + context_id, + sync_state.clone(), + shared_chain.clone(), + sync_manager.clone(), + tx_pool.clone(), + config.chain_validation_mode.clone(), + net_hooks, + ) + .map_err(|e| { + Error::ServerError(format!("Unable to initialize network chain adapter, {}", e)) + })?, + ); // Initialize our capabilities. // Currently either "default" or with optional "archive_mode" (block history) support enabled. @@ -1037,7 +1044,7 @@ mod tests { let mut config = ServerConfig::default(); config.dandelion_config.stem_probability = 101; - match Server::create_server(&secp, 0, config, Arc::new(StopState::new())) { + match Server::create_server(&secp, 0, config, Arc::new(StopState::new()), false) { Err(Error::Config(msg)) => { assert!(msg.contains("stem_probability")); assert!(msg.contains("0..=100")); diff --git a/servers/src/mwc/sync/block_headers_request_cache.rs b/servers/src/mwc/sync/block_headers_request_cache.rs index c1742aaff0..1283e17ebd 100644 --- a/servers/src/mwc/sync/block_headers_request_cache.rs +++ b/servers/src/mwc/sync/block_headers_request_cache.rs @@ -314,6 +314,7 @@ mod tests { HashSet::new(), None, None, + false, ) .unwrap(), ); diff --git a/servers/src/mwc/sync/body_sync.rs b/servers/src/mwc/sync/body_sync.rs index 1f0d239aba..67cfb9bc9e 100644 --- a/servers/src/mwc/sync/body_sync.rs +++ b/servers/src/mwc/sync/body_sync.rs @@ -68,13 +68,25 @@ impl BodySync { sync_peers: &SyncPeers, best_height: u64, ) -> Result { + // Do not gate block requests on spent-commitment index readiness. PIBD + // intentionally starts with an empty retained-body index, and the blocks + // requested here populate it one by one. The same readiness state can also + // occur temporarily during reorg or recovery, so treating it as BadState + // could reset an otherwise valid chain. // check if we need something let head = self.chain.head()?; let header_head = self.chain.header_head()?; let max_avail_height = cmp::min(best_height, header_head.height); - - // Last few blocks no need to sync, new mined blocks will be synced regular way + self.invalidate_stale_request_series(max_avail_height)?; + + // BodySync is only the bulk height catch-up path. Once the body head is + // within the normal near-tip window, readiness is intentionally based on + // height rather than fork_point. Normal block handling owns branch + // reconciliation: orphan tracking requests missing ancestors and + // Chain::process_block fully validates and applies the branch when it + // connects. Reorgs beyond the configured cut-through horizon are + // deliberately outside the automatic-rewind policy. if head.height > max_avail_height.saturating_sub(7) { // Expected by QT wallet info!( @@ -115,6 +127,10 @@ impl BodySync { } } + // Historical-block capability is selected from the body head, not the fork + // point, for the same reason: this path requests BLOCK_HIST only for bulk + // catch-up from an old body height. Near-tip competing branches are left to + // the regular orphan/reorg flow described above. let (peer_capabilities, required_capabilities) = if self.chain.archive_mode() && head.height <= archive_height { ( @@ -201,6 +217,9 @@ impl BodySync { orph.source_peers, ) { Ok(_) => { + let _ = self + .chain + .remove_orphan(next_block.height, &next_block_hash); debug!("push stuck orphan was successful. Should be able continue to go forward now"); fork_point = self.chain.fork_point()?; } @@ -398,6 +417,63 @@ impl BodySync { Ok(self.chain.is_orphan(&hash) || self.chain.block_exists(&hash)?) } + /// A request series is derived from one canonical header branch. Validate its + /// highest retained entry before using it again so a header reorg or a lower + /// sync target cannot leave body sync retrying hashes from an obsolete branch. + fn invalidate_stale_request_series( + &self, + max_avail_height: u64, + ) -> Result<(), mwc_chain::Error> { + // Keep the request-series lock while authenticating the anchor. send_requests + // takes the same lock before reading or sending entries, so no stale entry can + // be queued between validation and invalidation. + let mut request_series = self.request_series.write(); + let Some((anchor_hash, anchor_height)) = request_series.first().cloned() else { + return Ok(()); + }; + + let stale_reason = if anchor_height > max_avail_height { + Some(format!( + "anchor height {} exceeds available height {}", + anchor_height, max_avail_height + )) + } else { + match self.chain.get_header_by_height(anchor_height) { + Ok(header) => { + let canonical_hash = header.hash(self.chain.get_context_id())?; + if canonical_hash == anchor_hash { + None + } else { + Some(format!( + "anchor {} at {} is no longer canonical (current {})", + anchor_hash, anchor_height, canonical_hash + )) + } + } + Err(e) if e.is_not_found() => Some(format!( + "anchor header {} at {} is no longer available", + anchor_hash, anchor_height + )), + Err(e) => return Err(e), + } + }; + + if let Some(stale_reason) = stale_reason { + let cancelled_requests = self + .request_tracker + .cancel_requests(request_series.iter().map(|(hash, _)| hash)); + request_series.clear(); + *self.last_retry_height.write() = 0; + self.retry_expiration_times.write().clear(); + debug!( + "Invalidated stale body request series ({}); cancelled {} tracked requests", + stale_reason, cancelled_requests + ); + } + + Ok(()) + } + fn push_retry_expiration(&self, now: Instant) -> Result<(), mwc_chain::Error> { let retry_latency = self.request_tracker.get_retry_latency(); let retry_expiration = now.checked_add(retry_latency).ok_or_else(|| { @@ -585,19 +661,19 @@ impl BodySync { "Processing request for the block {} at {}, peer {:?}", hash, height, peer.info.addr ); + let request_token = self.request_tracker.register_request( + hash.clone(), + peer.info.addr.clone(), + format!("Block {}, {}", hash, height), + ); if let Err(e) = peer.send_block_request(hash.clone(), mwc_chain::Options::SYNC) { + self.request_tracker.rollback_request(&hash, &request_token); let msg = format!( "Failed to send block request to peer {}, {}", peer.info.addr, e ); warn!("{}", msg); sync_peers.report_no_response(&peer.info.addr, msg); - } else { - self.request_tracker.register_request( - hash.clone(), - peer.info.addr.clone(), - format!("Block {}, {}", hash, height), - ); } } } diff --git a/servers/src/mwc/sync/header_sync.rs b/servers/src/mwc/sync/header_sync.rs index b13ec45fc0..94630f67bb 100644 --- a/servers/src/mwc/sync/header_sync.rs +++ b/servers/src/mwc/sync/header_sync.rs @@ -387,15 +387,19 @@ impl HeaderSync { return Ok(resp); } + // Register before queueing the network message. The peer reader runs on a + // different thread and can otherwise process a fast response before the + // request is visible, leaving a stale request that later times out. + let request_token = self.request_tracker.register_request( + header_head_hash, + sync_peer.info.addr.clone(), + format!("Tail header for {}", header_head.height), + ); match self.request_headers(header_head, sync_peer.clone()) { - Ok(_) => { - self.request_tracker.register_request( - header_head_hash, - sync_peer.info.addr.clone(), - format!("Tail header for {}", header_head.height), - ); - } + Ok(_) => {} Err(e) => { + self.request_tracker + .rollback_request(&header_head_hash, &request_token); let msg = format!( "Failed to send headers request to {} for height {}, Error: {}", sync_peer.info.addr, header_head.height, e @@ -500,6 +504,12 @@ impl HeaderSync { bhs }; + // Do not confuse this with p2p's HeaderRequestTracker: that layer has + // already checked the first prev_hash against every hash in the requested + // locator. This scheduling tracker stores one exact Hash key per request + // (the newest locator hash for regular sync, or the checkpoint for PIBD), so + // a valid fork response starting from an older locator can be admitted by + // p2p without tracked_request being true here. let request_key = bhs[0].prev_hash; let tracked_request = self.request_tracker.has_request(&request_key); @@ -534,6 +544,11 @@ impl HeaderSync { if let Some(header_hashes_desegmenter) = header_hashes.as_ref() { let header_hashes = header_hashes_desegmenter.read_recursive(); if bhs[0].height <= header_hashes.get_target_height() { + // Below-horizon PIBD requests are sent by request_headers_for_hash() + // with a singleton locator containing the exact checkpoint hash. Unlike + // regular above-horizon sync, there is no older locator hash that may + // legitimately anchor the response: p2p rejects any response whose first + // prev_hash is not that requested checkpoint before it reaches this path. if !tracked_request { debug!( "headers_received: ignored unsolicited PIBD headers from {}, height {}", @@ -637,18 +652,21 @@ impl HeaderSync { // then we can request relevant headers in the next batch. if !self.request_tracker.has_request(&sync_head.last_block_h) { if let Some(sync_peer) = Self::choose_sync_peer(peers) { + let request_key = sync_head.last_block_h; + let request_token = self.request_tracker.register_request( + request_key, + sync_peer.info.addr.clone(), + format!("Tail headers for {}", sync_head.height), + ); match self.request_headers(sync_head, sync_peer.clone()) { Ok(_) => { - self.request_tracker.register_request( - sync_head.last_block_h, - sync_peer.info.addr.clone(), - format!("Tail headers for {}", sync_head.height), - ); if matched_request { sync_peers.report_ok_response(peer); } } Err(e) => { + self.request_tracker + .rollback_request(&request_key, &request_token); let msg = format!("Failed to send headers request to {} for height {}, Error: {}", sync_peer.info.addr, sync_head.height, e); error!("{}", msg); sync_peers.report_no_response(&sync_peer.info.addr, msg); @@ -741,6 +759,13 @@ impl HeaderSync { } /// Request some block headers from a peer to advance us. + /// + /// Locator construction and queueing are deliberately treated as one + /// best-effort scheduler operation. Callers use the same rollback path and a + /// soft `report_no_response` event if either step fails so sync backs off and + /// retries elsewhere without leaving partial request state. That event is + /// liveness bookkeeping, not a bad-data accusation or an immediate peer ban; + /// keeping the failure path unified here is intentional. fn request_headers( &self, sync_head: mwc_chain::Tip, @@ -894,15 +919,15 @@ impl HeaderSync { let peer = peers.choose(&mut rng).ok_or(mwc_chain::Error::Other( "Internal error. peers are empty".into(), ))?; + let request_token = self.request_tracker.register_request( + hash, + peer.info.addr.clone(), + format!("Header {}, {}", hash, height), + ); match self.request_headers_for_hash(hash.clone(), height, peer.clone()) { - Ok(_) => { - self.request_tracker.register_request( - hash, - peer.info.addr.clone(), - format!("Header {}, {}", hash, height), - ); - } + Ok(_) => {} Err(e) => { + self.request_tracker.rollback_request(&hash, &request_token); let msg = format!( "Failed to send headers request to {} for hash {}, Error: {}", peer.info.addr, hash, e diff --git a/servers/src/mwc/sync/orphans_sync.rs b/servers/src/mwc/sync/orphans_sync.rs index bb142e7bc1..6e5634fd55 100644 --- a/servers/src/mwc/sync/orphans_sync.rs +++ b/servers/src/mwc/sync/orphans_sync.rs @@ -28,18 +28,16 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; -const MAX_UNKNOWN_BLOCK_CANDIDATES_PER_HASH: usize = 4; -const MAX_UNKNOWN_BLOCK_SOURCE_PEERS: usize = 16; +const MAX_UNKNOWN_BLOCK_PEERS_PER_HASH: usize = 4; const MAX_ORPHAN_RETRY_REQUESTS_PER_PEER: usize = 1; struct UnknownBlock { block: Block, - // BLAKE2b over the canonical full-block serialization. The header hash - // alone is not a safe identity until the body is validated against the - // header roots. - serialized_hash: Hash, added: Instant, - source_peers: HashSet, + // PeerAddr equality and hashing intentionally ignore the port for non-loopback + // IP peers. Using PeerAddr here prevents one IP from consuming multiple + // candidate slots by reconnecting with different advertised ports. + source_peers: HashSet, } struct OrphanRetryBudget { @@ -99,7 +97,7 @@ impl OrphansSync { pub fn recieve_block_reporting( &self, block: Block, - source_peer: Option, + source_peer: Option, ) -> Result { let context_id = self.chain.get_context_id(); let bhash = block.hash(context_id)?; @@ -113,6 +111,11 @@ impl OrphansSync { if self.chain.block_exists(&bhash)? { return Ok(false); } + // A block already owned by the regular orphan pool must not also consume + // space in the unknown-header cache or be replayed from both caches. + if self.chain.is_orphan(&bhash) { + return Ok(need_prev_block); + } if !keep_unknown_block { return Ok(false); @@ -121,53 +124,42 @@ impl OrphansSync { let mut unknown_blocks = self.unknown_blocks.write(); let unknown_blocks_count: usize = unknown_blocks .values() - .map(|candidates| candidates.len()) + .map(|candidates| Self::unknown_block_slots(candidates)) .sum(); + let unknown_blocks_limit = self.unknown_blocks_limit(); if let Some(candidates) = unknown_blocks.get_mut(&bhash) { - let serialized_hash = Self::serialized_block_hash(context_id, &block)?; // A block hash is only the header hash. Until a full block is - // validated against the header roots, only byte-identical arrivals are - // duplicates. Byte-distinct arrivals are kept as bounded alternatives - // so a malicious first body cannot pin this cache for the header hash. - if let Some(unknown_block) = candidates - .iter_mut() - .find(|candidate| candidate.serialized_hash == serialized_hash) - { - if let Some(source_peer) = source_peer { - Self::insert_source_peer_capped(&mut unknown_block.source_peers, source_peer); - } - return Ok(need_prev_block); - } - - if candidates.len() >= MAX_UNKNOWN_BLOCK_CANDIDATES_PER_HASH - || unknown_blocks_count >= self.unknown_blocks_limit() - { - // We still need to request prev block, even cache wasn't updated with orphan - // Idea is requesting prev blocks until we reach the head - return Ok(need_prev_block); - } - - candidates.push(UnknownBlock { + // validated against the header roots, only losslessly identical arrivals + // are duplicates. Protocol-v3+ full-block serialization is not suitable + // for this comparison because it discards input features and the input + // representation. Distinct arrivals are kept as bounded alternatives so + // a malicious first body cannot pin this cache for the header hash or + // inherit an honest candidate's source peer. + // Each peer owns at most one candidate slot for this header hash. If it + // sends a different body, move its attribution to the new body. Identical + // bodies remain coalesced so multiple peers can attest to one candidate + // without duplicating the block in memory. + Self::cache_candidate( + context_id, + candidates, block, - serialized_hash, - added: Instant::now(), - source_peers: source_peer.into_iter().collect(), - }); + source_peer, + unknown_blocks_count, + unknown_blocks_limit, + )?; return Ok(need_prev_block); } - if unknown_blocks_count >= self.unknown_blocks_limit() { + if unknown_blocks_count >= unknown_blocks_limit { // We still need to request prev block, even cache wasn't updated with orphan // Idea is requesting prev blocks until we reach the head return Ok(need_prev_block); } - let serialized_hash = Self::serialized_block_hash(context_id, &block)?; unknown_blocks.insert( bhash, vec![UnknownBlock { block, - serialized_hash, added: Instant::now(), source_peers: source_peer.into_iter().collect(), }], @@ -197,22 +189,31 @@ impl OrphansSync { // Otherwise Chain::process_block can move one unvalidated candidate // into the regular orphan pool before body validation, recreating // first-writer poisoning there. - let mut blocks: Vec<(Hash, Hash, Block, HashSet)> = unknown_blocks + // The write lock held for this whole block keeps candidate indexes stable + // until bad candidates are removed below. + let mut blocks: Vec<(Hash, usize, Block, HashSet)> = unknown_blocks .iter() .flat_map(|(hash, candidates)| { - candidates.iter().map(move |unknown| { - ( - hash.clone(), - unknown.serialized_hash, - unknown.block.clone(), - unknown.source_peers.clone(), - ) - }) + candidates + .iter() + .enumerate() + .map(move |(candidate_index, unknown)| { + ( + hash.clone(), + candidate_index, + unknown.block.clone(), + unknown + .source_peers + .iter() + .map(|peer| peer.to_string()) + .collect(), + ) + }) }) .collect(); blocks.sort_by_key(|(_, _, b, _)| b.header.height); - let mut bad_candidates = Vec::new(); - for (hash, serialized_hash, b, source_peers) in blocks { + let mut bad_candidates: HashMap> = HashMap::new(); + for (hash, candidate_index, b, source_peers) in blocks { if self.chain.block_exists(&hash)? { continue; } @@ -225,13 +226,27 @@ impl OrphansSync { { Ok(_) => {} Err(mwc_chain::Error::Orphan(_)) => {} - Err(e) if e.is_bad_data() => bad_candidates.push((hash, serialized_hash)), + // Another peer can commit the same block after block_exists() + // above but before process_block() acquires the chain locks. + // The requested result is already present, so continue the pass. + Err(e) if e.is_known_block() => {} + Err(e) if e.is_bad_data() => { + bad_candidates + .entry(hash) + .or_default() + .insert(candidate_index); + } Err(e) => return Err(e), } } - for (hash, serialized_hash) in bad_candidates { + for (hash, bad_candidate_indexes) in bad_candidates { if let Some(candidates) = unknown_blocks.get_mut(&hash) { - candidates.retain(|unknown| unknown.serialized_hash != serialized_hash); + let mut candidate_index = 0; + candidates.retain(|_| { + let keep = !bad_candidate_indexes.contains(&candidate_index); + candidate_index += 1; + keep + }); } } @@ -253,7 +268,7 @@ impl OrphansSync { if unknown_blocks .values() - .map(|candidates| candidates.len()) + .map(|candidates| Self::unknown_block_slots(candidates)) .sum::() > self.unknown_blocks_limit() { @@ -274,8 +289,22 @@ impl OrphansSync { for orph_hash in &block_to_validate { let block_hash_height = match orphans_pool.get_orphan(orph_hash) { Some(orphan) => { - let prev_block_hash = orphan.block.header.prev_hash.clone(); let bl_height = orphan.block.header.height; + // A concurrently accepted block can leave an older body for the same + // header hash in the orphan pool. Do not replay that stale body: an + // input-only conflict with the stored block is intentionally neither a + // known-block nor bad-data error, so replaying it would otherwise keep + // returning the same terminal error without evicting the orphan. + if self.chain.block_exists(orph_hash)? { + let _ = self.chain.remove_orphan(bl_height, orph_hash); + info!( + "Dropped stale orphan {} at {} because the block is already stored", + orph_hash, bl_height + ); + continue; + } + + let prev_block_hash = orphan.block.header.prev_hash.clone(); if self.chain.block_exists(&prev_block_hash)? { // it is a stale oprphan, we can process it... let bl_hash = orphan.block.hash(context_id)?; @@ -468,40 +497,364 @@ impl OrphansSync { self.pibd_params.get_orphans_num_limit() } - fn insert_source_peer_capped(source_peers: &mut HashSet, source_peer: String) { - if source_peers.len() < MAX_UNKNOWN_BLOCK_SOURCE_PEERS - || source_peers.contains(&source_peer) + // A body without peer attribution still consumes one slot. Normally every + // network arrival is attributed, but keeping Option support makes the + // cache safe for internal callers as well. + fn unknown_block_slots(candidates: &[UnknownBlock]) -> usize { + candidates + .iter() + .map(|candidate| candidate.source_peers.len().max(1)) + .sum() + } + + fn cache_candidate( + context_id: u32, + candidates: &mut Vec, + block: Block, + source_peer: Option, + total_slots: usize, + total_limit: usize, + ) -> Result<(), mwc_chain::Error> { + // Finish all fallible comparisons before mutating the cache so a + // serialization/hash error leaves the previous candidate intact. + let mut matching_candidate = Self::find_lossless_candidate(context_id, candidates, &block)?; + let previous_candidate = source_peer.as_ref().and_then(|source_peer| { + candidates + .iter() + .position(|candidate| candidate.source_peers.contains(source_peer)) + }); + + // An exact repeat from the same peer neither consumes another slot nor + // refreshes the candidate's expiry time. + if matching_candidate.is_some() && matching_candidate == previous_candidate { + return Ok(()); + } + + let replacing_peer = previous_candidate.is_some(); + let mut available_total_slots = total_slots; + if let (Some(previous_candidate), Some(source_peer)) = + (previous_candidate, source_peer.as_ref()) { - source_peers.insert(source_peer); + let removed = candidates[previous_candidate] + .source_peers + .remove(source_peer); + debug_assert!(removed); + available_total_slots = available_total_slots.checked_sub(1).ok_or_else(|| { + mwc_chain::Error::DataOverflow( + "OrphansSync::cache_candidate peer slot count underflow".to_owned(), + ) + })?; + + if candidates[previous_candidate].source_peers.is_empty() { + candidates.remove(previous_candidate); + if let Some(matching_candidate) = matching_candidate.as_mut() { + if *matching_candidate > previous_candidate { + *matching_candidate -= 1; + } + } + } + } + + let hash_slots = Self::unknown_block_slots(candidates); + if let Some(matching_candidate) = matching_candidate { + if let Some(source_peer) = source_peer { + // Adding the first peer to an anonymous candidate adopts its existing + // slot. A replacement is also slot-neutral, so both remain possible + // when the cache is otherwise full. + let adopts_anonymous_slot = candidates[matching_candidate].source_peers.is_empty(); + if adopts_anonymous_slot + || replacing_peer + || (hash_slots < MAX_UNKNOWN_BLOCK_PEERS_PER_HASH + && available_total_slots < total_limit) + { + candidates[matching_candidate] + .source_peers + .insert(source_peer); + } + } + return Ok(()); } + + // At the global or per-hash limit, an alternative from a new peer is + // deliberately dropped instead of evicting an existing candidate. A bad + // first candidate cannot permanently pin the slot: once its previous full + // block is available, sync_orphans() passes its attributed source peers to + // Chain::process_block(). Bad-data reporting bans those peers, and this sync + // pass removes the bad candidate, leaving room for a subsequent (or + // re-requested) honest delivery. Sustained replacement from fresh peer + // identities is a peer/Sybil-flood concern rather than a cache-admission + // guarantee. + if replacing_peer + || (hash_slots < MAX_UNKNOWN_BLOCK_PEERS_PER_HASH + && available_total_slots < total_limit) + { + candidates.push(UnknownBlock { + block, + added: Instant::now(), + source_peers: source_peer.into_iter().collect(), + }); + } + + Ok(()) } - fn serialized_block_hash(context_id: u32, block: &Block) -> Result { - let block_bytes = ser::ser_vec(context_id, block, ProtocolVersion::local())?; - Ok(block_bytes.hash(context_id)?) + fn blocks_equal_lossless( + context_id: u32, + left: &Block, + right: &Block, + ) -> Result { + if left.header != right.header + || !left + .body + .inputs + .eq_by_hash(context_id, &right.body.inputs)? + { + return Ok(false); + } + + let version = ProtocolVersion::local(); + Ok(ser::ser_vec(context_id, &left.body.outputs, version)? + == ser::ser_vec(context_id, &right.body.outputs, version)? + && ser::ser_vec(context_id, &left.body.kernels, version)? + == ser::ser_vec(context_id, &right.body.kernels, version)?) + } + + fn find_lossless_candidate( + context_id: u32, + candidates: &[UnknownBlock], + block: &Block, + ) -> Result, mwc_chain::Error> { + for (candidate_index, candidate) in candidates.iter().enumerate() { + if Self::blocks_equal_lossless(context_id, &candidate.block, block)? { + return Ok(Some(candidate_index)); + } + } + Ok(None) } } #[cfg(test)] mod tests { use super::*; + use mwc_core::core::{CommitWrapper, Input, Inputs, OutputFeatures}; + use mwc_core::global::{self, ChainTypes}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + fn candidate_block(context_id: u32, value: u64) -> Block { + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let commit = secp.commit_value(value).unwrap(); + let mut block = Block::default(context_id); + block.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, commit)]); + block + } + + fn candidate_peer(ip_suffix: u8, port: u16) -> PeerAddr { + PeerAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(8, 8, 8, ip_suffix)), + port, + )) + } + + fn cache_for_peer( + context_id: u32, + candidates: &mut Vec, + block: Block, + peer: PeerAddr, + ) { + let total_slots = OrphansSync::unknown_block_slots(candidates); + OrphansSync::cache_candidate( + context_id, + candidates, + block, + Some(peer), + total_slots, + usize::MAX, + ) + .unwrap(); + } + + #[test] + fn same_peer_replaces_its_previous_candidate() { + let context_id = 0; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let mut candidates = Vec::new(); + let peer_a = candidate_peer(1, 3414); + + for value in 1..=6 { + cache_for_peer( + context_id, + &mut candidates, + candidate_block(context_id, value), + peer_a.clone(), + ); + } + + assert_eq!(candidates.len(), 1); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 1); + assert_eq!( + candidates[0].source_peers, + std::iter::once(peer_a).collect() + ); + assert!(OrphansSync::blocks_equal_lossless( + context_id, + &candidates[0].block, + &candidate_block(context_id, 6), + ) + .unwrap()); + } #[test] - fn insert_source_peer_capped_limits_unknown_block_metadata() { - let mut source_peers = HashSet::new(); + fn peer_moves_between_coalesced_candidates() { + let context_id = 0; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let first = candidate_block(context_id, 1); + let second = candidate_block(context_id, 2); + let mut candidates = Vec::new(); + let peer_a = candidate_peer(1, 3414); + let peer_b = candidate_peer(2, 3414); + + cache_for_peer(context_id, &mut candidates, first.clone(), peer_a.clone()); + cache_for_peer(context_id, &mut candidates, first.clone(), peer_b.clone()); + assert_eq!(candidates.len(), 1); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 2); + + cache_for_peer(context_id, &mut candidates, second.clone(), peer_a.clone()); + assert_eq!(candidates.len(), 2); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 2); + + let first_index = OrphansSync::find_lossless_candidate(context_id, &candidates, &first) + .unwrap() + .unwrap(); + let second_index = OrphansSync::find_lossless_candidate(context_id, &candidates, &second) + .unwrap() + .unwrap(); + assert_eq!( + candidates[first_index].source_peers, + std::iter::once(peer_b).collect() + ); + assert_eq!( + candidates[second_index].source_peers, + std::iter::once(peer_a).collect() + ); + } + + #[test] + fn same_ip_with_different_ports_owns_only_one_candidate_slot() { + let context_id = 0; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let first = candidate_block(context_id, 1); + let replacement = candidate_block(context_id, 2); + let mut candidates = Vec::new(); + + cache_for_peer(context_id, &mut candidates, first, candidate_peer(1, 3414)); + cache_for_peer( + context_id, + &mut candidates, + replacement.clone(), + candidate_peer(1, 4414), + ); + + assert_eq!(candidates.len(), 1); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 1); + assert!( + OrphansSync::blocks_equal_lossless(context_id, &candidates[0].block, &replacement,) + .unwrap() + ); + } - for idx in 0..(MAX_UNKNOWN_BLOCK_SOURCE_PEERS + 4) { - OrphansSync::insert_source_peer_capped(&mut source_peers, format!("peer-{}", idx)); + #[test] + fn four_peer_cache_rejects_a_fifth_peer_but_allows_replacement() { + let context_id = 0; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let mut candidates = Vec::new(); + + for peer_index in 0..MAX_UNKNOWN_BLOCK_PEERS_PER_HASH { + cache_for_peer( + context_id, + &mut candidates, + candidate_block(context_id, peer_index as u64 + 1), + candidate_peer(peer_index as u8 + 1, 3414), + ); } + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 4); + + let replacement = candidate_block(context_id, 10); + cache_for_peer( + context_id, + &mut candidates, + replacement.clone(), + candidate_peer(5, 3414), + ); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 4); + assert_eq!( + OrphansSync::find_lossless_candidate(context_id, &candidates, &replacement).unwrap(), + None + ); - assert_eq!(source_peers.len(), MAX_UNKNOWN_BLOCK_SOURCE_PEERS); + let peer_zero = candidate_peer(1, 3414); + cache_for_peer( + context_id, + &mut candidates, + replacement.clone(), + peer_zero.clone(), + ); + assert_eq!(OrphansSync::unknown_block_slots(&candidates), 4); + let replacement_index = + OrphansSync::find_lossless_candidate(context_id, &candidates, &replacement) + .unwrap() + .unwrap(); + assert!(candidates[replacement_index] + .source_peers + .contains(&peer_zero)); + } - let retained_peer = source_peers.iter().next().unwrap().clone(); - OrphansSync::insert_source_peer_capped(&mut source_peers, retained_peer); - assert_eq!(source_peers.len(), MAX_UNKNOWN_BLOCK_SOURCE_PEERS); + #[test] + fn lossless_unknown_block_identity_preserves_input_features_and_representation() { + let context_id = 0; + global::set_local_chain_type(ChainTypes::AutomatedTesting); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let commit = secp.commit_value(1).unwrap(); + + let mut plain = Block::default(context_id); + plain.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Plain, commit)]); + + let mut coinbase = plain.clone(); + coinbase.body.inputs = + Inputs::FeaturesAndCommit(vec![Input::new(OutputFeatures::Coinbase, commit)]); + + // The old local-protocol fingerprint cannot distinguish these bodies. + assert_eq!( + ser::ser_vec(context_id, &plain, ProtocolVersion::local()).unwrap(), + ser::ser_vec(context_id, &coinbase, ProtocolVersion::local()).unwrap() + ); + assert!(!OrphansSync::blocks_equal_lossless(context_id, &plain, &coinbase).unwrap()); + let candidates = vec![UnknownBlock { + block: plain.clone(), + added: Instant::now(), + source_peers: std::iter::once(candidate_peer(1, 3414)).collect(), + }]; + assert_eq!( + OrphansSync::find_lossless_candidate(context_id, &candidates, &coinbase).unwrap(), + None + ); - OrphansSync::insert_source_peer_capped(&mut source_peers, "overflow-peer".to_string()); - assert_eq!(source_peers.len(), MAX_UNKNOWN_BLOCK_SOURCE_PEERS); - assert!(!source_peers.contains("overflow-peer")); + let mut commit_only = plain.clone(); + commit_only.body.inputs = Inputs::CommitOnly(vec![CommitWrapper::from(commit)]); + assert_eq!( + ser::ser_vec(context_id, &plain, ProtocolVersion::local()).unwrap(), + ser::ser_vec(context_id, &commit_only, ProtocolVersion::local()).unwrap() + ); + assert!(!OrphansSync::blocks_equal_lossless(context_id, &plain, &commit_only).unwrap()); + assert_eq!( + OrphansSync::find_lossless_candidate(context_id, &candidates, &commit_only).unwrap(), + None + ); + assert_eq!( + OrphansSync::find_lossless_candidate(context_id, &candidates, &plain).unwrap(), + Some(0) + ); + assert!(OrphansSync::blocks_equal_lossless(context_id, &plain, &plain).unwrap()); } } diff --git a/servers/src/mwc/sync/state_sync.rs b/servers/src/mwc/sync/state_sync.rs index e03c34c860..818a641383 100644 --- a/servers/src/mwc/sync/state_sync.rs +++ b/servers/src/mwc/sync/state_sync.rs @@ -39,6 +39,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_PIBD_ROOT_RESPONSES: usize = 2; +// Accepted commitments are never evicted during a PIBD session. This limit is +// enforced before sending a request, so every accepted response still has a slot. +const MAX_PIBD_ROOT_RESPONSE_ENTRIES: usize = 10240; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PibdRootSelection { @@ -65,6 +68,8 @@ pub struct StateSync { target_archive_height: AtomicU64, target_archive_hash: RwLock, requested_root_hash: RwLock>, // Lock 1 + // Session-long peer commitments. Retain these across disconnect/reconnect so + // the peer remains tied to its advertised root and can be banned on failure. responded_root_hash: RwLock>, // Lock 2 // sync for segments request_tracker: RequestTracker<(SegmentType, u64)>, @@ -78,13 +83,23 @@ pub struct StateSync { send_requests_lock: RwLock, } -struct ValidatedDesegmenter<'a> { - // This guard binds the validation result to the live PIBD session. Without - // it, reset_desegmenter_data() could clear/replace the current session while - // the caller still applies a segment through the returned Arc. - _guard: RwLockReadGuard<'a, Option>>, - desegmenter: Arc, - root_hash: Hash, +enum DesegmenterValidation<'a> { + Valid { + // Bind validation to the live PIBD session through segment application + // and request tracking. + _guard: RwLockReadGuard<'a, Option>>, + desegmenter: Arc, + root_hash: Hash, + }, + // Keep the current session pinned until the caller has updated request + // tracking. Otherwise a reset can replace the session between validation and + // tracking, allowing this response to remove a new session's request. + PeerMismatch { + _guard: RwLockReadGuard<'a, Option>>, + }, + // The response belongs to an inactive, locally invalidated, or replaced + // session. It must not affect peer scoring or current-session requests. + Stale, } struct LiveDesegmenter<'a> { @@ -122,6 +137,18 @@ impl StateSync { return Capabilities::PIBD_HIST; } + fn remaining_pibd_root_peer_capacity( + requested_root_hash: &HashMap, + responded_root_hash: &HashMap, + max_entries: usize, + ) -> usize { + max_entries.saturating_sub( + requested_root_hash + .len() + .saturating_add(responded_root_hash.len()), + ) + } + fn select_pibd_root<'a>( root_hashes: impl Iterator, required_responses: usize, @@ -157,7 +184,7 @@ impl StateSync { best_height: u64, ) -> Result { // In case of archive mode, this step is must be skipped. Body sync will catch up. - if self.is_complete.load(Ordering::Relaxed) || self.chain.archive_mode() { + if self.is_complete.load(Ordering::Acquire) || self.chain.archive_mode() { return Ok(SyncResponse::new( SyncRequestResponses::StatePibdReady, Capabilities::UNKNOWN, @@ -194,7 +221,7 @@ impl StateSync { if head.height >= target_archive_height { // We are good, no needs to PIBD sync info!("No needs to sync, data until archive is ready"); - self.is_complete.store(true, Ordering::Relaxed); + self.complete_pibd_sync(); return Ok(SyncResponse::new( SyncRequestResponses::StatePibdReady, Capabilities::UNKNOWN, @@ -232,6 +259,19 @@ impl StateSync { } *self.target_archive_hash.write() = archive_header_hash; + let now = Instant::now(); + let request_timeout = Duration::from_secs(pibd_params::PIBD_REQUESTS_TIMEOUT_SECS as u64); + { + let mut requested_root_hash = self.requested_root_hash.write(); + requested_root_hash.retain(|peer, req_time| { + if now.saturating_duration_since(*req_time) > request_timeout { + sync_peers.report_no_response(peer, "root hash".into()); + return false; + } + true + }); + } + let excluded_peers = self .request_tracker .retain_expired(pibd_params::PIBD_REQUESTS_TIMEOUT_SECS, sync_peers); @@ -270,25 +310,24 @@ impl StateSync { } } - let now = Instant::now(); - let request_timeout = Duration::from_secs(pibd_params::PIBD_REQUESTS_TIMEOUT_SECS as u64); let mut root_request_failures = 0; { let mut requested_root_hash = self.requested_root_hash.write(); let responded_root_hash = self.responded_root_hash.read_recursive(); - - // checking to timeouts for handshakes... - requested_root_hash.retain(|peer, req_time| { - if req_time.elapsed() > request_timeout { - sync_peers.report_no_response(peer, "root hash".into()); - return false; - } - true - }); + let mut remaining_root_peer_capacity = Self::remaining_pibd_root_peer_capacity( + &requested_root_hash, + &responded_root_hash, + MAX_PIBD_ROOT_RESPONSE_ENTRIES, + ); // request handshakes if needed for peer in &peers { + // Never make room by evicting a response: it is the peer's root + // commitment for reconnect authorization and any later session ban. + if remaining_root_peer_capacity == 0 { + break; + } if !(requested_root_hash.contains_key(&peer.info.addr) || responded_root_hash.contains_key(&peer.info.addr)) { @@ -298,6 +337,8 @@ impl StateSync { { Ok(_) => { requested_root_hash.insert(peer.info.addr.clone(), now); + remaining_root_peer_capacity = + remaining_root_peer_capacity.saturating_sub(1); } Err(e) => { root_request_failures += 1; @@ -316,6 +357,16 @@ impl StateSync { } } + let stale_desegmenter = self + .desegmenter + .read_recursive() + .as_ref() + .map(|desegmenter| !desegmenter.is_current()) + .unwrap_or(false); + if stale_desegmenter { + warn!("Discarding PIBD desegmenter invalidated by chain-state recovery"); + self.reset_desegmenter.store(true, Ordering::Relaxed); + } if self.reset_desegmenter.swap(false, Ordering::Relaxed) { self.reset_desegmenter_data(); } @@ -405,16 +456,6 @@ impl StateSync { best_count, } => { if requested_root_hash.is_empty() { - let response_peers: Vec = peers - .iter() - .filter_map(|peer| { - if responded_root_hash.contains_key(&peer.info.addr) { - Some(peer.info.addr.clone()) - } else { - None - } - }) - .collect(); let msg = format!( concat!( "No quorum for PIBD root at archive height {}. ", @@ -427,14 +468,11 @@ impl StateSync { best_count, required_responses ); - drop(requested_root_hash); - drop(responded_root_hash); warn!("{}", msg); - for peer in &response_peers { - sync_peers.report_error_response(peer, msg.clone()); - } - self.requested_root_hash.write().clear(); - self.responded_root_hash.write().clear(); + // A split vote proves disagreement, but does not identify the + // dishonest peer. Keep the existing commitments so a newly + // connected peer can break the tie, and do not feed ambiguous + // votes into peer error scoring. return Ok(SyncResponse::new( SyncRequestResponses::WaitingForPeers, Self::get_peer_capabilities(), @@ -528,7 +566,7 @@ impl StateSync { match desegmenter.validate_complete_state(sync_state, stop_state) { Ok(_) => { info!("PIBD download and valiadion is done with success!"); - self.is_complete.store(true, Ordering::Relaxed); + self.complete_pibd_sync(); return Ok(SyncResponse::new( SyncRequestResponses::StatePibdReady, Capabilities::UNKNOWN, @@ -629,18 +667,30 @@ impl StateSync { self.reset_desegmenter.store(true, Ordering::Relaxed); } + fn clear_pibd_root_tracking(&self) { + self.requested_root_hash.write().clear(); + self.responded_root_hash.write().clear(); + } + + fn complete_pibd_sync(&self) { + // Publish completion before clearing peer commitments. Segment responses + // already in flight can then be recognized as terminal-session traffic and + // ignored instead of being counted as invalid peer responses. + self.is_complete.store(true, Ordering::Release); + self.clear_pibd_root_tracking(); + } + pub fn reset_desegmenter_data(&self) { // Keep this write lock as the first operation: receive handlers use the // desegmenter read guard as the session barrier from validation through // segment application. *self.desegmenter.write() = None; - self.requested_root_hash.write().clear(); - self.responded_root_hash.write().clear(); + self.clear_pibd_root_tracking(); *self.target_archive_hash.write() = Hash::default(); self.request_tracker.clear(); self.last_retry_idx.write().clear(); self.retry_expiration_times.write().clear(); - self.is_complete.store(false, Ordering::Relaxed); + self.is_complete.store(false, Ordering::Release); } pub fn recieve_pibd_status( @@ -669,6 +719,8 @@ impl StateSync { return; } + // Keep this commitment for the complete PIBD session. It authorizes this + // peer again after reconnect and preserves evidence for a session-wide ban. self.responded_root_hash .write() .insert(peer.clone(), (output_bitmap_root, Instant::now())); @@ -687,33 +739,46 @@ impl StateSync { } // Return the selected desegmenter and root hash if validation was successful. + // Distinguish stale session traffic from a peer mismatch so delayed responses + // after a reset are not counted against an honest peer. fn validated_desegmenter( &self, peer: &PeerAddr, archive_header_hash: &Hash, - ) -> Option> { + ) -> DesegmenterValidation<'_> { let guard = self.desegmenter.read_recursive(); - let desegmenter = guard.as_ref().cloned()?; + let desegmenter = match guard.as_ref().cloned() { + Some(desegmenter) => desegmenter, + None => return DesegmenterValidation::Stale, + }; + if !desegmenter.is_current() { + self.reset_desegmenter.store(true, Ordering::Relaxed); + return DesegmenterValidation::Stale; + } if *self.target_archive_hash.read_recursive() != *archive_header_hash { - return None; + return DesegmenterValidation::Stale; } let hash_for_peer = self.responded_root_hash.read_recursive().get(peer).cloned(); match hash_for_peer { Some((hash, _)) if *desegmenter.get_bitmap_root_hash() == hash => { - Some(ValidatedDesegmenter { + DesegmenterValidation::Valid { _guard: guard, desegmenter, root_hash: hash, - }) + } } - _ => None, + _ => DesegmenterValidation::PeerMismatch { _guard: guard }, } } fn live_desegmenter(&self) -> Option> { let guard = self.desegmenter.read_recursive(); let desegmenter = guard.as_ref().cloned()?; + if !desegmenter.is_current() { + self.reset_desegmenter.store(true, Ordering::Relaxed); + return None; + } let archive_hash = self.target_archive_hash.read_recursive().clone(); if archive_hash == Hash::default() { return None; @@ -728,6 +793,9 @@ impl StateSync { } // Return true if the response came from the registered peer. + // Segment receive callers keep their DesegmenterValidation alive through this + // call so reset_desegmenter_data() cannot replace the session between segment + // application and request removal/scheduling. fn track_and_request_more_segments( &self, key: &(SegmentType, u64), @@ -814,15 +882,20 @@ impl StateSync { peers: &Arc, sync_peers: &SyncPeers, ) -> Result<(), mwc_chain::Error> { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + let key = (SegmentType::Bitmap, segment.leaf_offset()?); let mut accepted_segment = false; - if let Some(validated) = self.validated_desegmenter(peer, archive_header_hash) { - let res = validated - .desegmenter - .add_bitmap_segment(segment, &validated.root_hash); - drop(validated); - match res { + let validation = self.validated_desegmenter(peer, archive_header_hash); + match &validation { + DesegmenterValidation::Valid { + desegmenter, + root_hash, + .. + } => match desegmenter.add_bitmap_segment(segment, root_hash) { Ok(_) => { accepted_segment = true; } @@ -837,10 +910,19 @@ impl StateSync { error!("{}", msg); sync_peers.report_error_response(peer, msg); } + }, + DesegmenterValidation::PeerMismatch { .. } => { + if self.is_complete.load(Ordering::Acquire) { + // Completion can race validation and clear the peer commitment after the + // initial check. A response from that completed session is not a peer fault. + return Ok(()); + } + sync_peers.report_error_response( + peer, + "bitmap_segment, validate_root_hash failure".into(), + ); } - } else { - sync_peers - .report_error_response(peer, "bitmap_segment, validate_root_hash failure".into()); + DesegmenterValidation::Stale => return Ok(()), } let matched_request = @@ -848,6 +930,7 @@ impl StateSync { if accepted_segment && matched_request { sync_peers.report_ok_response(peer); } + drop(validation); Ok(()) } @@ -859,18 +942,23 @@ impl StateSync { peers: &Arc, sync_peers: &SyncPeers, ) -> Result<(), mwc_chain::Error> { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + let key = (SegmentType::Output, segment.leaf_offset()?); let mut accepted_segment = false; // Be conservative here: every output-segment failure is counted against // the peer. Reporting an error only feeds peer scoring; it is not an // immediate ban. - if let Some(validated) = self.validated_desegmenter(peer, archive_header_hash) { - let res = validated - .desegmenter - .add_output_segment(segment, &validated.root_hash); - drop(validated); - match res { + let validation = self.validated_desegmenter(peer, archive_header_hash); + match &validation { + DesegmenterValidation::Valid { + desegmenter, + root_hash, + .. + } => match desegmenter.add_output_segment(segment, root_hash) { Ok(_) => { accepted_segment = true; } @@ -882,9 +970,14 @@ impl StateSync { error!("{}", msg); sync_peers.report_error_response(peer, msg); } + }, + DesegmenterValidation::PeerMismatch { .. } => { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + sync_peers.report_error_response(peer, "validate_root_hash failed".into()); } - } else { - sync_peers.report_error_response(peer, "validate_root_hash failed".into()); + DesegmenterValidation::Stale => return Ok(()), } let matched_request = @@ -892,6 +985,7 @@ impl StateSync { if accepted_segment && matched_request { sync_peers.report_ok_response(peer); } + drop(validation); Ok(()) } @@ -903,16 +997,21 @@ impl StateSync { peers: &Arc, sync_peers: &SyncPeers, ) -> Result<(), mwc_chain::Error> { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + let key = (SegmentType::RangeProof, segment.leaf_offset()?); let mut accepted_segment = false; // Process first, unregister after. During unregister we might issue more requests. - if let Some(validated) = self.validated_desegmenter(peer, archive_header_hash) { - let res = validated - .desegmenter - .add_rangeproof_segment(segment, &validated.root_hash); - drop(validated); - match res { + let validation = self.validated_desegmenter(peer, archive_header_hash); + match &validation { + DesegmenterValidation::Valid { + desegmenter, + root_hash, + .. + } => match desegmenter.add_rangeproof_segment(segment, root_hash) { Ok(_) => { accepted_segment = true; } @@ -930,9 +1029,14 @@ impl StateSync { error!("{}", msg); sync_peers.report_error_response(peer, msg); } + }, + DesegmenterValidation::PeerMismatch { .. } => { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + sync_peers.report_error_response(peer, "validate_root_hash error".into()); } - } else { - sync_peers.report_error_response(peer, "validate_root_hash error".into()); + DesegmenterValidation::Stale => return Ok(()), } let matched_request = @@ -940,6 +1044,7 @@ impl StateSync { if accepted_segment && matched_request { sync_peers.report_ok_response(peer); } + drop(validation); Ok(()) } @@ -951,15 +1056,20 @@ impl StateSync { peers: &Arc, sync_peers: &SyncPeers, ) -> Result<(), mwc_chain::Error> { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + let key = (SegmentType::Kernel, segment.leaf_offset()?); let mut accepted_segment = false; - if let Some(validated) = self.validated_desegmenter(peer, archive_header_hash) { - let res = validated - .desegmenter - .add_kernel_segment(segment, &validated.root_hash); - drop(validated); - match res { + let validation = self.validated_desegmenter(peer, archive_header_hash); + match &validation { + DesegmenterValidation::Valid { + desegmenter, + root_hash, + .. + } => match desegmenter.add_kernel_segment(segment, root_hash) { Ok(_) => { accepted_segment = true; } @@ -976,9 +1086,14 @@ impl StateSync { error!("{}", msg); sync_peers.report_error_response(peer, msg); } + }, + DesegmenterValidation::PeerMismatch { .. } => { + if self.is_complete.load(Ordering::Acquire) { + return Ok(()); + } + sync_peers.report_error_response(peer, "validate_root_hash failed".into()); } - } else { - sync_peers.report_error_response(peer, "validate_root_hash failed".into()); + DesegmenterValidation::Stale => return Ok(()), } let matched_request = @@ -986,6 +1101,7 @@ impl StateSync { if accepted_segment && matched_request { sync_peers.report_ok_response(peer); } + drop(validation); Ok(()) } @@ -1153,17 +1269,21 @@ impl StateSync { "Internal error, peers data is empty".into(), ))?; + // Register before queueing the network message. The p2p receive + // path runs concurrently and may otherwise process a fast response + // before this request is visible, leaving a stale request that later + // times out against a peer that actually responded. + let msg = format!("{:?}", key); + let request_token = self.request_tracker.register_request( + key.clone(), + peer.info.addr.clone(), + msg, + ); let send_res = Self::send_request(peer, &seg, target_archive_hash); match send_res { - Ok(_) => { - let msg = format!("{:?}", key); - self.request_tracker.register_request( - key, - peer.info.addr.clone(), - msg, - ); - } + Ok(_) => {} Err(e) => { + self.request_tracker.rollback_request(&key, &request_token); let msg = format!( "Error sending segment request to peer at {}, reason: {:?}", peer.info.addr, e @@ -1262,11 +1382,26 @@ impl StateSync { #[cfg(test)] mod tests { use super::*; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; fn test_hash(value: u8) -> Hash { Hash::from_vec(&[value; Hash::LEN]) } + #[test] + fn root_capacity_check_keeps_existing_commitment() { + let peer = PeerAddr::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 30_001)); + let root = test_hash(9); + let requested = HashMap::new(); + let responses = HashMap::from([(peer.clone(), (root, Instant::now()))]); + + assert_eq!( + StateSync::remaining_pibd_root_peer_capacity(&requested, &responses, 1), + 0 + ); + assert_eq!(responses.get(&peer).map(|(hash, _)| *hash), Some(root)); + } + #[test] fn select_pibd_root_rejects_single_response() { let root = test_hash(1); diff --git a/servers/src/mwc/sync/sync_manager.rs b/servers/src/mwc/sync/sync_manager.rs index 970917173b..89747e080b 100644 --- a/servers/src/mwc/sync/sync_manager.rs +++ b/servers/src/mwc/sync/sync_manager.rs @@ -505,8 +505,12 @@ impl SyncManager { } if valid_block != Some(false) && opts == mwc_chain::Options::NONE { - let source_peer = valid_block.is_none().then(|| peer.to_string()); - self.orphans.recieve_block_reporting(b, source_peer) + // Orphan candidate admission is peer-scoped. Keep the peer identity even + // for known/accepted reports; accepted blocks are discarded by the + // block-exists check, while conflicting orphan bodies still need correct + // attribution and replacement behavior. Preserve PeerAddr here so candidate + // ownership uses the same normalized identity as peer admission and banning. + self.orphans.recieve_block_reporting(b, Some(peer.clone())) } else { Ok(false) } diff --git a/servers/src/mwc/sync/sync_utils.rs b/servers/src/mwc/sync/sync_utils.rs index ece0cb911a..2a961f0a38 100644 --- a/servers/src/mwc/sync/sync_utils.rs +++ b/servers/src/mwc/sync/sync_utils.rs @@ -158,14 +158,16 @@ impl PeerTrackData { } pub struct RequestData { + token: Arc<()>, peer: PeerAddr, request_time: Instant, request_message: String, // for logging and debugging } impl RequestData { - fn new(peer: PeerAddr, request_message: String) -> Self { + fn new(token: Arc<()>, peer: PeerAddr, request_message: String) -> Self { RequestData { + token, peer, request_time: Instant::now(), request_message, @@ -173,6 +175,9 @@ impl RequestData { } } +/// Opaque identity for one exact request registration. +pub struct RequestToken(Arc<()>); + struct LatencyTracker { latency_history: VecDeque, latency_sum: i64, @@ -231,7 +236,7 @@ pub struct RequestTracker where K: std::cmp::Eq + std::hash::Hash, { - // Values: peer, time, message. + // Values: token, peer, time, message. requested: RwLock>, // Lock 1 // there are so many peers and many requests, so we better to hande 'slow' peer cases peers_stats: RwLock>, // Lock 2 @@ -262,6 +267,22 @@ where } } + fn decrement_peer_requests( + peers_stats: &mut HashMap, + peer: &PeerAddr, + ) { + let remove_peer = match peers_stats.get_mut(peer) { + Some(peer_stat) => { + peer_stat.requests = peer_stat.requests.saturating_sub(1); + peer_stat.requests == 0 + } + None => false, + }; + if remove_peer { + peers_stats.remove(peer); + } + } + pub fn retain_expired( &self, expiration_time_interval_sec: u32, @@ -275,14 +296,11 @@ where // first let's clean up stale requests... requested.retain(|_, request_data| { - let peer_stat = peers_stats.get_mut(&request_data.peer); if request_data.request_time.elapsed() > expiration_time_interval { sync_peers .report_no_response(&request_data.peer, request_data.request_message.clone()); res.insert(request_data.peer.clone()); - if let Some(n) = peer_stat { - n.requests = n.requests.saturating_sub(1); - } + Self::decrement_peer_requests(peers_stats, &request_data.peer); return false; } true @@ -345,14 +363,15 @@ where self.peers_stats.read_recursive().get(peer).cloned() } - pub fn register_request(&self, key: K, peer: PeerAddr, message: String) { + pub fn register_request(&self, key: K, peer: PeerAddr, message: String) -> RequestToken { + let token = Arc::new(()); let mut requested = self.requested.write(); let peers_stats = &mut self.peers_stats.write(); - if let Some(request_data) = requested.insert(key, RequestData::new(peer.clone(), message)) { - if let Some(n) = peers_stats.get_mut(&request_data.peer) { - n.requests = n.requests.saturating_sub(1); - } + if let Some(request_data) = + requested.insert(key, RequestData::new(token.clone(), peer.clone(), message)) + { + Self::decrement_peer_requests(peers_stats, &request_data.peer); } match peers_stats.get_mut(&peer) { @@ -363,6 +382,49 @@ where peers_stats.insert(peer.clone(), PeerTrackData::new(1)); } } + + RequestToken(token) + } + + /// Roll back a request that could not be sent. The token check prevents a late + /// send failure from removing a newer request registered for the same key. + /// Unlike response removal, rollback must not contribute a latency sample. + pub fn rollback_request(&self, key: &K, request_token: &RequestToken) -> bool { + let mut requested = self.requested.write(); + let peers_stats = &mut self.peers_stats.write(); + let matches_request = requested.get(key).map_or(false, |request_data| { + Arc::ptr_eq(&request_data.token, &request_token.0) + }); + if !matches_request { + return false; + } + + if let Some(request_data) = requested.remove(key) { + Self::decrement_peer_requests(peers_stats, &request_data.peer); + true + } else { + false + } + } + + /// Cancel obsolete requests without treating them as responses or timeouts. + pub fn cancel_requests<'a, I>(&self, keys: I) -> usize + where + K: 'a, + I: IntoIterator, + { + let mut requested = self.requested.write(); + let peers_stats = &mut self.peers_stats.write(); + let mut removed = 0usize; + + for key in keys { + if let Some(request_data) = requested.remove(key) { + Self::decrement_peer_requests(peers_stats, &request_data.peer); + removed = removed.saturating_add(1); + } + } + + removed } pub fn remove_request(&self, key: &K, peer: &PeerAddr) -> Option { @@ -372,13 +434,11 @@ where if let Some(request_data) = requested.get(key) { let res_peer = request_data.peer.clone(); if request_data.peer == *peer { - if let Some(n) = peers_stats.get_mut(&request_data.peer) { - n.requests = n.requests.saturating_sub(1); - } let latency_ms = i64::try_from(request_data.request_time.elapsed().as_millis()) .unwrap_or(i64::MAX / 15); self.latency_tracker.write().add_latency(latency_ms); requested.remove(key); + Self::decrement_peer_requests(peers_stats, &res_peer); } Some(res_peer) } else { @@ -390,20 +450,14 @@ where let mut requested = self.requested.write(); let peers_stats = &mut self.peers_stats.write(); - if let Some(request_data) = requested.get(key) { + if let Some(request_data) = requested.remove(key) { let res_peer = request_data.peer.clone(); - - if let Some(n) = peers_stats.get_mut(&request_data.peer) { - n.requests = n.requests.saturating_sub(1); - } - + Self::decrement_peer_requests(peers_stats, &res_peer); if request_data.peer == *peer { let latency_ms = i64::try_from(request_data.request_time.elapsed().as_millis()) .unwrap_or(i64::MAX / 15); self.latency_tracker.write().add_latency(latency_ms); } - - requested.remove(key); Some(res_peer) } else { None @@ -541,10 +595,11 @@ pub fn get_sync_peers( #[cfg(test)] mod tests { use super::{CachedResponse, LatencyTracker, QuorumSelection, RequestTracker}; + use crate::mwc::sync::sync_peers::SyncPeers; use mwc_chain::pibd_params; use mwc_p2p::PeerAddr; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::time::Duration; + use std::time::{Duration, Instant}; fn peer_for_idx(idx: u8) -> PeerAddr { PeerAddr::Ip(SocketAddr::new( @@ -679,13 +734,7 @@ mod tests { assert_eq!(tracker.remove_request(&42, &peer), Some(peer.clone())); assert_eq!(tracker.get_requests_num(), 0); - assert_eq!( - tracker - .get_peer_track_data(&peer) - .expect("peer stats") - .requests, - 0 - ); + assert!(tracker.get_peer_track_data(&peer).is_none()); } #[test] @@ -699,13 +748,7 @@ mod tests { assert_eq!(tracker.get_requests_num(), 1); assert_eq!(tracker.get_expected_peer(&42), Some(second_peer.clone())); - assert_eq!( - tracker - .get_peer_track_data(&first_peer) - .expect("first peer stats") - .requests, - 0 - ); + assert!(tracker.get_peer_track_data(&first_peer).is_none()); assert_eq!( tracker .get_peer_track_data(&second_peer) @@ -718,13 +761,7 @@ mod tests { tracker.remove_request(&42, &second_peer), Some(second_peer.clone()) ); - assert_eq!( - tracker - .get_peer_track_data(&second_peer) - .expect("second peer stats") - .requests, - 0 - ); + assert!(tracker.get_peer_track_data(&second_peer).is_none()); } #[test] @@ -764,14 +801,93 @@ mod tests { ); assert_eq!(tracker.get_requests_num(), 0); assert_eq!(tracker.get_expected_peer(&42), None); + assert!(tracker.get_peer_track_data(&original_peer).is_none()); + assert!(tracker.get_peer_track_data(&duplicate_peer).is_none()); + assert_eq!(tracker.get_average_latency(), None); + } + + #[test] + fn rollback_request_removes_matching_registration_without_latency() { + let tracker = RequestTracker::::new(); + let peer = peer_for_idx(1); + let request_token = tracker.register_request(42, peer.clone(), "request".into()); + + assert!(tracker.rollback_request(&42, &request_token)); + assert_eq!(tracker.get_requests_num(), 0); + assert!(tracker.get_peer_track_data(&peer).is_none()); + assert_eq!(tracker.get_average_latency(), None); + } + + #[test] + fn rollback_request_does_not_remove_newer_registration() { + let tracker = RequestTracker::::new(); + let first_peer = peer_for_idx(1); + let second_peer = peer_for_idx(2); + let first_request_token = tracker.register_request(42, first_peer.clone(), "first".into()); + let second_request_token = + tracker.register_request(42, second_peer.clone(), "second".into()); + + assert!(!tracker.rollback_request(&42, &first_request_token)); + assert_eq!(tracker.get_expected_peer(&42), Some(second_peer.clone())); assert_eq!( tracker - .get_peer_track_data(&original_peer) - .expect("original peer stats") + .get_peer_track_data(&second_peer) + .expect("second peer stats") .requests, - 0 + 1 ); - assert!(tracker.get_peer_track_data(&duplicate_peer).is_none()); + assert!(tracker.rollback_request(&42, &second_request_token)); + assert_eq!(tracker.get_requests_num(), 0); + assert!(tracker.get_peer_track_data(&first_peer).is_none()); + assert!(tracker.get_peer_track_data(&second_peer).is_none()); + } + + #[test] + fn cancel_requests_clears_accounting_without_latency() { + let tracker = RequestTracker::::new(); + let first_peer = peer_for_idx(1); + let second_peer = peer_for_idx(2); + tracker.register_request(41, first_peer.clone(), "first".into()); + tracker.register_request(42, second_peer.clone(), "second".into()); + + assert_eq!(tracker.cancel_requests([&41, &42]), 2); + assert_eq!(tracker.get_requests_num(), 0); + assert!(tracker.get_peer_track_data(&first_peer).is_none()); + assert!(tracker.get_peer_track_data(&second_peer).is_none()); assert_eq!(tracker.get_average_latency(), None); } + + #[test] + fn expired_request_removes_peer_stats() { + let tracker = RequestTracker::::new(); + let peer = peer_for_idx(1); + tracker.register_request(42, peer.clone(), "request".into()); + tracker + .requested + .write() + .get_mut(&42) + .expect("request") + .request_time = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("valid request time"); + + let expired = tracker.retain_expired(1, &SyncPeers::new()); + + assert_eq!(expired, std::collections::HashSet::from([peer.clone()])); + assert_eq!(tracker.get_requests_num(), 0); + assert!(tracker.get_peer_track_data(&peer).is_none()); + } + + #[test] + fn sequential_peer_replacement_does_not_accumulate_peer_stats() { + let tracker = RequestTracker::::new(); + + for idx in 1..=100 { + tracker.register_request(42, peer_for_idx(idx), "request".into()); + assert_eq!(tracker.peers_stats.read_recursive().len(), 1); + } + + assert_eq!(tracker.cancel_requests([&42]), 1); + assert!(tracker.peers_stats.read_recursive().is_empty()); + } } diff --git a/src/bin/cmd/server.rs b/src/bin/cmd/server.rs index 736217a907..9a22fdd7e7 100644 --- a/src/bin/cmd/server.rs +++ b/src/bin/cmd/server.rs @@ -38,9 +38,16 @@ pub fn start_server( config: mwc_servers::ServerConfig, tui_logs: Option, offline: bool, + skip_start_blockchain_validation: bool, ) -> Result<(), Error> { - start_server_tui(context_id, config, tui_logs, offline) - .map_err(|e| Error::ServerStart(e.to_string())) + start_server_tui( + context_id, + config, + tui_logs, + offline, + skip_start_blockchain_validation, + ) + .map_err(|e| Error::ServerStart(e.to_string())) } fn start_server_tui( @@ -48,6 +55,7 @@ fn start_server_tui( config: mwc_servers::ServerConfig, tui_logs: Option, offline: bool, + skip_start_blockchain_validation: bool, ) -> Result<(), mwc_node_workflow::Error> { let run_tui = config.run_tui.unwrap_or(false); let running = Arc::new(AtomicBool::new(true)); @@ -69,9 +77,12 @@ fn start_server_tui( } info!("Creating MWC node server..."); - if let Err(e) = - mwc_node_workflow::server::create_server(context_id, config.clone(), startup_stop_state) - { + if let Err(e) = mwc_node_workflow::server::create_server( + context_id, + config.clone(), + startup_stop_state, + skip_start_blockchain_validation, + ) { if !run_tui && !running.load(Ordering::SeqCst) { warn!("Received SIGINT (Ctrl+C) or SIGTERM (kill)."); mwc_node_workflow::server::release_server(context_id); @@ -169,6 +180,7 @@ pub fn server_command( server_args: Option<&ArgMatches<'_>>, global_config: GlobalConfig, tui_logs: Option, + skip_start_blockchain_validation: bool, ) -> Result<(), Error> { // just get defaults from the global config let mut server_config = global_config.members.server.clone(); @@ -224,7 +236,13 @@ pub fn server_command( if let Some(a) = server_args { match a.subcommand() { ("run", _) => { - start_server(context_id, server_config, tui_logs, offline)?; + start_server( + context_id, + server_config, + tui_logs, + offline, + skip_start_blockchain_validation, + )?; } ("", _) => { return Err(Error::ArgumentError( @@ -239,7 +257,13 @@ pub fn server_command( } } } else { - start_server(context_id, server_config, tui_logs, offline)?; + start_server( + context_id, + server_config, + tui_logs, + offline, + skip_start_blockchain_validation, + )?; } Ok(()) } diff --git a/src/bin/mwc.rs b/src/bin/mwc.rs index 6b62288967..afc5ecd409 100644 --- a/src/bin/mwc.rs +++ b/src/bin/mwc.rs @@ -81,6 +81,7 @@ fn real_main() -> i32 { } else { global::ChainTypes::Mainnet }; + let skip_start_blockchain_validation = args.is_present("skip_start_blockchain_validation"); // Deal with configuration file creation if let ("server", Some(server_args)) = args.subcommand() { @@ -182,6 +183,7 @@ fn real_main() -> i32 { Some(server_args), config, tui_logs, + skip_start_blockchain_validation, )), // client commands and options ("client", Some(client_args)) => { @@ -203,7 +205,13 @@ fn real_main() -> i32 { // If nothing is specified, try to just use the config file instead // this could possibly become the way to configure most things // with most command line options being phased out - _ => res_to_ret_val(cmd::server_command(context_id, None, config, tui_logs)), + _ => res_to_ret_val(cmd::server_command( + context_id, + None, + config, + tui_logs, + skip_start_blockchain_validation, + )), }; if let Err(e) = mwc_node_workflow::context::release_context(context_id) { @@ -218,6 +226,24 @@ fn real_main() -> i32 { res } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_skip_start_blockchain_validation_global_flag() { + let yml = YamlLoader::load_from_str(include_str!("mwc.yml")).unwrap(); + let args = App::from_yaml(yml.first().unwrap()).get_matches_from(vec![ + "mwc", + "--skip_start_blockchain_validation", + "server", + "run", + ]); + + assert!(args.is_present("skip_start_blockchain_validation")); + } +} + fn res_to_ret_val(res: Result<(), crate::cmd::Error>) -> i32 { match res { Ok(_) => 0, diff --git a/src/bin/mwc.yml b/src/bin/mwc.yml index debde104fd..ad0cb93028 100644 --- a/src/bin/mwc.yml +++ b/src/bin/mwc.yml @@ -12,6 +12,10 @@ args: help: Run mwc as a local-only network. Doesn't block peer connections but will not connect to any peer or seed long: usernet takes_value: false + - skip_start_blockchain_validation: + help: Skip comprehensive blockchain data validation during node startup + long: skip_start_blockchain_validation + takes_value: false subcommands: - clean: about: Clean MWC chain data diff --git a/store/Cargo.toml b/store/Cargo.toml index 93b8a05461..7733e7a1e1 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_store" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,7 +10,7 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } -mwc_core = { path = "../core", version = "7.0.0" } -mwc_util = { path = "../util", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } +mwc_core = { path = "../core", version = "7.1.0" } +mwc_util = { path = "../util", version = "7.1.0" } thiserror.workspace = true diff --git a/store/src/lib.rs b/store/src/lib.rs index d4b4b9b3e7..0506d84451 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -200,7 +200,14 @@ fn replacement_file_mode(original: &Path) -> Result { original.display() ), )), - Ok(metadata) => Ok(metadata.permissions().mode() & 0o777), + Ok(metadata) if metadata.file_type().is_file() => Ok(metadata.permissions().mode() & 0o777), + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "refusing to replace non-regular file {} via temporary file", + original.display() + ), + )), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0o600), Err(e) => Err(e), } @@ -408,6 +415,35 @@ mod tests { assert_eq!(std::fs::read(&target).unwrap(), b"target"); } + #[cfg(unix)] + #[test] + fn save_via_temp_file_rejects_socket_replacement_path() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::UnixListener; + + create_dir_all("target").unwrap(); + let path = test_path("replace_socket"); + let listener = UnixListener::bind(&path).unwrap(); + let mut writer_called = false; + + let err = save_via_temp_file(&path, ".tmp", |file| { + writer_called = true; + file.write_all(b"new") + }) + .unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!(err.to_string().contains("non-regular file")); + assert!(!writer_called); + assert!(std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_socket()); + + drop(listener); + std::fs::remove_file(path).unwrap(); + } + #[cfg(unix)] #[test] fn read_bitmap_rejects_symlink_path() { diff --git a/store/src/lmdb.rs b/store/src/lmdb.rs index e567e9a66c..784ed2a7b9 100644 --- a/store/src/lmdb.rs +++ b/store/src/lmdb.rs @@ -579,6 +579,20 @@ impl<'a> Batch<'a> { prefix: &[u8], deserialize: F, ) -> Result, Error> + where + F: Fn(&[u8], &[u8]) -> Result, + { + self.iter_from(prefix, prefix, deserialize) + } + + /// Produces an iterator from `start`, restricted to keys with the provided + /// prefix. The first returned key is greater than or equal to `start`. + pub fn iter_from( + &self, + prefix: &[u8], + start: &[u8], + deserialize: F, + ) -> Result, Error> where F: Fn(&[u8], &[u8]) -> Result, { @@ -586,6 +600,7 @@ impl<'a> Batch<'a> { let db = lock .as_ref() .ok_or_else(|| Error::DbUnavailable("chain db is None".to_string()))?; + let seek_key = if start < prefix { prefix } else { start }.to_vec(); if let Some(tx) = &self.tx_r { let cursor = tx.cursor(db.clone())?; @@ -594,6 +609,7 @@ impl<'a> Batch<'a> { cursor, seek: false, prefix: prefix.to_vec(), + seek_key, deserialize, _resize_guard: None, }) @@ -604,6 +620,7 @@ impl<'a> Batch<'a> { cursor, seek: false, prefix: prefix.to_vec(), + seek_key, deserialize, _resize_guard: None, }) @@ -687,6 +704,7 @@ where cursor: lmdb_zero::Cursor<'txn, 'static>, seek: bool, prefix: Vec, + seek_key: Vec, deserialize: F, _resize_guard: Option>, } @@ -707,20 +725,21 @@ where let cursor = &mut self.cursor; let seek = &mut self.seek; let prefix = &self.prefix; + let seek_key = &self.seek_key; let deserialize = &self.deserialize; match &self.tx { PrefixIteratorTransaction::OwnedRead(tx) => { let access = tx.access(); - next_with_access(cursor, seek, prefix, deserialize, &access) + next_with_access(cursor, seek, prefix, seek_key, deserialize, &access) } PrefixIteratorTransaction::BorrowedRead(tx) => { let access = tx.access(); - next_with_access(cursor, seek, prefix, deserialize, &access) + next_with_access(cursor, seek, prefix, seek_key, deserialize, &access) } PrefixIteratorTransaction::BorrowedWrite(tx) => { let access = tx.access(); - next_with_access(cursor, seek, prefix, deserialize, &access) + next_with_access(cursor, seek, prefix, seek_key, deserialize, &access) } } } @@ -730,6 +749,7 @@ fn next_with_access( cursor: &mut lmdb_zero::Cursor<'_, 'static>, seek: &mut bool, prefix: &[u8], + seek_key: &[u8], deserialize: &F, access: &lmdb_zero::ConstAccessor<'_>, ) -> Option> @@ -740,7 +760,7 @@ where cursor.next(access) } else { *seek = true; - cursor.seek_range_k(access, prefix) + cursor.seek_range_k(access, seek_key) }; match kv.to_opt() { @@ -771,6 +791,7 @@ where cursor, seek: false, prefix: prefix.to_vec(), + seek_key: prefix.to_vec(), deserialize, _resize_guard: resize_guard, } diff --git a/store/src/pmmr.rs b/store/src/pmmr.rs index f3628d01ae..94d36eed4c 100644 --- a/store/src/pmmr.rs +++ b/store/src/pmmr.rs @@ -570,52 +570,8 @@ impl Backend for PMMRBackend { /// Rewind the PMMR backend to the given position. fn rewind(&mut self, position: u64, rewind_rm_pos: &Bitmap) -> Result<(), Error> { - let (hash_pos, data_pos) = if self.prunable { - // Rewind the hash file accounting for pruned/compacted pos. - let shift = if position == 0 { - 0 - } else { - self.prune_list.get_shift(position - 1)? - }; - let hash_pos = position.checked_sub(shift).ok_or_else(|| { - Error::DataOverflow(format!( - "PMMRBackend::rewind position={} shift={}", - position, shift - )) - })?; - - // Rewind the data file accounting for pruned/compacted pos. - let flatfile_pos = pmmr::n_leaves(position)?; - let leaf_shift = if position == 0 { - 0 - } else { - self.prune_list.get_leaf_shift(position)? - }; - let data_pos = flatfile_pos.checked_sub(leaf_shift).ok_or_else(|| { - Error::DataOverflow(format!( - "PMMRBackend::rewind flatfile_pos={} leaf_shift={}", - flatfile_pos, leaf_shift - )) - })?; - (hash_pos, data_pos) - } else { - (position, pmmr::n_leaves(position)?) - }; - - let hash_size = self.hash_file.size_unsync()?; - if hash_pos > hash_size { - return Err(Error::InvalidState(format!( - "cannot rewind hash file forward from {} to {}", - hash_size, hash_pos - ))); - } - let data_size = self.data_file.size_unsync()?; - if data_pos > data_size { - return Err(Error::InvalidState(format!( - "cannot rewind data file forward from {} to {}", - data_size, data_pos - ))); - } + let (hash_pos, data_pos) = self.rewind_file_positions(position)?; + self.validate_rewind_file_positions(hash_pos, data_pos)?; // Only mutate backend state after all fallible position calculations succeed. if self.prunable { @@ -664,6 +620,89 @@ impl Backend for PMMRBackend { } impl PMMRBackend { + fn validate_rewind_boundary(&self, position: u64) -> Result<(), Error> { + if !self.prunable { + return Ok(()); + } + + if let Some(last_root_pos1) = self.prune_list.last_pruned_root_pos1() { + if position < last_root_pos1 { + return Err(Error::InvalidState(format!( + "cannot rewind to PMMR position {}: target would discard retained compacted-subtree root at position {}", + position, last_root_pos1 + ))); + } + } + Ok(()) + } + + fn rewind_file_positions(&self, position: u64) -> Result<(u64, u64), Error> { + // A retained pruned-subtree root replaces every physical record beneath + // it and contributes to the prune-list shift caches. Rewinding before such + // a root would truncate its hash while leaving its logical shifts behind. + self.validate_rewind_boundary(position)?; + + if self.prunable { + // Translate the logical PMMR position to the compacted hash file. + let shift = if position == 0 { + 0 + } else { + self.prune_list.get_shift(position - 1)? + }; + let hash_pos = position.checked_sub(shift).ok_or_else(|| { + Error::DataOverflow(format!( + "PMMRBackend::rewind position={} shift={}", + position, shift + )) + })?; + + // Translate the logical leaf count to the compacted flat data file. + let flatfile_pos = pmmr::n_leaves(position)?; + let leaf_shift = if position == 0 { + 0 + } else { + self.prune_list.get_leaf_shift(position)? + }; + let data_pos = flatfile_pos.checked_sub(leaf_shift).ok_or_else(|| { + Error::DataOverflow(format!( + "PMMRBackend::rewind flatfile_pos={} leaf_shift={}", + flatfile_pos, leaf_shift + )) + })?; + Ok((hash_pos, data_pos)) + } else { + Ok((position, pmmr::n_leaves(position)?)) + } + } + + fn validate_rewind_file_positions(&self, hash_pos: u64, data_pos: u64) -> Result<(), Error> { + let hash_size = self.hash_file.size_unsync()?; + if hash_pos > hash_size { + return Err(Error::InvalidState(format!( + "cannot rewind hash file forward from {} to {}", + hash_size, hash_pos + ))); + } + let data_size = self.data_file.size_unsync()?; + if data_pos > data_size { + return Err(Error::InvalidState(format!( + "cannot rewind data file forward from {} to {}", + data_size, data_pos + ))); + } + Ok(()) + } + + /// Validate that the backend files contain a requested rewind target. + /// + /// Compaction means a logical PMMR position cannot be compared directly with + /// either physical file length. This performs the same prune-aware calculation + /// as `rewind`, but does not mutate any backend state. + pub fn validate_rewind_target(&self, position: u64) -> Result<(), Error> { + let (hash_pos, data_pos) = self.rewind_file_positions(position)?; + self.validate_rewind_file_positions(hash_pos, data_pos) + } + /// Instantiates a new PMMR backend. /// If optional size is provided then treat as "fixed" size otherwise "variable" size backend. /// Use the provided dir to store its files. @@ -1143,8 +1182,13 @@ impl PMMRBackend { } } -/// Filter remove list to exclude roots. -/// We want to keep roots around so we have hashes for Merkle proofs. +/// Filter the remove list to exclude maximal roots. +/// +/// We keep the current pruned-subtree roots needed for current-state Merkle +/// proofs. When adjacent pruned subtrees are rolled into a larger root, their +/// child roots may be removed. That is intentional: the backend does not retain +/// every historical peak and does not support proofs against arbitrary old MMR +/// sizes. fn removed_excl_roots(removed: &Bitmap) -> Result { let mut bitmap = Bitmap::new(); for pos in removed.iter() { diff --git a/store/src/prune_list.rs b/store/src/prune_list.rs index 82d2f7d62b..6e9c342f9c 100644 --- a/store/src/prune_list.rs +++ b/store/src/prune_list.rs @@ -451,6 +451,14 @@ impl PruneList { self.bitmap.is_empty() } + /// Highest retained pruned-subtree root as a 1-based PMMR position. + /// + /// A rewind target below this position would truncate at least one retained + /// root while leaving its prune-list entry and derived shifts behind. + pub(crate) fn last_pruned_root_pos1(&self) -> Option { + self.bitmap.maximum().map(u64::from) + } + /// A pos is pruned if it is a pruned root directly or if it is /// beneath the "next" pruned subtree. /// We only need to consider the "next" subtree due to the append-only MMR structure. diff --git a/store/tests/lmdb.rs b/store/tests/lmdb.rs index 2c79982905..0beb7490cc 100644 --- a/store/tests/lmdb.rs +++ b/store/tests/lmdb.rs @@ -257,6 +257,30 @@ fn test_iter() -> Result<(), mwc_store::Error> { Ok(()) } +#[test] +fn batch_iter_from_is_inclusive_and_prefix_bounded() -> Result<(), mwc_store::Error> { + let test_dir = "target/batch_iter_from_is_inclusive_and_prefix_bounded"; + setup(test_dir); + + let store = mwc_store::Store::new(0, test_dir, Some("test1"), None, None)?; + let batch = store.batch_write()?; + for key in [[0, 1], [0, 3], [0, 5], [1, 0]] { + batch.put(&key, &key)?; + } + batch.commit()?; + + let batch = store.batch_read()?; + let mut iter = batch.iter_from(&[0], &[0, 3], |key, _| Ok(key.to_vec()))?; + assert_eq!(iter.next().transpose()?, Some(vec![0, 3])); + assert_eq!(iter.next().transpose()?, Some(vec![0, 5])); + assert_eq!(iter.next().transpose()?, None); + drop(iter); + drop(batch); + + clean_output_dir(test_dir); + Ok(()) +} + #[test] fn iter_returns_deserialize_errors() -> Result<(), mwc_store::Error> { let test_dir = "target/iter_returns_deserialize_errors"; diff --git a/store/tests/pmmr.rs b/store/tests/pmmr.rs index bd919700ed..7c3bec963c 100644 --- a/store/tests/pmmr.rs +++ b/store/tests/pmmr.rs @@ -2020,6 +2020,122 @@ fn pmmr_compact_entire_peak() { teardown(data_dir); } +#[test] +fn pmmr_rewind_rejects_target_inside_compacted_subtree() { + let (data_dir, elems) = setup("rewind_inside_compacted_subtree"); + { + let mut backend = mwc_store::pmmr::PMMRBackend::new( + data_dir.clone(), + true, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + let mmr_size = load(0, &elems[0..5], &mut backend); + assert_eq!(mmr_size, 8); + backend.sync().unwrap(); + + // Compact positions 0 through 6 into their retained root at position 6. + // The later leaf at position 7 ensures that the old count-only check has + // enough physical hash and data records to incorrectly accept target 1. + { + let mut pmmr: PMMR<'_, TestElem, _> = PMMR::at(&mut backend, mmr_size); + for pos0 in [0, 1, 3, 4] { + pmmr.prune(pos0).unwrap(); + } + } + backend.sync().unwrap(); + backend.check_compact(6, &Bitmap::new()).unwrap(); + + assert_eq!(backend.hash_size().unwrap(), 2); + assert_eq!(backend.data_size().unwrap(), 1); + + for target in [0, 1, 6] { + match backend.validate_rewind_target(target).unwrap_err() { + pmmr::Error::InvalidState(msg) => { + assert!(msg.contains("target would discard retained compacted-subtree root")); + } + other => panic!("unexpected rewind target error: {:?}", other), + } + } + + let hash_size = backend.hash_size().unwrap(); + let data_size = backend.data_size().unwrap(); + match backend.rewind(0, &Bitmap::new()).unwrap_err() { + pmmr::Error::InvalidState(msg) => { + assert!(msg.contains("target would discard retained compacted-subtree root")); + } + other => panic!("unexpected rewind error: {:?}", other), + } + assert_eq!(backend.hash_size().unwrap(), hash_size); + assert_eq!(backend.data_size().unwrap(), data_size); + assert_eq!(backend.unpruned_size().unwrap(), mmr_size); + + // A boundary after the retained root keeps the prune list consistent. + backend.validate_rewind_target(7).unwrap(); + backend.validate_rewind_target(mmr_size).unwrap(); + } + teardown(data_dir); +} + +#[test] +fn pmmr_rewind_rejects_target_before_later_compacted_subtree() { + let (data_dir, elems) = setup("rewind_before_later_compacted_subtree"); + { + let mut backend = mwc_store::pmmr::PMMRBackend::new( + data_dir.clone(), + true, + ProtocolVersion(1), + 0, + None, + VariableSizeMetadataValidation::Full, + ) + .unwrap(); + let mmr_size = load(0, &elems[0..5], &mut backend); + assert_eq!(mmr_size, 8); + backend.sync().unwrap(); + + // Keep positions 0 through 2, but compact positions 3 through 5 into + // their retained root at position 5. + { + let mut pmmr: PMMR<'_, TestElem, _> = PMMR::at(&mut backend, mmr_size); + for pos0 in [3, 4] { + pmmr.prune(pos0).unwrap(); + } + } + backend.sync().unwrap(); + backend.check_compact(6, &Bitmap::new()).unwrap(); + + // Target 3 is a valid PMMR boundary and is wholly before the compacted + // subtree, but rewinding there would truncate its retained root while + // leaving the prune-list entry and shifts behind. + match backend.validate_rewind_target(3).unwrap_err() { + pmmr::Error::InvalidState(msg) => { + assert!(msg.contains("target would discard retained compacted-subtree root")); + } + other => panic!("unexpected rewind target error: {:?}", other), + } + + let hash_size = backend.hash_size().unwrap(); + let data_size = backend.data_size().unwrap(); + match backend.rewind(3, &Bitmap::new()).unwrap_err() { + pmmr::Error::InvalidState(msg) => { + assert!(msg.contains("target would discard retained compacted-subtree root")); + } + other => panic!("unexpected rewind error: {:?}", other), + } + assert_eq!(backend.hash_size().unwrap(), hash_size); + assert_eq!(backend.data_size().unwrap(), data_size); + assert_eq!(backend.unpruned_size().unwrap(), mmr_size); + + backend.validate_rewind_target(7).unwrap(); + backend.validate_rewind_target(mmr_size).unwrap(); + } + teardown(data_dir); +} + #[test] fn pmmr_compact_horizon() { let (data_dir, elems) = setup("compact_horizon"); diff --git a/util/Cargo.toml b/util/Cargo.toml index 9a682ebb16..384b5f8a91 100644 --- a/util/Cargo.toml +++ b/util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mwc_util" -version = "7.0.0" +version = "7.1.0" authors = ["Mwc Developers "] description = "Simple, private and scalable cryptocurrency implementation based on the MimbleWimble chain format." license = "Apache-2.0" @@ -10,5 +10,5 @@ workspace = ".." edition = "2024" [dependencies] -mwc_crates = { path = "../mwc_crates", version = "7.0.0" } +mwc_crates = { path = "../mwc_crates", version = "7.1.0" } thiserror.workspace = true diff --git a/util/src/file.rs b/util/src/file.rs index ffca074a36..c07c924e40 100644 --- a/util/src/file.rs +++ b/util/src/file.rs @@ -353,14 +353,62 @@ where #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - replacement + if let Err(error) = replacement .as_file() - .set_permissions(fs::Permissions::from_mode(0o600))?; + .set_permissions(fs::Permissions::from_mode(0o600)) + { + return Err(cleanup_replacement_after_error( + replacement, + "setting replacement permissions failed", + error, + )); + } } - write_all_and_sync(replacement.as_file_mut(), bytes.as_ref())?; - replacement.persist(path).map_err(|e| e.error)?; - sync_parent_dir(path) + if let Err(error) = write_all_and_sync(replacement.as_file_mut(), bytes.as_ref()) { + return Err(cleanup_replacement_after_error( + replacement, + "writing or synchronizing replacement failed", + error, + )); + } + + match replacement.persist(path) { + Ok(_) => sync_parent_dir(path), + Err(persist_error) => { + let mwc_crates::tempfile::PersistError { error, file } = persist_error; + Err(cleanup_replacement_after_error( + file, + "persisting replacement failed", + error, + )) + } + } +} + +fn cleanup_replacement_after_error( + replacement: mwc_crates::tempfile::NamedTempFile, + context: &str, + primary: io::Error, +) -> io::Error { + let replacement_path = replacement.path().to_path_buf(); + match replacement.close() { + Ok(()) => primary, + Err(cleanup) => { + // This is the final cleanup attempt. A failure here cannot itself be + // recovered by another cleanup attempt because `close` consumes the + // temporary-file handle; reporting both errors and the path is the + // limit of this API. + let kind = cleanup.kind(); + io::Error::new( + kind, + format!( + "{context}: {primary}; additionally failed to remove temporary owner-only file {}: {cleanup}", + replacement_path.display() + ), + ) + } + } } /// Create or truncate an owner-only regular file. @@ -584,12 +632,17 @@ fn create_owner_only_file_impl(path: &Path, create_new: bool) -> io::Result io::Result { +fn create_owner_only_file_impl(path: &Path, create_new: bool) -> io::Result { let mut options = fs::OpenOptions::new(); options.write(true); - if create_new2 { + if create_new { options.create_new(true); } else { + match owner_only_regular_file_metadata(path) { + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } options.create(true).truncate(true); } let file = options.open(path)?; @@ -809,6 +862,57 @@ mod tests { } } + #[test] + fn replace_owner_only_file_cleans_up_after_persist_failure() { + let temp_dir = mwc_crates::tempfile::TempDir::new().unwrap(); + let parent = temp_dir.path().join("parent"); + let destination = parent.join("destination"); + fs::create_dir(&parent).unwrap(); + fs::create_dir(&destination).unwrap(); + + replace_owner_only_file(&destination, b"owner-only contents").unwrap_err(); + + assert!(destination.is_dir()); + assert!(fs::read_dir(&parent).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".mwc-owner-only-") + })); + } + + #[cfg(unix)] + #[test] + fn cleanup_replacement_after_error_reports_terminal_cleanup_failure() { + let temp_dir = mwc_crates::tempfile::TempDir::new().unwrap(); + let original_parent = temp_dir.path().join("original"); + let moved_parent = temp_dir.path().join("moved"); + fs::create_dir(&original_parent).unwrap(); + let replacement = mwc_crates::tempfile::Builder::new() + .prefix(".mwc-owner-only-") + .tempfile_in(&original_parent) + .unwrap(); + let original_path = replacement.path().to_path_buf(); + let moved_path = moved_parent.join(original_path.file_name().unwrap()); + fs::rename(&original_parent, &moved_parent).unwrap(); + + let error = cleanup_replacement_after_error( + replacement, + "simulated replacement failure", + io::Error::new(io::ErrorKind::WriteZero, "primary write error"), + ); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + let message = error.to_string(); + assert!(message.contains("simulated replacement failure")); + assert!(message.contains("primary write error")); + assert!(message.contains("additionally failed to remove")); + assert!(message.contains(&original_path.display().to_string())); + assert!(moved_path.exists()); + fs::remove_file(moved_path).unwrap(); + } + #[cfg(unix)] #[test] fn ensure_owner_only_dir_tightens_existing_non_writable_directory() { diff --git a/util/src/logger.rs b/util/src/logger.rs index 06c62dbfc3..5c32830c39 100644 --- a/util/src/logger.rs +++ b/util/src/logger.rs @@ -12,19 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Logging wrapper to be used throughout all crates in the workspace +//! Logging wrapper to be used throughout all crates in the workspace. +//! +//! This module exclusively owns the process-global [`log`](mwc_crates::log) +//! logger. External code must never install a global `log` logger directly; +//! every logger installation must use one of this module's initialization +//! functions so repeated and concurrent calls can be rejected before file +//! appenders are constructed. use mwc_crates::anyhow; use mwc_crates::lazy_static::lazy_static; -use mwc_crates::log4rs; use mwc_crates::parking_lot::Mutex; use mwc_crates::tracing; +use std::cell::RefCell; use std::ops::Deref; use std::sync::Arc; use crate::Error; use mwc_crates::backtrace::Backtrace; use mwc_crates::log::{error, info}; -use mwc_crates::log::{Level, Record}; +use mwc_crates::log::{Level, LevelFilter, Record}; use mwc_crates::log4rs::append::console::ConsoleAppender; use mwc_crates::log4rs::append::file::FileAppender; use mwc_crates::log4rs::append::rolling_file::{ @@ -42,6 +48,7 @@ use mwc_crates::serde::{self, Deserialize, Serialize}; use mwc_crates::tracing::field::{Field, Visit}; use mwc_crates::tracing::Event; use mwc_crates::tracing_subscriber; +use mwc_crates::tracing_subscriber::filter::LevelFilter as TracingLevelFilter; use mwc_crates::tracing_subscriber::layer::SubscriberExt; use mwc_crates::tracing_subscriber::registry::LookupSpan; use mwc_crates::tracing_subscriber::Layer; @@ -58,9 +65,20 @@ lazy_static! { static ref LOGGER_BUFFER: Mutex> = Mutex::new(None); + /// Serializes all process-global logger initialization paths. This module is + /// the exclusive owner of the global `log` logger; external code must never + /// install one directly. + static ref LOGGER_INITIALIZED: Mutex = Mutex::new(false); + static ref CONSOLE_OUTPUT_ENABLED: AtomicBool = AtomicBool::new(true); } +std::thread_local! { + /// Prevents a callback from recursively dispatching records to itself on the same thread. + /// Nested records still pass through the appender and are retained in `LOGGER_BUFFER`. + static CALLBACK_DISPATCH_GUARD: RefCell<()> = const { RefCell::new(()) }; +} + /// True if everything is running as a console app. Otherwice it is a library, /// so no console output is expected pub fn is_console_output_enabled() -> bool { @@ -69,8 +87,8 @@ pub fn is_console_output_enabled() -> bool { const LOGGING_PATTERN: &str = "{d(%Y%m%d %H:%M:%S%.3f)} {h({l})} {M} - {m}{n}"; -/// 32 log files to rotate over by default -const DEFAULT_ROTATE_LOG_FILES: u32 = 32 as u32; +/// Three archived log files to retain by default. +const DEFAULT_ROTATE_LOG_FILES: u32 = 3 as u32; /// Number of recent log entries retained for the TUI. pub const TUI_LOG_BUFFER_CAPACITY: usize = 200; @@ -205,7 +223,7 @@ impl Default for LoggingConfig { file_log_level: Level::Info, log_file_path: String::from("mwc.log"), log_file_append: true, - log_max_size: Some(1024 * 1024 * 16), // 16 megabytes default + log_max_size: Some(1024 * 1024 * 4), // 4 MiB by default log_max_files: Some(DEFAULT_ROTATE_LOG_FILES), tui_running: None, } @@ -295,6 +313,27 @@ impl Visit for EventVisitor { struct Log4rsLayer; +fn tracing_level_to_log(level: &tracing::Level) -> Level { + match *level { + tracing::Level::ERROR => Level::Error, + tracing::Level::WARN => Level::Warn, + tracing::Level::INFO => Level::Info, + tracing::Level::DEBUG => Level::Debug, + tracing::Level::TRACE => Level::Trace, + } +} + +fn log_level_to_tracing_filter(level: LevelFilter) -> TracingLevelFilter { + match level { + LevelFilter::Off => TracingLevelFilter::OFF, + LevelFilter::Error => TracingLevelFilter::ERROR, + LevelFilter::Warn => TracingLevelFilter::WARN, + LevelFilter::Info => TracingLevelFilter::INFO, + LevelFilter::Debug => TracingLevelFilter::DEBUG, + LevelFilter::Trace => TracingLevelFilter::TRACE, + } +} + fn should_skip_log(target: &str, msg: &str) -> bool { // Filtering Arti false alarm messages. // Intentionally the event level is not checked. msg.contains used to suppress noisy massages that user don't @@ -328,6 +367,12 @@ where S: tracing::Subscriber + for<'a> LookupSpan<'a>, { fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + let metadata = event.metadata(); + let level = tracing_level_to_log(metadata.level()); + if !mwc_crates::log::log_enabled!(target: metadata.target(), level) { + return; + } + let mut visitor = EventVisitor { message: None, fields: Vec::new(), @@ -335,28 +380,20 @@ where event.record(&mut visitor); if let Some(message) = visitor.into_log_message() { - let target = event.metadata().target(); + let target = metadata.target(); if should_skip_log(target, &message) { return; } - let level = match *event.metadata().level() { - tracing::Level::ERROR => Level::Error, - tracing::Level::WARN => Level::Warn, - tracing::Level::INFO => Level::Info, - tracing::Level::DEBUG => Level::Debug, - tracing::Level::TRACE => Level::Trace, - }; - let log_args = format_args!("{}", message); let record = Record::builder() .args(log_args) .level(level) .target(target) .module_path(Some(target)) - .file(event.metadata().file()) - .line(event.metadata().line()) + .file(metadata.file()) + .line(metadata.line()) .build(); mwc_crates::log::logger().log(&record); @@ -389,23 +426,54 @@ impl Append for TuiLogAppender { fn flush(&self) {} } -/// Initialize the logger with the given configuration +fn active_root_level(config: &LoggingConfig, tui_running: bool) -> LevelFilter { + [ + (tui_running || config.log_to_stdout).then_some(config.stdout_log_level.to_level_filter()), + config + .log_to_file + .then_some(config.file_log_level.to_level_filter()), + ] + .into_iter() + .flatten() + .max() + .unwrap_or(LevelFilter::Off) +} + +fn install_tracing_bridge(level: LevelFilter) { + let subscriber = tracing_subscriber::registry() + .with(Log4rsLayer.with_filter(log_level_to_tracing_filter(level))); + if let Err(e) = tracing::subscriber::set_global_default(subscriber) { + // Log4rs is already committed and cannot be rolled back. An embedding + // application may legitimately own the tracing subscriber. + error!( + "Unable to capture Arti/Tor logs. tracing set_global_default failed with error: {}", + e + ); + } +} + +/// Initialize the process-global logger with the given configuration. +/// +/// Initialization is one-shot: after the first successful configuration, every +/// later or concurrent call returns a logging error. An attempt that fails +/// before configuration is committed may be retried. pub fn init_logger( config: Option<&LoggingConfig>, tui_logs: Option, ) -> Result<(), Error> { if let Some(c) = config { + let mut initialized = LOGGER_INITIALIZED.lock(); + if *initialized { + return Err(Error::Logging( + "init_logger, logging is already initialized".into(), + )); + } + let tui_running = c.tui_running.unwrap_or(false); let level_stdout = c.stdout_log_level.to_level_filter(); let level_file = c.file_log_level.to_level_filter(); - - // Determine minimum logging level for Root logger - let level_minimum = if level_stdout > level_file { - level_stdout - } else { - level_file - }; + let root_level = active_root_level(c, tui_running); // Start logger let stdout = ConsoleAppender::builder() @@ -440,52 +508,47 @@ pub fn init_logger( } if c.log_to_file { - // If maximum log size is specified, use rolling file appender - // or use basic one otherwise // Note, we don't want enforcing restrictive file or directory permissions and without validating ownership/symlink status // because it is overcomplicated the setup for users. Instead we never log security related data. let filter = Box::new(ThresholdFilter::new(level_file)); - let file: Box = { - if let Some(size) = c.log_max_size { - let count = c.log_max_files.unwrap_or_else(|| DEFAULT_ROTATE_LOG_FILES); - let roller = FixedWindowRoller::builder() - .build(&format!("{}.{{}}.gz", c.log_file_path), count) + let file: Box = if let Some(size) = c.log_max_size { + let count = c.log_max_files.unwrap_or(DEFAULT_ROTATE_LOG_FILES); + let roller = FixedWindowRoller::builder() + .build(&format!("{}.{{}}.gz", c.log_file_path), count) + .map_err(|e| { + Error::Logging(format!( + "init_logger, unable to build FixedWindowRoller, {}", + e + )) + })?; + let policy = + CompoundPolicy::new(Box::new(SizeTrigger::new(size)), Box::new(roller)); + + Box::new( + RollingFileAppender::builder() + .append(c.log_file_append) + .encoder(Box::new(SanitizingEncoder::new(&LOGGING_PATTERN))) + .build(c.log_file_path.clone(), Box::new(policy)) .map_err(|e| { Error::Logging(format!( - "init_logger, unable to build FixedWindowRoller, {}", - e + "init_logger, failed to create logfile at {}, {}", + c.log_file_path, e )) - })?; - let trigger = SizeTrigger::new(size); - - let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller)); - - Box::new( - RollingFileAppender::builder() - .append(c.log_file_append) - .encoder(Box::new(SanitizingEncoder::new(&LOGGING_PATTERN))) - .build(c.log_file_path.clone(), Box::new(policy)) - .map_err(|e| { - Error::Logging(format!( - "init_logger, failed to create logfile at {}, {}", - c.log_file_path, e - )) - })?, - ) - } else { - Box::new( - FileAppender::builder() - .append(c.log_file_append) - .encoder(Box::new(SanitizingEncoder::new(&LOGGING_PATTERN))) - .build(c.log_file_path.clone()) - .map_err(|e| { - Error::Logging(format!( - "init_logger, failed to create logfile at {}, {}", - c.log_file_path, e - )) - })?, - ) - } + })?, + ) + } else { + Box::new( + FileAppender::builder() + .append(c.log_file_append) + .encoder(Box::new(SanitizingEncoder::new(&LOGGING_PATTERN))) + .build(c.log_file_path.clone()) + .map_err(|e| { + Error::Logging(format!( + "init_logger, failed to create logfile at {}, {}", + c.log_file_path, e + )) + })?, + ) }; appenders.push(Appender::builder().filter(filter).build("file", file)); @@ -494,30 +557,23 @@ pub fn init_logger( let config = Config::builder() .appenders(appenders) - .build(root.build(level_minimum)) + .build(root.build(root_level)) .map_err(|e| { Error::Logging(format!("init_logger, failed to build Config object, {}", e)) })?; - let _ = - init_config_with_err_handler(config, Box::new(|err| println!("Logger error: {}", err))) - .map_err(|e| { - Error::Logging(format!("init_logger, failed to init log4rs, {}", e)) - })?; + init_config_with_err_handler(config, Box::new(|err| println!("Logger error: {}", err))) + .map_err(|e| { + Error::Logging(format!("init_logger, failed to register log4rs, {}", e)) + })?; + *initialized = true; + drop(initialized); - // forward tracing events into the `log` crate (i.e. into log4rs) - // Then set up tracing with your custom layer - let subscriber = tracing_subscriber::registry().with(Log4rsLayer); - tracing::subscriber::set_global_default(subscriber).map_err(|e| { - Error::Logging(format!( - "init_logger, failed to redirect logs with tracing, {}", - e - )) - })?; + install_tracing_bridge(root_level); info!( - "log4rs is initialized, file level: {:?}, stdout level: {:?}, min. level: {:?}", - level_file, level_stdout, level_minimum + "log4rs is initialized, file level: {:?}, stdout level: {:?}, root level: {:?}", + level_file, level_stdout, root_level ); // Now, tracing macros will go through your layer and into log4rs @@ -535,12 +591,19 @@ pub fn init_test_logger() -> Result<(), Error> { if *was_init_ref.deref() { return Ok(()); } + let mut initialized = LOGGER_INITIALIZED.lock(); + if *initialized { + return Err(Error::Logging( + "init_test_logger, logging is already initialized".into(), + )); + } + let mut logger = LoggingConfig::default(); logger.log_to_file = false; logger.stdout_log_level = Level::Debug; let level_stdout = logger.stdout_log_level.to_level_filter(); - let level_minimum = level_stdout; // minimum logging level for Root logger + let root_level = level_stdout; // Start logger let stdout = ConsoleAppender::builder() @@ -565,7 +628,7 @@ pub fn init_test_logger() -> Result<(), Error> { let config = Config::builder() .appenders(appenders) - .build(root.build(level_minimum)) + .build(root.build(root_level)) .map_err(|e| { Error::Logging(format!( "init_test_logger, unable to build log config, {}", @@ -573,16 +636,18 @@ pub fn init_test_logger() -> Result<(), Error> { )) })?; - _ = log4rs::init_config(config).map_err(|e| { - Error::Logging(format!( - "init_test_logger, unable to init the testing logs, {}", - e - )) - })?; + init_config_with_err_handler(config, Box::new(|err| println!("Logger error: {}", err))) + .map_err(|e| { + Error::Logging(format!( + "init_test_logger, failed to register log4rs, {}", + e + )) + })?; + *initialized = true; info!( - "log4rs is initialized, stdout level: {:?}, min. level: {:?}", - level_stdout, level_minimum + "log4rs is initialized, stdout level: {:?}, root level: {:?}", + level_stdout, root_level ); *was_init_ref = true; @@ -615,7 +680,11 @@ impl Append for CallbackAppender { }; if let Some(cb) = &*self.callback { - (cb)(entry.clone()); + CALLBACK_DISPATCH_GUARD.with(|guard| { + if let Ok(_dispatch_guard) = guard.try_borrow_mut() { + (cb)(entry.clone()); + } + }); } let mut logger_buffer = LOGGER_BUFFER.lock(); @@ -652,12 +721,19 @@ impl Append for CallbackAppender { /// Init logs as a callback logs. By design the first callback and cached buffer remain active for /// the process lifetime. It is expected that logging system can be set once and never changed after. pub fn init_callback_logger(config: CallbackLoggingConfig) -> Result<(), Error> { + let mut initialized = LOGGER_INITIALIZED.lock(); + if *initialized { + return Err(Error::Logging( + "init_callback_logger, logging is already initialized".into(), + )); + } let mut logger_buffer = LOGGER_BUFFER.lock(); if logger_buffer.is_some() { return Err(Error::Logging( "init_callback_logger, CallbackLoggingConfig is already set".into(), )); } + let root_level = config.log_level.to_level_filter(); let callback_appender = CallbackAppender { // Logg message formatter @@ -667,15 +743,13 @@ pub fn init_callback_logger(config: CallbackLoggingConfig) -> Result<(), Error> let mut root = Root::builder(); let appenders = vec![Appender::builder() - .filter(Box::new(ThresholdFilter::new( - config.log_level.to_level_filter(), - ))) + .filter(Box::new(ThresholdFilter::new(root_level))) .build("callback", Box::new(callback_appender))]; root = root.appender("callback"); let log4rs_config = Config::builder() .appenders(appenders) - .build(root.build(config.log_level.to_level_filter())) + .build(root.build(root_level)) .map_err(|e| { Error::Logging(format!( "init_callback_logger, unable to build log4rs config, {}", @@ -683,34 +757,30 @@ pub fn init_callback_logger(config: CallbackLoggingConfig) -> Result<(), Error> )) })?; - let _ = log4rs::init_config(log4rs_config).map_err(|e| { - Error::Logging(format!( - "init_callback_logger, unable to init log4rs, {}", - e - )) - })?; - - CONSOLE_OUTPUT_ENABLED.store(false, Ordering::Relaxed); - *logger_buffer = Some(LogBuffer { buffer: VecDeque::with_capacity(config.log_buffer_size), log_buffer_size: config.log_buffer_size, // current id last_id: 0, }); + if let Err(e) = init_config_with_err_handler( + log4rs_config, + Box::new(|err| println!("Logger error: {}", err)), + ) { + *logger_buffer = None; + return Err(Error::Logging(format!( + "init_callback_logger, failed to register log4rs, {}", + e + ))); + } + *initialized = true; + + CONSOLE_OUTPUT_ENABLED.store(false, Ordering::Relaxed); drop(logger_buffer); + drop(initialized); - // forward tracing events into the `log` crate (i.e. into log4rs) - // Then set up tracing with your custom layer - let subscriber = tracing_subscriber::registry().with(Log4rsLayer); - if let Err(e) = tracing::subscriber::set_global_default(subscriber) { - // Logs capturing is not fatal error, so we can go forward - error!( - "Unable to capture Arti/Tor logs. tracing set_global_default failed with error: {}", - e - ); - } + install_tracing_bridge(root_level); let cb_enabled = if config.callback.is_some() { "ON" @@ -829,6 +899,41 @@ fn send_panic_to_log() { #[cfg(test)] mod tests { use super::*; + use std::process::Command; + use std::sync::atomic::AtomicUsize; + + const LOGGER_TEST_MODE: &str = "MWC_UTIL_LOGGER_TEST_MODE"; + const LOGGER_TEST_PATH: &str = "MWC_UTIL_LOGGER_TEST_PATH"; + + fn file_logging_config(path: String) -> LoggingConfig { + LoggingConfig { + log_to_stdout: false, + log_to_file: true, + log_file_path: path, + log_file_append: false, + log_max_size: None, + ..LoggingConfig::default() + } + } + + fn run_logger_child(mode: &str) { + let tempdir = mwc_crates::tempfile::tempdir().unwrap(); + let log_path = tempdir.path().join("logger.log"); + let output = Command::new(std::env::current_exe().unwrap()) + .arg("logger_process_child") + .arg("--nocapture") + .env(LOGGER_TEST_MODE, mode) + .env(LOGGER_TEST_PATH, &log_path) + .output() + .unwrap(); + + assert!( + output.status.success(), + "logger child failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } fn log_entry(number: usize) -> LogEntry { LogEntry { @@ -837,6 +942,149 @@ mod tests { } } + #[test] + fn active_root_level_uses_only_active_destinations() { + let mut config = LoggingConfig { + log_to_stdout: true, + stdout_log_level: Level::Warn, + log_to_file: false, + file_log_level: Level::Debug, + ..LoggingConfig::default() + }; + assert_eq!(active_root_level(&config, false), LevelFilter::Warn); + + config.log_to_stdout = false; + assert_eq!(active_root_level(&config, false), LevelFilter::Off); + assert_eq!(active_root_level(&config, true), LevelFilter::Warn); + + config.log_to_file = true; + assert_eq!(active_root_level(&config, false), LevelFilter::Debug); + } + + #[test] + fn tracing_level_filter_skips_disabled_field_evaluation() { + let evaluations = AtomicUsize::new(0); + let subscriber = + tracing_subscriber::registry().with(Log4rsLayer.with_filter(TracingLevelFilter::WARN)); + + tracing::subscriber::with_default(subscriber, || { + tracing::debug!( + expensive = evaluations.fetch_add(1, Ordering::SeqCst), + "filtered event" + ); + }); + + assert_eq!(evaluations.load(Ordering::SeqCst), 0); + } + + #[test] + fn repeated_initialization_does_not_truncate_active_log() { + run_logger_child("repeat"); + } + + #[test] + fn concurrent_initialization_has_exactly_one_winner() { + run_logger_child("concurrent"); + } + + #[test] + fn existing_tracing_subscriber_is_degraded_success() { + run_logger_child("existing_tracing"); + } + + #[test] + fn reentrant_callback_is_suppressed_but_nested_logs_are_buffered() { + run_logger_child("reentrant_callback"); + } + + #[test] + fn logger_process_child() { + let Some(mode) = std::env::var_os(LOGGER_TEST_MODE) else { + return; + }; + let path = std::env::var_os(LOGGER_TEST_PATH).unwrap(); + let path_string = std::path::PathBuf::from(&path) + .to_string_lossy() + .into_owned(); + + match mode.to_string_lossy().as_ref() { + "repeat" => { + let config = file_logging_config(path_string); + init_logger(Some(&config), None).unwrap(); + mwc_crates::log::warn!("first initialization marker"); + assert!(init_logger(Some(&config), None).is_err()); + assert!(std::fs::read_to_string(path) + .unwrap() + .contains("first initialization marker")); + } + "concurrent" => { + let config = Arc::new(file_logging_config(path_string)); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let calls = (0..2) + .map(|_| { + let config = Arc::clone(&config); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + init_logger(Some(&config), None) + }) + }) + .collect::>(); + let results = calls + .into_iter() + .map(|call| call.join().unwrap()) + .collect::>(); + + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); + let error = results.into_iter().find_map(Result::err).unwrap(); + assert!(error.to_string().contains("logging is already initialized")); + } + "existing_tracing" => { + tracing::subscriber::set_global_default(tracing_subscriber::registry()).unwrap(); + let config = file_logging_config(path_string); + init_logger(Some(&config), None).unwrap(); + mwc_crates::log::warn!("log4rs remains active"); + assert!(std::fs::read_to_string(path) + .unwrap() + .contains("log4rs remains active")); + } + "reentrant_callback" => { + let callback_calls = Arc::new(AtomicUsize::new(0)); + let calls_from_callback = Arc::clone(&callback_calls); + let callback: Box = Box::new(move |_| { + calls_from_callback.fetch_add(1, Ordering::SeqCst); + mwc_crates::log::info!("nested callback marker"); + }); + + init_callback_logger(CallbackLoggingConfig { + log_level: Level::Info, + log_buffer_size: 16, + callback: Arc::new(Some(callback)), + }) + .unwrap(); + + assert_eq!(callback_calls.load(Ordering::SeqCst), 1); + + mwc_crates::log::info!("top-level callback marker"); + assert_eq!(callback_calls.load(Ordering::SeqCst), 2); + + let buffered = read_buffered_logs(None, usize::MAX).unwrap(); + assert_eq!( + buffered + .iter() + .filter(|entry| entry.log_entry.log.contains("nested callback marker")) + .count(), + 2 + ); + assert!(buffered + .iter() + .any(|entry| entry.log_entry.log.contains("top-level callback marker"))); + } + mode => panic!("unknown logger child mode: {}", mode), + } + } + #[test] fn skips_expected_tor_circuit_manager_noise() { assert!(should_skip_log( diff --git a/util/tests/file.rs b/util/tests/file.rs index 4f1d14efdf..c4455aa33d 100644 --- a/util/tests/file.rs +++ b/util/tests/file.rs @@ -234,6 +234,33 @@ fn open_owner_only_file_or_exposed_rejects_symlink_path() { fs::remove_dir_all(path.parent().unwrap()).unwrap(); } +#[cfg(any(unix, windows))] +#[test] +fn create_owner_only_file_rejects_symlink_without_truncating_target() { + let path = owner_file_test_path("create_symlink_path"); + let link = path.with_file_name("secret_link"); + fs::write(&path, b"original").unwrap(); + + match create_file_symlink(&path, &link) { + Ok(()) => {} + Err(err) => { + let symlink_privilege_missing = cfg!(windows) + && (err.kind() == io::ErrorKind::PermissionDenied + || err.raw_os_error() == Some(1314)); + if symlink_privilege_missing { + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + return; + } + panic!("failed to create symlink: {}", err); + } + } + + let err = file::create_owner_only_file(&link).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert_eq!(fs::read(&path).unwrap(), b"original"); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + #[cfg(unix)] #[test] fn get_owner_only_first_line_reads_owner_only_file() { From bcd7e214a6dafeef0a2a81159d6567af825df736 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Sun, 23 Aug 2026 10:08:14 -0700 Subject: [PATCH 3/7] Fix Windows Tor issues, reported problem to Tor Devs --- Cargo.lock | 53 ++++- Cargo.toml | 6 +- mwc_crates/Cargo.toml | 2 +- vendor/saturating-time/Cargo.toml | 45 ++++ vendor/saturating-time/LICENSE-APACHE | 201 +++++++++++++++++ vendor/saturating-time/LICENSE-MIT | 21 ++ vendor/saturating-time/MWC-PATCH.md | 19 ++ vendor/saturating-time/README.md | 101 +++++++++ vendor/saturating-time/src/internal.rs | 277 +++++++++++++++++++++++ vendor/saturating-time/src/lib.rs | 294 +++++++++++++++++++++++++ 10 files changed, 1015 insertions(+), 4 deletions(-) create mode 100644 vendor/saturating-time/Cargo.toml create mode 100644 vendor/saturating-time/LICENSE-APACHE create mode 100644 vendor/saturating-time/LICENSE-MIT create mode 100644 vendor/saturating-time/MWC-PATCH.md create mode 100644 vendor/saturating-time/README.md create mode 100644 vendor/saturating-time/src/internal.rs create mode 100644 vendor/saturating-time/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8f6deb971a..6a3b1a4983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,9 @@ checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", + "liblzma", + "zstd", + "zstd-safe", ] [[package]] @@ -3333,6 +3336,26 @@ dependencies = [ "libc", ] +[[package]] +name = "liblzma" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c36d08cad03a3fbe2c4e7bb3a9e84c57e4ee4135ed0b065cade3d98480c648" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -5430,8 +5453,6 @@ dependencies = [ [[package]] name = "saturating-time" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" [[package]] name = "schannel" @@ -8741,3 +8762,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 8b933577cb..cb7d702ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,11 @@ edition = "2024" [workspace] members = ["api", "chain", "config", "core", "keychain", "mwc_crates", "p2p", "servers", "store", "util", "pool", "node_workflow", "mwc_node_lib"] -exclude = ["etc/gen_gen"] +exclude = ["etc/gen_gen", "vendor/saturating-time"] + +[patch.crates-io] +# Arti 0.44+ exercises a Windows hang in saturating-time 0.4.0 while parsing a consensus. +saturating-time = { path = "vendor/saturating-time" } [workspace.dependencies] thiserror = "2" diff --git a/mwc_crates/Cargo.toml b/mwc_crates/Cargo.toml index b590f08f5d..717205767d 100644 --- a/mwc_crates/Cargo.toml +++ b/mwc_crates/Cargo.toml @@ -28,7 +28,7 @@ regex = "1.12" serial_test = "3.4" # Tor / Arti onion-service stack used by `p2p/src/tor/*` and the Tor playground. -arti_client = { package = "arti-client", version = "0.45", default-features = false, features = ["tokio", "rustls", "onion-service-service", "onion-service-client", "pt-client", "keymgr", "ephemeral-keystore", "experimental-api"] } +arti_client = { package = "arti-client", version = "0.45", default-features = false, features = ["tokio", "rustls", "compression", "onion-service-service", "onion-service-client", "pt-client", "keymgr", "ephemeral-keystore", "experimental-api"] } safelog = "0.9" tor_cell = { package = "tor-cell", version = "0.45.0" } tor_config = { package = "tor-config", version = "0.45.0" } diff --git a/vendor/saturating-time/Cargo.toml b/vendor/saturating-time/Cargo.toml new file mode 100644 index 0000000000..5ef0e19db0 --- /dev/null +++ b/vendor/saturating-time/Cargo.toml @@ -0,0 +1,45 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.85" +name = "saturating-time" +version = "0.4.0" +authors = ["Clara Engler"] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A trait for limits and saturations on types inside std::time" +readme = "README.md" +keywords = [ + "time", + "system-time", + "instant", + "saturating", +] +categories = [ + "date-and-time", + "os", + "rust-patterns", +] +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/cve/saturating-time" + +[features] +nightly = [] + +[lib] +name = "saturating_time" +path = "src/lib.rs" diff --git a/vendor/saturating-time/LICENSE-APACHE b/vendor/saturating-time/LICENSE-APACHE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/vendor/saturating-time/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/saturating-time/LICENSE-MIT b/vendor/saturating-time/LICENSE-MIT new file mode 100644 index 0000000000..d101fd821d --- /dev/null +++ b/vendor/saturating-time/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Clara Engler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/saturating-time/MWC-PATCH.md b/vendor/saturating-time/MWC-PATCH.md new file mode 100644 index 0000000000..7babd96707 --- /dev/null +++ b/vendor/saturating-time/MWC-PATCH.md @@ -0,0 +1,19 @@ +# MWC Windows patch + +This directory is based on `saturating-time` 0.4.0, commit +`1ba4c02c398e290487098c36b75995033327a06a`. + +Arti 0.44 and newer call `SystemTime::saturating_sub` while parsing a network +consensus. The upstream implementation eagerly calculates `SystemTime`'s +minimum value, whose limit search never terminates on Windows: Windows stores +`SystemTime` in 100 ns intervals, so a 1 ns subtraction succeeds without +changing the value. + +The local patch: + +- calculates saturation limits only when checked arithmetic actually fails; +- terminates the limit search when a platform rounds a step to no change; +- makes the crate's tests account for platform clock precision. + +Remove the workspace override after these changes are available in a released +upstream version and the Arti dependency range selects that release. diff --git a/vendor/saturating-time/README.md b/vendor/saturating-time/README.md new file mode 100644 index 0000000000..74d463a525 --- /dev/null +++ b/vendor/saturating-time/README.md @@ -0,0 +1,101 @@ +# `saturating-time` + +A trait for limits and saturations on types inside [`std::time`]. + +* **Easy**: `saturating-time` only adds a single trait, [`SaturatingTime`] that + is implemented for various types from the standard library. +* **Future-Proof**: In the case that [`SaturatingTime::saturating_add()`] and + [`SaturatingTime::saturating_sub()`] become a part of the standard library, + developers would only have to remove the `use saturating_time::SaturatingTime` + line from their code.[^1] [^2]. +* **Portable**: The algorithm for determining the limits is portable across + operating systems: enjoy this crate from Windows, Darwin, and Linux, across + the BSD systems, up to exotic ones such as Hermit OS and Redox. +* **Secure**: This crate does not make any use of `unsafe` Rust code. + +## Overview + +`saturating-time` is a very minimal crate that only exposes a minimal trait: +[`SaturatingTime`]. + +The trait itself offers the following methods: +* [`SaturatingTime::max_value()`] – Returns the maximum value for this type. +* [`SaturatingTime::min_value()`] – Returns the minimum value for this type. +* [`SaturatingTime::saturating_add()`] – Saturating addition for this type. +* [`SaturatingTime::saturating_sub()`] – Saturating subtraction for this type. +* [`SaturatingTime::saturating_duration_since()`] - Saturating time deltas for this type. + +This trait is sealed, meaning applications may not implement it themselves. +However, this crate implements this trait for two structures: +* [`std::time::Instant`] +* [`std::time::SystemTime`] + +## Example + +Add the following to your `Cargo.toml`: +```toml +[dependencies] +saturating-time = "0.4.0" +``` + +If you use Rust nightly, you may want to do: +```toml +[dependencies] +saturating-time = { version = "0.4.0", features = ["nightly"] } +``` + +Now, you can use `saturating-time` in your code: +```rust +use std::time::{Duration, SystemTime}; +use saturating_time::SaturatingTime; + +// Get the maximum and minimum. +let max = SystemTime::max_value(); +let min = SystemTime::min_value(); + +assert_eq!(max.saturating_add(Duration::new(1, 0)), max); +assert_eq!(min.saturating_sub(Duration::new(1, 0)), min); +assert!(max.saturating_duration_since(SystemTime::UNIX_EPOCH) >= Duration::ZERO); +``` + +## Standardization Efforts + +The eventual goal is to get this functionality into the Rust standard library. + +### `SystemTime` + +#### `SystemTime::MIN` and `SystemTime::MAX` + +In December 2025, `SystemTime::MIN` and `SystemTime::MAX` got merged +into nightly.[^3] + +This feature is guarded behind `time_systemtime_limits` and a tracking +issue regarding the stabilization of it exists.[^4] + +#### `SystemTime::saturating_add()`, et, al. + +In January 2025, `SystemTime::saturating_add()`, `SystemTime::saturating_sub()`, +and `SystemTime::saturating_duration_since()` got merged into nightly.[^5] + +This feature is guarded behind `time_saturating_systemtime` and a tracking +issue regarding the stabilization of it exists.[^6] + +### `Instant` + +None yet. + +[^1]: This is an effort the maintainers are actively working upon. +[^2]: Assuming the name, signature, and behavior does not change. + Unfortunately, we likely have to change the signature for the parameters + because we currently use `self` whereas the standard library uses `&self`. + It should not be a big problem though, because both `Instant` and + `SystemTime` implement `Copy`. +[^3]: +[^4]: +[^5]: +[^6]: + +## License and Copyright + +This crate is licensed under `MIT OR Apache-2.0`. +See the respective `LICENSE-*` files in the repository for more information. diff --git a/vendor/saturating-time/src/internal.rs b/vendor/saturating-time/src/internal.rs new file mode 100644 index 0000000000..824fe3635b --- /dev/null +++ b/vendor/saturating-time/src/internal.rs @@ -0,0 +1,277 @@ +//! Internal parts used for sealing. +//! +//! This module primarily consists of the internal [`SaturatingTime`] trait, an +//! unstable abstraction used internally to implement the main logic behind +//! this. +//! +//! Normal users should not be using this. + +use std::{ + cmp, + sync::LazyLock, + time::{Duration, Instant, SystemTime}, +}; + +/// The maximum value of [`SystemTime`] for this platform. +static MAX_SYSTEM_TIME: LazyLock = LazyLock::new(find_max); + +/// The minimum value of [`SystemTime`] for this platform. +static MIN_SYSTEM_TIME: LazyLock = LazyLock::new(find_min); + +/// The maximum value of [`Instant`] for this platform. +static MAX_INSTANT: LazyLock = LazyLock::new(find_max); + +/// The minimum value of [`Instant`] for this platform. +static MIN_INSTANT: LazyLock = LazyLock::new(find_min); + +/// An internal trait implementing the actual magic behind this. +pub trait SaturatingTime: Sized + Copy + PartialEq { + /// Anchor method to obtain an instance of this type. + fn anchor() -> Self; + + /// Returns the maximum value of this type. + fn max_value() -> Self; + + /// Returns the minimum value of this type. + fn min_value() -> Self; + + /// Performs a checked addition on this type. + fn checked_add(&self, duration: Duration) -> Option; + + /// Performs a checked subtraction on this type. + fn checked_sub(&self, duration: Duration) -> Option; + + /// Performs a checked time delta on this type. + fn checked_duration_since(&self, earlier: Self) -> Option; +} + +impl SaturatingTime for SystemTime { + fn anchor() -> Self { + Self::UNIX_EPOCH + } + + fn max_value() -> Self { + *MAX_SYSTEM_TIME + } + + fn min_value() -> Self { + *MIN_SYSTEM_TIME + } + + fn checked_add(&self, duration: Duration) -> Option { + Self::checked_add(self, duration) + } + + fn checked_sub(&self, duration: Duration) -> Option { + Self::checked_sub(self, duration) + } + + fn checked_duration_since(&self, earlier: Self) -> Option { + Self::duration_since(self, earlier).ok() + } +} + +impl SaturatingTime for Instant { + fn anchor() -> Self { + Self::now() + } + + fn max_value() -> Self { + *MAX_INSTANT + } + + fn min_value() -> Self { + *MIN_INSTANT + } + + fn checked_add(&self, duration: Duration) -> Option { + Self::checked_add(self, duration) + } + + fn checked_sub(&self, duration: Duration) -> Option { + Self::checked_sub(self, duration) + } + + /// DO NOT USE! + /// + /// Instead, override the top-level provided implementation with the already + /// existing [`Instant::saturating_duration_since()`]. + fn checked_duration_since(&self, _earlier: Self) -> Option { + unreachable!() + } +} + +/// Finds the value for [`SaturatingTime::max_value()`]. +fn find_max() -> T { + find_limit(T::checked_add) +} + +/// Finds the value for [`SaturatingTime::min_value()`]. +fn find_min() -> T { + find_limit(T::checked_sub) +} + +/// Internal algorithm of [`find_max()`] and [`find_min()`]. +/// +/// It works by performing `f` with a very large [`Duration`] onto +/// [`SaturatingTime::anchor()`] until this call returns [`None`], in which case +/// this [`Duration`] gets halved. This process is repeated until `f` returns +/// [`None`] and the [`Duration`] has reached 1ns, or the platform rounds the +/// operation to no change because the step is below its clock precision. +/// +/// # Algorithm +/// +/// 1. Set `step` to `INITIAL_STEP` and `res` to [`SaturatingTime::anchor()`]. +/// 2. Call `f(&res, step)`. +/// 1. If [`Some`] returns a different value, set `res` to it and continue. +/// 2. If [`Some`] returns `res`, return `res`. +/// 3. If [`None`] and `step == 1ns`, return `res`. +/// 4. Else, set `step` to `MAX{1ns, step / 2}` and continue. +fn find_limit(f: F) -> T +where + T: SaturatingTime, + F: Fn(&T, Duration) -> Option, +{ + const INITIAL_STEP: Duration = Duration::new(1_000_000_000_000_000_000, 0); + const ONE_NS: Duration = Duration::new(0, 1); + + // (1) Set step to INITIAL_STEP and res to T::anchor(). + let mut step = INITIAL_STEP; + let mut res = T::anchor(); + + loop { + // (2) Call f(). + let next = f(&res, step); + match next { + Some(st) => { + // Windows SystemTime uses 100ns intervals. A smaller duration + // succeeds without changing the value, so it marks the limit. + if st == res { + return res; + } + // (2.1) If Some, set res to the returned value and continue. + res = st + } + None => { + if step == ONE_NS { + // (2.2) If None and step == 1ns, return res. + return res; + } else { + // (2.3) Else, set step to MAX{1ns, step / 2}. + step = cmp::max(ONE_NS, step / 2); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fmt::Debug, + ops::{Add, Sub}, + }; + + use super::*; + + fn minimum_effective_step() -> Duration { + let anchor = T::anchor(); + let mut nanos = 1; + loop { + let step = Duration::from_nanos(nanos); + if anchor.checked_add(step) != Some(anchor) { + return step; + } + nanos *= 10; + } + } + + /// Checks whether the minimum and maximum values are correct. + fn min_max() + where + T: SaturatingTime + + PartialEq + + Debug + + Add + + Sub, + { + let step = minimum_effective_step::(); + assert_eq!( + T::max_value().checked_add(Duration::ZERO), + Some(T::max_value()) + ); + assert_eq!(T::max_value().checked_add(step), None); + assert_eq!( + T::max_value().checked_sub(Duration::ZERO), + Some(T::max_value()) + ); + assert_eq!( + T::max_value().checked_sub(step), + Some(T::max_value() - step) + ); + + assert_eq!( + T::min_value().checked_sub(Duration::ZERO), + Some(T::min_value()) + ); + assert_eq!(T::min_value().checked_sub(step), None); + assert_eq!( + T::min_value().checked_add(Duration::ZERO), + Some(T::min_value()) + ); + assert_eq!( + T::min_value().checked_add(step), + Some(T::min_value() + step) + ); + } + + /// Verifies [`SystemTime::min_value()`] and [`SystemTime::max_value()`] are + /// correct. + #[test] + fn system_time_min_max() { + min_max::(); + } + + /// Verifies [`Instant::min_value()`] and [`Instant::max_value()`] are + /// correct. + #[test] + fn instant_min_max() { + min_max::(); + } + + /// Verifies [`SystemTime::min_value()`] and [`SystemTime::max_value()`] are + /// correct on Unix systems. + #[cfg(target_family = "unix")] + #[test] + fn system_time_min_max_unix() { + assert_eq!( + SystemTime::max_value(), + SystemTime::UNIX_EPOCH + Duration::new(i64::MAX as u64, 999_999_999) + ); + assert_eq!( + SystemTime::min_value(), + SystemTime::UNIX_EPOCH - Duration::new(i64::MAX as u64 + 1, 0) + ); + } + + /// Verifies that [`Instant::min_value()`] and [`Instant::max_value()`] are + /// correct on Unix systems. + #[test] + #[cfg(target_family = "unix")] + fn instant_min_max_unix() { + // Using format is not nice but I cannot see a better way for now. + assert_eq!( + format!("{:?}", Instant::max_value()), + format!( + "Instant {{ tv_sec: {}, tv_nsec: {} }}", + i64::MAX, + 999_999_999 + ) + ); + + assert_eq!( + format!("{:?}", Instant::min_value()), + format!("Instant {{ tv_sec: {}, tv_nsec: {} }}", i64::MIN, 0) + ); + } +} diff --git a/vendor/saturating-time/src/lib.rs b/vendor/saturating-time/src/lib.rs new file mode 100644 index 0000000000..a68787a3cc --- /dev/null +++ b/vendor/saturating-time/src/lib.rs @@ -0,0 +1,294 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![cfg_attr( + feature = "nightly", + feature(time_systemtime_limits, time_saturating_systemtime) +)] + +use std::time::{Duration, Instant, SystemTime}; + +mod internal; + +/// The core trait of this crait, [`SaturatingTime`]. +/// +/// This trait provides methods for performing saturating arithmetic on those +/// types in [`std::time`] that not already come with such a functionality, +/// such as [`SystemTime`] or [`Instant`]. +/// +/// The trait itself is not implementable from the outside, because it is sealed +/// by an internal trait. +/// +/// See the methods or the top-level documentation for concrete code examples. +pub trait SaturatingTime: internal::SaturatingTime { + /// Returns the maximum value for this type on the current platform. + /// + /// This limit is highly platform specific. It differs heavily between + /// Unix, Windows, and other operating systems. + /// + /// The limit itself is calculated dynamically during runtime with a correct + /// algorithm. Afterwards, it gets stored in a lazy static value, meaning + /// that only the first call to it will be slightly more expensive, whereas + /// all latter calls will result in an immediate return of the value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let max = SystemTime::max_value(); + /// + /// // Adding zero to the maximum value will change nothing. + /// assert!(max.checked_add(Duration::ZERO).is_some()); + /// + /// // Adding 1s to the maximum value will fail. + /// assert!(max.checked_add(Duration::new(1, 0)).is_none()); + /// + /// // Subtracting 1s from the maximum value will work of course. + /// assert!(max.checked_sub(Duration::new(1, 0)).is_some()); + /// ``` + fn max_value() -> Self { + internal::SaturatingTime::max_value() + } + + /// Returns the minimum value for this type on the current platform. + /// + /// This limit is highly platform specific. It differs heavily between + /// Unix, Windows, and other operating systems. + /// + /// The limit itself is calculated dynamically during runtime with a correct + /// algorithm. Afterwards, it gets stored in a lazy static value, meaning + /// that only the first call to it will be slightly more expensive, whereas + /// all latter calls will result in an immediate return of the value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let min = SystemTime::min_value(); + /// + /// // Subtracting a zero from the minimum value will change nothing. + /// assert!(min.checked_sub(Duration::ZERO).is_some()); + /// + /// // Subtracting 1s from the minimum value will fail. + /// assert!(min.checked_sub(Duration::new(1, 0)).is_none()); + /// + /// // Adding 1s to the minimum value will work of course. + /// assert!(min.checked_add(Duration::new(1, 0)).is_some()); + /// ``` + fn min_value() -> Self { + internal::SaturatingTime::min_value() + } + + /// Performs a saturating addition of a [`Duration`]. + /// + /// The resulting value will saturate to [`SaturatingTime::max_value()`] in + /// the case the addition would have caused an overflow of value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let max = SystemTime::max_value(); + /// + /// // Adding zero will change nothing. + /// assert_eq!(max.saturating_add(Duration::ZERO), max); + /// + /// // Adding 1s would overflow so we saturate to the maximum. + /// assert_eq!(max.saturating_add(Duration::new(1, 0)), max); + /// ``` + fn saturating_add(self, duration: Duration) -> Self { + self.checked_add(duration) + .unwrap_or_else(SaturatingTime::max_value) + } + + /// Performs a saturating subtraction of a [`Duration`]. + /// + /// The resulting value will saturate to [`SaturatingTime::min_value()`] in + /// the case the subtraction would have caused an overflow of value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let min = SystemTime::min_value(); + /// + /// // Subtracting zero will change nothing. + /// assert_eq!(min.saturating_sub(Duration::ZERO), min); + /// + /// // Subtracting 1s would overflow so we saturate to the minimum. + /// assert_eq!(min.saturating_sub(Duration::new(1, 0)), min); + /// ``` + fn saturating_sub(self, duration: Duration) -> Self { + self.checked_sub(duration) + .unwrap_or_else(SaturatingTime::min_value) + } + + /// Performs a saturating time difference calculation between two points. + /// + /// The resulting value will saturate to [`Duration::ZERO`] in the case that + /// the `earlier` point in time is actually not earlier, thereby resulting + /// in a negative difference. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let epoch = SystemTime::UNIX_EPOCH; + /// let now = SystemTime::now(); + /// let min = SystemTime::min_value(); + /// + /// assert!(now.saturating_duration_since(epoch).as_secs() > 0); + /// assert!(epoch.saturating_duration_since(epoch) == Duration::ZERO); + /// assert!(min.saturating_duration_since(epoch) == Duration::ZERO); + /// ``` + fn saturating_duration_since(&self, earlier: Self) -> Duration { + self.checked_duration_since(earlier) + .unwrap_or(Duration::ZERO) + } +} + +// Use nightly implementation if compiled with the nightly feature. +#[cfg(feature = "nightly")] +impl SaturatingTime for SystemTime { + fn max_value() -> Self { + Self::MAX + } + + fn min_value() -> Self { + Self::MIN + } + + fn saturating_add(self, duration: Duration) -> Self { + Self::saturating_add(&self, duration) + } + + fn saturating_sub(self, duration: Duration) -> Self { + Self::saturating_sub(&self, duration) + } + + fn saturating_duration_since(&self, earlier: Self) -> Duration { + Self::saturating_duration_since(&self, earlier) + } +} + +// Otherwise, use the default one. +#[cfg(not(feature = "nightly"))] +impl SaturatingTime for SystemTime {} + +impl SaturatingTime for Instant { + // Override to use the provided implementation from the standard library. + fn saturating_duration_since(&self, earlier: Self) -> Duration { + Self::saturating_duration_since(self, earlier) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::internal; + use std::{ + fmt::Debug, + ops::{Add, Sub}, + time::{Instant, SystemTime}, + }; + + fn minimum_effective_step() -> Duration { + let anchor = ::anchor(); + let mut nanos = 1; + loop { + let step = Duration::from_nanos(nanos); + if ::checked_add(&anchor, step) != Some(anchor) { + return step; + } + nanos *= 10; + } + } + + /// Verifies the maximum and minimum values of [`SaturatingTime`] equal + /// their pedant in [`internal::SaturatingTime`]. + fn min_max() { + assert_eq!( + ::max_value(), + ::max_value() + ); + assert_eq!( + ::min_value(), + ::min_value() + ); + } + + /// Verifies the saturating arithmetic for [`SaturatingTime`]. + fn saturating_add_sub< + T: SaturatingTime + PartialEq + Debug + Add + Sub, + >() { + let step = minimum_effective_step::(); + let max = ::max_value(); + assert_eq!(max.saturating_add(Duration::ZERO), max); + assert_eq!(max.saturating_add(step), max); + assert_eq!(max.saturating_sub(Duration::ZERO), max); + assert_eq!(max.saturating_sub(step), max - step); + + let min = ::min_value(); + assert_eq!(min.saturating_sub(Duration::ZERO), min); + assert_eq!(min.saturating_sub(step), min); + assert_eq!(min.saturating_add(Duration::ZERO), min); + assert_eq!(min.saturating_add(step), min + step); + } + + /// Verifies whether the saturating logic behind [`Duration`] types work. + fn saturating_duration() { + // The duration from the same anchor should always be zero. + let anchor = T::anchor(); + assert_eq!(anchor.saturating_duration_since(anchor), Duration::ZERO); + + // Try with a later anchor. + let later_anchor = anchor.checked_add(Duration::from_secs(1)).unwrap(); + assert!(later_anchor.saturating_duration_since(anchor) == Duration::from_secs(1)); + assert_eq!( + anchor.saturating_duration_since(later_anchor), + Duration::ZERO + ); + + // Try with min and max. + let max = ::max_value(); + let min = ::min_value(); + + assert_ne!(max.saturating_duration_since(min), Duration::ZERO); + assert_eq!(min.saturating_duration_since(max), Duration::ZERO); + } + + /// Calls [`min_max()`] using [`SystemTime`]. + #[test] + fn system_time_min_max() { + min_max::(); + } + + /// Calls [`min_max()`] using [`Instant`]. + #[test] + fn instant_min_max() { + min_max::(); + } + + /// Calls [`saturating_add_sub()`] and [`saturating_duration()`] using [`SystemTime`]. + #[test] + fn system_time_saturating() { + saturating_add_sub::(); + saturating_duration::(); + } + + /// Calls [`saturating_add_sub()`] and [`saturating_duration()`] using [`Instant`]. + #[test] + fn instant_saturating() { + saturating_add_sub::(); + saturating_duration::(); + } +} From 4b256e16534e62c539b705e754652414b5fc0d10 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Sun, 23 Aug 2026 11:28:12 -0700 Subject: [PATCH 4/7] Issues with PIBD Sync under the Windows --- api/tests/rest.rs | 5 +- chain/src/chain.rs | 328 +++++++++++++++++++++++++-- chain/src/tests/mine_simple_chain.rs | 31 ++- chain/src/txhashset/txhashset.rs | 19 +- config/src/config.rs | 1 + 5 files changed, 354 insertions(+), 30 deletions(-) diff --git a/api/tests/rest.rs b/api/tests/rest.rs index 0d9106e9e9..ea87b28627 100644 --- a/api/tests/rest.rs +++ b/api/tests/rest.rs @@ -19,7 +19,9 @@ use mwc_crates::serde_json; use mwc_api::client::HttpClient; use mwc_api::*; use mwc_core::global; -use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::net::SocketAddr; +#[cfg(not(windows))] +use std::net::TcpListener as StdTcpListener; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -41,6 +43,7 @@ pub struct CounterMiddleware { counter: AtomicUsize, } +#[allow(dead_code)] impl CounterMiddleware { fn new() -> CounterMiddleware { CounterMiddleware { diff --git a/chain/src/chain.rs b/chain/src/chain.rs index e1f74cee3a..5a7b676f82 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -402,6 +402,7 @@ pub struct Chain { genesis: Block, cache_header_difficulty: Arc>, pibd_params: Arc, + stop_state: Option>, requires_init_recovery: Arc, pibd_state_generation: Arc, #[cfg(test)] @@ -872,6 +873,7 @@ impl Chain { &mut txhashset, &secp, self.pow_verifier, + self.stop_state.clone(), ) { Ok(()) => { self.requires_init_recovery.store(false, Ordering::SeqCst); @@ -1151,6 +1153,8 @@ impl Chain { // blocks here. Chain::init_spent_commitment_index(&store, stop_state.clone())?; + mark_interrupted_pibd_for_recovery(&genesis, &store, &txhashset)?; + recover_pending_chain_operation( &genesis, &store, @@ -1158,6 +1162,7 @@ impl Chain { &mut txhashset, secp, pow_verifier, + stop_state.clone(), )?; setup_head( @@ -1167,6 +1172,7 @@ impl Chain { &mut txhashset, &secp, pow_verifier, + stop_state.clone(), skip_start_blockchain_validation, None, )?; @@ -1214,6 +1220,7 @@ impl Chain { genesis: genesis, cache_header_difficulty: Arc::new(RwLock::new(DifficultyCache::new())), pibd_params, + stop_state, requires_init_recovery: Arc::new(AtomicBool::new(false)), pibd_state_generation: Arc::new(AtomicU64::new(0)), #[cfg(test)] @@ -1269,6 +1276,7 @@ impl Chain { &mut txhashset, &secp, self.pow_verifier, + self.stop_state.clone(), ); match res { @@ -1351,6 +1359,8 @@ impl Chain { &mut txhashset, &secp, self.pow_verifier, + self.stop_state.clone(), + true, ); match res { Ok(()) => { @@ -5724,6 +5734,57 @@ impl Chain { } } +fn mark_interrupted_pibd_for_recovery( + genesis: &Block, + store: &store::ChainStore, + txhashset: &TxHashSet, +) -> Result<(), Error> { + if store.pending_chain_operation()?.is_some() { + return Ok(()); + } + + let batch = store.batch_read()?; + let stored_head = match batch.head() { + Ok(head) => head, + Err(NotFoundErr(_)) => return Ok(()), + Err(e) => return Err(Error::StoreErr(e, "interrupted PIBD load HEAD".into())), + }; + let (_, head) = canonical_tip_header("HEAD", &stored_head, &batch)?; + let genesis_head = Tip::try_from_header(&genesis.header)?; + if head != genesis_head { + return Ok(()); + } + + let expected_sizes = ( + genesis.header.output_mmr_size, + genesis.header.output_mmr_size, + genesis.header.kernel_mmr_size, + ); + let actual_sizes = ( + txhashset.output_mmr_size(), + txhashset.rangeproof_mmr_size(), + txhashset.kernel_mmr_size(), + ); + if actual_sizes == expected_sizes { + return Ok(()); + } + drop(batch); + + warn!( + "Detected interrupted PIBD body state at genesis: output/rangeproof/kernel PMMR sizes are {}/{}/{}, expected {}/{}/{}. Scheduling a full PIBD body reset while preserving HEADER_HEAD", + actual_sizes.0, + actual_sizes.1, + actual_sizes.2, + expected_sizes.0, + expected_sizes.1, + expected_sizes.2, + ); + if !store.set_pending_chain_operation_if_absent(&PendingChainOperation::PibdReset)? { + warn!("A pending chain operation was installed while scheduling interrupted PIBD recovery"); + } + Ok(()) +} + fn reset_pibd_chain_state( genesis: &Block, store: &store::ChainStore, @@ -5731,8 +5792,18 @@ fn reset_pibd_chain_state( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, ) -> Result<(), Error> { - reset_chain_head_to_genesis_state(genesis, store, header_pmmr, txhashset, secp, pow_verifier)?; + reset_chain_head_to_genesis_state( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + stop_state, + false, + )?; Ok(()) } @@ -5780,6 +5851,7 @@ fn recover_pending_chain_operation( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, ) -> Result<(), Error> { let op = match store.pending_chain_operation()? { None => return Ok(()), @@ -5794,6 +5866,7 @@ fn recover_pending_chain_operation( txhashset, secp, pow_verifier, + stop_state, &op, ) } @@ -5805,12 +5878,19 @@ fn recover_marked_chain_operation( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, op: &PendingChainOperation, ) -> Result<(), Error> { let res = match op { - PendingChainOperation::PibdReset => { - reset_pibd_chain_state(genesis, store, header_pmmr, txhashset, secp, pow_verifier) - } + PendingChainOperation::PibdReset => reset_pibd_chain_state( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + stop_state.clone(), + ), PendingChainOperation::ResetToGenesis => reset_chain_head_to_genesis_state( genesis, store, @@ -5818,6 +5898,8 @@ fn recover_marked_chain_operation( txhashset, secp, pow_verifier, + stop_state.clone(), + true, ), PendingChainOperation::Compact { original_body_head, @@ -5830,6 +5912,7 @@ fn recover_marked_chain_operation( txhashset, secp, pow_verifier, + stop_state.clone(), original_body_head, original_header_head, target_body_tail, @@ -5845,13 +5928,20 @@ fn recover_marked_chain_operation( txhashset, secp, pow_verifier, + stop_state.clone(), original_body_head, original_header_head, ), PendingChainOperation::ResetChainHead { .. } - | PendingChainOperation::ReconcileHeads { .. } => { - reconcile_pmmrs_to_db_heads(genesis, store, header_pmmr, txhashset, secp, pow_verifier) - } + | PendingChainOperation::ReconcileHeads { .. } => reconcile_pmmrs_to_db_heads( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + stop_state, + ), }; match res { @@ -5887,6 +5977,7 @@ fn recover_legacy_compact_chain_operation( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, original_body_head: &Tip, original_header_head: &Tip, ) -> Result<(), Error> { @@ -5928,6 +6019,7 @@ fn recover_legacy_compact_chain_operation( txhashset, secp, pow_verifier, + stop_state, &canonical_body_head, &canonical_header_head, &target_body_tail, @@ -5948,6 +6040,7 @@ fn recover_compact_chain_operation( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, original_body_head: &Tip, original_header_head: &Tip, target_body_tail: &Tip, @@ -6021,7 +6114,15 @@ fn recover_compact_chain_operation( // Compaction does not intentionally change either head, but its marker also // protects against unrelated speculative PMMR writes. Repair those first. - reconcile_pmmrs_to_db_heads(genesis, store, header_pmmr, txhashset, secp, pow_verifier)?; + reconcile_pmmrs_to_db_heads( + genesis, + store, + header_pmmr, + txhashset, + secp, + pow_verifier, + stop_state, + )?; let batch = store.batch_read()?; let stored_body_head = batch.head()?; @@ -6221,6 +6322,7 @@ fn reconcile_pmmrs_to_db_heads( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, ) -> Result<(), Error> { // Reconciliation repairs PMMRs to the durable DB-selected heads. It // intentionally does not apply INVALID_BLOCK_HASHES to ancestry already @@ -6260,6 +6362,7 @@ fn reconcile_pmmrs_to_db_heads( header_pmmr, &header_header, pow_verifier, + stop_state.clone(), )?; reconcile_body_pmmr_to_header( &genesis.header, @@ -6269,6 +6372,7 @@ fn reconcile_pmmrs_to_db_heads( secp, &body_header, pow_verifier, + stop_state, )?; // Publish both repaired caches only after both PMMRs authenticate. Keeping @@ -6289,9 +6393,17 @@ fn reconcile_body_pmmr_to_header( secp: &Secp256k1, header: &BlockHeader, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, ) -> Result<(), Error> { let context_id = store.get_context_id(); let authenticate_header = |candidate: &BlockHeader| { + if stop_state + .as_ref() + .map(|state| state.is_stopped()) + .unwrap_or(false) + { + return Err(Error::Stopped); + } authenticate_persisted_header_for_recovery(context_id, genesis, candidate, pow_verifier) }; let mut batch = store.batch_write()?; @@ -6311,7 +6423,7 @@ fn reconcile_body_pmmr_to_header( // output-position index before recovery can clear its marker. ext.extension.validate_output_pos_index(batch, header)?; ext.extension - .validate(genesis, true, None, header, None, secp)?; + .validate(genesis, true, None, header, stop_state.clone(), secp)?; Ok(()) })?; batch.commit()?; @@ -6324,9 +6436,17 @@ fn reconcile_header_pmmr_to_header( header_pmmr: &mut PMMRHandle, header: &BlockHeader, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, ) -> Result<(), Error> { let context_id = store.get_context_id(); let authenticate_header = |candidate: &BlockHeader| { + if stop_state + .as_ref() + .map(|state| state.is_stopped()) + .unwrap_or(false) + { + return Err(Error::Stopped); + } authenticate_persisted_header_for_recovery(context_id, genesis, candidate, pow_verifier) }; let mut batch = store.batch_write()?; @@ -6343,7 +6463,7 @@ fn reconcile_header_pmmr_to_header( batch, &authenticate_header, )?; - ext.validate_persisted_ancestry(header, batch, pow_verifier) + ext.validate_persisted_ancestry(header, batch, pow_verifier, stop_state.as_deref()) }, )?; batch.commit()?; @@ -6474,9 +6594,14 @@ fn reset_chain_head_to_genesis_state( txhashset: &mut TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, + validate_retained_headers: bool, ) -> Result<(), Error> { validate_genesis_context_id(genesis, store.get_context_id())?; let head = Tip::try_from_header(&genesis.header)?; + // Header sync authenticates every header before storing it. A PIBD body reset + // intentionally preserves that chain, so it must not turn into another full + // persisted-ancestry validation while holding both PMMR write locks. setup_head( genesis, store, @@ -6484,20 +6609,30 @@ fn reset_chain_head_to_genesis_state( txhashset, secp, pow_verifier, - false, + stop_state.clone(), + !validate_retained_headers, Some(head), )?; - // Resetting body state to genesis intentionally preserves the downloaded - // header chain. init_head() and rewind() establish only its logical head and - // size; they do not authenticate HeaderEntry data, leaf hashes, parent hashes, - // or persisted prev_hash ancestry. Reconcile the retained PMMR so callers may - // clear a recovery marker only after validate_persisted_ancestry succeeds. + if !validate_retained_headers { + return Ok(()); + } + + // Preserve the original reset ordering and validation boundaries: setup the + // genesis body state first, then reconcile the retained header PMMR to its + // durable HEADER_HEAD. let batch = store.batch_read()?; let stored_header_head = batch.header_head()?; let (header, _) = canonical_tip_header("HEADER_HEAD", &stored_header_head, &batch)?; drop(batch); - reconcile_header_pmmr_to_header(&genesis.header, store, header_pmmr, &header, pow_verifier) + reconcile_header_pmmr_to_header( + &genesis.header, + store, + header_pmmr, + &header, + pow_verifier, + stop_state, + ) } fn validate_genesis_context_id(genesis: &Block, context_id: u32) -> Result<(), Error> { @@ -6806,6 +6941,7 @@ fn setup_head( txhashset: &mut txhashset::TxHashSet, secp: &Secp256k1, pow_verifier: fn(u32, &BlockHeader) -> Result<(), pow::Error>, + stop_state: Option>, skip_start_blockchain_validation: bool, body_head_override: Option, ) -> Result<(), Error> { @@ -6860,7 +6996,12 @@ fn setup_head( if skip_start_blockchain_validation { Ok(()) } else { - ext.validate_persisted_ancestry(&header, batch, pow_verifier) + ext.validate_persisted_ancestry( + &header, + batch, + pow_verifier, + stop_state.as_deref(), + ) } })?; if stored_head != head { @@ -6877,7 +7018,12 @@ fn setup_head( let head = Tip::try_from_header(&header)?; if !skip_start_blockchain_validation { txhashset::header_extending(header_pmmr, &mut batch, |ext, batch| { - ext.validate_persisted_ancestry(&header, batch, pow_verifier) + ext.validate_persisted_ancestry( + &header, + batch, + pow_verifier, + stop_state.as_deref(), + ) })?; } batch.save_header_head(&head)?; @@ -6914,13 +7060,13 @@ fn setup_head( Ok(()) })?; - txhashset.init_output_pos_index(&batch, None, None)?; - txhashset.init_recent_kernel_pos_index(&batch, None, None)?; + txhashset.init_output_pos_index(&batch, None, stop_state.clone())?; + txhashset.init_recent_kernel_pos_index(&batch, None, stop_state.clone())?; batch.commit()?; // Clear any full kernel_pos entries left above genesis and rebuild the // complete index before the reset operation reports success. - txhashset.init_kernel_pos_index_chunked(store, None, None)?; + txhashset.init_kernel_pos_index_chunked(store, None, stop_state.clone())?; return Ok(()); } batch.save_body_head(&head)?; @@ -6980,8 +7126,14 @@ fn setup_head( // Fast validation still verifies the MMRs, roots, sizes, leaf-set // pairing, and kernel sums; it skips only rangeproof and kernel // signature verification. - let (utxo_sum, kernel_sum) = - extension.validate(&genesis.header, true, None, &header, None, secp)?; + let (utxo_sum, kernel_sum) = extension.validate( + &genesis.header, + true, + None, + &header, + stop_state.clone(), + secp, + )?; // PMMR roots authenticate append history, but not the exact // membership of the prunable output/rangeproof leaf sets. For a // zero-step startup rewind, bind those leaf sets to the independently @@ -9031,6 +9183,85 @@ mod tests { let _ = fs::remove_dir_all(&chain_dir); } + #[test] + fn pibd_reset_skips_but_explicit_reset_validates_retained_headers() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/pibd_reset_skips_retained_header_validation_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let genesis = global::get_genesis_block(&secp, 0).unwrap(); + let chain = Chain::init( + &secp, + 0, + chain_dir.clone(), + Arc::new(crate::types::NoopAdapter {}), + genesis.clone(), + reject_non_genesis_pow, + false, + HashSet::new(), + None, + None, + false, + ) + .unwrap(); + + let header = recovery_test_header(1, genesis.hash(0).unwrap(), 1); + let header_tip = Tip::try_from_header(&header).unwrap(); + { + let mut header_pmmr = chain.header_pmmr.write(); + let mut batch = chain.store.batch_write().unwrap(); + txhashset::header_extending(&mut header_pmmr, &mut batch, |ext, batch| { + ext.apply_header(&header)?; + batch.save_block_header(&header)?; + batch.save_header_head(&header_tip)?; + Ok(()) + }) + .unwrap(); + batch.commit().unwrap(); + } + + // The configured verifier rejects this non-genesis header. Both the live + // PIBD reset and recovery from its durable marker must still preserve it. + chain.reset_pibd_chain().unwrap(); + assert_eq!(chain.header_head().unwrap(), header_tip); + + chain + .store + .set_pending_chain_operation(&PendingChainOperation::PibdReset) + .unwrap(); + chain.requires_init_recovery.store(true, Ordering::SeqCst); + chain.ensure_chain_robust().unwrap(); + assert_eq!(chain.header_head().unwrap(), header_tip); + assert!(chain.store.pending_chain_operation().unwrap().is_none()); + + let err = chain.reset_chain_head_to_genesis().unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidPersistedChainState(msg) + if msg.contains("forced non-genesis PoW failure") + ), + "unexpected explicit reset error: {:?}", + err + ); + assert_eq!( + chain + .store + .pending_chain_operation() + .unwrap() + .unwrap() + .kind(), + ChainOperationKind::ResetToGenesis + ); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn existing_pending_chain_operation_marks_recovery_required() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); @@ -9605,6 +9836,7 @@ mod tests { &mut header_pmmr, &header, accept_recovery_test_pow, + None, ) .unwrap(); assert_eq!(header_pmmr.size, 3); @@ -9656,6 +9888,7 @@ mod tests { &mut header_pmmr, &old_header, accept_recovery_test_pow, + None, ) .unwrap_err(); assert!(matches!( @@ -9710,6 +9943,7 @@ mod tests { &mut header_pmmr, &authoritative_header, accept_recovery_test_pow, + None, ) .unwrap_err(); assert!(matches!( @@ -9768,6 +10002,7 @@ mod tests { &mut header_pmmr, &altered, pow::verify_size, + None, ) .unwrap_err(); assert!(matches!( @@ -9825,6 +10060,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, ) .unwrap_err(); assert!(matches!( @@ -9923,6 +10159,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, ) .unwrap_err(); let details = match err { @@ -10032,6 +10269,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, ) .unwrap_err(); let details = match err { @@ -10087,6 +10325,7 @@ mod tests { &secp, &genesis.header, pow::verify_size, + None, ) .unwrap_err(); assert!(matches!( @@ -10145,6 +10384,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, &op, ) .unwrap_err(); @@ -10210,6 +10450,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, ) .unwrap(); } @@ -10968,6 +11209,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, false, None, ) @@ -10989,6 +11231,42 @@ mod tests { let _ = fs::remove_dir_all(&corrupt_header_dir); } + #[test] + fn setup_head_persisted_ancestry_validation_honors_stop_state() { + global::set_local_chain_type(global::ChainTypes::AutomatedTesting); + global::set_local_nrd_enabled(false); + let secp = Secp256k1::with_caps(ContextFlag::Commit).unwrap(); + let chain_dir = format!( + "target/setup_head_stopped_ancestry_validation_{}", + std::process::id() + ); + let _ = fs::remove_dir_all(&chain_dir); + let chain = init_automated_test_chain(&chain_dir, &secp); + let stop_state = Arc::new(StopState::new()); + stop_state.stop(); + + let err = { + let mut header_pmmr = chain.header_pmmr.write(); + let mut txhashset = chain.txhashset.write(); + setup_head( + &chain.genesis, + &chain.store, + &mut header_pmmr, + &mut txhashset, + &secp, + pow::verify_size, + Some(stop_state), + false, + None, + ) + .unwrap_err() + }; + assert!(matches!(err, Error::Stopped)); + + drop(chain); + let _ = fs::remove_dir_all(&chain_dir); + } + #[test] fn setup_head_requires_durable_head_full_block() { global::set_local_chain_type(global::ChainTypes::AutomatedTesting); @@ -11020,6 +11298,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, false, None, ) @@ -11157,6 +11436,7 @@ mod tests { &mut txhashset, &secp, pow::verify_size, + None, false, None, ) diff --git a/chain/src/tests/mine_simple_chain.rs b/chain/src/tests/mine_simple_chain.rs index 7a14a3f945..6da1e2dcad 100644 --- a/chain/src/tests/mine_simple_chain.rs +++ b/chain/src/tests/mine_simple_chain.rs @@ -38,7 +38,9 @@ use mwc_keychain::{ use mwc_util::StopState; use std::collections::HashSet; use std::convert::TryInto; +#[cfg(unix)] use std::fs; +#[cfg(unix)] use std::path::Path; use std::sync::Arc; @@ -596,8 +598,8 @@ fn init_output_pos_index_rebuilds_missing_genesis_output_at_height_zero() { } #[test] -fn reset_pibd_chain_keeps_genesis_output_visible_after_compaction() { - let chain_dir = test_chain_dir("pibd_reset_genesis_after_compaction"); +fn restart_during_pibd_resets_compacted_body_and_keeps_headers() { + let chain_dir = test_chain_dir("pibd_restart_genesis_after_compaction"); clean_output_dir(&chain_dir); global::set_local_chain_type(ChainTypes::AutomatedTesting); global::set_local_nrd_enabled(false); @@ -611,7 +613,7 @@ fn reset_pibd_chain_keeps_genesis_output_visible_after_compaction() { let genesis_commit = genesis.outputs()[0].commitment(); { - let chain = init_chain_accepting_test_pow(&secp, &chain_dir, genesis); + let chain = init_chain_accepting_test_pow(&secp, &chain_dir, genesis.clone()); let mut head = chain.head_header().unwrap(); let b = prepare_block_key_idx(&mut secp, &keychain, &head, &chain, 2, 2); @@ -697,7 +699,28 @@ fn reset_pibd_chain_keeps_genesis_output_visible_after_compaction() { batch.commit().unwrap(); } - chain.reset_pibd_chain().unwrap(); + let retained_header_head = chain.header_head().unwrap(); + let genesis_head = Tip::try_from_header(&genesis.header).unwrap(); + { + let store = chain.get_store_for_tests(); + let batch = store.batch_write().unwrap(); + batch.save_body_head(&genesis_head).unwrap(); + batch.save_body_tail(&genesis_head).unwrap(); + batch.commit().unwrap(); + } + drop(chain); + + // A completed PIBD reset leaves HEAD at genesis while subsequently applied + // segments can leave compacted body PMMR roots beyond it. Restart must + // discard that incomplete body state without discarding downloaded headers. + let chain = init_chain_accepting_test_pow(&secp, &chain_dir, genesis.clone()); + assert_eq!(chain.head().unwrap(), genesis_head); + assert_eq!(chain.header_head().unwrap(), retained_header_head); + assert!(chain + .get_store_for_tests() + .pending_chain_operation() + .unwrap() + .is_none()); let txhashset = chain.get_txhashset_for_test(); let txhashset = txhashset.read_recursive(); diff --git a/chain/src/txhashset/txhashset.rs b/chain/src/txhashset/txhashset.rs index 9ccc9634e6..ffad135bc8 100644 --- a/chain/src/txhashset/txhashset.rs +++ b/chain/src/txhashset/txhashset.rs @@ -2697,6 +2697,7 @@ impl<'a> HeaderExtension<'a> { header: &BlockHeader, batch: &Batch<'_>, pow_verifier: fn(u32, &BlockHeader) -> Result<(), mwc_core::pow::Error>, + stop_state: Option<&StopState>, ) -> Result<(), Error> { let started = Instant::now(); info!( @@ -2704,7 +2705,13 @@ impl<'a> HeaderExtension<'a> { header.height, self.size() ); - let result = self.validate_persisted_ancestry_inner(header, batch, pow_verifier, &started); + let result = self.validate_persisted_ancestry_inner( + header, + batch, + pow_verifier, + stop_state, + &started, + ); match &result { Ok(()) => info!( "validate_persisted_ancestry: finished successfully in {}s", @@ -2724,8 +2731,12 @@ impl<'a> HeaderExtension<'a> { header: &BlockHeader, batch: &Batch<'_>, pow_verifier: fn(u32, &BlockHeader) -> Result<(), mwc_core::pow::Error>, + stop_state: Option<&StopState>, started: &Instant, ) -> Result<(), Error> { + if stop_state.map(StopState::is_stopped).unwrap_or(false) { + return Err(Error::Stopped); + } let context_id = self.pmmr.get_context_id(); let expected_head = Tip::try_from_header(header)?; if self.head != expected_head { @@ -2762,6 +2773,9 @@ impl<'a> HeaderExtension<'a> { let mut expected_current_hash = expected_head.last_block_h; let mut last_progress_log = Instant::now(); loop { + if stop_state.map(StopState::is_stopped).unwrap_or(false) { + return Err(Error::Stopped); + } crate::pipe::validate_header_context_id(context_id, ¤t).map_err(|e| { Error::InvalidPersistedChainState(format!( "persisted header at height {} failed context validation: {}", @@ -2880,6 +2894,9 @@ impl<'a> HeaderExtension<'a> { let mut node_stack: Vec<(u64, Hash)> = Vec::new(); last_progress_log = Instant::now(); for pos0 in 0..expected_size { + if stop_state.map(StopState::is_stopped).unwrap_or(false) { + return Err(Error::Stopped); + } let height = pmmr::bintree_postorder_height(pos0); if height == 0 { let leaf_hash = leaf_hashes.next().ok_or_else(|| { diff --git a/config/src/config.rs b/config/src/config.rs index 54deacea1d..4c62a4c97a 100644 --- a/config/src/config.rs +++ b/config/src/config.rs @@ -725,6 +725,7 @@ fn api_secret_test_path(name: &str) -> PathBuf { dir } +#[allow(unused_variables)] #[cfg(test)] fn set_api_secret_owner_only(path: &Path) { #[cfg(unix)] From 150b491aa3bdea90b2966880bebaab7a5ea4c397 Mon Sep 17 00:00:00 2001 From: bay Date: Sun, 23 Aug 2026 23:23:40 -0700 Subject: [PATCH 5/7] Make secp static recursion tolearant --- Cargo.lock | 84 ++++++------ util/src/secp_static.rs | 280 ++++++++++++++++++++++++++++++---------- 2 files changed, 257 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a3b1a4983..03789d1ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -790,9 +790,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel 2.5.0", "async-task", @@ -877,9 +877,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -1182,9 +1182,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1631,7 +1631,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 3.0.3", + "syn 3.0.4", "unicode-ident", "void", ] @@ -1823,7 +1823,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1921,9 +1921,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -2385,7 +2385,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2569,9 +2569,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -2936,9 +2936,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3262,9 +3262,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -3316,9 +3316,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.7+1.9.6" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", @@ -3338,18 +3338,18 @@ dependencies = [ [[package]] name = "liblzma" -version = "0.4.5" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c36d08cad03a3fbe2c4e7bb3a9e84c57e4ee4135ed0b065cade3d98480c648" +checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1" dependencies = [ "liblzma-sys", ] [[package]] name = "liblzma-sys" -version = "0.4.4" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +checksum = "a0dad045e4b1b7b170be4b60b54b780cafb4490165461bac7d1cf7b703f61d5f" dependencies = [ "cc", "libc", @@ -3440,9 +3440,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", "value-bag", @@ -5006,7 +5006,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5343,9 +5343,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -5583,7 +5583,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5766,7 +5766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.2", "sponge-cursor", ] @@ -6112,9 +6112,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -6274,7 +6274,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -6378,7 +6378,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -7977,9 +7977,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -8704,9 +8704,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -8716,13 +8716,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] diff --git a/util/src/secp_static.rs b/util/src/secp_static.rs index c231c9e14a..1c6ba4fde3 100644 --- a/util/src/secp_static.rs +++ b/util/src/secp_static.rs @@ -13,24 +13,84 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Thread-local secp256k1 contexts to avoid repeated initialization overhead -//! without sharing a context across threads. +//! Thread-local secp256k1 context pools to avoid repeated initialization overhead +//! without sharing a context across threads. Each pool grows on demand when a +//! call recursively requests another context and retains that context for reuse. -use mwc_crates::log::debug; +use mwc_crates::log::warn; use mwc_crates::secp; use mwc_crates::secp::constants; use mwc_crates::secp::{ContextFlag, Secp256k1}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; use std::thread::LocalKey; -type CachedContext = RefCell>; +type CachedContext = Result; +type CachedContextSlot = Rc>; + +struct ContextPool { + caps: ContextFlag, + contexts: RefCell>, + active: Cell, +} + +impl ContextPool { + fn new(caps: ContextFlag) -> Self { + Self { + caps, + contexts: RefCell::new(Vec::new()), + active: Cell::new(0), + } + } + + /// Acquires the context for the current recursion depth, creating it if this + /// thread has not reached that depth before. + fn acquire(&self) -> (CachedContextSlot, ContextLease<'_>) { + let index = self.active.get(); + // Checked arithmetic is unnecessary here: `index` is the number of live + // recursive calls on this thread. The thread would exhaust its stack long + // before reaching `usize::MAX`; normally this pool contains only a few contexts. + let depth = index + 1; + let (context, grew) = { + let mut contexts = self.contexts.borrow_mut(); + let grew = index == contexts.len(); + if grew { + contexts.push(Rc::new(RefCell::new(create_context(self.caps)))); + } + (Rc::clone(&contexts[index]), grew) + }; + + self.active.set(depth); + let lease = ContextLease { + active: &self.active, + }; + if grew && index >= 4 { + warn!( + "Thread-local secp256k1 {:?} context pool at recursion depth {}", + self.caps, depth + ); + } + (context, lease) + } +} + +struct ContextLease<'a> { + active: &'a Cell, +} + +impl Drop for ContextLease<'_> { + fn drop(&mut self) { + let active = self.active.get(); + debug_assert!(active > 0); + self.active.set(active - 1); + } +} thread_local! { - static SECP_NONE: CachedContext = RefCell::new(Secp256k1::without_caps()); - static SECP_FULL: CachedContext = RefCell::new(Secp256k1::with_caps(ContextFlag::Full)); - static SECP_VERIFY_ONLY: CachedContext = - RefCell::new(Secp256k1::with_caps(ContextFlag::VerifyOnly)); - static SECP_COMMIT: CachedContext = RefCell::new(Secp256k1::with_caps(ContextFlag::Commit)); + static SECP_NONE: ContextPool = ContextPool::new(ContextFlag::None); + static SECP_FULL: ContextPool = ContextPool::new(ContextFlag::Full); + static SECP_VERIFY_ONLY: ContextPool = ContextPool::new(ContextFlag::VerifyOnly); + static SECP_COMMIT: ContextPool = ContextPool::new(ContextFlag::Commit); } fn create_context(caps: ContextFlag) -> Result { @@ -40,9 +100,19 @@ fn create_context(caps: ContextFlag) -> Result { } } +fn use_context(cached: &CachedContext, map_context_error: M, f: F) -> Result +where + F: FnOnce(&Secp256k1) -> Result, + M: FnOnce(secp::Error) -> E, +{ + match cached { + Ok(secp) => f(secp), + Err(e) => Err(map_context_error(*e)), + } +} + fn with_context( - context: &'static LocalKey, - caps: ContextFlag, + context: &'static LocalKey, map_context_error: M, f: F, ) -> Result @@ -50,25 +120,15 @@ where F: FnOnce(&Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - context.with(|context| match context.try_borrow() { - Ok(cached) => match &*cached { - Ok(secp) => f(secp), - Err(e) => Err(map_context_error(*e)), - }, - Err(e) => { - debug!( - "Thread-local secp256k1 {:?} context is already mutably borrowed; using temporary context: {}", - caps, e - ); - let secp = create_context(caps).map_err(map_context_error)?; - f(&secp) - } + context.with(|pool| { + let (context, _lease) = pool.acquire(); + let cached = context.borrow(); + use_context(&cached, map_context_error, f) }) } fn with_context_mut( - context: &'static LocalKey, - caps: ContextFlag, + context: &'static LocalKey, map_context_error: M, f: F, ) -> Result @@ -76,18 +136,12 @@ where F: FnOnce(&mut Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - context.with(|context| match context.try_borrow_mut() { - Ok(mut cached) => match &mut *cached { + context.with(|pool| { + let (context, _lease) = pool.acquire(); + let mut cached = context.borrow_mut(); + match &mut *cached { Ok(secp) => f(secp), Err(e) => Err(map_context_error(*e)), - }, - Err(e) => { - debug!( - "Thread-local secp256k1 {:?} context is already borrowed; using temporary context: {}", - caps, e - ); - let mut secp = create_context(caps).map_err(map_context_error)?; - f(&mut secp) } }) } @@ -98,7 +152,7 @@ where F: FnOnce(&Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context(&SECP_NONE, ContextFlag::None, map_context_error, f) + with_context(&SECP_NONE, map_context_error, f) } /// Uses this thread's cached mutable context with no secp256k1 capabilities. @@ -107,7 +161,7 @@ where F: FnOnce(&mut Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context_mut(&SECP_NONE, ContextFlag::None, map_context_error, f) + with_context_mut(&SECP_NONE, map_context_error, f) } /// Uses this thread's cached full secp256k1 context. @@ -116,7 +170,7 @@ where F: FnOnce(&Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context(&SECP_FULL, ContextFlag::Full, map_context_error, f) + with_context(&SECP_FULL, map_context_error, f) } /// Uses this thread's cached mutable full secp256k1 context. @@ -125,7 +179,7 @@ where F: FnOnce(&mut Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context_mut(&SECP_FULL, ContextFlag::Full, map_context_error, f) + with_context_mut(&SECP_FULL, map_context_error, f) } /// Uses this thread's cached verify-only secp256k1 context. @@ -134,12 +188,7 @@ where F: FnOnce(&Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context( - &SECP_VERIFY_ONLY, - ContextFlag::VerifyOnly, - map_context_error, - f, - ) + with_context(&SECP_VERIFY_ONLY, map_context_error, f) } /// Uses this thread's cached mutable verify-only secp256k1 context. @@ -148,12 +197,7 @@ where F: FnOnce(&mut Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context_mut( - &SECP_VERIFY_ONLY, - ContextFlag::VerifyOnly, - map_context_error, - f, - ) + with_context_mut(&SECP_VERIFY_ONLY, map_context_error, f) } /// Uses this thread's cached commitment-capable secp256k1 context. @@ -162,7 +206,7 @@ where F: FnOnce(&Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context(&SECP_COMMIT, ContextFlag::Commit, map_context_error, f) + with_context(&SECP_COMMIT, map_context_error, f) } /// Uses this thread's cached mutable commitment-capable secp256k1 context. @@ -171,7 +215,7 @@ where F: FnOnce(&mut Secp256k1) -> Result, M: FnOnce(secp::Error) -> E, { - with_context_mut(&SECP_COMMIT, ContextFlag::Commit, map_context_error, f) + with_context_mut(&SECP_COMMIT, map_context_error, f) } /// Convenient way to generate a commitment to zero. @@ -185,15 +229,121 @@ mod tests { use super::*; #[test] - fn reentrant_access_uses_temporary_context() { - let res = with_commit_mut( - |e| e, - |_secp| { - with_commit(|e| e, |_nested| Ok(()))?; - with_commit_mut(|e| e, |_nested| Ok(()))?; - Ok(()) - }, - ); - assert!(res.is_ok()); + fn reentrant_mutable_access_grows_and_reuses_the_context_pool() { + fn recurse( + remaining: usize, + contexts: &mut Vec<*const Secp256k1>, + ) -> Result<(), secp::Error> { + if remaining == 0 { + return Ok(()); + } + with_commit_mut( + |e| e, + |secp| { + contexts.push(secp as *const Secp256k1); + recurse(remaining - 1, contexts) + }, + ) + } + + std::thread::spawn(|| { + const RECURSION_DEPTH: usize = 6; + + SECP_COMMIT.with(|pool| assert!(pool.contexts.borrow().is_empty())); + + let mut first_use = Vec::new(); + recurse(RECURSION_DEPTH, &mut first_use).unwrap(); + assert_eq!(first_use.len(), RECURSION_DEPTH); + assert!(first_use + .iter() + .enumerate() + .all(|(index, context)| !first_use[..index].contains(context))); + SECP_COMMIT.with(|pool| { + assert_eq!(pool.active.get(), 0); + assert_eq!(pool.contexts.borrow().len(), RECURSION_DEPTH); + }); + + let mut second_use = Vec::new(); + recurse(RECURSION_DEPTH, &mut second_use).unwrap(); + assert_eq!(second_use, first_use); + SECP_COMMIT.with(|pool| { + assert_eq!(pool.active.get(), 0); + assert_eq!(pool.contexts.borrow().len(), RECURSION_DEPTH); + }); + }) + .join() + .unwrap(); + } + + #[test] + fn reentrant_shared_access_uses_the_next_context() { + std::thread::spawn(|| { + let mut outer_address = std::ptr::null(); + let mut inner_address = std::ptr::null(); + + with_commit( + |e| e, + |outer| { + outer_address = outer; + with_commit( + |e| e, + |inner| { + inner_address = inner; + Ok(()) + }, + ) + }, + ) + .unwrap(); + + assert!(!std::ptr::eq(outer_address, inner_address)); + SECP_COMMIT.with(|pool| { + assert_eq!(pool.active.get(), 0); + assert_eq!(pool.contexts.borrow().len(), 2); + }); + + with_commit( + |e| e, + |reused| { + assert!(std::ptr::eq(outer_address, reused)); + Ok(()) + }, + ) + .unwrap(); + }) + .join() + .unwrap(); + } + + #[test] + fn panic_restores_the_active_context_index() { + std::thread::spawn(|| { + let mut first_address = std::ptr::null(); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _: Result<(), secp::Error> = with_none( + |e| e, + |secp| { + first_address = secp; + panic!("test callback panic"); + }, + ); + })); + assert!(panic.is_err()); + + SECP_NONE.with(|pool| { + assert_eq!(pool.active.get(), 0); + assert_eq!(pool.contexts.borrow().len(), 1); + }); + with_none( + |e| e, + |reused| { + assert!(std::ptr::eq(first_address, reused)); + Ok(()) + }, + ) + .unwrap(); + }) + .join() + .unwrap(); } } From 7f2032aeb3a1d579cbd75e184c0cb27631c89b3f Mon Sep 17 00:00:00 2001 From: bay Date: Mon, 24 Aug 2026 00:00:59 -0700 Subject: [PATCH 6/7] Add logs to cover other validation functions --- chain/src/chain.rs | 8 +++ chain/src/txhashset/txhashset.rs | 83 +++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/chain/src/chain.rs b/chain/src/chain.rs index 5a7b676f82..a5cde4aca3 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -7156,6 +7156,10 @@ fn setup_head( // BlockSums is a derived acceleration cache. Replace it from the // validated txhashset rather than accepting any persisted value. batch.save_block_sums(&header_hash, block_sums)?; + info!( + "setup_head: startup txhashset validation finished at height {}; synchronizing PMMR backends", + header.height + ); debug!( "init: rewinding and validating before we start... {} at {}", @@ -7167,6 +7171,10 @@ fn setup_head( match res { Ok(()) => { + info!( + "setup_head: PMMR backend synchronization finished at height {}", + header.height + ); let output_mmr_size_after = txhashset.output_mmr_size(); let kernel_mmr_size_after = txhashset.kernel_mmr_size(); let output_mmr_rewound = output_mmr_size_before > output_mmr_size_after; diff --git a/chain/src/txhashset/txhashset.rs b/chain/src/txhashset/txhashset.rs index ffad135bc8..304e58231e 100644 --- a/chain/src/txhashset/txhashset.rs +++ b/chain/src/txhashset/txhashset.rs @@ -63,6 +63,7 @@ const COMMIT_SUM_BATCH_SIZE: usize = 10_000; const INDEX_REBUILD_LOG_INTERVAL_SECS: u64 = 1; const PERSISTED_ANCESTRY_LOG_INTERVAL_SECS: u64 = 5; const KERNEL_SUM_PROGRESS_LOG_INTERVAL_SECS: u64 = 5; +const OUTPUT_POS_VALIDATION_PROGRESS_LOG_INTERVAL_SECS: u64 = 5; const OUTPUT_SUBDIR: &str = "output"; const RANGE_PROOF_SUBDIR: &str = "rangeproof"; @@ -1197,6 +1198,12 @@ impl TxHashSet { // relative height. // Safe: WEEK_HEIGHT is a small fixed consensus constant. let cutoff = head.height.saturating_sub(WEEK_HEIGHT * 2); + let ancestry_links = head.height.saturating_sub(cutoff); + let ancestry_boundaries = ancestry_links.saturating_add(1); + info!( + "init_recent_kernel_pos_index: starting recent NRD kernel_pos index rebuild from height {} to {}; collecting {} body header boundaries", + cutoff, head.height, ancestry_boundaries + ); // HEAD and the kernel PMMR describe the validated body chain. The header // PMMR may legally be ahead on a different fork, so recover every kernel @@ -1205,6 +1212,8 @@ impl TxHashSet { let mut current_header = head_header.clone(); let mut boundaries = Vec::new(); let mut visited = HashSet::new(); + let ancestry_started = Instant::now(); + let mut last_ancestry_log = Instant::now(); while current_header.height > cutoff { Self::check_stop_state(&stop_state)?; boundaries.push(KernelHeaderBoundary::from(¤t_header)); @@ -1226,6 +1235,17 @@ impl TxHashSet { ))); } current_header = prev_header; + if last_ancestry_log.elapsed().as_secs() >= PERSISTED_ANCESTRY_LOG_INTERVAL_SECS { + let traversed = head.height.saturating_sub(current_header.height); + info!( + "init_recent_kernel_pos_index: body ancestry progress {}/{} ({}%), reached height {}", + traversed, + ancestry_links, + traversed.saturating_mul(100) / ancestry_links.max(1), + current_header.height + ); + last_ancestry_log = Instant::now(); + } } if current_header.height != cutoff { return Err(Error::TxHashSetErr(format!( @@ -1260,8 +1280,9 @@ impl TxHashSet { }; info!( - "init_recent_kernel_pos_index: starting recent NRD kernel_pos index rebuild from height {} to {}", - cutoff, head.height + "init_recent_kernel_pos_index: collected {} body header boundaries in {}s; starting recent kernel scan", + boundaries.len(), + ancestry_started.elapsed().as_secs() ); self.verify_kernel_pos_index_with_status( &cutoff_header, @@ -1739,6 +1760,7 @@ impl TxHashSet { let mut count = 0u64; let mut applied = 0u64; let status_throttle = SyncStatusUpdateThrottle::new(); + let mut last_progress_log = Instant::now(); if let Some(ref s) = status { if build_status { s.update(SyncStatus::TxHashsetKernelPosIndexBuild { @@ -1836,6 +1858,19 @@ impl TxHashSet { } } } + if Self::should_log_index_rebuild_progress(&mut last_progress_log, false) { + info!( + "verify_kernel_pos_index: {} progress {}/{} kernels ({}%)", + if build_status { + "rebuild" + } else { + "validation" + }, + applied, + total, + applied.saturating_mul(100) / total.max(1) + ); + } } if let Some(ref s) = stop_state { if s.is_stopped() { @@ -4272,6 +4307,14 @@ impl<'a> Extension<'a> { )); } + let now = Instant::now(); + let total_outputs = self.output_pmmr.n_unpruned_leaves()?; + info!( + "validate_output_pos_index: starting bidirectional output_pos validation at height {}, output_mmr_size {}, utxos {}", + header.height, header.output_mmr_size, total_outputs + ); + let index_pass_started = Instant::now(); + let mut last_progress_log = Instant::now(); let mut indexed_outputs = 0u64; let output_pos_iter = batch .output_pos_iter() @@ -4313,8 +4356,26 @@ impl<'a> Extension<'a> { indexed_outputs = indexed_outputs.checked_add(1).ok_or_else(|| { Error::DataOverflow("validate_output_pos_index indexed output count".into()) })?; + if last_progress_log.elapsed().as_secs() + >= OUTPUT_POS_VALIDATION_PROGRESS_LOG_INTERVAL_SECS + { + info!( + "validate_output_pos_index: index-to-UTXO progress {}/{} entries ({}%)", + indexed_outputs, + total_outputs, + (indexed_outputs.saturating_mul(100) / total_outputs.max(1)).min(100) + ); + last_progress_log = Instant::now(); + } } + info!( + "validate_output_pos_index: index-to-UTXO pass finished, checked {} entries in {}s; starting UTXO-to-index pass", + indexed_outputs, + index_pass_started.elapsed().as_secs() + ); + let utxo_pass_started = Instant::now(); + last_progress_log = Instant::now(); let mut output_leaves = 0u64; for pos0 in self.output_pmmr.leaf_pos_iter()? { let pos0 = pos0?; @@ -4360,6 +4421,17 @@ impl<'a> Extension<'a> { output_leaves = output_leaves.checked_add(1).ok_or_else(|| { Error::DataOverflow("validate_output_pos_index output leaf count".into()) })?; + if last_progress_log.elapsed().as_secs() + >= OUTPUT_POS_VALIDATION_PROGRESS_LOG_INTERVAL_SECS + { + info!( + "validate_output_pos_index: UTXO-to-index progress {}/{} outputs ({}%)", + output_leaves, + total_outputs, + (output_leaves.saturating_mul(100) / total_outputs.max(1)).min(100) + ); + last_progress_log = Instant::now(); + } } if indexed_outputs != output_leaves { @@ -4369,6 +4441,13 @@ impl<'a> Extension<'a> { ))); } + info!( + "validate_output_pos_index: finished successfully, checked {} entries and {} UTXOs in {}s (UTXO-to-index pass {}s)", + indexed_outputs, + output_leaves, + now.elapsed().as_secs(), + utxo_pass_started.elapsed().as_secs() + ); Ok(()) } From 23ec22062e30fa2ab1c2d31b30f9ea72724c9649 Mon Sep 17 00:00:00 2001 From: bay Date: Mon, 24 Aug 2026 16:20:57 -0700 Subject: [PATCH 7/7] Fix webtunnelclient issue --- Cargo.lock | 2 - Cargo.toml | 6 +- doc/releases/release-7.1.0.md | 3 +- p2p/src/tor/arti.rs | 28 +- vendor/tor-ptmgr/Cargo.toml | 186 ++++ vendor/tor-ptmgr/Cargo.toml.orig | 76 ++ vendor/tor-ptmgr/MWC-PATCH.md | 53 + vendor/tor-ptmgr/README.md | 58 ++ vendor/tor-ptmgr/examples/run-pt.rs | 27 + vendor/tor-ptmgr/src/config.rs | 211 ++++ vendor/tor-ptmgr/src/err.rs | 172 ++++ vendor/tor-ptmgr/src/ipc.rs | 1477 +++++++++++++++++++++++++++ vendor/tor-ptmgr/src/lib.rs | 381 +++++++ vendor/tor-ptmgr/src/managed.rs | 336 ++++++ 14 files changed, 3010 insertions(+), 6 deletions(-) create mode 100644 vendor/tor-ptmgr/Cargo.toml create mode 100644 vendor/tor-ptmgr/Cargo.toml.orig create mode 100644 vendor/tor-ptmgr/MWC-PATCH.md create mode 100644 vendor/tor-ptmgr/README.md create mode 100644 vendor/tor-ptmgr/examples/run-pt.rs create mode 100644 vendor/tor-ptmgr/src/config.rs create mode 100644 vendor/tor-ptmgr/src/err.rs create mode 100644 vendor/tor-ptmgr/src/ipc.rs create mode 100644 vendor/tor-ptmgr/src/lib.rs create mode 100644 vendor/tor-ptmgr/src/managed.rs diff --git a/Cargo.lock b/Cargo.lock index 03789d1ccc..4ef5012869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7514,8 +7514,6 @@ dependencies = [ [[package]] name = "tor-ptmgr" version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d052da1eadd9f65cc46aa367adb8b573d397acf3d0635d39351670bbe40a82f7" dependencies = [ "async-trait", "cfg-if", diff --git a/Cargo.toml b/Cargo.toml index cb7d702ea9..e9ccfef062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,15 @@ edition = "2024" [workspace] members = ["api", "chain", "config", "core", "keychain", "mwc_crates", "p2p", "servers", "store", "util", "pool", "node_workflow", "mwc_node_lib"] -exclude = ["etc/gen_gen", "vendor/saturating-time"] +exclude = ["etc/gen_gen", "vendor/saturating-time", "vendor/tor-ptmgr"] [patch.crates-io] # Arti 0.44+ exercises a Windows hang in saturating-time 0.4.0 while parsing a consensus. saturating-time = { path = "vendor/saturating-time" } +# Arti 0.45's PT stdout worker cannot observe transport destruction while a +# quiet child is blocked without producing another stdout line. Keep the +# managed child supervisor under our control until the fix is released upstream. +tor-ptmgr = { path = "vendor/tor-ptmgr" } [workspace.dependencies] thiserror = "2" diff --git a/doc/releases/release-7.1.0.md b/doc/releases/release-7.1.0.md index 5ddeedabfd..1350b2a3ac 100644 --- a/doc/releases/release-7.1.0.md +++ b/doc/releases/release-7.1.0.md @@ -38,4 +38,5 @@ - Fixed PIBD Segmenter size estimation issue for segments that are partly pruned. - Handle server create/release race conditions. - Limit PIBD root hash cache. -- Connect PIDB related requests with active Desegmenter. That allow to ignore late responses from the peers. \ No newline at end of file +- Connect PIDB related requests with active Desegmenter. That allow to ignore late responses from the peers. +- Fix Arti possible process leaking for webtunnelclient. Fix webtunnelclient problem Under the Windows (it didn't start there). \ No newline at end of file diff --git a/p2p/src/tor/arti.rs b/p2p/src/tor/arti.rs index a3cb1cd089..edda9fae56 100644 --- a/p2p/src/tor/arti.rs +++ b/p2p/src/tor/arti.rs @@ -1143,7 +1143,10 @@ impl ArtiCore { match tor_client { Ok(tor_client) => Ok((tor_client, arti_rt)), Err(e) => { - arti_rt.shutdown_timeout(Duration::from_secs(5)); + // The managed PT gets five seconds to exit gracefully before its + // supervisor kills and reaps it. Give runtime destruction enough + // time to finish that cleanup before trying the next bridge. + arti_rt.shutdown_timeout(Duration::from_secs(10)); Err(e) } } @@ -1295,6 +1298,13 @@ impl ArtiCore { res } + fn webtunnel_client_filename() -> String { + // Release builds use the native executable suffix. In particular, the + // Windows package contains webtunnelclient.exe, while Unix packages use + // webtunnelclient without a suffix. + format!("webtunnelclient{}", std::env::consts::EXE_SUFFIX) + } + // return config and expiration time fn build_config( webtunnel_bridge: &Option, @@ -1324,7 +1334,7 @@ impl ArtiCore { let path = exe .parent() .ok_or(Error::TorConfig("Failed to locate executable path".into()))?; - let client_path = path.join("webtunnelclient"); + let client_path = path.join(Self::webtunnel_client_filename()); if !client_path.try_exists().map_err(|e| { Error::TorConfig(format!( @@ -1729,6 +1739,20 @@ fn build_config_rejects_malformed_creation_timestamp_without_cleanup() { assert!(marker_file.exists()); } +#[test] +fn webtunnel_client_filename_uses_target_executable_suffix() { + let filename = ArtiCore::webtunnel_client_filename(); + assert_eq!( + filename, + format!("webtunnelclient{}", std::env::consts::EXE_SUFFIX) + ); + + #[cfg(windows)] + assert_eq!(filename, "webtunnelclient.exe"); + #[cfg(not(windows))] + assert_eq!(filename, "webtunnelclient"); +} + #[test] fn bridge_cache_key_uses_stable_sha256_digest() { assert_eq!( diff --git a/vendor/tor-ptmgr/Cargo.toml b/vendor/tor-ptmgr/Cargo.toml new file mode 100644 index 0000000000..e24f3070a5 --- /dev/null +++ b/vendor/tor-ptmgr/Cargo.toml @@ -0,0 +1,186 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.91" +name = "tor-ptmgr" +version = "0.45.0" +authors = [ + "The Tor Project, Inc.", + "Nick Mathewson ", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Manage a set of pluggable transports to circumvent censorship" +homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" +readme = "README.md" +keywords = [ + "tor", + "arti", + "censorship", +] +categories = [ + "network-programming", + "cryptography", +] +license = "MIT OR Apache-2.0" +repository = "https://gitlab.torproject.org/tpo/core/arti.git/" + +[package.metadata.docs.rs] +all-features = true + +[features] +__is_experimental = [] +default = [ + "managed-pts", + "tor-channel-factory", +] +experimental = ["experimental-api"] +experimental-api = [ + "visibility", + "__is_experimental", +] +full = [ + "tor-channel-factory", + "managed-pts", + "fs-mistrust/full", + "tor-chanmgr/full", + "tor-config/full", + "tor-error/full", + "tor-linkspec/full", + "tor-rtcompat/full", + "tor-socksproto/full", + "tor-async-utils/full", + "tor-basic-utils/full", + "oneshot-fused-workaround/full", + "tor-config-path/full", + "tor-proto/full", + "web-time-compat/full", +] +managed-pts = [] +tor-channel-factory = [] + +[lib] +name = "tor_ptmgr" +path = "src/lib.rs" + +[[example]] +name = "run-pt" +path = "examples/run-pt.rs" +required-features = ["experimental-api"] + +[dependencies.async-trait] +version = "0.1.54" + +[dependencies.cfg-if] +version = "1.0.0" + +[dependencies.derive-deftly] +version = "~1.11.4" +features = [ + "full", + "beta", +] + +[dependencies.derive_builder] +version = "0.11.2" +package = "derive_builder_fork_arti" + +[dependencies.fs-mistrust] +version = "0.15.0" + +[dependencies.futures] +version = "0.3.14" + +[dependencies.itertools] +version = "0.15.0" + +[dependencies.oneshot-fused-workaround] +version = "0.7.0" + +[dependencies.serde] +version = "1.0.103" +features = ["derive"] + +[dependencies.thiserror] +version = "2" + +[dependencies.tor-async-utils] +version = "0.45.0" + +[dependencies.tor-basic-utils] +version = "0.45.0" + +[dependencies.tor-chanmgr] +version = "0.45.0" +features = ["pt-client"] + +[dependencies.tor-config] +version = "0.45.0" + +[dependencies.tor-config-path] +version = "0.45.0" + +[dependencies.tor-error] +version = "0.45.0" +features = ["tracing"] + +[dependencies.tor-linkspec] +version = "0.45.0" +features = ["pt-client"] + +[dependencies.tor-proto] +version = "0.45.0" + +[dependencies.tor-rtcompat] +version = "0.45.0" + +[dependencies.tor-socksproto] +version = "0.45.0" + +[dependencies.tracing] +version = "0.1.36" + +[dependencies.visibility] +version = "0.1.0" +optional = true + +[dependencies.web-time-compat] +version = "0.2.0" + +[dev-dependencies.anyhow] +version = "1.0.23" + +[dev-dependencies.tokio] +version = "1.47.1" +features = [ + "rt", + "rt-multi-thread", + "io-util", + "net", + "time", + "macros", +] + +[dev-dependencies.tor-rtcompat] +version = "0.45.0" +features = [ + "tokio", + "native-tls", +] + +[dev-dependencies.tracing-subscriber] +version = "0.3.20" diff --git a/vendor/tor-ptmgr/Cargo.toml.orig b/vendor/tor-ptmgr/Cargo.toml.orig new file mode 100644 index 0000000000..9fecaca815 --- /dev/null +++ b/vendor/tor-ptmgr/Cargo.toml.orig @@ -0,0 +1,76 @@ +[package] +name = "tor-ptmgr" +version = "0.45.0" +authors = ["The Tor Project, Inc.", "Nick Mathewson "] +edition = "2024" +rust-version = "1.91" +license = "MIT OR Apache-2.0" +homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" +description = "Manage a set of pluggable transports to circumvent censorship" +keywords = ["tor", "arti", "censorship"] +categories = ["network-programming", "cryptography"] +repository = "https://gitlab.torproject.org/tpo/core/arti.git/" + +[features] +default = ["managed-pts", "tor-channel-factory"] +tor-channel-factory = [] +managed-pts = [] +full = [ + "tor-channel-factory", + "managed-pts", + "fs-mistrust/full", + "tor-chanmgr/full", + "tor-config/full", + "tor-error/full", + "tor-linkspec/full", + "tor-rtcompat/full", + "tor-socksproto/full", + "tor-async-utils/full", + "tor-basic-utils/full", + "oneshot-fused-workaround/full", + "tor-config-path/full", + "tor-proto/full", + "web-time-compat/full", +] + +experimental = ["experimental-api"] +experimental-api = ["visibility", "__is_experimental"] +__is_experimental = [] + +[dependencies] +async-trait = "0.1.54" +cfg-if = "1.0.0" +derive-deftly = { version = "~1.11.4", features = ["full", "beta"] } +derive_builder = { version = "0.11.2", package = "derive_builder_fork_arti" } +fs-mistrust = { version = "0.15.0", path = "../fs-mistrust" } +futures = "0.3.14" +itertools = "0.15.0" +oneshot-fused-workaround = { path = "../oneshot-fused-workaround", version = "0.7.0" } +serde = { version = "1.0.103", features = ["derive"] } +thiserror = "2" +tor-async-utils = { version = "0.45.0", path = "../tor-async-utils" } +tor-basic-utils = { path = "../tor-basic-utils", version = "0.45.0" } +tor-chanmgr = { version = "0.45.0", path = "../tor-chanmgr", features = ["pt-client"] } +tor-config = { version = "0.45.0", path = "../tor-config" } +tor-config-path = { version = "0.45.0", path = "../tor-config-path" } +tor-error = { version = "0.45.0", path = "../tor-error", features = ["tracing"] } +tor-linkspec = { version = "0.45.0", path = "../tor-linkspec", features = ["pt-client"] } +tor-proto = { version = "0.45.0", path = "../tor-proto" } +tor-rtcompat = { version = "0.45.0", path = "../tor-rtcompat" } +tor-socksproto = { version = "0.45.0", path = "../tor-socksproto" } +tracing = "0.1.36" +visibility = { version = "0.1.0", optional = true } +web-time-compat = { version = "0.2.0", path = "../web-time-compat" } + +[dev-dependencies] +anyhow = "1.0.23" +tokio = { version = "1.47.1", features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros"] } +tor-rtcompat = { path = "../tor-rtcompat", version = "0.45.0", features = ["tokio", "native-tls"] } +tracing-subscriber = "0.3.20" + +[[example]] +name = "run-pt" +required-features = ["experimental-api"] + +[package.metadata.docs.rs] +all-features = true diff --git a/vendor/tor-ptmgr/MWC-PATCH.md b/vendor/tor-ptmgr/MWC-PATCH.md new file mode 100644 index 0000000000..befbe97bc2 --- /dev/null +++ b/vendor/tor-ptmgr/MWC-PATCH.md @@ -0,0 +1,53 @@ +# MWC tor-ptmgr patch + +This directory vendors `tor-ptmgr` 0.45.0 from crates.io, whose source commit is +`009354f78d1a61214a878d6f1712a50844e6c215`. + +## Problem + +Upstream 0.45.0 moves the `Child`, its `ChildStdin`, and its blocking stdout +reader into one OS thread. That thread notices that `AsyncPtChild` was dropped +only when it tries to forward another stdout line into the disconnected channel. +After PT negotiation, `webtunnelclient` is normally quiet while waiting for +SOCKS connections, so the blocked stdout read may never return and stdin stays +open. A failed Arti bridge attempt can therefore leave its old +`webtunnelclient` running while subsequent bridges start more instances. + +## Exact changes + +- [`src/ipc.rs`](src/ipc.rs), `supervise_pt_child`: adds a dedicated process + supervisor which owns the `Child` and `ChildStdin`, independently of the + blocking stdout reader. It polls for normal process exit and also waits for an + explicit shutdown message. +- [`src/ipc.rs`](src/ipc.rs), `AsyncPtChild::new`: leaves only `ChildStdout` in + the stdout forwarding thread and starts the separate process supervisor with + `Child` and `ChildStdin`. +- [`src/ipc.rs`](src/ipc.rs), `AsyncPtChild::drop`: sends the explicit shutdown + message and joins the supervisor. The supervisor closes stdin, waits up to + `GRACEFUL_EXIT_TIME` (five seconds), then kills and reaps a child which did not + exit. Joining guarantees that a failed PT is gone before Arti can start the + next bridge attempt. +- [`src/ipc.rs`](src/ipc.rs), + `dropping_quiet_child_stops_and_reaps_process`: adds a regression test whose + child becomes quiet and exits only after stdin EOF. This reproduces the + lifecycle of `webtunnelclient` after successful PT negotiation. +- [`../../p2p/src/tor/arti.rs`](../../p2p/src/tor/arti.rs), + `ArtiCore::bootstrap_tor_client`: gives failed Arti runtime destruction ten + seconds, so the supervisor's bounded five-second graceful/forced shutdown can + finish before the next bridge is tried. +- [`../../p2p/src/tor/arti.rs`](../../p2p/src/tor/arti.rs), + `ArtiCore::webtunnel_client_filename`: uses the target platform's executable + suffix, allowing Windows builds to find the packaged `webtunnelclient.exe`. +- [`../../Cargo.toml`](../../Cargo.toml), `[patch.crates-io]`: redirects + `tor-ptmgr` to this patched source. + +## Resulting process lifecycle + +Dropping a failed bridge's `AsyncPtChild` no longer depends on PT output. It +wakes the supervisor immediately, and `webtunnelclient` normally observes stdin +EOF and exits gracefully. If it does not respond, it is killed after five +seconds and then reaped. Only the PT belonging to the selected bridge remains +running. + +The override can be removed after an equivalent fix is available in the pinned +Arti release. diff --git a/vendor/tor-ptmgr/README.md b/vendor/tor-ptmgr/README.md new file mode 100644 index 0000000000..8d664f1bb4 --- /dev/null +++ b/vendor/tor-ptmgr/README.md @@ -0,0 +1,58 @@ +# tor-ptmgr + +Manage a set of anti-censorship pluggable transports. + +## Overview + +This crate is part of [Arti](https://gitlab.torproject.org/tpo/core/arti/), +a project to implement [Tor](https://www.torproject.org/) in Rust. + +In Tor, a "transport" is a mechanism used to avoid censorship by disguising +the Tor protocol as some other kind of traffic. + +A "pluggable transport" is one that is not implemented by default as part of +the Tor protocol, but which can instead be added later on by the packager or +the user. Pluggable transports are typically provided as external binaries +that implement a SOCKS proxy, along with certain other configuration +protocols. + +This crate provides a means to manage a set of configured pluggable +transports + +## Limitations + +TODO: Currently, the APIs for this crate make it quite +tor-specific. Notably, it can only return Channels! It would be good +instead to adapt it so that it was more generally useful by other projects +that want to use pluggable transports in rust. For now, I have put the +Tor-channel-specific stuff behind a `tor-channel-factory` feature, but there +are no APIs for using PTs without that feature currently. That should +change. (See issue [arti#666](https://gitlab.torproject.org/tpo/core/arti/-/issues/666)) + +TODO: The first version of this crate will probably only conform +to the original Tor pluggable transport protocol, and not to more recent variants +as documented at `pluggabletransports.info` + +## Feature flags + +### Additive features + +* `tor-channel-factory`: Build with support for a ChannelFactory implementation + that allows this crate's use with Tor. (Currently, this is the only way to + use the crate; see "Limitations" section above.) + +* `full` -- Build with all the features above. + +### Experimental and unstable features + + Note that the APIs enabled by these features are NOT covered by semantic + versioning guarantees: we might break them or remove them between patch + versions. + +* `experimental-api` -- build with experimental, unstable API support. + +* `experimental` -- Build with all experimental features above, along with + all experimental features from other arti crates. + + +License: MIT OR Apache-2.0 diff --git a/vendor/tor-ptmgr/examples/run-pt.rs b/vendor/tor-ptmgr/examples/run-pt.rs new file mode 100644 index 0000000000..2818bdb7ab --- /dev/null +++ b/vendor/tor-ptmgr/examples/run-pt.rs @@ -0,0 +1,27 @@ +//! Very very very basic soak test that runs obfs4proxy. + +use anyhow::Result; +use tor_ptmgr::ipc::{ + PluggableClientTransport, PluggableTransport, PtClientParameters, PtCommonParameters, +}; +use tor_rtcompat::PreferredRuntime; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + let common_params = PtCommonParameters::builder() + .state_location("/tmp/arti-pt".into()) + .build() + .unwrap(); + let client_params = PtClientParameters::builder() + .transports(vec!["obfs4".parse().unwrap()]) + .build() + .unwrap(); + let mut pt = + PluggableClientTransport::new("./obfs4proxy".into(), vec![], common_params, client_params); + pt.launch(PreferredRuntime::current()?).await?; + loop { + info!("message: {:?}", pt.next_message().await?); + } +} diff --git a/vendor/tor-ptmgr/src/config.rs b/vendor/tor-ptmgr/src/config.rs new file mode 100644 index 0000000000..edfde3f69a --- /dev/null +++ b/vendor/tor-ptmgr/src/config.rs @@ -0,0 +1,211 @@ +//! Configuration logic for tor-ptmgr. + +use std::net::SocketAddr; + +use derive_deftly::Deftly; +use tor_config::derive::prelude::*; +use tor_config::ConfigBuildError; +use tor_config_path::CfgPath; +use tor_linkspec::PtTransportName; + +#[cfg(feature = "tor-channel-factory")] +use {crate::PtClientMethod, tor_socksproto::SocksVersion}; + +/// A single pluggable transport. +/// +/// Pluggable transports are programs that transform and obfuscate traffic on +/// the network between a Tor client and a Tor bridge, so that an adversary +/// cannot recognize it as Tor traffic. +/// +/// A pluggable transport can be either _managed_ (run as an external process +/// that we launch and monitor), or _unmanaged_ (running on a local port, not +/// controlled by Arti). +#[derive(Clone, Debug, Deftly, Eq, PartialEq)] +#[derive_deftly(TorConfig)] +#[deftly(tor_config(no_default_trait, pre_build = "Self::validate"))] +pub struct TransportConfig { + /// Names of the transport protocols that we are willing to use from this transport. + /// + /// (These protocols are arbitrary identifiers that describe which protocols + /// we want. They must match names that the binary knows how to provide.) + // + // NOTE(eta): This doesn't use the list builder stuff, because you're not likely to + // set this field more than once. + #[deftly(tor_config(no_magic, no_default))] + pub(crate) protocols: Vec, + + /// The path to the binary to run, if any. + /// + /// This needs to be the path to some executable file on disk. + /// + /// Present only for managed transports. + #[deftly(tor_config(default, setter(strip_option)))] + pub(crate) path: Option, + + /// One or more command-line arguments to pass to the binary. + /// + /// Meaningful only for managed transports. + // TODO: Should this be OsString? That's a pain to parse... + // + // NOTE(eta): This doesn't use the list builder stuff, because you're not likely to + // set this field more than once. + #[deftly(tor_config(no_magic, default))] + pub(crate) arguments: Vec, + + /// The location at which to contact this transport. + /// + /// Present only for unmanaged transports. + #[deftly(tor_config(default, setter(strip_option)))] + pub(crate) proxy_addr: Option, + + /// If true, launch this transport on startup. Otherwise, we launch + /// it on demand. + /// + /// Meaningful only for managed transports. + #[deftly(tor_config(default))] + pub(crate) run_on_startup: bool, +} + +impl TransportConfigBuilder { + /// Inspect the list of protocols (ie, transport names) + /// + /// If none have yet been specified, returns an empty list. + pub fn get_protocols(&self) -> &[PtTransportName] { + self.protocols.as_deref().unwrap_or_default() + } + + /// Make sure that this builder is internally consistent. + fn validate(&self) -> Result<(), ConfigBuildError> { + // `path` can only be set if the `managed-pts` feature is enabled + #[cfg(not(feature = "managed-pts"))] + if self.path.is_some() { + return Err(ConfigBuildError::NoCompileTimeSupport { + field: "path".into(), + problem: + "Indicates a managed transport, but support is not enabled by cargo features" + .into(), + }); + } + + match (&self.path, &self.proxy_addr) { + (Some(_), Some(_)) => Err(ConfigBuildError::Inconsistent { + fields: vec!["path".into(), "proxy_addr".into()], + problem: "Cannot provide both path and proxy_addr".into(), + }), + (None, None) => Err(ConfigBuildError::MissingOneOf { + min_required: 1, + fields: vec!["path".into(), "proxy_addr".into()], + }), + (None, Some(_)) => { + if self.arguments.as_ref().is_some_and(|v| !v.is_empty()) { + Err(ConfigBuildError::Inconsistent { + fields: vec!["proxy_addr".into(), "arguments".into()], + problem: "Cannot provide arguments for an unmanaged transport".into(), + }) + } else if self.run_on_startup.is_some() { + Err(ConfigBuildError::Inconsistent { + fields: vec!["proxy_addr".into(), "run_on_startup".into()], + problem: "run_on_startup is meaningless for an unmanaged transport".into(), + }) + } else { + Ok(()) + } + } + (Some(_), None) => Ok(()), + } + } +} + +/// The pluggable transport structure used internally. This is more type-safe than working with +/// `TransportConfig` directly, since we can't change `TransportConfig` as it's part of the public +/// API. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TransportOptions { + /// Options for a managed PT transport. + #[cfg(feature = "managed-pts")] + Managed(ManagedTransportOptions), + /// Options for an unmanaged PT transport. + Unmanaged(UnmanagedTransportOptions), +} + +impl TryFrom for TransportOptions { + type Error = tor_error::Bug; + fn try_from(config: TransportConfig) -> Result { + // We rely on the validation performed in `TransportConfigBuilder::validate` to ensure that + // mutually exclusive options were not set. We could do validation again here, but it would + // be error-prone to duplicate the validation logic. We also couldn't check things like if + // `run_on_startup` was `Some`/`None`, since that's only available to the builder. + + if let Some(path) = config.path { + cfg_if::cfg_if! { + if #[cfg(feature = "managed-pts")] { + Ok(TransportOptions::Managed(ManagedTransportOptions { + protocols: config.protocols, + path, + arguments: config.arguments, + run_on_startup: config.run_on_startup, + })) + } else { + let _ = path; + Err(tor_error::internal!( + "Path is set but 'managed-pts' feature is not enabled. How did this pass builder validation?" + )) + } + } + } else if let Some(proxy_addr) = config.proxy_addr { + Ok(TransportOptions::Unmanaged(UnmanagedTransportOptions { + protocols: config.protocols, + proxy_addr, + })) + } else { + Err(tor_error::internal!( + "Neither path nor proxy are set. How did this pass builder validation?" + )) + } + } +} + +/// A pluggable transport that is run as an external process that we launch and monitor. +#[cfg(feature = "managed-pts")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ManagedTransportOptions { + /// See [TransportConfig::protocols]. + pub(crate) protocols: Vec, + + /// See [TransportConfig::path]. + pub(crate) path: CfgPath, + + /// See [TransportConfig::arguments]. + pub(crate) arguments: Vec, + + /// See [TransportConfig::run_on_startup]. + pub(crate) run_on_startup: bool, +} + +/// A pluggable transport running on a local port, not controlled by Arti. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct UnmanagedTransportOptions { + /// See [TransportConfig::protocols]. + pub(crate) protocols: Vec, + + /// See [TransportConfig::proxy_addr]. + pub(crate) proxy_addr: SocketAddr, +} + +impl UnmanagedTransportOptions { + /// A client method that can be used to contact this transport. + #[cfg(feature = "tor-channel-factory")] + pub(crate) fn cmethod(&self) -> PtClientMethod { + PtClientMethod { + // TODO: Someday we might want to support other protocols; + // but for now, let's see if we can get away with just socks5. + kind: SocksVersion::V5, + endpoint: self.proxy_addr, + } + } + + /// Return true if this transport is configured on localhost. + pub(crate) fn is_localhost(&self) -> bool { + self.proxy_addr.ip().is_loopback() + } +} diff --git a/vendor/tor-ptmgr/src/err.rs b/vendor/tor-ptmgr/src/err.rs new file mode 100644 index 0000000000..ee86a01361 --- /dev/null +++ b/vendor/tor-ptmgr/src/err.rs @@ -0,0 +1,172 @@ +//! Errors to do with pluggable transports. + +use fs_mistrust::anon_home::PathExt as _; +use futures::task::SpawnError; +use std::path::PathBuf; +use std::sync::Arc; +use tor_chanmgr::factory::AbstractPtError; +use tor_config_path::{CfgPath, CfgPathError}; +use tor_error::{ErrorKind, HasKind, HasRetryTime, RetryTime}; + +/// An error spawning or managing a pluggable transport. +#[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PtError { + /// We failed to launch a set of pluggable transports in the provided deadline. + #[error("PT launch timed out")] + Timeout, + /// A PT binary does not support a set of pluggable transports. + #[error("PT binary does not support transports: {0:?}")] + ClientTransportsUnsupported(Vec), + /// A PT binary failed to launch a pluggable transport, and reported + /// an error message. + #[error("Transport '{}' failed to launch, saying: {:?}", transport, message)] + TransportGaveError { + /// The transport that failed. + transport: String, + /// The failure message. + message: String, + }, + /// A pluggable transport binary failed to understand us. + #[error("PT reported protocol error: {0}")] + ChildProtocolViolation(String), + /// A pluggable transport binary violated the protocol. + #[error("PT violated protocol: {0}")] + ProtocolViolation(String), + /// A pluggable transport binary doesn't support version 1 of the IPC protocol. + #[error("PT binary uses unsupported protocol version")] + UnsupportedVersion, + /// A pluggable transport binary couldn't use the provided proxy URI. + #[error("PT binary failed to use proxy URI: {0}")] + ProxyError(String), + /// A pluggable transport binary quit or was stopped. + #[error("PT binary gone")] + ChildGone, + /// An error was encountered communicating with a pluggable transport binary. The PT is no + /// longer usable. + #[error("Failed to read from PT binary: {0}")] + ChildReadFailed(Arc), + /// We couldn't spawn a pluggable transport binary as a child process. + #[error("Couldn't execute PT binary at {}: {}", path.anonymize_home(), error)] + ChildSpawnFailed { + /// The binary path we tried to execute. + path: PathBuf, + /// The I/O error returned. + #[source] + error: Arc, + }, + /// We failed to parse something a pluggable transport sent us. + #[error("Couldn't parse IPC line \"{}\": {}", line, error)] + IpcParseFailed { + /// The offending line. + line: String, + /// The error encountered parsing it. + error: String, + }, + /// We couldn't create a state directory. + #[error("Failed to create a state directory at {}: {}", path.anonymize_home(), error)] + StatedirCreateFailed { + /// The offending path. + path: PathBuf, + /// The error encountered. + #[source] + error: Arc, + }, + /// We couldn't expand a path. + #[error("Failed to expand path {}: {}", path, error)] + PathExpansionFailed { + /// The offending path. + path: CfgPath, + /// The error encountered. + #[source] + error: CfgPathError, + }, + /// A binary path does not have the syntax of a *file* name. + /// + /// For example, it ends in a slash, indicating a directory. + // + // TODO: this should be rejected at the configuration parsing level, and treated as a bug here. + #[error("Configured binary path {} doesn't have syntax of a file", path.anonymize_home())] + NotAFile { + /// The offending path. + path: PathBuf, + }, + /// Unable to spawn reactor task. + #[error("Unable to spawn reactor task.")] + Spawn { + /// What happened when we tried to spawn it. + #[source] + cause: Arc, + }, + /// The requested transport was found to be missing due to racing with reconfiguration + #[error("Transport not found due to concurrent reconfiguration")] + // TODO: That this can occur at all is a bug. + // See https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/901#note_2858455 + UnconfiguredTransportDueToConcurrentReconfiguration, + /// The pluggable transport reactor failed. + #[error("Internal error")] + Internal(#[from] tor_error::Bug), +} + +impl HasKind for PtError { + fn kind(&self) -> ErrorKind { + use ErrorKind as EK; + use PtError as E; + match self { + E::ClientTransportsUnsupported(_) => EK::InvalidConfig, + E::ChildProtocolViolation(_) + | E::ProtocolViolation(_) + | E::UnsupportedVersion + | E::IpcParseFailed { .. } => EK::LocalProtocolViolation, + E::Timeout + | E::TransportGaveError { .. } + | E::ChildGone + | E::ChildReadFailed(_) + | E::ChildSpawnFailed { .. } + | E::ProxyError(_) => EK::ExternalToolFailed, + E::StatedirCreateFailed { .. } => EK::PersistentStateAccessFailed, + E::UnconfiguredTransportDueToConcurrentReconfiguration => EK::TransientFailure, + E::PathExpansionFailed { .. } => EK::InvalidConfig, + E::NotAFile { .. } => EK::InvalidConfig, + E::Internal(e) => e.kind(), + E::Spawn { cause, .. } => cause.kind(), + } + } +} + +impl HasRetryTime for PtError { + fn retry_time(&self) -> RetryTime { + use PtError as E; + use RetryTime as RT; + match self { + E::ClientTransportsUnsupported(_) + | E::ChildProtocolViolation(_) + | E::ProtocolViolation(_) + | E::IpcParseFailed { .. } + | E::NotAFile { .. } + | E::UnsupportedVersion + | E::Internal(_) + | E::Spawn { .. } + | E::PathExpansionFailed { .. } => RT::Never, + E::StatedirCreateFailed { .. } + | E::TransportGaveError { .. } + | E::Timeout + | E::UnconfiguredTransportDueToConcurrentReconfiguration + | E::ProxyError(_) + | E::ChildGone + | E::ChildReadFailed(_) => RT::AfterWaiting, + E::ChildSpawnFailed { error, .. } => { + if error.kind() == std::io::ErrorKind::NotFound { + RT::Never + } else { + RT::AfterWaiting + } + } + } + } +} + +impl AbstractPtError for PtError {} + +/// Standard-issue `Result` alias, with [`PtError`]. +pub type Result = std::result::Result; diff --git a/vendor/tor-ptmgr/src/ipc.rs b/vendor/tor-ptmgr/src/ipc.rs new file mode 100644 index 0000000000..55ce70b8e0 --- /dev/null +++ b/vendor/tor-ptmgr/src/ipc.rs @@ -0,0 +1,1477 @@ +//! Launching pluggable transport binaries and communicating with them. +//! +//! This module contains utilities to launch pluggable transports supporting pt-spec.txt +//! version 1, and communicate with them in order to specify configuration parameters and +//! receive updates as to the current state of the PT. + +use crate::err; +use crate::err::PtError; +use crate::PtClientMethod; +use futures::channel::mpsc::Receiver; +use futures::StreamExt; +use itertools::Itertools; +use std::borrow::Cow; +use std::collections::HashMap; +use std::ffi::OsString; +use std::io::{BufRead, BufReader}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::str::FromStr; +use std::sync::{mpsc as std_mpsc, Arc}; +use std::{io, thread}; +use tor_basic_utils::PathExt as _; +use tor_error::{internal, warn_report}; +use tor_linkspec::PtTransportName; +use tor_rtcompat::{Runtime, SleepProviderExt}; +use tor_socksproto::SocksVersion; +use tracing::{debug, error, info, trace, warn}; +use web_time_compat::{Duration, Instant, InstantExt}; + +/// Amount of time we give a pluggable transport child process to exit gracefully. +const GRACEFUL_EXIT_TIME: Duration = Duration::from_secs(5); +/// Poll often enough to notice child exit without delaying the next bridge attempt. +const CHILD_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(50); +/// Default timeout for PT binary startup. +const PT_START_TIMEOUT: Duration = Duration::from_secs(30); +/// Size for the buffer storing pluggable transport stdout lines. +const PT_STDIO_BUFFER: usize = 64; + +/// An arbitrary key/value status update from a pluggable transport. +#[derive(PartialEq, Eq, Debug, Clone)] +pub struct PtStatus { + /// Arbitrary key-value data about the state of this transport, from the binary running + /// said transport. + // NOTE(eta): This is assumed to not have duplicate keys. + data: HashMap, +} + +/// A message sent from a pluggable transport child process. +/// +/// For more in-depth information about these messages, consult pt-spec.txt. +#[derive(PartialEq, Eq, Debug, Clone)] +#[non_exhaustive] +#[cfg_attr(feature = "experimental-api", visibility::make(pub))] +pub enum PtMessage { + /// `VERSION-ERROR`: No compatible pluggable transport specification version was provided. + VersionError(String), + /// `VERSION`: Specifies the version the binary is using for the IPC protocol. + Version(String), + /// `ENV-ERROR`: Reports an error with the provided environment variables. + EnvError(String), + /// `PROXY DONE`: The configured proxy was correctly initialised. + ProxyDone, + /// `PROXY-ERROR`: An error was encountered setting up the configured proxy. + ProxyError(String), + /// `CMETHOD`: A client transport has been launched. + ClientTransportLaunched { + /// The name of the launched transport. + transport: PtTransportName, + /// The protocol used ('socks4' or 'socks5'). + protocol: String, + /// An address to connect via this transport. + /// (This should be localhost.) + endpoint: SocketAddr, + }, + /// `CMETHOD-ERROR`: An error was encountered setting up a client transport. + ClientTransportFailed { + /// The name of the transport. + transport: PtTransportName, + /// The error message. + message: String, + }, + /// `CMETHODS DONE`: All client transports that are supported have been launched. + ClientTransportsDone, + /// `SMETHOD`: A server transport has been launched. + ServerTransportLaunched { + /// The name of the launched transport. + transport: PtTransportName, + /// The endpoint clients should use the reach the transport. + endpoint: SocketAddr, + /// Additional per-transport information. + // NOTE(eta): This assumes it actually is k/v and repeated keys aren't allowed... + options: HashMap, + }, + /// `SMETHOD-ERROR`: An error was encountered setting up a server transport. + ServerTransportFailed { + /// The name of the transport. + transport: PtTransportName, + /// The error message. + message: String, + }, + /// `SMETHODS DONE`: All server transports that are supported have been launched. + ServerTransportsDone, + /// `LOG`: A log message. + Log { + /// The severity (one of 'error', 'warning', 'notice', 'info', 'debug'). + severity: String, + /// The log message. + message: String, + }, + /// `STATUS`: Arbitrary key/value status messages. + Status(PtStatus), + /// A line containing an unknown command. + Unknown(String), +} + +/// Parse a value (something on the RHS of an =), which could be a CString as defined by +/// control-spec.txt §2. Returns (value, unparsed rest of string). +fn parse_one_value(from: &str) -> Result<(String, &str), &'static str> { + let first_char = from.chars().next(); + Ok(if first_char.is_none() { + (String::new(), "") + } else if let Some('"') = first_char { + // This is a CString, so we're going to need to parse it char-by-char. + // FIXME(eta): This currently doesn't parse octal escape codes, even though the spec says + // we should. That's finicky, though, and probably not used. + let mut ret = String::new(); + let mut chars = from.chars(); + assert_eq!(chars.next(), Some('"')); // discard " + loop { + let ch = chars.next().ok_or("ran out of input parsing CString")?; + match ch { + '\\' => match chars + .next() + .ok_or("encountered trailing backslash in CString")? + { + 'n' => ret.push('\n'), + 'r' => ret.push('\r'), + 't' => ret.push('\t'), + '0'..='8' => return Err("attempted unsupported octal escape code"), + ch2 => ret.push(ch2), + }, + '"' => break, + _ => ret.push(ch), + } + } + (ret, chars.as_str()) + } else { + // Simple: just find the space + if let Some((start, rest)) = from.split_once(' ') { + (start.to_string(), rest) + } else { + (from.to_string(), "") + } + }) +} + +/// Chomp one key/value pair off a list of smethod args. +/// Returns (k, v, unparsed rest of string). +/// Will also chomp the comma at the end, if there is one. +fn parse_one_smethod_arg(args: &str) -> Result<(String, String, &str), &'static str> { + // NOTE(eta): Apologies for this looking a bit gnarly. Ideally, this is what you'd use + // something like `nom` for, but I didn't want to bring in a dep just for this. + + let mut key = String::new(); + let mut val = String::new(); + // If true, we're reading the value, not the key. + let mut reading_val = false; + let mut chars = args.chars(); + while let Some(c) = chars.next() { + let target = if reading_val { &mut val } else { &mut key }; + match c { + '\\' => { + let c = chars + .next() + .ok_or("smethod arg terminates with backslash")?; + target.push(c); + } + '=' => { + if reading_val { + return Err("encountered = while parsing value"); + } + reading_val = true; + } + ',' => break, + c => target.push(c), + } + } + if !reading_val { + return Err("ran out of chars parsing smethod arg"); + } + Ok((key, val, chars.as_str())) +} + +impl FromStr for PtMessage { + type Err = Cow<'static, str>; + + // NOTE(eta): This, of course, implies that the PT IPC communications are valid UTF-8. + // This assumption might turn out to be false. + fn from_str(s: &str) -> Result { + // TODO(eta): Maybe tolerate additional whitespace (using `split_whitespace`)?. + // This requires modified words.join() logic, though. + let mut words = s.split(' '); + let first_word = words.next().ok_or_else(|| Cow::from("empty line"))?; + Ok(match first_word { + "VERSION-ERROR" => { + let rest = words.join(" "); + Self::VersionError(rest) + } + "VERSION" => { + let vers = words.next().ok_or_else(|| Cow::from("no version"))?; + Self::Version(vers.into()) + } + "ENV-ERROR" => { + let rest = words.join(" "); + Self::EnvError(rest) + } + "PROXY" => match words.next() { + Some("DONE") => Self::ProxyDone, + _ => Self::Unknown(s.into()), + }, + "PROXY-ERROR" => { + let rest = words.join(" "); + Self::ProxyError(rest) + } + "CMETHOD" => { + let transport = words.next().ok_or_else(|| Cow::from("no transport"))?; + let protocol = words.next().ok_or_else(|| Cow::from("no protocol"))?; + let endpoint = words + .next() + .ok_or_else(|| Cow::from("no endpoint"))? + .parse::() + .map_err(|e| Cow::from(format!("failed to parse endpoint: {}", e)))?; + if !endpoint.ip().is_loopback() { + return Err(Cow::from(format!( + "CMETHOD endpoint {endpoint} was not localhost" + ))); + } + Self::ClientTransportLaunched { + transport: transport + .parse() + .map_err(|_| Cow::from("bad transport ID"))?, + protocol: protocol.to_string(), + endpoint, + } + } + "CMETHOD-ERROR" => { + let transport = words.next().ok_or_else(|| Cow::from("no transport"))?; + let rest = words.join(" "); + Self::ClientTransportFailed { + transport: transport + .parse() + .map_err(|_| Cow::from("bad transport ID"))?, + message: rest, + } + } + "CMETHODS" => match words.next() { + Some("DONE") => Self::ClientTransportsDone, + _ => Self::Unknown(s.into()), + }, + "SMETHOD" => { + let transport = words.next().ok_or_else(|| Cow::from("no transport"))?; + let endpoint = words + .next() + .ok_or_else(|| Cow::from("no endpoint"))? + .parse::() + .map_err(|e| Cow::from(format!("failed to parse endpoint: {}", e)))?; + // The SMETHOD endpoint is the place where _clients_ connect, and it shouldn't be localhost. + let mut parsed_args = HashMap::new(); + + // NOTE(eta): pt-spec.txt seems to imply these options can't contain spaces, so + // we work under that assumption. + // It also doesn't actually parse them out -- but seeing as the API to + // feed these back in will want them as separated k/v pairs, I think + // it makes sense to here. + for option in words { + if let Some(mut args) = option.strip_prefix("ARGS:") { + while !args.is_empty() { + let (k, v, rest) = parse_one_smethod_arg(args).map_err(|e| { + Cow::from(format!("failed to parse SMETHOD ARGS: {}", e)) + })?; + if parsed_args.contains_key(&k) { + // At least check our assumption that this is actually k/v + // and not Vec<(String, String)>. + warn!("PT SMETHOD arguments contain repeated key {}!", k); + } + parsed_args.insert(k, v); + args = rest; + } + } + } + Self::ServerTransportLaunched { + transport: transport + .parse() + .map_err(|_| Cow::from("bad transport ID"))?, + endpoint, + options: parsed_args, + } + } + "SMETHOD-ERROR" => { + let transport = words.next().ok_or_else(|| Cow::from("no transport"))?; + let rest = words.join(" "); + Self::ServerTransportFailed { + transport: transport + .parse() + .map_err(|_| Cow::from("bad transport ID"))?, + message: rest, + } + } + "SMETHODS" => match words.next() { + Some("DONE") => Self::ServerTransportsDone, + _ => Self::Unknown(s.into()), + }, + "LOG" => { + let severity = words + .next() + .ok_or_else(|| Cow::from("no severity"))? + .strip_prefix("SEVERITY=") + .ok_or_else(|| Cow::from("badly formatted severity"))?; + let message = words.join(" "); + let message = parse_one_value( + message + .strip_prefix("MESSAGE=") + .ok_or_else(|| Cow::from("no or badly formatted message"))?, + ) + .map_err(Cow::from)? + .0; + Self::Log { + severity: severity.into(), + message, + } + } + "STATUS" => { + let mut ret = HashMap::new(); + let message = words.join(" "); + let mut message = &message as &str; + while !message.is_empty() { + let (k, rest) = message + .split_once('=') + .ok_or_else(|| Cow::from(format!("failed to find = in '{}'", message)))?; + if rest.is_empty() { + return Err(Cow::from("key with no value")); + } + let (v, rest) = parse_one_value(rest).map_err(Cow::from)?; + if ret.contains_key(k) { + // At least check our assumption that this is actually k/v + // and not Vec<(String, String)>. + warn!("STATUS contains repeated key {}!", k); + } + ret.insert(k.to_owned(), v); + message = rest; + if let Some(remainder) = message.strip_prefix(" ") { + message = remainder; + } + } + Self::Status(PtStatus { data: ret }) + } + _ => Self::Unknown(s.into()), + }) + } +} + +use sealed::*; +/// Sealed trait to protect private types and default trait implementations +pub(crate) mod sealed { + use super::*; + + /// Own a PT process independently of its blocking stdout reader. + /// + /// In particular, shutdown must not depend on the child producing another + /// stdout line: a successfully initialized PT is commonly silent while it + /// waits for SOCKS connections. The explicit shutdown channel lets dropping + /// [`AsyncPtChild`] close stdin immediately, then kill and reap a child that + /// does not honor `TOR_PT_EXIT_ON_STDIN_CLOSE` within the grace period. + fn supervise_pt_child( + mut child: Child, + stdin: ChildStdin, + shutdown: std_mpsc::Receiver<()>, + ident: String, + ) { + // Observe both normal child exit and an explicit owner shutdown. A + // blocking Child::wait() cannot be used here because it would prevent + // the shutdown channel from waking this supervisor. + loop { + match child.try_wait() { + Ok(Some(status)) => { + debug!("PT {} exited with status {}.", ident, status); + return; + } + Ok(None) => {} + Err(e) => { + warn_report!(e, "Failed to call try_wait() on spawned PT {}", ident); + break; + } + } + + match shutdown.recv_timeout(CHILD_STATUS_POLL_INTERVAL) { + Ok(()) | Err(std_mpsc::RecvTimeoutError::Disconnected) => break, + Err(std_mpsc::RecvTimeoutError::Timeout) => {} + } + } + + trace!("Asking PT {} to exit, nicely.", ident); + // Managed transports are started with TOR_PT_EXIT_ON_STDIN_CLOSE=1. + // webtunnelclient responds to this EOF by stopping its listener and + // terminating, so stdin must be owned outside the blocking stdout reader. + drop(stdin); + + let deadline = Instant::get() + GRACEFUL_EXIT_TIME; + loop { + match child.try_wait() { + Ok(Some(status)) => { + debug!( + "PT {} shut down successfully with status {}.", + ident, status + ); + return; + } + Ok(None) => {} + Err(e) => { + warn_report!(e, "Failed to call try_wait() on spawned PT {}", ident); + break; + } + } + + let remaining = deadline.saturating_duration_since(Instant::get()); + if remaining.is_zero() { + break; + } + thread::sleep(CHILD_STATUS_POLL_INTERVAL.min(remaining)); + } + + debug!("Sending kill signal to PT {}", ident); + if let Err(e) = child.kill() { + // The child can exit between the last try_wait() and kill(). Check + // once more before reporting a failed forced shutdown. + match child.try_wait() { + Ok(Some(status)) => { + debug!("PT {} exited with status {} before kill().", ident, status); + } + _ => warn_report!(e, "Failed to kill() spawned PT {}", ident), + } + return; + } + + // Child::kill() does not reap the process. Waiting here prevents a + // terminated PT from remaining as a zombie on Unix. + match child.wait() { + Ok(status) => debug!("PT {} was killed and reaped with status {}.", ident, status), + Err(e) => warn_report!(e, "Failed to wait() for killed PT {}", ident), + } + } + + /// A handle to receive lines from a pluggable transport process' stdout asynchronously. + // + // FIXME(eta): This currently spawns an OS thread, since there's no other way to do this without + // being async-runtime dependent (or adding process spawning to tor-rtcompat). + #[derive(Debug)] + pub struct AsyncPtChild { + /// Channel to receive lines from the child process stdout. + stdout: Receiver>, + /// Wake the process supervisor without waiting for child stdout. + shutdown: std_mpsc::Sender<()>, + /// Joined on drop so a failed PT is gone before another one is started. + supervisor: Option>, + /// Identifier to put in logging messages. + pub identifier: String, + } + + impl AsyncPtChild { + /// Wrap an OS child process by spawning a worker thread to forward output from the child + /// to the asynchronous runtime via use of a channel. + pub fn new(mut child: Child, identifier: String) -> Result { + let (stdin, stdout) = ( + child.stdin.take().ok_or_else(|| { + PtError::Internal(internal!("Created child process without stdin pipe")) + })?, + child.stdout.take().ok_or_else(|| { + PtError::Internal(internal!("Created child process without stdout pipe")) + })?, + ); + // TODO RELAY #1649 We don't use a tor_memquota::mq_queue here yet + let (mut tx, rx) = tor_async_utils::mpsc_channel_no_memquota(PT_STDIO_BUFFER); + let reader_ident = identifier.clone(); + // Keep Child and ChildStdin out of this closure. A functioning PT is + // normally silent after negotiation, so this blocking stdout read + // cannot be relied upon to notice that AsyncPtChild was dropped. + thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut noted_full = false; + // Forward lines from the blocking reader to the async channel. + for line in reader.lines() { + let err = line.is_err(); + match &line { + Ok(l) => trace!("<-- PT {}: {:?}", reader_ident, l), + Err(e) => trace!("<-- PT {}: Error: {:?}", reader_ident, e), + } + if let Err(e) = tx.try_send(line) { + if e.is_disconnected() { + debug!("PT {} output receiver is disconnected.", reader_ident); + break; + } + // The other kind of error is "full", which we can't do anything about. + // Just throw the line away. + if !noted_full { + noted_full = true; // warn only once per PT. + warn!( + "Bug: Message queue for PT {} became full; dropping message", + reader_ident + ); + } + } + if err { + // Encountered an error reading, so ensure the process is shut down (it's + // probably "broken pipe" anyway, so this is slightly redundant, but the + // rest of the code assumes errors are nonrecoverable). + break; + } + } + }); + + let (shutdown_tx, shutdown_rx) = std_mpsc::channel(); + let supervisor_ident = identifier.clone(); + // The separate owner can be woken explicitly even while the stdout + // thread is blocked, allowing failed bridge retries to close the old + // transport before starting a new webtunnelclient. + let supervisor = thread::spawn(move || { + supervise_pt_child(child, stdin, shutdown_rx, supervisor_ident) + }); + + Ok(AsyncPtChild { + stdout: rx, + shutdown: shutdown_tx, + supervisor: Some(supervisor), + identifier, + }) + } + + /// Receive a message from the pluggable transport binary asynchronously. + /// + /// Note: This will convert `PtMessage::Log` into a tracing log call automatically. + pub async fn recv(&mut self) -> err::Result { + loop { + match self.stdout.next().await { + None => return Err(PtError::ChildGone), + Some(Ok(line)) => { + let line = + line.parse::() + .map_err(|e| PtError::IpcParseFailed { + line, + error: e.into(), + })?; + if let PtMessage::Log { severity, message } = line { + // FIXME(eta): I wanted to make this integrate with `tracing` more nicely, + // but gave up after 15 minutes of clicking through spaghetti. + match &severity as &str { + "error" => error!("[pt {}] {}", self.identifier, message), + "warning" => warn!("[pt {}] {}", self.identifier, message), + "notice" => info!("[pt {}] {}", self.identifier, message), + "info" => debug!("[pt {}] {}", self.identifier, message), + "debug" => trace!("[pt {}] {}", self.identifier, message), + x => warn!("[pt] {} {} {}", self.identifier, x, message), + } + } else { + return Ok(line); + } + } + Some(Err(e)) => { + return Err(PtError::ChildReadFailed(Arc::new(e))); + } + } + } + } + } + + impl Drop for AsyncPtChild { + fn drop(&mut self) { + // Arti drops this handle when it discards a failed bridge. Wake the + // process owner directly; waiting for another stdout line caused + // quiet webtunnelclient instances to survive into later retries. + // The supervisor may already have reaped a normally exited child, + // in which case send() harmlessly returns an error. + let _ = self.shutdown.send(()); + // Joining is intentional: when Drop returns, the previous bridge's + // process has exited or has gone through the bounded kill path, so + // the next attempt cannot accumulate another live PT instance. + if let Some(supervisor) = self.supervisor.take() { + if supervisor.join().is_err() { + warn!("PT {} process supervisor panicked", self.identifier); + } + } + } + } + + /// Defines some helper methods that are required later on + #[async_trait::async_trait] + pub trait PluggableTransportPrivate { + /// Return the [`AsyncPtChild`] if it exists + fn inner(&mut self) -> Result<&mut AsyncPtChild, PtError>; + + /// Set the [`AsyncPtChild`] + fn set_inner(&mut self, newval: Option); + + /// Return a loggable identifier for this transport. + fn identifier(&self) -> &str; + + /// Checks whether a transport is specified in our specific parameters + fn specific_params_contains(&self, transport: &PtTransportName) -> bool; + + /// Common handler for `ClientTransportLaunched` and `ServerTransportLaunched` + fn common_transport_launched_handler( + &self, + protocol: Option, + transport: PtTransportName, + endpoint: SocketAddr, + methods: &mut HashMap, + ) -> Result<(), PtError> { + if !self.specific_params_contains(&transport) { + return Err(PtError::ProtocolViolation(format!( + "binary launched unwanted transport '{}'", + transport + ))); + } + let protocol = match protocol { + Some(protocol_str) => match &protocol_str as &str { + "socks4" => SocksVersion::V4, + "socks5" => SocksVersion::V5, + x => { + return Err(PtError::ProtocolViolation(format!( + "unknown CMETHOD protocol '{}'", + x + ))); + } + }, + None => SocksVersion::V5, + }; + let method = PtClientMethod { + kind: protocol, + endpoint, + }; + info!("Transport '{}' uses method {:?}", transport, method); + methods.insert(transport, method); + Ok(()) + } + + /// Attempt to launch the PT and return the corresponding `[AsyncPtChild]` + fn get_child_from_pt_launch( + inner: &Option, + transports: &Vec, + binary_path: &PathBuf, + arguments: &[String], + all_env_vars: HashMap, + ) -> Result { + if inner.is_some() { + let warning_msg = + format!("Attempted to launch PT binary for {:?} twice.", transports); + warn!("{warning_msg}"); + // WARN: this may not be the correct error to throw here + return Err(PtError::ChildProtocolViolation(warning_msg)); + } + info!( + "Launching pluggable transport at {} for {:?}", + binary_path.display_lossy(), + transports + ); + let child = Command::new(binary_path) + .args(arguments.iter()) + .envs(all_env_vars) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| PtError::ChildSpawnFailed { + path: binary_path.clone(), + error: Arc::new(e), + })?; + + let identifier = crate::managed::pt_identifier(binary_path)?; + AsyncPtChild::new(child, identifier) + } + + /// Consolidates some of the [`PtMessage`] potential matches to + /// deduplicate code + /// + /// Note that getting a [`PtMessage`] from this method implies that + /// the method was unable to match it and thus you should continue handling + /// the message. Getting [`None`] after error handling means that a match + /// was found and the appropriate action was successfully taken, and you don't + /// need to worry about it. + async fn try_match_common_messages( + &self, + rt: &R, + deadline: Instant, + async_child: &mut AsyncPtChild, + ) -> Result, PtError> { + match rt + .timeout( + // FIXME(eta): It'd be nice if SleepProviderExt took an `Instant` natively. + deadline.saturating_duration_since(Instant::get()), + async_child.recv(), + ) + .await + .map_err(|_| PtError::Timeout)?? + { + PtMessage::ClientTransportFailed { transport, message } + | PtMessage::ServerTransportFailed { transport, message } => { + warn!( + "PT {} unable to launch {}. It said: {:?}", + async_child.identifier, transport, message + ); + return Err(PtError::TransportGaveError { + transport: transport.to_string(), + message, + }); + } + PtMessage::VersionError(e) => { + if e != "no-version" { + warn!("weird VERSION-ERROR: {}", e); + } + return Err(PtError::UnsupportedVersion); + } + PtMessage::Version(vers) => { + if vers != "1" { + return Err(PtError::ProtocolViolation(format!( + "stated version is {}, asked for 1", + vers + ))); + } + Ok(None) + } + PtMessage::EnvError(e) => return Err(PtError::ChildProtocolViolation(e)), + PtMessage::ProxyError(e) => return Err(PtError::ProxyError(e)), + // TODO(eta): We don't do anything with these right now! + PtMessage::Status(_) => Ok(None), + PtMessage::Unknown(x) => { + warn!("unknown PT line: {}", x); + Ok(None) + } + // Return the PtMessage as it is for further processing + // TODO: handle [`PtError::ProtocolViolation`] here somehow + x => { + return Ok(Some(x)); + } + } + } + } +} + +/// Common parameters passed to a pluggable transport. +#[derive(PartialEq, Eq, Clone, Debug, derive_builder::Builder)] +pub struct PtCommonParameters { + /// A path where the launched PT can store state. + state_location: PathBuf, + /// An IPv4 address to bind outgoing connections to (if specified). + /// + /// Leaving this out will mean the PT uses a sane default. + #[builder(default)] + outbound_bind_v4: Option, + /// An IPv6 address to bind outgoing connections to (if specified). + /// + /// Leaving this out will mean the PT uses a sane default. + #[builder(default)] + outbound_bind_v6: Option, + /// The maximum time we should wait for a pluggable transport binary to report successful + /// initialization. If `None`, a default value is used. + #[builder(default)] + timeout: Option, +} + +impl PtCommonParameters { + /// Return a new `PtCommonParametersBuilder` for constructing a set of parameters. + pub fn builder() -> PtCommonParametersBuilder { + PtCommonParametersBuilder::default() + } + + /// Convert these parameters into a set of environment variables to be passed to the PT binary + /// in accordance with the specification. + fn common_environment_variables(&self) -> HashMap { + let mut ret = HashMap::new(); + ret.insert("TOR_PT_MANAGED_TRANSPORT_VER".into(), "1".into()); + ret.insert( + "TOR_PT_STATE_LOCATION".into(), + self.state_location.clone().into_os_string(), + ); + ret.insert("TOR_PT_EXIT_ON_STDIN_CLOSE".into(), "1".into()); + if let Some(v4) = self.outbound_bind_v4 { + ret.insert( + "TOR_PT_OUTBOUND_BIND_ADDRESS_V4".into(), + v4.to_string().into(), + ); + } + if let Some(v6) = self.outbound_bind_v6 { + // pt-spec.txt: "IPv6 addresses MUST always be wrapped in square brackets." + ret.insert( + "TOR_PT_OUTBOUND_BIND_ADDRESS_V6".into(), + format!("[{}]", v6).into(), + ); + } + ret + } +} + +/// Parameters passed only to a pluggable transport client. +#[derive(PartialEq, Eq, Clone, Debug, derive_builder::Builder)] +pub struct PtClientParameters { + /// A SOCKS URI specifying a proxy to use. + #[builder(default)] + proxy_uri: Option, + /// A list of transports to initialise. + /// + /// The PT launch will fail if all transports are not successfully initialised. + transports: Vec, +} + +impl PtClientParameters { + /// Return a new `PtClientParametersBuilder` for constructing a set of parameters. + pub fn builder() -> PtClientParametersBuilder { + PtClientParametersBuilder::default() + } + + /// Convert these parameters into a set of environment variables to be passed to the PT binary + /// in accordance with the specification. + fn environment_variables( + &self, + common_params: &PtCommonParameters, + ) -> HashMap { + let mut ret = common_params.common_environment_variables(); + if let Some(ref proxy_uri) = self.proxy_uri { + ret.insert("TOR_PT_PROXY".into(), proxy_uri.clone().into()); + } + ret.insert( + "TOR_PT_CLIENT_TRANSPORTS".into(), + self.transports.iter().join(",").into(), + ); + ret + } +} + +/// Parameters passed only to a pluggable transport server. +#[derive(PartialEq, Eq, Clone, Debug, derive_builder::Builder)] +pub struct PtServerParameters { + /// A list of transports to initialise. + /// + /// The PT launch will fail if all transports are not successfully initialised. + transports: Vec, + /// Transport options for each server transport + #[builder(default)] + server_transport_options: String, + /// Set host:port on which the server transport should listen for connections + #[builder(default)] + server_bindaddr: String, + /// Set host:port on which the server transport should forward requests + #[builder(default)] + server_orport: Option, + /// Set host:port on which the server transport should forward requests (extended ORPORT) + #[builder(default)] + server_extended_orport: Option, +} + +impl PtServerParameters { + /// Return a new `PtServerParametersBuilder` for constructing a set of parameters. + pub fn builder() -> PtServerParametersBuilder { + PtServerParametersBuilder::default() + } + + /// Convert these parameters into a set of environment variables to be passed to the PT binary + /// in accordance with the specification. + fn environment_variables( + &self, + common_params: &PtCommonParameters, + ) -> HashMap { + let mut ret = common_params.common_environment_variables(); + ret.insert( + "TOR_PT_SERVER_TRANSPORTS".into(), + self.transports.iter().join(",").into(), + ); + ret.insert( + "TOR_PT_SERVER_TRANSPORT_OPTIONS".into(), + self.server_transport_options.clone().into(), + ); + ret.insert( + "TOR_PT_SERVER_BINDADDR".into(), + self.server_bindaddr.clone().into(), + ); + if let Some(ref server_orport) = self.server_orport { + ret.insert("TOR_PT_ORPORT".into(), server_orport.into()); + } + if let Some(ref server_extended_orport) = self.server_extended_orport { + ret.insert( + "TOR_PT_EXTENDED_SERVER_PORT".into(), + server_extended_orport.into(), + ); + } + ret + } +} + +/// Common functionality implemented to allow code reuse +#[async_trait::async_trait] +#[cfg_attr(feature = "experimental-api", visibility::make(pub))] +pub trait PluggableTransport: PluggableTransportPrivate { + /// Get all client methods returned by the binary, if it has been launched. + /// + /// If it hasn't been launched, the returned map will be empty. + // TODO(eta): Actually figure out a way to expose this more stably. + fn transport_methods(&self) -> &HashMap; + + /// Get the next [`PtMessage`] from the running transport. It is recommended to call this + /// in a loop once a PT has been launched, in order to forward log messages and find out about + /// status updates. + // + // FIXME(eta): This API will probably go away and get replaced with something better. + // In particular, we'd want to cache `Status` messages from before this method + // was called. + async fn next_message(&mut self) -> err::Result { + let inner = self.inner()?; + let ret = inner.recv().await; + if let Err(PtError::ChildGone) | Err(PtError::ChildReadFailed { .. }) = &ret { + // FIXME(eta): Currently this lets the caller still think the methods work by calling + // transport_methods. + debug!( + "PT {}: Received {:?}; shutting down.", + self.identifier(), + ret + ); + self.set_inner(None); + } + ret + } +} +/// A pluggable transport binary in a child process. +/// +/// These start out inert, and must be launched with [`PluggableClientTransport::launch`] in order +/// to be useful. +#[derive(Debug)] +pub struct PluggableClientTransport { + /// The currently running child, if there is one. + inner: Option, + /// The path to the binary to run. + pub(crate) binary_path: PathBuf, + /// Arguments to pass to the binary. + arguments: Vec, + /// Configured parameters. + common_params: PtCommonParameters, + /// Configured client-only parameters. + client_params: PtClientParameters, + /// Information about client methods obtained from the PT. + cmethods: HashMap, +} + +impl PluggableTransport for PluggableClientTransport { + fn transport_methods(&self) -> &HashMap { + &self.cmethods + } +} + +impl PluggableTransportPrivate for PluggableClientTransport { + fn inner(&mut self) -> Result<&mut AsyncPtChild, PtError> { + self.inner.as_mut().ok_or(PtError::ChildGone) + } + fn set_inner(&mut self, newval: Option) { + self.inner = newval; + } + fn identifier(&self) -> &str { + match &self.inner { + Some(child) => &child.identifier, + None => "", + } + } + fn specific_params_contains(&self, transport: &PtTransportName) -> bool { + self.client_params.transports.contains(transport) + } +} + +impl PluggableClientTransport { + /// Create a new pluggable transport wrapper, wrapping the binary at `binary_path` and passing + /// the `params` to it. + /// + /// You must call [`PluggableClientTransport::launch`] to actually run the PT. + pub fn new( + binary_path: PathBuf, + arguments: Vec, + common_params: PtCommonParameters, + client_params: PtClientParameters, + ) -> Self { + Self { + common_params, + client_params, + arguments, + binary_path, + inner: None, + cmethods: Default::default(), + } + } + + /// Launch the pluggable transport, executing the binary. + /// + /// Will return an error if the launch fails, one of the transports fail, not all transports + /// were launched, or the launch times out. + pub async fn launch(&mut self, rt: R) -> err::Result<()> { + let all_env_vars = self + .client_params + .environment_variables(&self.common_params); + + let mut async_child = + ::get_child_from_pt_launch( + &self.inner, + &self.client_params.transports, + &self.binary_path, + &self.arguments, + all_env_vars, + )?; + + let deadline = Instant::get() + self.common_params.timeout.unwrap_or(PT_START_TIMEOUT); + let mut cmethods = HashMap::new(); + let mut proxy_done = self.client_params.proxy_uri.is_none(); + + loop { + match self + .try_match_common_messages(&rt, deadline, &mut async_child) + .await + { + Ok(maybe_message) => { + if let Some(message) = maybe_message { + match message { + PtMessage::ClientTransportLaunched { + transport, + protocol, + endpoint, + } => { + self.common_transport_launched_handler( + Some(protocol), + transport, + endpoint, + &mut cmethods, + )?; + } + PtMessage::ProxyDone => { + if proxy_done { + return Err(PtError::ProtocolViolation( + "binary initiated proxy when not asked (or twice)".into(), + )); + } + info!("PT binary now proxying connections via supplied URI"); + proxy_done = true; + } + // TODO: unify most of the handling of ClientTransportsDone with ServerTransportsDone + PtMessage::ClientTransportsDone => { + let unsupported = self + .client_params + .transports + .iter() + .filter(|&x| !cmethods.contains_key(x)) + .map(|x| x.to_string()) + .collect::>(); + if !unsupported.is_empty() { + warn!( + "PT binary failed to initialise transports: {:?}", + unsupported + ); + return Err(PtError::ClientTransportsUnsupported(unsupported)); + } + info!("PT binary initialisation done"); + break; + } + x => { + return Err(PtError::ProtocolViolation(format!( + "received unexpected {:?}", + x + ))); + } + } + } + } + Err(e) => return Err(e), + } + } + self.cmethods = cmethods; + self.inner = Some(async_child); + // TODO(eta): We need to expose the log and status messages after this function exits! + Ok(()) + } +} + +/// A pluggable transport server binary in a child process. +/// +/// These start out inert, and must be launched with [`PluggableServerTransport::launch`] in order +/// to be useful. +#[derive(Debug)] +pub struct PluggableServerTransport { + /// The currently running child, if there is one. + inner: Option, + /// The path to the binary to run. + pub(crate) binary_path: PathBuf, + /// Arguments to pass to the binary. + arguments: Vec, + /// Configured parameters. + common_params: PtCommonParameters, + /// Configured server-only parameters. + server_params: PtServerParameters, + /// Information about server methods obtained from the PT. + smethods: HashMap, +} + +impl PluggableTransportPrivate for PluggableServerTransport { + fn inner(&mut self) -> Result<&mut AsyncPtChild, PtError> { + self.inner.as_mut().ok_or(PtError::ChildGone) + } + fn set_inner(&mut self, newval: Option) { + self.inner = newval; + } + fn identifier(&self) -> &str { + match &self.inner { + Some(child) => &child.identifier, + None => "", + } + } + fn specific_params_contains(&self, transport: &PtTransportName) -> bool { + self.server_params.transports.contains(transport) + } +} + +impl PluggableTransport for PluggableServerTransport { + fn transport_methods(&self) -> &HashMap { + &self.smethods + } +} + +impl PluggableServerTransport { + /// Create a new pluggable transport wrapper, wrapping the binary at `binary_path` and passing + /// the `params` to it. + /// + /// You must call [`PluggableServerTransport::launch`] to actually run the PT. + pub fn new( + binary_path: PathBuf, + arguments: Vec, + common_params: PtCommonParameters, + server_params: PtServerParameters, + ) -> Self { + Self { + common_params, + server_params, + arguments, + binary_path, + inner: None, + smethods: Default::default(), + } + } + + /// Launch the pluggable transport, executing the binary. + /// + /// Will return an error if the launch fails, one of the transports fail, not all transports + /// were launched, or the launch times out. + pub async fn launch(&mut self, rt: R) -> err::Result<()> { + let all_env_vars = self + .server_params + .environment_variables(&self.common_params); + + let mut async_child = + ::get_child_from_pt_launch( + &self.inner, + &self.server_params.transports, + &self.binary_path, + &self.arguments, + all_env_vars, + )?; + + let deadline = Instant::get() + self.common_params.timeout.unwrap_or(PT_START_TIMEOUT); + let mut smethods = HashMap::new(); + + loop { + match self + .try_match_common_messages(&rt, deadline, &mut async_child) + .await + { + Ok(maybe_message) => { + if let Some(message) = maybe_message { + match message { + PtMessage::ServerTransportLaunched { + transport, + endpoint, + options: _, + } => { + self.common_transport_launched_handler( + None, + transport, + endpoint, + &mut smethods, + )?; + } + PtMessage::ServerTransportsDone => { + let unsupported = self + .server_params + .transports + .iter() + .filter(|&x| !smethods.contains_key(x)) + .map(|x| x.to_string()) + .collect::>(); + if !unsupported.is_empty() { + warn!( + "PT binary failed to initialise transports: {:?}", + unsupported + ); + return Err(PtError::ClientTransportsUnsupported(unsupported)); + } + info!("PT binary initialisation done"); + break; + } + x => { + return Err(PtError::ProtocolViolation(format!( + "received unexpected {:?}", + x + ))); + } + } + } + } + Err(e) => return Err(e), + } + } + self.smethods = smethods; + self.inner = Some(async_child); + // TODO(eta): We need to expose the log and status messages after this function exits! + Ok(()) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + + use crate::ipc::{sealed::AsyncPtChild, PtMessage, PtStatus}; + use std::borrow::Cow; + use std::collections::HashMap; + use std::fs; + use std::io::Read; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::process::{Command, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + use std::time::{Duration as StdDuration, Instant as StdInstant}; + + const QUIET_CHILD_READY_ENV: &str = "TOR_PTMGR_TEST_QUIET_CHILD_READY"; + const QUIET_CHILD_STOPPED_ENV: &str = "TOR_PTMGR_TEST_QUIET_CHILD_STOPPED"; + static QUIET_CHILD_TEST_ID: AtomicU64 = AtomicU64::new(0); + + /// Subprocess helper for `dropping_quiet_child_stops_and_reaps_process`. + /// + /// The regular test invocation has no marker environment and returns. The + /// spawned invocation remains silent and waits solely for stdin EOF, which + /// reproduces webtunnelclient after its initial PT negotiation completes. + #[test] + fn quiet_pt_child_helper() { + let Some(ready_path) = std::env::var_os(QUIET_CHILD_READY_ENV) else { + return; + }; + let stopped_path = std::env::var_os(QUIET_CHILD_STOPPED_ENV) + .expect("quiet PT child missing stopped marker path"); + + fs::write(ready_path, b"ready").unwrap(); + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + fs::write(stopped_path, b"stopped").unwrap(); + } + + fn wait_for_test_marker(path: &std::path::Path) { + let deadline = StdInstant::now() + StdDuration::from_secs(5); + while !path.exists() && StdInstant::now() < deadline { + thread::sleep(StdDuration::from_millis(10)); + } + assert!(path.exists(), "timed out waiting for {}", path.display()); + } + + #[test] + fn dropping_quiet_child_stops_and_reaps_process() { + let id = QUIET_CHILD_TEST_ID.fetch_add(1, Ordering::Relaxed); + let marker_prefix = format!("tor-ptmgr-quiet-child-{}-{id}", std::process::id()); + let ready_path = std::env::temp_dir().join(format!("{marker_prefix}-ready")); + let stopped_path = std::env::temp_dir().join(format!("{marker_prefix}-stopped")); + + let child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ipc::test::quiet_pt_child_helper", "--nocapture"]) + .env(QUIET_CHILD_READY_ENV, &ready_path) + .env(QUIET_CHILD_STOPPED_ENV, &stopped_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let child = AsyncPtChild::new(child, "quiet-test-pt".into()).unwrap(); + wait_for_test_marker(&ready_path); + // Let the stdout worker consume the test harness's startup output and + // block on the now-quiet child before disconnecting its receiver. + thread::sleep(StdDuration::from_millis(100)); + + drop(child); + + // Drop joins the supervisor, so the EOF-aware helper must have exited + // and the child must have been reaped before control returns here. + assert!(stopped_path.exists()); + let _ = fs::remove_file(ready_path); + let _ = fs::remove_file(stopped_path); + } + + #[test] + fn it_parses_spec_examples() { + assert_eq!( + "VERSION-ERROR no-version".parse(), + Ok(PtMessage::VersionError("no-version".into())) + ); + assert_eq!("VERSION 1".parse(), Ok(PtMessage::Version("1".into()))); + assert_eq!( + "ENV-ERROR No TOR_PT_AUTH_COOKIE_FILE when TOR_PT_EXTENDED_SERVER_PORT set".parse(), + Ok(PtMessage::EnvError( + "No TOR_PT_AUTH_COOKIE_FILE when TOR_PT_EXTENDED_SERVER_PORT set".into() + )) + ); + assert_eq!("PROXY DONE".parse(), Ok(PtMessage::ProxyDone)); + assert_eq!( + "PROXY-ERROR SOCKS 4 upstream proxies unsupported".parse(), + Ok(PtMessage::ProxyError( + "SOCKS 4 upstream proxies unsupported".into() + )) + ); + assert_eq!( + "CMETHOD trebuchet socks5 127.0.0.1:19999".parse(), + Ok(PtMessage::ClientTransportLaunched { + transport: "trebuchet".parse().unwrap(), + protocol: "socks5".to_string(), + endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 19999) + }) + ); + assert_eq!( + "CMETHOD-ERROR trebuchet no rocks available".parse(), + Ok(PtMessage::ClientTransportFailed { + transport: "trebuchet".parse().unwrap(), + message: "no rocks available".to_string() + }) + ); + assert_eq!("CMETHODS DONE".parse(), Ok(PtMessage::ClientTransportsDone)); + assert_eq!( + "SMETHOD trebuchet 198.51.100.1:19999".parse(), + Ok(PtMessage::ServerTransportLaunched { + transport: "trebuchet".parse().unwrap(), + endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), 19999), + options: Default::default() + }) + ); + let mut map = HashMap::new(); + map.insert("N".to_string(), "13".to_string()); + assert_eq!( + "SMETHOD rot_by_N 198.51.100.1:2323 ARGS:N=13".parse(), + Ok(PtMessage::ServerTransportLaunched { + transport: "rot_by_N".parse().unwrap(), + endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), 2323), + options: map + }) + ); + let mut map = HashMap::new(); + map.insert( + "cert".to_string(), + "HszPy3vWfjsESCEOo9ZBkRv6zQ/1mGHzc8arF0y2SpwFr3WhsMu8rK0zyaoyERfbz3ddFw".to_string(), + ); + map.insert("iat-mode".to_string(), "0".to_string()); + assert_eq!( + "SMETHOD obfs4 198.51.100.1:43734 ARGS:cert=HszPy3vWfjsESCEOo9ZBkRv6zQ/1mGHzc8arF0y2SpwFr3WhsMu8rK0zyaoyERfbz3ddFw,iat-mode=0".parse(), + Ok(PtMessage::ServerTransportLaunched { + transport: "obfs4".parse().unwrap(), + endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), 43734), + options: map + }) + ); + assert_eq!( + "SMETHOD-ERROR trebuchet no cows available".parse(), + Ok(PtMessage::ServerTransportFailed { + transport: "trebuchet".parse().unwrap(), + message: "no cows available".to_string() + }) + ); + assert_eq!( + "LOG SEVERITY=debug MESSAGE=\"Connected to bridge A\"".parse(), + Ok(PtMessage::Log { + severity: "debug".to_string(), + message: "Connected to bridge A".to_string() + }) + ); + assert_eq!( + "LOG SEVERITY=debug MESSAGE=\"\\r\\n\\t\"".parse(), + Ok(PtMessage::Log { + severity: "debug".to_string(), + message: "\r\n\t".to_string() + }) + ); + assert_eq!( + "LOG SEVERITY=debug MESSAGE=".parse(), + Ok(PtMessage::Log { + severity: "debug".to_string(), + message: "".to_string() + }) + ); + assert_eq!( + "LOG SEVERITY=debug MESSAGE=\"\\a\"".parse::(), + Ok(PtMessage::Log { + severity: "debug".to_string(), + message: "a".to_string() + }) + ); + + for i in 0..9 { + let msg = format!("LOG SEVERITY=debug MESSAGE=\"\\{i}\""); + assert_eq!( + msg.parse::(), + Err(Cow::from("attempted unsupported octal escape code")) + ); + } + assert_eq!( + "SMETHOD obfs4 198.51.100.1:43734 ARGS:iat-mode=0\\".parse::(), + Err(Cow::from( + "failed to parse SMETHOD ARGS: smethod arg terminates with backslash" + )) + ); + assert_eq!( + "SMETHOD obfs4 198.51.100.1:43734 ARGS:iat-mode=fo=o".parse::(), + Err(Cow::from( + "failed to parse SMETHOD ARGS: encountered = while parsing value" + )) + ); + assert_eq!( + "SMETHOD obfs4 198.51.100.1:43734 ARGS:iat-mode".parse::(), + Err(Cow::from( + "failed to parse SMETHOD ARGS: ran out of chars parsing smethod arg" + )) + ); + + let mut map = HashMap::new(); + map.insert("ADDRESS".to_string(), "198.51.100.123:1234".to_string()); + map.insert("CONNECT".to_string(), "Success".to_string()); + assert_eq!( + "STATUS ADDRESS=198.51.100.123:1234 CONNECT=Success".parse(), + Ok(PtMessage::Status(PtStatus { data: map })) + ); + + let mut map = HashMap::new(); + map.insert("ADDRESS".to_string(), "198.51.100.123:1234".to_string()); + map.insert("CONNECT".to_string(), "Success".to_string()); + map.insert("TRANSPORT".to_string(), "obfs4".to_string()); + assert_eq!( + "STATUS TRANSPORT=obfs4 ADDRESS=198.51.100.123:1234 CONNECT=Success".parse(), + Ok(PtMessage::Status(PtStatus { data: map })) + ); + + let mut map = HashMap::new(); + map.insert("ADDRESS".to_string(), "198.51.100.222:2222".to_string()); + map.insert("CONNECT".to_string(), "Failed".to_string()); + map.insert("FINGERPRINT".to_string(), "".to_string()); + map.insert("ERRSTR".to_string(), "Connection refused".to_string()); + assert_eq!( + "STATUS ADDRESS=198.51.100.222:2222 CONNECT=Failed FINGERPRINT= ERRSTR=\"Connection refused\"".parse(), + Ok(PtMessage::Status(PtStatus { + data: map + })) + ); + } +} diff --git a/vendor/tor-ptmgr/src/lib.rs b/vendor/tor-ptmgr/src/lib.rs new file mode 100644 index 0000000000..3b57f1a2b8 --- /dev/null +++ b/vendor/tor-ptmgr/src/lib.rs @@ -0,0 +1,381 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc = include_str!("../README.md")] +// @@ begin lint list maintained by maint/add_warning @@ +#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable) +#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly) +#![warn(missing_docs)] +#![warn(noop_method_call)] +#![warn(unreachable_pub)] +#![warn(clippy::all)] +#![deny(clippy::await_holding_lock)] +#![deny(clippy::cargo_common_metadata)] +#![deny(clippy::cast_lossless)] +#![deny(clippy::checked_conversions)] +#![allow(clippy::cognitive_complexity)] // See arti#2556 +#![deny(clippy::debug_assert_with_mut_call)] +#![deny(clippy::exhaustive_enums)] +#![deny(clippy::exhaustive_structs)] +#![deny(clippy::expl_impl_clone_on_copy)] +#![deny(clippy::fallible_impl_from)] +#![deny(clippy::implicit_clone)] +#![deny(clippy::large_stack_arrays)] +#![warn(clippy::manual_ok_or)] +#![deny(clippy::missing_docs_in_private_items)] +#![warn(clippy::needless_borrow)] +#![warn(clippy::needless_pass_by_value)] +#![warn(clippy::option_option)] +#![deny(clippy::print_stderr)] +#![deny(clippy::print_stdout)] +#![warn(clippy::rc_buffer)] +#![deny(clippy::ref_option_ref)] +#![warn(clippy::semicolon_if_nothing_returned)] +#![warn(clippy::trait_duplication_in_bounds)] +#![deny(clippy::unchecked_time_subtraction)] +#![deny(clippy::unnecessary_wraps)] +#![warn(clippy::unseparated_literal_suffix)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::mod_module_files)] +#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945 +#![allow(clippy::result_large_err)] // temporary workaround for arti#587 +#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best +#![allow(clippy::needless_lifetimes)] // See arti#1765 +#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060 +#![allow(clippy::collapsible_if)] // See arti#2342 +#![deny(clippy::unused_async)] +#![deny(clippy::string_slice)] // See arti#2571 +//! + +pub mod config; +pub mod err; + +#[cfg(feature = "managed-pts")] +pub mod ipc; + +#[cfg(feature = "managed-pts")] +mod managed; + +use crate::config::{TransportConfig, TransportOptions}; +use crate::err::PtError; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use tor_chanmgr::ProxyProtocol; +use tor_config_path::CfgPathResolver; +use tor_linkspec::PtTransportName; +use tor_rtcompat::Runtime; +use tor_socksproto::SocksVersion; +use tracing::warn; +#[cfg(feature = "managed-pts")] +use { + crate::managed::{PtReactor, PtReactorMessage}, + futures::channel::mpsc::{self, UnboundedSender}, + tor_error::error_report, + tor_rtcompat::SpawnExt, +}; +#[cfg(feature = "tor-channel-factory")] +use { + async_trait::async_trait, + tor_chanmgr::{ + builder::ChanBuilder, + factory::{AbstractPtError, ChannelFactory}, + transport::ExternalProxyPlugin, + }, + tracing::trace, +}; +#[cfg(all(feature = "managed-pts", feature = "tor-channel-factory"))] +use {oneshot_fused_workaround as oneshot, tracing::info}; + +/// Shared mutable state between the `PtReactor` and `PtMgr`. +#[derive(Default, Debug)] +struct PtSharedState { + /// Connection information for pluggable transports from currently running binaries. + /// + /// Unmanaged pluggable transports are not included in this map. + #[cfg(feature = "managed-pts")] + managed_cmethods: HashMap, + /// Current configured set of pluggable transports. + configured: HashMap, + /// The global Tor outbound proxy, if any. + outbound_proxy: Option, +} + +/// A pluggable transport manager knows how to make different +/// kinds of connections to the Tor network, for censorship avoidance. +pub struct PtMgr { + /// An underlying `Runtime`, used to spawn background tasks. + #[allow(dead_code)] + runtime: R, + /// State for this `PtMgr` that's shared with the `PtReactor`. + state: Arc>, + /// PtReactor channel when the `managed-pts` feature is enabled. + #[cfg(feature = "managed-pts")] + tx: UnboundedSender, +} + +impl PtMgr { + /// Transform the config into a more useful representation indexed by transport name. + fn transform_config( + binaries: Vec, + ) -> Result, tor_error::Bug> { + let mut ret = HashMap::new(); + // FIXME(eta): You can currently specify overlapping protocols, and it'll + // just use the last transport specified. + // I attempted to fix this, but decided I didn't want to stare into the list + // builder macro void after trying it for 15 minutes. + for thing in binaries { + for tn in thing.protocols.iter() { + ret.insert(tn.clone(), thing.clone().try_into()?); + } + } + for opt in ret.values() { + match opt { + TransportOptions::Unmanaged(u) => { + if !u.is_localhost() { + warn!( + "Configured to connect to a PT on a non-local addresses. This is usually insecure! We recommend running PTs on localhost only." + ); + } + } + #[cfg(feature = "managed-pts")] + TransportOptions::Managed(_) => { + // Nothing to check here, + // since we should spawn the managed PT and + // we shouldn't know what address it will listen on yet. + } + } + } + Ok(ret) + } + + /// Create a new PtMgr. + // TODO: maybe don't have the Vec directly exposed? + pub fn new( + transports: Vec, + #[allow(unused)] state_dir: PathBuf, + #[allow(unused)] path_resolver: Arc, + outbound_proxy: Option, + rt: R, + ) -> Result { + let state = PtSharedState { + #[cfg(feature = "managed-pts")] + managed_cmethods: Default::default(), + configured: Self::transform_config(transports)?, + outbound_proxy, + }; + let state = Arc::new(RwLock::new(state)); + + // reactor is only needed if we support managed pts + #[cfg(feature = "managed-pts")] + let tx = { + let (tx, rx) = mpsc::unbounded(); + + let mut reactor = + PtReactor::new(rt.clone(), state.clone(), rx, state_dir, path_resolver); + rt.spawn(async move { + loop { + match reactor.run_one_step().await { + Ok(true) => return, + Ok(false) => {} + Err(e) => { + error_report!(e, "PtReactor failed"); + return; + } + } + } + }) + .map_err(|e| PtError::Spawn { cause: Arc::new(e) })?; + + tx + }; + + Ok(Self { + runtime: rt, + state, + #[cfg(feature = "managed-pts")] + tx, + }) + } + + /// Reload the configuration + pub fn reconfigure( + &self, + how: tor_config::Reconfigure, + transports: Vec, + outbound_proxy: Option, + ) -> Result<(), tor_config::ReconfigureError> { + let configured = Self::transform_config(transports)?; + if how == tor_config::Reconfigure::CheckAllOrNothing { + return Ok(()); + } + { + let mut inner = self.state.write().expect("ptmgr poisoned"); + inner.configured = configured; + inner.outbound_proxy = outbound_proxy; + } + // We don't have any way of propagating this sanely; the caller will find out the reactor + // has died later on anyway. + #[cfg(feature = "managed-pts")] + let _ = self.tx.unbounded_send(PtReactorMessage::Reconfigured); + Ok(()) + } + + /// Given a transport name, return a method that we can use to contact that transport. + /// + /// May have to launch a managed transport as needed. + /// + /// Returns Ok(None) if no such transport exists. + #[cfg(feature = "tor-channel-factory")] + async fn get_cmethod_for_transport( + &self, + transport: &PtTransportName, + ) -> Result, PtError> { + let (cfg, managed_cmethod) = { + // NOTE(eta): This is using a RwLock inside async code (but not across an await point). + // Arguably this is fine since it's just a small read, and nothing should ever + // hold this lock for very long. + let inner = self.state.read().expect("ptmgr poisoned"); + let cfg = inner.configured.get(transport); + let managed_cmethod = inner.managed_cmethods.get(transport); + (cfg.cloned(), managed_cmethod.cloned()) + }; + + #[cfg(not(feature = "managed-pts"))] + let _ = managed_cmethod; // avoid unused variable warning + + match cfg { + Some(TransportOptions::Unmanaged(cfg)) => { + let cmethod = cfg.cmethod(); + trace!( + "Found configured unmanaged transport {transport} accessible via {cmethod:?}" + ); + Ok(Some(cmethod)) + } + #[cfg(feature = "managed-pts")] + Some(TransportOptions::Managed(_cfg)) => { + match managed_cmethod { + // A configured-and-running cmethod. + Some(cmethod) => { + trace!( + "Found configured managed transport {transport} accessible via {cmethod:?}" + ); + Ok(Some(cmethod)) + } + // A configured-but-not-running cmethod. + None => { + // There is going to be a lot happening "under the hood" here. + // + // When we are asked to get a ChannelFactory for a given + // connection, we will need to: + // - launch the binary for that transport if it is not already running*. + // - If we launched the binary, talk to it and see which ports it + // is listening on. + // - Return a ChannelFactory that connects via one of those ports, + // using the appropriate version of SOCKS, passing K=V parameters + // encoded properly. + // + // * As in other managers, we'll need to avoid trying to launch the same + // transport twice if we get two concurrent requests. + // + // Later if the binary crashes, we should detect that. We should relaunch + // it on demand. + // + // On reconfigure, we should shut down any no-longer-used transports. + // + // Maybe, we should shut down transports that haven't been used + // for a long time. + Ok(Some(self.spawn_transport(transport).await?)) + } + } + } + // No configuration for this transport. + None => { + trace!("Got a request for transport {transport}, which is not configured."); + Ok(None) + } + } + } + + /// Communicate with the PT reactor to launch a managed transport. + #[cfg(all(feature = "tor-channel-factory", feature = "managed-pts"))] + async fn spawn_transport( + &self, + transport: &PtTransportName, + ) -> Result { + // Tell the reactor to spawn the PT, and wait for it. + // (The reactor will handle coalescing multiple requests.) + info!( + "Got a request for transport {transport}, which is not currently running. Launching it." + ); + + let (tx, rx) = oneshot::channel(); + self.tx + .unbounded_send(PtReactorMessage::Spawn { + pt: transport.clone(), + result: tx, + }) + .map_err(|_| { + PtError::Internal(tor_error::internal!("PT reactor closed unexpectedly")) + })?; + + let method = match rx.await { + Err(_) => { + return Err(PtError::Internal(tor_error::internal!( + "PT reactor closed unexpectedly" + ))); + } + Ok(Err(e)) => { + warn!("PT for {transport} failed to launch: {e}"); + return Err(e); + } + Ok(Ok(method)) => method, + }; + + info!("Successfully launched PT for {transport} at {method:?}."); + Ok(method) + } +} + +/// A SOCKS endpoint to connect through a pluggable transport. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PtClientMethod { + /// The SOCKS protocol version to use. + pub(crate) kind: SocksVersion, + /// The socket address to connect to. + pub(crate) endpoint: SocketAddr, +} + +impl PtClientMethod { + /// Get the SOCKS protocol version to use. + pub fn kind(&self) -> SocksVersion { + self.kind + } + + /// Get the socket address to connect to. + pub fn endpoint(&self) -> SocketAddr { + self.endpoint + } +} + +#[cfg(feature = "tor-channel-factory")] +#[async_trait] +impl tor_chanmgr::factory::AbstractPtMgr for PtMgr { + async fn factory_for_transport( + &self, + transport: &PtTransportName, + ) -> Result>, Arc> { + let cmethod = match self.get_cmethod_for_transport(transport).await { + Err(e) => return Err(Arc::new(e)), + Ok(None) => return Ok(None), + Ok(Some(m)) => m, + }; + + let proxy = ExternalProxyPlugin::new(self.runtime.clone(), cmethod.endpoint, cmethod.kind); + let factory = ChanBuilder::new_client(self.runtime.clone(), proxy); + // FIXME(eta): Should we cache constructed factories? If no: should this still be an Arc? + // FIXME(eta): Should we track what transports are live somehow, so we can shut them down? + Ok(Some(Arc::new(factory))) + } +} diff --git a/vendor/tor-ptmgr/src/managed.rs b/vendor/tor-ptmgr/src/managed.rs new file mode 100644 index 0000000000..5ab7119faa --- /dev/null +++ b/vendor/tor-ptmgr/src/managed.rs @@ -0,0 +1,336 @@ +//! Infrastructure required to support managed PTs. + +use crate::config::{ManagedTransportOptions, TransportOptions}; +use crate::err; +use crate::err::PtError; +use crate::ipc::{ + sealed::PluggableTransportPrivate, PluggableClientTransport, PluggableTransport, + PtClientParameters, PtCommonParameters, +}; +use crate::{PtClientMethod, PtSharedState}; +use futures::channel::mpsc::UnboundedReceiver; +use futures::stream::FuturesUnordered; +use futures::{select, FutureExt, StreamExt}; +use oneshot_fused_workaround as oneshot; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use tor_chanmgr::ProxyProtocol; +use tor_config_path::CfgPathResolver; +use tor_error::internal; +use tor_linkspec::PtTransportName; +use tor_rtcompat::Runtime; +use tracing::{debug, warn}; + +/// A message to the `PtReactor`. +pub(crate) enum PtReactorMessage { + /// Notify the reactor that the currently configured set of PTs has changed. + Reconfigured, + /// Ask the reactor to spawn a pluggable transport binary. + #[cfg_attr(not(feature = "tor-channel-factory"), allow(dead_code))] + Spawn { + /// Spawn a binary to provide this PT. + pt: PtTransportName, + /// Notify the result via this channel. + result: oneshot::Sender>, + }, +} + +/// The result of a spawn attempt: the list of transports the spawned binary covers, and the result. +type SpawnResult = (Vec, err::Result); + +/// Background reactor to handle managing pluggable transport binaries. +pub(crate) struct PtReactor { + /// Runtime. + rt: R, + /// Currently running pluggable transport binaries. + running: Vec, + /// A map of asked-for transports. + /// + /// If a transport name has an entry, we will append any additional requests for that entry. + /// If no entry is present, we will start a request. + requests: HashMap>>>, + /// FuturesUnordered that spawned tasks get pushed on to. + /// + /// WARNING: This MUST always contain one "will never resolve" future! + spawning: FuturesUnordered + Send>>>, + /// State for the corresponding PtMgr. + state: Arc>, + /// PtMgr channel. + /// (Unbounded so that we can reconfigure without blocking: we're unlikely to have the reactor + /// get behind.) + rx: UnboundedReceiver, + /// State directory. + state_dir: PathBuf, + /// Path resolver for configuration files. + path_resolver: Arc, +} + +impl PtReactor { + /// Make a new reactor. + pub(crate) fn new( + rt: R, + state: Arc>, + rx: UnboundedReceiver, + state_dir: PathBuf, + path_resolver: Arc, + ) -> Self { + let spawning = FuturesUnordered::new(); + spawning.push(Box::pin(futures::future::pending::()) + as Pin + Send>>); + Self { + rt, + running: vec![], + requests: Default::default(), + spawning, + state, + rx, + state_dir, + path_resolver, + } + } + + /// Called when a spawn request completes. + #[allow(clippy::needless_pass_by_value)] + fn handle_spawned( + &mut self, + covers: Vec, + result: err::Result, + ) { + match result { + Err(e) => { + warn!("Spawning PT for {:?} failed: {}", covers, e); + // Go and tell all the transports about the bad news. + let senders = covers + .iter() + .flat_map(|x| self.requests.remove(x)) + .flatten(); + for sender in senders { + // We don't really care if the sender went away. + let _ = sender.send(Err(e.clone())); + } + } + Ok(pt) => { + let mut state = self.state.write().expect("ptmgr state poisoned"); + for (transport, method) in pt.transport_methods() { + state + .managed_cmethods + .insert(transport.clone(), method.clone()); + for sender in self.requests.remove(transport).into_iter().flatten() { + let _ = sender.send(Ok(method.clone())); + } + } + + let requested: HashSet<_> = covers.iter().collect(); + let found: HashSet<_> = pt.transport_methods().keys().collect(); + if requested != found { + warn!( + "Bug: PT {} succeeded, but did not give the same transports we asked for. ({:?} vs {:?})", + pt.identifier(), + found, + requested + ); + } + self.running.push(pt); + } + } + } + + /// Called to remove a pluggable transport from the shared state. + fn remove_pt(&self, pt: PluggableClientTransport) { + let mut state = self.state.write().expect("ptmgr state poisoned"); + for transport in pt.transport_methods().keys() { + state.managed_cmethods.remove(transport); + } + // to satisfy clippy, and make it clear that this is a desired side-effect: doing this + // shuts down the PT (asynchronously). + drop(pt); + } + + /// Run one step of the reactor. Returns true if the reactor should terminate. + pub(crate) async fn run_one_step(&mut self) -> err::Result { + use futures::future::Either; + + let mut all_next_messages = self + .running + .iter_mut() + .map(|pt| pt.next_message()) + .collect::>(); + + // We can't construct a select_all if all_next_messages is empty. + let mut next_message = if all_next_messages.is_empty() { + Either::Left(futures::future::pending()) + } else { + Either::Right(futures::future::select_all(all_next_messages.iter_mut()).fuse()) + }; + + select! { + (result, idx, _) = next_message => { + drop(all_next_messages); // no idea why NLL doesn't just infer this but sure + + match result { + Ok(m) => { + // FIXME(eta): We should forward the Status messages onto API consumers. + debug!("PT {} message: {:?}", self.running[idx].identifier(), m); + }, + Err(e) => { + warn!("PT {} quit: {:?}", self.running[idx].identifier(), e); + let pt = self.running.remove(idx); + self.remove_pt(pt); + } + } + }, + spawn_result = self.spawning.next() => { + drop(all_next_messages); + // See the Warning in this field's documentation. + let (covers, result) = spawn_result.expect("self.spawning should never dry up"); + self.handle_spawned(covers, result); + } + internal = self.rx.next() => { + drop(all_next_messages); + + match internal { + Some(PtReactorMessage::Reconfigured) => {}, + Some(PtReactorMessage::Spawn { pt, result }) => { + // Make sure we don't already have a running request. + if let Some(requests) = self.requests.get_mut(&pt) { + requests.push(result); + return Ok(false); + } + // Make sure we don't already have a binary for this PT. + for rpt in self.running.iter() { + if let Some(cmethod) = rpt.transport_methods().get(&pt) { + let _ = result.send(Ok(cmethod.clone())); + return Ok(false); + } + } + // We don't, so time to spawn one. + let (config, outbound_proxy) = { + let state = self.state.read().expect("ptmgr state poisoned"); + (state.configured.get(&pt).cloned(), state.outbound_proxy.clone()) + }; + + let Some(config) = config else { + let _ = result.send(Err(PtError::UnconfiguredTransportDueToConcurrentReconfiguration)); + return Ok(false); + }; + + let TransportOptions::Managed(config) = config else { + let _ = result.send(Err(internal!("Tried to spawn an unmanaged transport").into())); + return Ok(false); + }; + + // Keep track of the request, and also fill holes in other protocols so + // we don't try and run another spawn request for those. + self.requests.entry(pt).or_default().push(result); + for proto in config.protocols.iter() { + self.requests.entry(proto.clone()).or_default(); + } + + // Add the spawn future to our pile of them. + let spawn_fut = Box::pin( + spawn_from_config( + self.rt.clone(), + self.state_dir.clone(), + config.clone(), + Arc::clone(&self.path_resolver), + outbound_proxy, + ) + .map(|result| (config.protocols, result)) + ); + self.spawning.push(spawn_fut); + }, + None => return Ok(true) + } + } + } + Ok(false) + } +} + +/// Spawn a managed `PluggableTransport` using a `ManagedTransportOptions`. +async fn spawn_from_config( + rt: R, + state_dir: PathBuf, + cfg: ManagedTransportOptions, + path_resolver: Arc, + outbound_proxy: Option, +) -> Result { + // FIXME(eta): I really think this expansion should happen at builder validation time... + + let cfg_path = cfg.path; + + let binary_path = cfg_path + .path(&path_resolver) + .map_err(|e| PtError::PathExpansionFailed { + path: cfg_path.clone(), + error: e, + })?; + + let filename = pt_identifier_as_path(&binary_path)?; + + // HACK(eta): Currently the state directory is named after the PT binary name. Maybe we should + // invent a better way of doing this? + let new_state_dir = state_dir.join(filename); + std::fs::create_dir_all(&new_state_dir).map_err(|e| PtError::StatedirCreateFailed { + path: new_state_dir.clone(), + error: Arc::new(e), + })?; + + // FIXME(eta): make the rest of these parameters configurable + let pt_common_params = PtCommonParameters::builder() + .state_location(new_state_dir) + .build() + .expect("PtCommonParameters constructed incorrectly"); + + // The PT spec defines `TOR_PT_PROXY` as a URI, so we only render the + // structured `ProxyProtocol` to a string at this boundary. + let pt_client_params = PtClientParameters::builder() + .transports(cfg.protocols) + .proxy_uri(outbound_proxy.as_ref().map(ToString::to_string)) + .build() + .expect("PtClientParameters constructed incorrectly"); + + let mut pt = PluggableClientTransport::new( + binary_path, + cfg.arguments, + pt_common_params, + pt_client_params, + ); + pt.launch(rt).await?; + Ok(pt) +} + +/// Given a path to a binary for a pluggable transport, return an identifier for +/// that binary in a format that can be used as a path component. +fn pt_identifier_as_path(binary_path: impl AsRef) -> Result { + // Extract the final component. + let mut filename = + PathBuf::from( + binary_path + .as_ref() + .file_name() + .ok_or_else(|| PtError::NotAFile { + path: binary_path.as_ref().to_path_buf(), + })?, + ); + + // Strip an "exe" off the end, if appropriate. + if let Some(ext) = filename.extension() { + if ext.eq_ignore_ascii_case(std::env::consts::EXE_EXTENSION) { + filename.set_extension(""); + } + } + + Ok(filename) +} + +/// Given a path to a binary for a pluggable transport, return an identifier for +/// that binary in human-readable form. +pub(crate) fn pt_identifier(binary_path: impl AsRef) -> Result { + Ok(pt_identifier_as_path(binary_path)? + .to_string_lossy() + .to_string()) +}