diff --git a/bridges/centralized-ethereum/src/actors/wit_poller.rs b/bridges/centralized-ethereum/src/actors/wit_poller.rs index 4bd31feae6..27d8d7c58f 100644 --- a/bridges/centralized-ethereum/src/actors/wit_poller.rs +++ b/bridges/centralized-ethereum/src/actors/wit_poller.rs @@ -4,8 +4,8 @@ use actix::prelude::*; use serde_json::json; use witnet_data_structures::{ - chain::{Block, ConsensusConstants, DataRequestInfo, Hash, Hashable}, - proto::versioning::ProtocolInfo, + chain::{Block, ConsensusConstants, DataRequestInfo, Epoch, Hash, Hashable}, + proto::versioning::{ProtocolInfo, ProtocolVersion}, }; use witnet_net::client::tcp::{JsonRpcClient, jsonrpc}; use witnet_node::utils::stop_system_if_panicking; @@ -239,8 +239,13 @@ async fn get_consensus_constants( } }; let consensus_constants = match result { - Ok(value) => serde_json::from_value::(value) - .expect("failed to deserialize consensus constants"), + Ok(value) => match serde_json::from_value::(value) { + Ok(consensus_constants) => consensus_constants, + Err(error) => { + log::error!("failed to deserialize consensus constants: {error}"); + return Err(()); + } + }, Err(e) => { log::error!("error in getConsensusConstants call: {e:?}"); return Err(()); @@ -273,40 +278,94 @@ async fn get_dr_timestamp( } }; let block = match report { - Ok(value) => serde_json::from_value::(value).expect("failed to deserialize block"), + Ok(value) => match serde_json::from_value::(value) { + Ok(block) => block, + Err(error) => { + log::error!("failed to deserialize block {drt_block_hash}: {error}"); + return Err(()); + } + }, Err(e) => { log::error!("error in getBlock call ({drt_block_hash}): {e:?}"); return Err(()); } }; - let dr_last_commit_epoch = - block.block_header.beacon.checkpoint + u32::from(dr_commits_round + 1); - let protocol_version = if protocol_info + derive_dr_last_commit_timestamp( + block.block_header.beacon.checkpoint, + dr_commits_round, + consensus_constants, + protocol_info, + ) + .map_err(|error| { + log::error!("cannot derive data-request timestamp for block {drt_block_hash}: {error}"); + }) +} + +/// Derive the timestamp of the epoch after the final commit round from the complete protocol +/// schedule returned by the node. +/// +/// A resolved request can predate more than one activation, so selecting only the current or +/// immediately previous protocol is insufficient once Wit/2.1 is registered. The implicit V1.7 +/// epoch-zero fallback deliberately has no period entry and must use the configured consensus base +/// clock rather than a hard-coded mainnet value. +fn derive_dr_last_commit_timestamp( + request_epoch: Epoch, + commit_round: u16, + consensus_constants: &ConsensusConstants, + protocol_info: &ProtocolInfo, +) -> Result { + protocol_info + .validate_timing_topology(consensus_constants) + .map_err(|error| format!("invalid protocol timing topology: {error}"))?; + + let commit_epoch_delta = u32::from(commit_round) + .checked_add(1) + .ok_or_else(|| "commit-round epoch delta overflows u32".to_string())?; + let last_commit_epoch = request_epoch + .checked_add(commit_epoch_delta) + .ok_or_else(|| "last commit epoch overflows u32".to_string())?; + let protocol_version = protocol_info .all_versions - .get_activation_epoch(protocol_info.current_version) - <= dr_last_commit_epoch + .version_for_epoch(last_commit_epoch); + let activation_epoch = match protocol_info + .all_versions + .try_get_activation_epoch(protocol_version) { - protocol_info.current_version - } else { - protocol_info.current_version.prev() + Some(epoch) => epoch, + None if protocol_version == ProtocolVersion::MIN => 0, + None => { + return Err(format!( + "protocol {protocol_version} has no registered activation epoch" + )); + } }; - let protocol_activation_timestamp = protocol_info + let activation_timestamp = protocol_info .derive_activation_timestamp(protocol_version, consensus_constants) - .unwrap_or(consensus_constants.checkpoint_zero_timestamp); - let protocol_activation_epoch = protocol_info - .all_versions - .try_get_activation_epoch(protocol_version) - .unwrap_or_default(); - let protocol_checkpoint_period = protocol_info + .ok_or_else(|| format!("cannot derive activation timestamp for {protocol_version}"))?; + let checkpoint_period = match protocol_info .all_checkpoints_periods .get(&protocol_version) - .unwrap_or(&45u16); - - Ok(protocol_activation_timestamp - + i64::from( - (dr_last_commit_epoch - protocol_activation_epoch) - * u32::from(*protocol_checkpoint_period), - )) + .copied() + { + Some(period) => period, + None if protocol_version == ProtocolVersion::MIN => consensus_constants.checkpoints_period, + None => { + return Err(format!( + "protocol {protocol_version} has no registered checkpoint period" + )); + } + }; + let elapsed_epochs = last_commit_epoch.checked_sub(activation_epoch).ok_or_else(|| { + format!( + "last commit epoch {last_commit_epoch} precedes {protocol_version} activation {activation_epoch}" + ) + })?; + let elapsed_seconds = i64::from(elapsed_epochs) + .checked_mul(i64::from(checkpoint_period)) + .ok_or_else(|| "data-request epoch span overflows i64 seconds".to_string())?; + activation_timestamp + .checked_add(elapsed_seconds) + .ok_or_else(|| "data-request timestamp overflows i64".to_string()) } /// Get current protocol info from the Witnet node @@ -326,11 +385,84 @@ async fn get_protocol_info(witnet_client: Addr) -> Result Ok(serde_json::from_value::(value) - .expect("failed to deserialize protocol info")), + Ok(value) => serde_json::from_value::(value).map_err(|error| { + log::error!("failed to deserialize protocol info: {error}"); + }), Err(e) => { log::error!("Error when getting protocol info: {e:?}"); Err(()) } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn multi_version_protocol_info() -> ProtocolInfo { + let mut info = ProtocolInfo::default(); + info.try_register(10, ProtocolVersion::V1_8, 30).unwrap(); + info.try_register(20, ProtocolVersion::V2_0, 15).unwrap(); + info.try_register(30, ProtocolVersion::V2_1, 15).unwrap(); + info.current_version = ProtocolVersion::V2_1; + info + } + + #[test] + fn historical_request_timestamp_uses_its_exact_protocol_regime() { + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: 1_000, + checkpoints_period: 30, + ..Default::default() + }; + let info = multi_version_protocol_info(); + + // The last commit is epoch 13 under V1.8, two versions behind current V2.1. + assert_eq!( + derive_dr_last_commit_timestamp(12, 0, &consensus_constants, &info), + Ok(1_390) + ); + } + + #[test] + fn sparse_valid_schedule_uses_the_last_registered_timing_regime() { + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: 1_000, + checkpoints_period: 30, + ..Default::default() + }; + let mut info = ProtocolInfo::default(); + info.try_register(20, ProtocolVersion::V2_0, 15).unwrap(); + info.try_register(30, ProtocolVersion::V2_1, 15).unwrap(); + info.current_version = ProtocolVersion::V2_1; + + // V1.8 is intentionally absent. Epoch 20 starts at 1,600 on the base clock, and the final + // commit epoch 21 is one 15-second V2.0 checkpoint later. + assert_eq!( + derive_dr_last_commit_timestamp(20, 0, &consensus_constants, &info), + Ok(1_615) + ); + } + + #[test] + fn implicit_v1_7_uses_the_configured_base_period_and_overflow_fails_closed() { + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: 1_000, + checkpoints_period: 30, + ..Default::default() + }; + let info = multi_version_protocol_info(); + + assert_eq!( + derive_dr_last_commit_timestamp(4, 0, &consensus_constants, &info), + Ok(1_150) + ); + assert!(derive_dr_last_commit_timestamp( + Epoch::MAX, + u16::MAX, + &consensus_constants, + &info, + ) + .is_err()); + } +} diff --git a/config/src/config.rs b/config/src/config.rs index cde4dde8e7..1efd832321 100644 --- a/config/src/config.rs +++ b/config/src/config.rs @@ -322,6 +322,10 @@ pub struct Storage { pub rad_hashes_index: bool, /// RocksDB option max_open_files. -1 means unlimited. pub max_open_files: i32, + /// Hex-encoded compressed secp256k1 public keys trusted to sign delegated-staking snapshot + /// checkpoint anchors. An empty list deliberately keeps non-genesis delegated snapshots + /// unavailable; genesis-only and pre-delegated compatibility imports do not need an anchor. + pub snapshot_trust_public_keys: Vec, } /// JsonRPC API configuration @@ -378,6 +382,12 @@ pub struct Mining { /// Minimum value transfer transaction fee that allows being included into a block #[partial_struct(serde(default, rename = "minimum_vtt_fee_nanowits"))] pub minimum_vtt_fee: u64, + /// Minimum stake transaction fee that allows being retained in the local transaction pool + #[partial_struct(serde(default, rename = "minimum_st_fee_nanowits"))] + pub minimum_st_fee: u64, + /// Minimum unstake transaction fee that allows being retained in the local transaction pool + #[partial_struct(serde(default, rename = "minimum_ut_fee_nanowits"))] + pub minimum_ut_fee: u64, } /// NTP-related configuration @@ -404,7 +414,10 @@ pub struct Ntp { #[derive(PartialStruct, Debug, Clone, PartialEq, Eq)] #[partial_struct(derive(Deserialize, Serialize, Default, Debug, Clone, PartialEq, Eq))] pub struct Mempool { - /// Timeout to use again an UTXO spent by a pending transaction + /// Timeout to use again an UTXO spent by a pending transaction. + /// + /// Zero deliberately makes the output immediately selectable again. Asynchronous Stake + /// rollback ownership is tracked by an opaque process-local generation, never by this value. pub tx_pending_timeout: u64, /// Maximum number of recovered transactions to include by epoch pub max_reinserted_transactions: u32, @@ -427,14 +440,17 @@ pub struct Protocol { pub v1_7: Option<(Epoch, u16)>, pub v1_8: Option<(Epoch, u16)>, pub v2_0: Option<(Epoch, u16)>, + /// Optional Wit/2.1 activation. No network default is registered yet. + pub v2_1: Option<(Epoch, u16)>, } impl Protocol { - pub fn iter(&self) -> IntoIter<(ProtocolVersion, Option<(Epoch, u16)>), 3> { + pub fn iter(&self) -> IntoIter<(ProtocolVersion, Option<(Epoch, u16)>), 4> { [ (ProtocolVersion::V1_7, self.v1_7), (ProtocolVersion::V1_8, self.v1_8), (ProtocolVersion::V2_0, self.v2_0), + (ProtocolVersion::V2_1, self.v2_1), ] .into_iter() } @@ -788,6 +804,10 @@ impl Storage { max_open_files: config .max_open_files .unwrap_or_else(|| defaults.storage_max_open_files()), + snapshot_trust_public_keys: config + .snapshot_trust_public_keys + .clone() + .unwrap_or_default(), } } @@ -799,6 +819,7 @@ impl Storage { utxos_in_memory: Some(self.utxos_in_memory), rad_hashes_index: Some(self.rad_hashes_index), max_open_files: Some(self.max_open_files), + snapshot_trust_public_keys: Some(self.snapshot_trust_public_keys.clone()), } } } @@ -872,6 +893,14 @@ impl Mining { .minimum_vtt_fee .to_owned() .unwrap_or_else(|| defaults.mining_minimum_vtt_fee()), + minimum_st_fee: config + .minimum_st_fee + .to_owned() + .unwrap_or_else(|| defaults.mining_minimum_st_fee()), + minimum_ut_fee: config + .minimum_ut_fee + .to_owned() + .unwrap_or_else(|| defaults.mining_minimum_ut_fee()), } } @@ -885,6 +914,8 @@ impl Mining { mint_external_address: self.mint_external_address.clone(), transactions_pool_total_weight_limit: Some(self.transactions_pool_total_weight_limit), minimum_vtt_fee: Some(self.minimum_vtt_fee), + minimum_st_fee: Some(self.minimum_st_fee), + minimum_ut_fee: Some(self.minimum_ut_fee), } } } @@ -988,7 +1019,9 @@ pub struct Wallet { /// Allow to use outputs that have not been confirmed by a superblock in new transactions pub use_unconfirmed_utxos: bool, /// If a transaction has not been included in a block for this amount of seconds, mark the UTXOs - /// as available again. + /// as available again. Zero deliberately makes them immediately selectable. The wallet writes + /// this availability timestamp synchronously while holding its state lock and has no deferred + /// timeout-based rollback callback, so this value is not used as reservation ownership. pub pending_transactions_timeout_seconds: u64, } @@ -1223,6 +1256,9 @@ impl Partializable for Protocol { v2_0: config .v2_0 .or(defaults.get(&ProtocolVersion::V2_0).copied()), + v2_1: config + .v2_1 + .or(defaults.get(&ProtocolVersion::V2_1).copied()), } } @@ -1369,6 +1405,7 @@ mod tests { utxos_in_memory: None, rad_hashes_index: None, max_open_files: None, + snapshot_trust_public_keys: None, }; let config = Storage::from_partial(&partial_config, &Testnet); diff --git a/config/src/defaults.rs b/config/src/defaults.rs index 2c78873921..e92427040d 100644 --- a/config/src/defaults.rs +++ b/config/src/defaults.rs @@ -282,6 +282,18 @@ pub trait Defaults { 0 } + /// Allow setting a minimum stake transaction fee to be retained in the local pool. + /// Setting it to zero accepts every consensus-valid fee. + fn mining_minimum_st_fee(&self) -> u64 { + 0 + } + + /// Allow setting a minimum unstake transaction fee to be retained in the local pool. + /// Setting it to zero accepts every consensus-valid fee. + fn mining_minimum_ut_fee(&self) -> u64 { + 0 + } + /// A network-wide reward to collateral ratio for data requests to be included in a block /// The absolute minimum accepted by the network should be defined as a consensus constant, /// but to avoid affecting the magic number, it's a "pseudoconsensus constant" for now. diff --git a/crypto/src/hash.rs b/crypto/src/hash.rs index 1ac0981920..e0cc029232 100644 --- a/crypto/src/hash.rs +++ b/crypto/src/hash.rs @@ -3,7 +3,7 @@ use digest::Digest; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use std::fmt::Formatter; +use std::{fmt::Formatter, io::Read}; /// Enumeration of hash-function names #[derive(Clone, PartialEq, Eq, Debug)] @@ -43,3 +43,36 @@ pub fn calculate_sha256(bytes: &[u8]) -> Sha256 { hash.copy_from_slice(&hasher.result()); Sha256(hash) } + +/// Calculate a SHA-256 digest while reading a stream in bounded chunks. +/// +/// Snapshot components can be substantially larger than the available memory on a node. Keep the +/// existing byte-slice helper for protocol code, but expose a reader variant for persistence and +/// snapshot integrity checks so hashing itself never requires a second full component allocation. +pub fn calculate_sha256_reader(reader: &mut R) -> std::io::Result<(u64, Sha256)> { + let mut hasher = sha2::Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut byte_len = 0_u64; + + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + byte_len = byte_len + .checked_add(u64::try_from(read).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::Other, + "stream read length does not fit u64", + ) + })?) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "stream length overflows u64") + })?; + hasher.input(&buffer[..read]); + } + + let mut hash = [0; 32]; + hash.copy_from_slice(&hasher.result()); + Ok((byte_len, Sha256(hash))) +} diff --git a/data_structures/src/bincode_compatibility.rs b/data_structures/src/bincode_compatibility.rs new file mode 100644 index 0000000000..f312fa5e50 --- /dev/null +++ b/data_structures/src/bincode_compatibility.rs @@ -0,0 +1,482 @@ +//! Compatibility decoders for historical persisted transaction and block layouts. +//! +//! Bincode serializes structs positionally, so adding `authorization_terms` to `StakeOutput` +//! changes every enclosing binary layout. These helpers keep the wire/protobuf representation +//! independent while allowing storage readers to recognize the exact legacy layout and convert it +//! into the current in-memory types. They also structurally decode blocks from before the staking +//! merkle roots and transaction sections existed, including blocks nested inside snapshot batches. + +use std::{collections::HashMap, io::Cursor}; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::{ + chain::{ + Block, BlockHeader, BlockMerkleRoots, BlockTransactions, Bn256PublicKey, CheckpointBeacon, + Hash, Input, KeyedSignature, PublicKeyHash, StakeOutput, ValueTransferOutput, + }, + staking::prelude::StakeKey, + transaction::{ + CommitTransaction, DRTransaction, MintTransaction, RevealTransaction, StakeTransaction, + StakeTransactionBody, TallyTransaction, Transaction, UnstakeTransaction, VTTransaction, + }, + vrf::BlockEligibilityClaim, +}; + +/// Deserialize one complete bincode payload and reject otherwise-valid values with trailing data. +pub fn deserialize_exact(bytes: &[u8]) -> Result +where + T: DeserializeOwned, +{ + let mut cursor = Cursor::new(bytes); + let value = bincode::deserialize_from(&mut cursor)?; + if cursor.position() != bytes.len() as u64 { + return Err(Box::new(bincode::ErrorKind::Custom( + "bincode payload contains trailing bytes".to_string(), + ))); + } + + Ok(value) +} + +/// `StakeOutput` layout written before the Wit/2.1 authorization terms were added. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LegacyStakeOutput { + pub authorization: KeyedSignature, + pub key: StakeKey, + pub value: u64, +} + +impl From for StakeOutput { + fn from(output: LegacyStakeOutput) -> Self { + Self { + authorization: output.authorization, + authorization_terms: None, + key: output.key, + value: output.value, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyStakeTransactionBody { + inputs: Vec, + output: LegacyStakeOutput, + change: Option, +} + +impl From for StakeTransactionBody { + fn from(body: LegacyStakeTransactionBody) -> Self { + Self::new(body.inputs, body.output.into(), body.change) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyStakeTransaction { + body: LegacyStakeTransactionBody, + signatures: Vec, +} + +impl From for StakeTransaction { + fn from(transaction: LegacyStakeTransaction) -> Self { + Self::new(transaction.body.into(), transaction.signatures) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +enum LegacyTransaction { + ValueTransfer(VTTransaction), + DataRequest(DRTransaction), + Commit(CommitTransaction), + Reveal(RevealTransaction), + Tally(TallyTransaction), + Mint(MintTransaction), + Stake(LegacyStakeTransaction), + Unstake(UnstakeTransaction), +} + +impl From for Transaction { + fn from(transaction: LegacyTransaction) -> Self { + match transaction { + LegacyTransaction::ValueTransfer(transaction) => Self::ValueTransfer(transaction), + LegacyTransaction::DataRequest(transaction) => Self::DataRequest(transaction), + LegacyTransaction::Commit(transaction) => Self::Commit(transaction), + LegacyTransaction::Reveal(transaction) => Self::Reveal(transaction), + LegacyTransaction::Tally(transaction) => Self::Tally(transaction), + LegacyTransaction::Mint(transaction) => Self::Mint(transaction), + LegacyTransaction::Stake(transaction) => Self::Stake(transaction.into()), + LegacyTransaction::Unstake(transaction) => Self::Unstake(transaction), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyBlockTransactions { + mint: MintTransaction, + value_transfer_txns: Vec, + data_request_txns: Vec, + commit_txns: Vec, + reveal_txns: Vec, + tally_txns: Vec, + stake_txns: Vec, + unstake_txns: Vec, +} + +impl From for BlockTransactions { + fn from(transactions: LegacyBlockTransactions) -> Self { + Self { + mint: transactions.mint, + value_transfer_txns: transactions.value_transfer_txns, + data_request_txns: transactions.data_request_txns, + commit_txns: transactions.commit_txns, + reveal_txns: transactions.reveal_txns, + tally_txns: transactions.tally_txns, + stake_txns: transactions + .stake_txns + .into_iter() + .map(StakeTransaction::from) + .collect(), + unstake_txns: transactions.unstake_txns, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyBlock { + block_header: BlockHeader, + block_sig: KeyedSignature, + txns: LegacyBlockTransactions, +} + +impl From for Block { + fn from(block: LegacyBlock) -> Self { + Self::new(block.block_header, block.block_sig, block.txns.into()) + } +} + +/// Block-header merkle-root layout written before stake and unstake transaction sections existed. +/// +/// The inventory reader historically repaired this representation with fixed byte offsets. A +/// structural type is required for portable `Vec` snapshots because each nested block has a +/// variable length and therefore has no safe global insertion offset. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct PreStakingBlockMerkleRoots { + mint_hash: Hash, + vt_hash_merkle_root: Hash, + dr_hash_merkle_root: Hash, + commit_hash_merkle_root: Hash, + reveal_hash_merkle_root: Hash, + tally_hash_merkle_root: Hash, +} + +impl From for BlockMerkleRoots { + fn from(roots: PreStakingBlockMerkleRoots) -> Self { + Self { + mint_hash: roots.mint_hash, + vt_hash_merkle_root: roots.vt_hash_merkle_root, + dr_hash_merkle_root: roots.dr_hash_merkle_root, + commit_hash_merkle_root: roots.commit_hash_merkle_root, + reveal_hash_merkle_root: roots.reveal_hash_merkle_root, + tally_hash_merkle_root: roots.tally_hash_merkle_root, + stake_hash_merkle_root: Hash::default(), + unstake_hash_merkle_root: Hash::default(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct PreStakingBlockHeader { + signals: u32, + beacon: CheckpointBeacon, + merkle_roots: PreStakingBlockMerkleRoots, + proof: BlockEligibilityClaim, + bn256_public_key: Option, +} + +impl From for BlockHeader { + fn from(header: PreStakingBlockHeader) -> Self { + Self { + signals: header.signals, + beacon: header.beacon, + merkle_roots: header.merkle_roots.into(), + proof: header.proof, + bn256_public_key: header.bn256_public_key, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct PreStakingBlockTransactions { + mint: MintTransaction, + value_transfer_txns: Vec, + data_request_txns: Vec, + commit_txns: Vec, + reveal_txns: Vec, + tally_txns: Vec, +} + +impl From for BlockTransactions { + fn from(transactions: PreStakingBlockTransactions) -> Self { + Self { + mint: transactions.mint, + value_transfer_txns: transactions.value_transfer_txns, + data_request_txns: transactions.data_request_txns, + commit_txns: transactions.commit_txns, + reveal_txns: transactions.reveal_txns, + tally_txns: transactions.tally_txns, + stake_txns: vec![], + unstake_txns: vec![], + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct PreStakingBlock { + block_header: PreStakingBlockHeader, + block_sig: KeyedSignature, + txns: PreStakingBlockTransactions, +} + +impl From for Block { + fn from(block: PreStakingBlock) -> Self { + Self::new(block.block_header.into(), block.block_sig, block.txns.into()) + } +} + +fn normalize_legacy( + bytes: &[u8], + convert: impl FnOnce(L) -> T, +) -> Result, bincode::Error> +where + T: DeserializeOwned + Serialize, + L: DeserializeOwned, +{ + if deserialize_exact::(bytes).is_ok() { + return Ok(bytes.to_vec()); + } + + let legacy = deserialize_exact::(bytes)?; + bincode::serialize(&convert(legacy)) +} + +/// Return a current `StakeOutput` map payload, accepting the exact pre-terms layout as fallback. +pub fn normalize_stake_output_set(bytes: &[u8]) -> Result, bincode::Error> { + type Current = HashMap, StakeOutput>; + type Legacy = HashMap, LegacyStakeOutput>; + + normalize_legacy::(bytes, |legacy| { + legacy + .into_iter() + .map(|(key, output)| (key, output.into())) + .collect() + }) +} + +/// Return a current `Transaction` payload, accepting the exact pre-terms layout as fallback. +pub fn normalize_transaction(bytes: &[u8]) -> Result, bincode::Error> { + normalize_legacy::(bytes, Transaction::from) +} + +/// Return a current `Block` payload, accepting exact pre-terms and pre-staking layouts as +/// fallbacks. +pub fn normalize_block(bytes: &[u8]) -> Result, bincode::Error> { + if deserialize_exact::(bytes).is_ok() { + return Ok(bytes.to_vec()); + } + + if let Ok(block) = deserialize_exact::(bytes) { + return bincode::serialize(&Block::from(block)); + } + + deserialize_exact::(bytes) + .and_then(|block| bincode::serialize(&Block::from(block))) +} + +/// Decode a complete block-batch payload, accepting exact pre-terms and pre-staking layouts as +/// fallbacks. +pub fn deserialize_blocks(bytes: &[u8]) -> Result, bincode::Error> { + match deserialize_exact::>(bytes) { + Ok(blocks) => Ok(blocks), + Err(_) => match deserialize_exact::>(bytes) { + Ok(blocks) => Ok(blocks.into_iter().map(Block::from).collect()), + Err(_) => deserialize_exact::>(bytes) + .map(|blocks| blocks.into_iter().map(Block::from).collect()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain::StakeAuthorization; + + fn legacy_stake_transaction() -> LegacyStakeTransaction { + LegacyStakeTransaction { + body: LegacyStakeTransactionBody { + inputs: vec![], + output: LegacyStakeOutput { + authorization: KeyedSignature::default(), + key: StakeKey::default(), + value: 10_000, + }, + change: None, + }, + signatures: vec![], + } + } + + fn pre_staking_block() -> PreStakingBlock { + PreStakingBlock { + block_header: PreStakingBlockHeader { + signals: 1, + beacon: CheckpointBeacon::default(), + merkle_roots: PreStakingBlockMerkleRoots { + mint_hash: Hash::default(), + vt_hash_merkle_root: Hash::default(), + dr_hash_merkle_root: Hash::default(), + commit_hash_merkle_root: Hash::default(), + reveal_hash_merkle_root: Hash::default(), + tally_hash_merkle_root: Hash::default(), + }, + proof: BlockEligibilityClaim::default(), + bn256_public_key: None, + }, + block_sig: KeyedSignature::default(), + txns: PreStakingBlockTransactions::default(), + } + } + + #[test] + fn legacy_block_and_transaction_payloads_decode_without_terms() { + let legacy_transaction = LegacyTransaction::Stake(legacy_stake_transaction()); + let transaction_bytes = bincode::serialize(&legacy_transaction).unwrap(); + let current_transaction_bytes = normalize_transaction(&transaction_bytes).unwrap(); + let transaction: Transaction = deserialize_exact(¤t_transaction_bytes).unwrap(); + let Transaction::Stake(transaction) = transaction else { + panic!("expected stake transaction"); + }; + assert_eq!(transaction.body.output.authorization_terms, None); + + let legacy_block = LegacyBlock { + block_header: BlockHeader::default(), + block_sig: KeyedSignature::default(), + txns: LegacyBlockTransactions { + stake_txns: vec![legacy_stake_transaction()], + ..Default::default() + }, + }; + let block_bytes = bincode::serialize(&legacy_block).unwrap(); + let current_block_bytes = normalize_block(&block_bytes).unwrap(); + let block: Block = deserialize_exact(¤t_block_bytes).unwrap(); + assert_eq!(block.txns.stake_txns[0].body.output.authorization_terms, None); + + let legacy_batch_bytes = bincode::serialize(&vec![legacy_block]).unwrap(); + let blocks = deserialize_blocks(&legacy_batch_bytes).unwrap(); + assert_eq!( + blocks[0].txns.stake_txns[0].body.output.authorization_terms, + None + ); + } + + #[test] + fn legacy_stake_output_set_is_reencoded_with_an_explicit_none() { + let key = StakeKey::default(); + let legacy = HashMap::from([( + key.clone(), + LegacyStakeOutput { + authorization: KeyedSignature::default(), + key: key.clone(), + value: 10_000, + }, + )]); + let legacy_bytes = bincode::serialize(&legacy).unwrap(); + let current_bytes = normalize_stake_output_set(&legacy_bytes).unwrap(); + let current: HashMap, StakeOutput> = + deserialize_exact(¤t_bytes).unwrap(); + + assert_eq!(current[&key].authorization_terms, None); + assert_ne!(legacy_bytes, current_bytes); + + let mut current_with_terms = current; + current_with_terms.get_mut(&key).unwrap().authorization_terms = + Some(StakeAuthorization::default()); + let current_with_terms_bytes = bincode::serialize(¤t_with_terms).unwrap(); + assert_eq!( + normalize_stake_output_set(¤t_with_terms_bytes).unwrap(), + current_with_terms_bytes + ); + } + + #[test] + fn current_block_batch_preserves_authorization_terms() { + let mut transaction: StakeTransaction = legacy_stake_transaction().into(); + transaction.body.output.authorization_terms = Some(StakeAuthorization::default()); + let transaction_bytes = + bincode::serialize(&Transaction::Stake(transaction.clone())).unwrap(); + assert_eq!( + normalize_transaction(&transaction_bytes).unwrap(), + transaction_bytes + ); + let block = Block::new( + BlockHeader::default(), + KeyedSignature::default(), + BlockTransactions { + stake_txns: vec![transaction], + ..Default::default() + }, + ); + let bytes = bincode::serialize(&vec![block]).unwrap(); + let decoded = deserialize_blocks(&bytes).unwrap(); + + assert_eq!( + decoded[0].txns.stake_txns[0].body.output.authorization_terms, + Some(StakeAuthorization::default()) + ); + } + + /// Portable snapshots store blocks inside one bincode `Vec`, so the inventory reader's old + /// fixed-offset repair cannot be applied to each nested block. This fixture locks the + /// structural pre-staking fallback for both a standalone inventory value and a batch. + #[test] + fn pre_staking_block_and_batch_decode_with_empty_staking_sections() { + let legacy_block = pre_staking_block(); + let legacy_block_bytes = bincode::serialize(&legacy_block).unwrap(); + let normalized = normalize_block(&legacy_block_bytes).unwrap(); + let block: Block = deserialize_exact(&normalized).unwrap(); + + assert_eq!( + block.block_header.merkle_roots.stake_hash_merkle_root, + Hash::default() + ); + assert_eq!( + block.block_header.merkle_roots.unstake_hash_merkle_root, + Hash::default() + ); + assert!(block.txns.stake_txns.is_empty()); + assert!(block.txns.unstake_txns.is_empty()); + + let legacy_batch_bytes = bincode::serialize(&vec![legacy_block]).unwrap(); + let blocks = deserialize_blocks(&legacy_batch_bytes).unwrap(); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].txns.stake_txns.is_empty()); + assert!(blocks[0].txns.unstake_txns.is_empty()); + } + + #[test] + fn compatibility_decoders_reject_trailing_bytes() { + let mut bytes = bincode::serialize(&LegacyTransaction::Stake( + legacy_stake_transaction(), + )) + .unwrap(); + // One zero byte is a valid current `Option::None` tag when a legacy payload is + // interpreted through the current layout. Keep one additional byte so the fixture is + // unambiguously trailing for both the current and legacy decoders. + bytes.extend([0, 0]); + + assert!(normalize_transaction(&bytes).is_err()); + + let mut pre_staking_batch = bincode::serialize(&vec![pre_staking_block()]).unwrap(); + pre_staking_batch.extend([0, 0]); + assert!(deserialize_blocks(&pre_staking_batch).is_err()); + } +} diff --git a/data_structures/src/chain/mod.rs b/data_structures/src/chain/mod.rs index 883f4a6286..3496b61513 100644 --- a/data_structures/src/chain/mod.rs +++ b/data_structures/src/chain/mod.rs @@ -4,11 +4,11 @@ use ethereum_types::U256; use futures::future::BoxFuture; use ordered_float::OrderedFloat; use partial_struct::PartialStruct; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::{ cell::{Cell, RefCell}, cmp::Ordering, - collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}, convert::{TryFrom, TryInto}, fmt, fmt::Write as _, @@ -41,9 +41,9 @@ use crate::{ get_environment, get_protocol_version, get_protocol_version_activation_epoch, proto::{ ProtobufConvert, schema, - versioning::{ProtocolVersion, Versioned, VersionedHashable}, + versioning::{ProtocolInfo, ProtocolVersion, Versioned, VersionedHashable}, }, - serialization_helpers::number_from_string, + serialization_helpers::{number_from_string, number_to_string}, staking::prelude::*, superblock::SuperBlockState, transaction::{ @@ -51,8 +51,8 @@ use crate::{ }, transaction::{ CommitTransaction, DRTransaction, DRTransactionBody, Memoized, MintTransaction, - RevealTransaction, StakeTransaction, TallyTransaction, Transaction, TxInclusionProof, - UnstakeTransaction, VTTransaction, + RevealTransaction, StakeTransaction, TallyTransaction, Transaction, + TxInclusionProof, UnstakeTransaction, VTTransaction, }, utxo_pool::{OldUnspentOutputsPool, OwnUnspentOutputsPool, UnspentOutputsPool}, vrf::{BlockEligibilityClaim, DataRequestEligibilityClaim}, @@ -307,21 +307,42 @@ impl ConsensusConstantsWit2 { /// Replication factor for data request solving pub fn get_replication_factor(self, epoch: Epoch, prev_epoch: Epoch) -> u16 { - if get_protocol_version(Some(epoch)) >= ProtocolVersion::V2_0 { - // linearly increasing replication factor - if epoch >= prev_epoch { - 4u16.saturating_mul(2u16.saturating_pow(epoch - prev_epoch)) - } else { - 0 - } - } else { - 0 + Self::get_replication_factor_for_protocol( + epoch, + prev_epoch, + get_protocol_version(Some(epoch)), + ) + } + + /// Replication factor for data request solving under an explicitly selected protocol. + /// + /// Historical snapshot replay must use its pinned protocol schedule instead of consulting the + /// mutable process-global registry. Live callers should continue using `get_replication_factor`, + /// which delegates to this pure rule after resolving the current protocol version. + pub fn get_replication_factor_for_protocol( + epoch: Epoch, + prev_epoch: Epoch, + protocol_version: ProtocolVersion, + ) -> u16 { + if protocol_version < ProtocolVersion::V2_0 || epoch < prev_epoch { + return 0; } + + // Linearly increasing replication factor. + 4u16.saturating_mul(2u16.saturating_pow(epoch - prev_epoch)) } /// Maximum weight units that a block can devote to `StakeTransaction`s. pub fn get_maximum_stake_block_weight(self, epoch: Epoch) -> u32 { - if get_protocol_version(Some(epoch)) > ProtocolVersion::V1_7 { + self.get_maximum_stake_block_weight_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the maximum Stake weight for an explicitly selected protocol version. + pub fn get_maximum_stake_block_weight_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u32 { + if protocol_version > ProtocolVersion::V1_7 { match get_environment() { Environment::Testnet => 10_000_000, _ => 20_000, @@ -333,7 +354,15 @@ impl ConsensusConstantsWit2 { /// Maximum weight units that a block can devote to `UnstakeTransaction`s. pub fn get_maximum_unstake_block_weight(self, epoch: Epoch) -> u32 { - if get_protocol_version(Some(epoch)) >= ProtocolVersion::V2_0 { + self.get_maximum_unstake_block_weight_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the maximum Unstake weight for an explicitly selected protocol version. + pub fn get_maximum_unstake_block_weight_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u32 { + if protocol_version >= ProtocolVersion::V2_0 { 5_000 } else { 0 @@ -342,7 +371,19 @@ impl ConsensusConstantsWit2 { /// Unstake transactions are only spendable after below specified time lock expires pub fn get_unstaking_delay_seconds(self, epoch: Epoch) -> u64 { - if get_protocol_version(Some(epoch)) >= ProtocolVersion::V2_0 { + self.get_unstaking_delay_seconds_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the Unstake delay for an explicitly selected protocol version. + /// + /// Historical block replay uses this entry point so the delay cannot change because a + /// mutable process-global protocol schedule was replaced while the snapshot was being + /// validated. + pub fn get_unstaking_delay_seconds_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u64 { + if protocol_version >= ProtocolVersion::V2_0 { match get_environment() { Environment::Development | Environment::Testnet => { 3_600 // 1 hour @@ -359,8 +400,16 @@ impl ConsensusConstantsWit2 { /// Minimum amount of nanoWits that a `StakeTransaction` can add, and minimum amount that can be /// left in stake by an `UnstakeTransaction`. pub fn get_validator_min_stake_nanowits(self, epoch: Epoch) -> u64 { - if get_protocol_version(Some(epoch)) > ProtocolVersion::V1_7 { - 10_000_000_000_000 // 10,000 Wit + self.get_validator_min_stake_nanowits_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the minimum stake for an explicitly selected protocol version. + pub fn get_validator_min_stake_nanowits_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u64 { + if protocol_version > ProtocolVersion::V1_7 { + DELEGATED_STAKING_MINIMUM_NANOWITS } else { 0 } @@ -368,8 +417,16 @@ impl ConsensusConstantsWit2 { /// Maximum amount of nanoWits that a `StakeTransaction` can add (and can be staked on a single validator). pub fn get_validator_max_stake_nanowits(self, epoch: Epoch) -> u64 { - if get_protocol_version(Some(epoch)) > ProtocolVersion::V1_7 { - 10_000_000_000_000_000 + self.get_validator_max_stake_nanowits_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the maximum stake for an explicitly selected protocol version. + pub fn get_validator_max_stake_nanowits_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u64 { + if protocol_version > ProtocolVersion::V1_7 { + DELEGATED_STAKING_MAXIMUM_NANOWITS } else { 0 } @@ -377,7 +434,15 @@ impl ConsensusConstantsWit2 { /// Validator reward for a proposed and accepted block pub fn get_validator_block_reward(self, epoch: Epoch) -> u64 { - if get_protocol_version(Some(epoch)) >= ProtocolVersion::V2_0 { + self.get_validator_block_reward_for_protocol(get_protocol_version(Some(epoch))) + } + + /// Return the proposer reward for an explicitly selected protocol version. + pub fn get_validator_block_reward_for_protocol( + self, + protocol_version: ProtocolVersion, + ) -> u64 { + if protocol_version >= ProtocolVersion::V2_0 { 50_000_000_000 } else { 0 @@ -656,9 +721,18 @@ impl Block { } pub fn st_weight(&self) -> u32 { + let protocol_version = ProtocolVersion::from_epoch(self.block_header.beacon.checkpoint); + self.st_weight_for_protocol(protocol_version) + } + + /// Return the total Stake transaction weight using an explicitly selected protocol. + /// + /// Block readers and consensus replay should use this variant when their schedule is already + /// snapshotted; [`Self::st_weight`] remains the epoch-derived compatibility API. + pub fn st_weight_for_protocol(&self, protocol_version: ProtocolVersion) -> u32 { let mut st_weight = 0; for st_txn in self.txns.stake_txns.iter() { - st_weight += st_txn.weight(); + st_weight += st_txn.weight_for_protocol(protocol_version); } st_weight } @@ -672,12 +746,36 @@ impl Block { } pub fn weight(&self) -> u32 { - self.dr_weight() + self.vt_weight() + self.st_weight() + self.ut_weight() + let protocol_version = ProtocolVersion::from_epoch(self.block_header.beacon.checkpoint); + self.weight_for_protocol(protocol_version) + } + + /// Return the block weight using an explicitly selected protocol for Stake transactions. + pub fn weight_for_protocol(&self, protocol_version: ProtocolVersion) -> u32 { + self.dr_weight() + + self.vt_weight() + + self.st_weight_for_protocol(protocol_version) + + self.ut_weight() } pub fn is_genesis(&self, genesis: &Hash) -> bool { self.hash().eq(genesis) } + + /// Return whether this block is the configured genesis block under an explicitly selected + /// protocol version. + /// + /// `Block::hash()` is memoized through the process-global guessed protocol, which is suitable + /// for legacy callers but can identify an epoch-zero header with the wrong fork after a later + /// protocol has activated. Consensus replay should use this variant with the block's pinned + /// protocol instead. + pub fn is_genesis_for_protocol( + &self, + genesis: &Hash, + protocol_version: ProtocolVersion, + ) -> bool { + self.versioned_hash(protocol_version).eq(genesis) + } } impl BlockTransactions { @@ -750,19 +848,20 @@ impl BlockTransactions { } /// Create a list of `(transaction_hash, pointer_to_block)` for all the transactions inside - /// this block + /// this block using the historical, unversioned transaction hashes. + /// + /// This compatibility entry point is retained for callers compiled against the pre-Wit/2.1 + /// API. New inventory and persistence code must use + /// [`Self::try_create_pointers_to_transactions`] so that the containing protocol version is + /// explicit and oversized pointer indexes are reported instead of panicking. pub fn create_pointers_to_transactions(&self, block_hash: Hash) -> Vec<(Hash, PointerToBlock)> { - // Store all the transactions as well let mut pointer_to_block = PointerToBlock { block_hash, transaction_index: TransactionPointer::Mint, }; let mut items_to_add = Vec::with_capacity(self.len()); - // Push mint transaction - { - let tx_hash = self.mint.hash(); - items_to_add.push((tx_hash, pointer_to_block.clone())); - } + + items_to_add.push((self.mint.hash(), pointer_to_block.clone())); for (i, tx) in self.value_transfer_txns.iter().enumerate() { pointer_to_block.transaction_index = TransactionPointer::ValueTransfer(u32::try_from(i).unwrap()); @@ -774,33 +873,131 @@ impl BlockTransactions { items_to_add.push((tx.hash(), pointer_to_block.clone())); } for (i, tx) in self.commit_txns.iter().enumerate() { - pointer_to_block.transaction_index = - TransactionPointer::Commit(u32::try_from(i).unwrap()); + pointer_to_block.transaction_index = TransactionPointer::Commit(u32::try_from(i).unwrap()); items_to_add.push((tx.hash(), pointer_to_block.clone())); } for (i, tx) in self.reveal_txns.iter().enumerate() { - pointer_to_block.transaction_index = - TransactionPointer::Reveal(u32::try_from(i).unwrap()); + pointer_to_block.transaction_index = TransactionPointer::Reveal(u32::try_from(i).unwrap()); items_to_add.push((tx.hash(), pointer_to_block.clone())); } for (i, tx) in self.tally_txns.iter().enumerate() { - pointer_to_block.transaction_index = - TransactionPointer::Tally(u32::try_from(i).unwrap()); + pointer_to_block.transaction_index = TransactionPointer::Tally(u32::try_from(i).unwrap()); items_to_add.push((tx.hash(), pointer_to_block.clone())); } for (i, tx) in self.stake_txns.iter().enumerate() { - pointer_to_block.transaction_index = - TransactionPointer::Stake(u32::try_from(i).unwrap()); + pointer_to_block.transaction_index = TransactionPointer::Stake(u32::try_from(i).unwrap()); items_to_add.push((tx.hash(), pointer_to_block.clone())); } for (i, tx) in self.unstake_txns.iter().enumerate() { - pointer_to_block.transaction_index = - TransactionPointer::Unstake(u32::try_from(i).unwrap()); + pointer_to_block.transaction_index = TransactionPointer::Unstake(u32::try_from(i).unwrap()); items_to_add.push((tx.hash(), pointer_to_block.clone())); } items_to_add } + + /// Create a list of `(transaction_hash, pointer_to_block)` for all the transactions inside + /// this block, using the containing block's protocol version for every transaction hash. + /// + /// The transaction pointer format uses `u32` indexes. Persisted or snapshot-supplied blocks + /// must therefore reject an oversized section instead of panicking while constructing their + /// inventory entries. + pub fn try_create_pointers_to_transactions( + &self, + block_hash: Hash, + protocol_version: ProtocolVersion, + ) -> Result, anyhow::Error> { + // Store all the transactions as well + let mut pointer_to_block = PointerToBlock { + block_hash, + transaction_index: TransactionPointer::Mint, + }; + let mut items_to_add = Vec::with_capacity(self.len()); + // Push mint transaction + { + let tx_hash = self.mint.versioned_hash(protocol_version); + items_to_add.push((tx_hash, pointer_to_block.clone())); + } + for (i, tx) in self.value_transfer_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::ValueTransfer( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("value-transfer transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.data_request_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::DataRequest( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("data-request transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.commit_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::Commit( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("commit transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.reveal_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::Reveal( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("reveal transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.tally_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::Tally( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("tally transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.stake_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::Stake( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("stake transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + for (i, tx) in self.unstake_txns.iter().enumerate() { + pointer_to_block.transaction_index = TransactionPointer::Unstake( + u32::try_from(i).map_err(|_| { + anyhow::anyhow!("unstake transaction index {i} does not fit in u32") + })?, + ); + items_to_add.push(( + tx.versioned_hash(protocol_version), + pointer_to_block.clone(), + )); + } + + Ok(items_to_add) + } } impl Hashable for BlockHeader { @@ -1116,6 +1313,37 @@ impl SuperBlockVote { ] .concat() } + + /// Verify the BN256 signature against the alternate key bound to this vote's secp256k1 + /// identity. + /// + /// Superblock votes intentionally carry only the BN256 signature on the wire. The + /// corresponding public key is committed separately in [`AltKeys`], indexed by the + /// secp256k1 public-key hash. Keeping this check beside the canonical message constructor + /// prevents callers from authenticating a different byte sequence or a key belonging to a + /// different committee identity. + pub fn verify_bn256_signature( + &self, + public_key: &Bn256PublicKey, + ) -> Result<(), anyhow::Error> { + if self.bn256_signature.signature.is_empty() { + return Err(anyhow::anyhow!( + "superblock vote has an empty BN256 signature" + )); + } + + let message = self.bn256_signature_message(); + Bn256 + .verify( + &self.bn256_signature.signature, + &message, + &public_key.public_key, + ) + .map_err(|error| { + anyhow::anyhow!("invalid superblock vote BN256 signature: {error:?}") + }) + } + /// The message to be signed with the secp256k1 key is the concatenation /// of the superblock index in big endian, the hash of the superblock and the BN256 signature as bytes: pub fn secp256k1_signature_message(&self) -> Vec { @@ -1644,8 +1872,9 @@ pub struct DataRequestOutput { pub min_consensus_percentage: u32, /// The amount of nanowits that each witness must collateralize in order to claim eligibility of /// this data request. - /// This field must be >= collateral_minimum, or zero. - /// If zero, it will be treated as collateral_minimum. + /// This field must be >= collateral_minimum, or zero. For pre-Wit/2.0 and native Wit/2.1 + /// requests, zero is interpreted as collateral_minimum; a cross-fork Wit/2.0 request retains + /// its serialized zero owner-only compatibility semantics. #[serde(deserialize_with = "number_from_string")] pub collateral: u64, } @@ -1691,19 +1920,465 @@ impl DataRequestOutput { } } +/// Virtual version used by the RPC/CLI compatibility surface for legacy stake authorizations. +/// Legacy authorization codes have no embedded version field and sign only the zero-padded +/// withdrawer address, but callers still use `1` to request and identify that format. +pub const STAKE_AUTHORIZATION_VERSION_V1: u32 = 1; +/// Current explicit stake-authorization format. Legacy authorizations have no +/// version field and sign only the zero-padded withdrawer address. +pub const STAKE_AUTHORIZATION_VERSION_V2: u32 = 2; +/// A regular delegated stake or top-up authorization. +pub const STAKE_AUTHORIZATION_ACTION_DELEGATE: u32 = 0; +/// An authorization that changes validator-wide commission without adding stake. +pub const STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE: u32 = 1; +/// Explicitly bind a previously unregistered validator to its immutable owner withdrawer. +pub const STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER: u32 = 2; +/// A withdrawer-signed request to stop assigning new obligations to one stake entry. +pub const STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT: u32 = 3; +/// Wall-clock notice required before a commission increase can become active. +pub const COMMISSION_INCREASE_NOTICE_SECONDS: u64 = 7 * 24 * 60 * 60; + +const STAKE_AUTHORIZATION_DOMAIN: &[u8] = b"WITNET_STAKE_AUTHORIZATION_V2\0"; +const STAKE_AUTHORIZATION_CODE_PREFIX: &[u8; 4] = b"WIT2"; +const STAKE_AUTHORIZATION_CODE_HEADER_LEN: usize = 72; +const STAKE_AUTHORIZATION_CODE_LEN: usize = STAKE_AUTHORIZATION_CODE_HEADER_LEN + 65; + +/// Consensus fields signed by a validator for stake/commission actions or by the withdrawer for +/// an exit request. The action is covered by the same domain-separated signature. +/// +/// `network` is the chain's consensus magic number. `expiration_epoch` is +/// exclusive: an authorization is invalid at and after that epoch. A value of +/// [`Epoch::MAX`] represents an authorization without a practical expiry. #[derive(Debug, Default, Eq, PartialEq, Clone, Hash, Serialize, Deserialize, ProtobufConvert)] -#[protobuf_convert(source = "schema::witnet::StakeOutput")] +#[protobuf_convert(source = "schema::witnet::StakeAuthorization")] +pub struct StakeAuthorization { + pub version: u32, + pub network: u32, + pub validator: PublicKeyHash, + pub withdrawer: PublicKeyHash, + pub expiration_epoch: Epoch, + pub commission_percent: u32, + pub action: u32, + /// Replay-protection nonce. Delegation/top-up and owner-registration + /// actions use zero; commission updates increase monotonically for each + /// validator policy; exit requests bind the exact live-entry nonce. + #[serde( + serialize_with = "number_to_string", + deserialize_with = "number_from_string" + )] + pub nonce: u64, +} + +impl StakeAuthorization { + /// Construct a regular Wit/2.1 delegation authorization. + pub fn delegation( + network: u16, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + expiration_epoch: Epoch, + commission_percent: u32, + ) -> Self { + Self { + version: STAKE_AUTHORIZATION_VERSION_V2, + network: u32::from(network), + validator, + withdrawer, + expiration_epoch, + commission_percent, + action: STAKE_AUTHORIZATION_ACTION_DELEGATE, + nonce: 0, + } + } + + /// Construct a validator-wide commission update authorization. + pub fn commission_update( + network: u16, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + expiration_epoch: Epoch, + commission_percent: u32, + nonce: u64, + ) -> Self { + Self { + action: STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, + nonce, + ..Self::delegation( + network, + validator, + withdrawer, + expiration_epoch, + commission_percent, + ) + } + } + + /// Bind the initial owner explicitly instead of allowing a normal delegation to register it. + pub fn register_owner( + network: u16, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + expiration_epoch: Epoch, + commission_percent: u32, + ) -> Self { + Self { + action: STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + ..Self::delegation(network, validator, withdrawer, expiration_epoch, commission_percent) + } + } + + /// Request irrevocable draining of the exact stake entry at its current nonce. + pub fn request_exit( + network: u16, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + expiration_epoch: Epoch, + nonce: u64, + ) -> Self { + Self { + action: STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, + nonce, + ..Self::delegation(network, validator, withdrawer, expiration_epoch, 0) + } + } + + /// Identity whose signature authorizes this action. + pub fn authorizer(&self) -> PublicKeyHash { + if self.action == STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT { + self.withdrawer + } else { + self.validator + } + } + + /// Validate fields that do not depend on chain state or the inclusion epoch. + pub fn validate_structure(&self) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.version == STAKE_AUTHORIZATION_VERSION_V2, + "unsupported stake authorization version: {}", + self.version + ); + anyhow::ensure!( + self.network <= u32::from(u16::MAX), + "stake authorization network does not fit a consensus magic number" + ); + anyhow::ensure!( + self.commission_percent <= u32::from(MAX_COMMISSION_PERCENT), + "validator commission must be an integer between 0 and {} percent", + MAX_COMMISSION_PERCENT + ); + anyhow::ensure!( + matches!( + self.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE + | STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE + | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT + ), + "unsupported stake authorization action: {}", + self.action + ); + if matches!(self.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + ) { + anyhow::ensure!( + self.nonce == 0, + "delegation and owner-registration authorizations must use nonce zero" + ); + } else if self.action == STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE { + anyhow::ensure!( + self.nonce > 0, + "commission update authorizations must use a positive nonce" + ); + } else { + anyhow::ensure!( + self.commission_percent == 0, + "exit requests must use the canonical zero commission field" + ); + } + + Ok(()) + } + + /// Validate the network binding and exclusive expiration epoch. + pub fn validate_at(&self, network: u16, epoch: Epoch) -> Result<(), anyhow::Error> { + self.validate_structure()?; + anyhow::ensure!( + self.network == u32::from(network), + "stake authorization is for network {}, expected {}", + self.network, + network + ); + anyhow::ensure!( + epoch < self.expiration_epoch, + "stake authorization expired at epoch {}", + self.expiration_epoch + ); + + Ok(()) + } + + /// Canonical bytes covered by the action-specific Wit/2.1 authorizer signature. + pub fn signing_bytes(&self) -> Result, anyhow::Error> { + self.validate_structure()?; + + let mut bytes = Vec::with_capacity(STAKE_AUTHORIZATION_DOMAIN.len() + 68); + bytes.extend_from_slice(STAKE_AUTHORIZATION_DOMAIN); + bytes.extend_from_slice(&self.version.to_be_bytes()); + bytes.extend_from_slice(&self.network.to_be_bytes()); + bytes.extend_from_slice(self.validator.as_ref()); + bytes.extend_from_slice(self.withdrawer.as_ref()); + bytes.extend_from_slice(&self.expiration_epoch.to_be_bytes()); + bytes.extend_from_slice(&self.commission_percent.to_be_bytes()); + bytes.extend_from_slice(&self.action.to_be_bytes()); + bytes.extend_from_slice(&self.nonce.to_be_bytes()); + + Ok(bytes) + } + + /// SHA-256 digest signed by the action-specific Wit/2.1 authorizer. + pub fn signing_digest(&self) -> Result<[u8; 32], anyhow::Error> { + let Sha256(digest) = calculate_sha256(&self.signing_bytes()?); + + Ok(digest) + } + + fn code_header_bytes(&self) -> Result, anyhow::Error> { + self.validate_structure()?; + + let mut bytes = Vec::with_capacity(STAKE_AUTHORIZATION_CODE_HEADER_LEN); + bytes.extend_from_slice(STAKE_AUTHORIZATION_CODE_PREFIX); + bytes.extend_from_slice(&self.version.to_be_bytes()); + bytes.extend_from_slice(&self.network.to_be_bytes()); + bytes.extend_from_slice(self.validator.as_ref()); + bytes.extend_from_slice(self.withdrawer.as_ref()); + bytes.extend_from_slice(&self.expiration_epoch.to_be_bytes()); + bytes.extend_from_slice(&self.commission_percent.to_be_bytes()); + bytes.extend_from_slice(&self.action.to_be_bytes()); + bytes.extend_from_slice(&self.nonce.to_be_bytes()); + + Ok(bytes) + } +} + +/// Portable Wit/2.1 authorization code, including the signed terms and the +/// action-specific author's recoverable signature. +#[derive(Debug, Default, Eq, PartialEq, Clone, Hash, Serialize, Deserialize)] +pub struct StakeAuthorizationCode { + pub terms: StakeAuthorization, + pub signature: KeyedSignature, +} + +impl StakeAuthorizationCode { + /// Verify the action-specific authorizer identity and signature over the canonical terms. + pub fn verify(&self) -> Result<(), anyhow::Error> { + self.terms.validate_structure()?; + anyhow::ensure!( + self.signature.public_key.pkh() == self.terms.authorizer(), + "stake authorization signature does not belong to the action authorizer" + ); + + let digest = self.terms.signing_digest()?; + let msg = Message::from_digest(digest); + let public_key = Secp256k1_PublicKey::from_slice(&self.signature.public_key.to_bytes())?; + self.signature.signature.verify(&msg, &public_key) + } + + /// Encode a self-contained authorization as a fixed-width hexadecimal code. + pub fn to_hex(&self) -> Result { + self.verify()?; + let digest = self.terms.signing_digest()?; + let recoverable = self + .signature + .clone() + .to_recoverable_bytes(&digest)?; + let mut bytes = self.terms.code_header_bytes()?; + bytes.extend_from_slice(&recoverable); + + Ok(hex::encode(bytes)) + } + + /// Decode and verify a fixed-width Wit/2.1 authorization code. An optional `0x`/`0X` prefix + /// is accepted as presentation syntax and is not part of the signed code bytes. + pub fn from_hex(code: &str) -> Result { + // The SDK's public HexString helpers accept the conventional optional `0x` prefix. Keep + // the Rust decoder equally permissive at this presentation boundary while preserving the + // exact unprefixed bytes that are signed and embedded in the transaction. + let code = code + .strip_prefix("0x") + .or_else(|| code.strip_prefix("0X")) + .unwrap_or(code); + let bytes = hex::decode(code)?; + anyhow::ensure!( + bytes.len() == STAKE_AUTHORIZATION_CODE_LEN, + "Wit/2.1 stake authorization code has unexpected length: {} != {}", + bytes.len(), + STAKE_AUTHORIZATION_CODE_LEN + ); + anyhow::ensure!( + &bytes[..4] == STAKE_AUTHORIZATION_CODE_PREFIX, + "invalid Wit/2.1 stake authorization prefix" + ); + + let terms = StakeAuthorization { + version: u32::from_be_bytes(bytes[4..8].try_into()?), + network: u32::from_be_bytes(bytes[8..12].try_into()?), + validator: PublicKeyHash::from_bytes(&bytes[12..32])?, + withdrawer: PublicKeyHash::from_bytes(&bytes[32..52])?, + expiration_epoch: u32::from_be_bytes(bytes[52..56].try_into()?), + commission_percent: u32::from_be_bytes(bytes[56..60].try_into()?), + action: u32::from_be_bytes(bytes[60..64].try_into()?), + nonce: u64::from_be_bytes(bytes[64..72].try_into()?), + }; + terms.validate_structure()?; + let digest = terms.signing_digest()?; + let signature = KeyedSignature::from_recoverable_slice(&bytes[72..], &digest)?; + let authorization = Self { terms, signature }; + authorization.verify()?; + + Ok(authorization) + } +} + +#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)] pub struct StakeOutput { pub authorization: KeyedSignature, + /// Wit/2.1 authorization terms. Their absence identifies a legacy authorization. + pub authorization_terms: Option, pub key: StakeKey, - #[serde(deserialize_with = "number_from_string")] pub value: u64, } +// The derive macro always invokes every generated message setter. For an `Option` that turns +// `None` into a present, synthesized default nested message at field 4 and changes every legacy +// `StakeOutput` wire representation. Keep the conversion presence-aware so a missing Wit/2.1 field +// retains the exact pre-extension protobuf bytes, while an explicitly present (even +// malformed/default) message remains distinguishable and can be rejected by V2.1 validation. +impl ProtobufConvert for StakeOutput { + type ProtoStruct = schema::witnet::StakeOutput; + + fn to_pb(&self) -> Self::ProtoStruct { + let mut pb = schema::witnet::StakeOutput::new(); + pb.set_value(self.value); + pb.set_key(self.key.to_pb()); + pb.set_authorization(self.authorization.to_pb()); + if let Some(authorization_terms) = &self.authorization_terms { + pb.set_authorization_terms(authorization_terms.to_pb()); + } + + pb + } + + fn from_pb(pb: Self::ProtoStruct) -> Result { + let authorization_terms = if pb.has_authorization_terms() { + Some(StakeAuthorization::from_pb( + pb.get_authorization_terms().to_owned(), + )?) + } else { + None + }; + + Ok(Self { + authorization: KeyedSignature::from_pb(pb.get_authorization().to_owned())?, + authorization_terms, + key: StakeKey::::from_pb(pb.get_key().to_owned())?, + value: pb.get_value(), + }) + } +} + +// `skip_serializing_if` cannot be used directly on `authorization_terms`: bincode encodes structs +// positionally, so omitting a `None` field makes the following `key` bytes look like the option +// discriminant. Keep the JSON representation optional while always writing the option in binary +// persistence formats. +#[derive(Serialize)] +struct HumanReadableStakeOutput<'a> { + authorization: &'a KeyedSignature, + #[serde(skip_serializing_if = "Option::is_none")] + authorization_terms: Option<&'a StakeAuthorization>, + key: &'a StakeKey, + // A delegated stake may be as large as 10,000,000 WIT (10^16 nanowits), + // which is outside JavaScript's exact-integer range. Keep the JSON form + // lossless while BinaryStakeOutput below retains the native u64 layout. + #[serde(serialize_with = "number_to_string")] + value: u64, +} + +#[derive(Serialize)] +struct BinaryStakeOutput<'a> { + authorization: &'a KeyedSignature, + authorization_terms: &'a Option, + key: &'a StakeKey, + value: u64, +} + +#[derive(Deserialize)] +struct DeserializableStakeOutput { + authorization: KeyedSignature, + #[serde(default)] + authorization_terms: Option, + key: StakeKey, + #[serde(deserialize_with = "number_from_string")] + value: u64, +} + +impl Serialize for StakeOutput { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if serializer.is_human_readable() { + HumanReadableStakeOutput { + authorization: &self.authorization, + authorization_terms: self.authorization_terms.as_ref(), + key: &self.key, + value: self.value, + } + .serialize(serializer) + } else { + BinaryStakeOutput { + authorization: &self.authorization, + authorization_terms: &self.authorization_terms, + key: &self.key, + value: self.value, + } + .serialize(serializer) + } + } +} + +impl<'de> Deserialize<'de> for StakeOutput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let output = DeserializableStakeOutput::deserialize(deserializer)?; + + Ok(Self { + authorization: output.authorization, + authorization_terms: output.authorization_terms, + key: output.key, + value: output.value, + }) + } +} + impl StakeOutput { + #[inline] + pub fn weight_for_protocol(&self, protocol_version: ProtocolVersion) -> u32 { + let authorization_terms_weight = if protocol_version >= ProtocolVersion::V2_1 + && self.authorization_terms.is_some() + { + crate::transaction::STAKE_AUTHORIZATION_TERMS_WEIGHT + } else { + 0 + }; + + crate::transaction::STAKE_OUTPUT_WEIGHT.saturating_add(authorization_terms_weight) + } + #[inline] pub fn weight(&self) -> u32 { - crate::transaction::STAKE_OUTPUT_WEIGHT + self.weight_for_protocol(ProtocolVersion::V2_1) } } @@ -1788,7 +2463,14 @@ impl KeyedSignature { string: &str, msg: &[u8], ) -> Result { - let bytes = hex::decode(string).map_err(|e| Secp256k1ConversionError::HexDecode { + // Hex is a presentation boundary. Accept the conventional optional prefix here as well + // as in the higher-level authorization decoder, so direct V1 callers cannot observe a + // different result merely because they bypassed the RPC normalizer. + let normalized = string + .strip_prefix("0x") + .or_else(|| string.strip_prefix("0X")) + .unwrap_or(string); + let bytes = hex::decode(normalized).map_err(|e| Secp256k1ConversionError::HexDecode { hex: String::from(string), inner: e, })?; @@ -1813,10 +2495,20 @@ impl KeyedSignature { } // Recovers a keyed signature from its serialized form and a known message. + // The recovery-id prefix plus compact secp256k1 signature must be exactly 65 bytes. pub fn from_recoverable_slice( compact: &[u8], message: &[u8], ) -> Result { + if compact.len() != 65 { + return Err(Secp256k1ConversionError::Other { + inner: format!( + "recoverable signature must be exactly 65 bytes, is {}", + compact.len() + ), + }); + } + let recid = RecoveryId::from_i32(i32::from(compact[0])) .map_err(|e| Secp256k1ConversionError::Secp256k1 { inner: e })?; let recoverable = RecoverableSignature::from_compact(&compact[1..], recid) @@ -1839,18 +2531,32 @@ impl KeyedSignature { })?; recoverable_bytes[1..].clone_from_slice(&bytes); - // Silly algorithm that tries recovery with different recovery IDs in an attempt to guess which one is correct, - // provided that our `KeyedSignature` misses that information in comparison with `RecoverableSignature` + // Try recovery IDs in an attempt to reconstruct the missing value. A keyed signature does + // not carry that byte, but returning an arbitrary final candidate when none matches would + // emit a portable authorization code whose signature recovers to a different public key. + let mut matched = false; for i in 0..4 { recoverable_bytes[0] = i; - let recovered = KeyedSignature::from_recoverable_slice(&recoverable_bytes, message)?; - - if recovered.public_key == self.public_key { - break; + // Some recovery IDs are structurally invalid for a given compact signature (notably + // when the x-coordinate overflow bit is set). They are simply candidates to skip; + // the valid ID may be later in this bounded set. + if let Ok(recovered) = + KeyedSignature::from_recoverable_slice(&recoverable_bytes, message) + { + if recovered.public_key == self.public_key { + matched = true; + break; + } } } + if !matched { + return Err(Secp256k1ConversionError::Other { + inner: "recoverable signature does not match its keyed public key".to_owned(), + }); + } + Ok(recoverable_bytes) } } @@ -2369,25 +3075,185 @@ impl From for RADTally { type PrioritizedHash = (OrderedFloat, Hash); type PrioritizedVTTransaction = (OrderedFloat, VTTransaction); type PrioritizedDRTransaction = (OrderedFloat, DRTransaction); -type PrioritizedStakeTransaction = (OrderedFloat, StakeTransaction); +// Keep the priority used by the sorted index separate from the protocol-specific admission +// weight. The latter must survive a protocol snapshot change so pool removal can decrement the +// aggregate counter by the same amount that insertion charged. +type PrioritizedStakeTransaction = (OrderedFloat, u32, StakeTransaction); type PrioritizedUnstakeTransaction = (OrderedFloat, UnstakeTransaction); +type PendingDelegatedAction = (Hash, u64); -#[derive(Debug, Clone, Default)] -struct UnconfirmedTransactions { - current: Vec, - previous: Vec, +/// Deterministic tie breaker for the shared transaction-pool pressure policy. +/// +/// The VT/DR ratio first selects at most one ordinary-transaction candidate. That candidate then +/// competes with Stake and Unstake by the common fee/weight priority. At an exact priority tie we +/// evict the ordinary candidate first, then Stake, and Unstake last. Retaining an equally priced +/// exit action is preferable to retaining an equally priced entry or ordinary transfer, and the +/// declared order makes the result independent from hash-map iteration. +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +enum PoolEvictionClass { + ValueTransfer, + DataRequest, + Stake, + Unstake, +} + +type PoolEvictionCandidate = (OrderedFloat, PoolEvictionClass, Hash); + +/// Opaque ownership proof for one asynchronous transaction admission. Protocol-versioned +/// transaction hashes identify the consensus body under the pinned admission snapshot, so +/// signature-only wire variants share one verifier slot while a monotonic generation prevents a +/// callback from an invalidated attempt acquiring an ABA-equivalent claim over a later attempt for +/// the same body. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct PendingTransactionAdmissionReservation { + transaction_hash: Hash, + reservation_id: u64, + // Wit/2.1 Commit work remains counted after epoch/recovery ownership invalidation until the + // stale verifier callback presents this exact generation and releases it. + wit21_commit_dr_pointer: Option, +} + +/// Result of reserving one asynchronous admission slot under the caller's pinned protocol. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum PendingTransactionAdmissionOutcome { + /// This exact consensus body already owns a verifier generation; retries are idempotent. + AlreadyPending, + /// The per-request Wit/2.1 verifier bound is occupied. No generic admission, unconfirmed hash, + /// or verifier work was registered for the rejected body. + Wit21CommitCapacityReached { + dr_pointer: Hash, + limit: usize, + }, + /// The untrusted Commit pointer does not identify a canonical request currently accepting + /// commitments. No map entry or work token was created. + Wit21CommitRequestUnavailable { dr_pointer: Hash }, + /// The process-local ownership generation cannot be advanced without wrapping. No map entry, + /// unconfirmed hash, or verifier work was created; the caller must fail closed and retry only + /// after recovering the process-local admission state. + GenerationExhausted, + /// A new generation now owns one verifier slot. + Reserved(PendingTransactionAdmissionReservation), +} + +/// Semantic slot shared by stake-envelope policy actions. The historical type name remains +/// internal: action 1 is validator/nonce scoped, action 2 is validator scoped, and action 3 is +/// exact-entry/nonce scoped. A malformed owner claim cannot fan out registration/commission slots. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +struct CommissionUpdateActionKey { + validator: PublicKeyHash, + nonce: u64, + action: u32, + withdrawer: Option, +} + +impl CommissionUpdateActionKey { + fn conflict_error(&self, claimed_withdrawer: PublicKeyHash) -> TransactionError { + if self.action == STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE { + TransactionError::PendingCommissionUpdateConflict { + validator: self.validator, withdrawer: claimed_withdrawer, nonce: self.nonce, + } + } else { + TransactionError::PendingStakePolicyActionConflict { + validator: self.validator, + withdrawer: claimed_withdrawer, + action: self.action, + nonce: self.nonce, + } + } + } } -impl UnconfirmedTransactions { - fn update(&mut self) { - std::mem::swap(&mut self.previous, &mut self.current); - self.current.clear(); +/// Consensus action identity for one delegated stake entry's withdrawal transition. Transaction +/// hashes are intentionally not part of this key: different bodies carrying the same +/// validator/withdrawer nonce compete for the same state transition. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +struct DelegatedActionKey { + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + nonce: u64, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PendingDelegatedActionKey { + CommissionUpdate(CommissionUpdateActionKey), + Unstake(DelegatedActionKey), +} + +/// Opaque ownership proof for one asynchronous delegated-action admission slot. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct PendingDelegatedActionReservation { + key: PendingDelegatedActionKey, + transaction_hash: Hash, + reservation_id: u64, +} + +fn commission_update_action_key( + transaction: &StakeTransaction, +) -> Option { + let terms = transaction.body.output.authorization_terms.as_ref()?; + let (nonce, withdrawer) = match terms.action { + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => (terms.nonce, None), + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => (0, None), + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => + (terms.nonce, Some(transaction.body.output.key.withdrawer)), + _ => return None, + }; + Some(CommissionUpdateActionKey { + validator: transaction.body.output.key.validator, + nonce, + action: terms.action, + withdrawer, + }) +} + +fn unstake_action_key(transaction: &UnstakeTransaction) -> DelegatedActionKey { + DelegatedActionKey { + validator: transaction.body.operator, + // The withdrawal address is part of the body hash and standalone validation requires the + // signing public key to resolve to this same address. Key pre-verification reservations + // from the body-committed identity so malformed signer variants of one body cannot fan + // out into independent semantic slots and verifier work. + withdrawer: transaction.body.withdrawal.pkh, + nonce: transaction.body.nonce, + } +} + +/// Return every protocol identity under which one retained Stake body may be addressed. +/// +/// Wit/2.1 appends authorization terms to the consensus body, while older protocols project the +/// same in-memory value through the terms-free representation. Keeping both identities avoids a +/// process-global protocol lookup and lets reorg cleanup resolve either form in constant time. +fn stake_transaction_hash_aliases(transaction: &StakeTransaction) -> [Hash; 2] { + [ + transaction.versioned_hash(ProtocolVersion::V2_1), + transaction + .clone() + .normalized_for_protocol(ProtocolVersion::V2_0) + .versioned_hash(ProtocolVersion::V2_0), + ] +} + +#[derive(Debug, Clone, Default)] +struct UnconfirmedTransactions { + current: Vec, + previous: Vec, +} + +impl UnconfirmedTransactions { + fn update(&mut self) { + std::mem::swap(&mut self.previous, &mut self.current); + self.current.clear(); } fn insert(&mut self, h: Hash) { self.current.push(h); } + fn remove(&mut self, h: &Hash) { + self.current.retain(|candidate| candidate != h); + self.previous.retain(|candidate| candidate != h); + } + fn clear(&mut self) { self.current.clear(); self.previous.clear(); @@ -2433,6 +3299,10 @@ pub struct TransactionsPool { total_ut_weight: u64, // TransactionsPool size limit in weight units weight_limit: u64, + // Maximum number of Wit/2.1 Commit candidates retained, and separately under asynchronous + // verification, for one data request. This is derived from the configured DR block-weight + // limit, whose own accounting includes one COMMIT_WEIGHT unit per requested witness. + wit21_commit_candidates_per_dr_limit: usize, // Ratio of value transfer transaction to data request transaction that should be in the // transactions pool. This is used to decide what type of transaction should be removed when // the transactions pool is full. @@ -2451,13 +3321,40 @@ pub struct TransactionsPool { required_reward_collateral_ratio: u64, // Map for unconfirmed transactions unconfirmed_transactions: UnconfirmedTransactions, + // Current asynchronous-admission ownership: at most one generation per consensus body. + pending_transaction_admissions: HashMap, + // Physical Wit/2.1 Commit verification work by data request. Unlike current body ownership, + // these generation tokens survive epoch/recovery invalidation until each stale callback returns; + // otherwise repeated invalidation could bypass the per-request concurrency bound. + in_flight_wit21_commit_admissions: HashMap>, + // Monotonic ownership generation. Preserve this across pool clears so a callback from before + // an epoch transition or chain-state recovery cannot match a replacement admission token. + next_pending_transaction_admission_id: u64, // TODO: refactor to use Rc> or // Arc> to prevent the current indirect lookup (having to // first query the index for the hash, and then using the hash to find the actual data) st_transactions: HashMap, sorted_st_index: BTreeSet, + // Protocol-versioned Stake aliases mapped to every retained canonical pool key that exposes + // them. More than one V2.1 body may intentionally collapse to the same legacy projection; + // alias-only lookup succeeds only when that projection is unambiguous. + st_hash_alias_index: HashMap>, + // One pending validator-wide commission transition per consensus nonce. Delegated-action + // replacement-by-fee is deliberately disabled: a distinct later body cannot replace the + // first retained body regardless of fee. + commission_update_action_index: HashMap, + // Semantic slots reserved while commission-update signatures are being verified. + pending_commission_update_actions: HashMap, ut_transactions: HashMap, sorted_ut_index: BTreeSet, + // One pending withdrawal choice per validator/withdrawer stake-entry nonce. As with + // commission changes, this is a first-seen-wins slot rather than an RBF index. + unstake_action_index: HashMap, + // Semantic slots reserved while unstake signatures are being verified. + pending_unstake_actions: HashMap, + // Monotonic local ownership generation; deliberately preserved across pool clears so a late + // verifier callback cannot acquire an ABA-equivalent token for a replacement reservation. + next_pending_delegated_action_id: u64, // Minimum fee required to include a Stake Transaction into a block. We check for this fee in the // TransactionPool so we can choose not to insert a transaction we will not mine anyway. minimum_st_fee: u64, @@ -2484,6 +3381,11 @@ impl Default for TransactionsPool { total_ut_weight: 0, // Unlimited by default weight_limit: u64::MAX, + // Wit/2.1 is a new admission domain, so an unconfigured pool has no safe basis for an + // untrusted per-request memory/work allowance. Fail it closed until the node installs + // the consensus-derived DR weight bound. Pre-Wit/2.1 Commit insertion and verification + // deliberately ignore this field and retain their historical behavior. + wit21_commit_candidates_per_dr_limit: 0, // Try to keep the same amount of value transfer weight and data request weight vt_to_dr_factor: 1.0, // Default is to include all transactions into the pool and blocks @@ -2497,10 +3399,19 @@ impl Default for TransactionsPool { // Required minimum reward to collateral percentage is defined as a consensus constant required_reward_collateral_ratio: u64::MAX, unconfirmed_transactions: Default::default(), + pending_transaction_admissions: Default::default(), + in_flight_wit21_commit_admissions: Default::default(), + next_pending_transaction_admission_id: 0, st_transactions: Default::default(), sorted_st_index: Default::default(), + st_hash_alias_index: Default::default(), + commission_update_action_index: Default::default(), + pending_commission_update_actions: Default::default(), ut_transactions: Default::default(), sorted_ut_index: Default::default(), + unstake_action_index: Default::default(), + pending_unstake_actions: Default::default(), + next_pending_delegated_action_id: 0, } } } @@ -2525,7 +3436,10 @@ impl TransactionsPool { /// `vt_to_dr_factor` sets the preference between value transfer transactions and data request /// transactions. For example, a factor of 4.0 will try to keep 4/5 of the total weight in /// value transfer transactions, and the remaining 1/5 in data request transactions. Note that - /// this ratio only applies when the `TransactionsPool` is full. + /// this ratio only applies when the `TransactionsPool` is full. The ratio selects the lowest + /// priority candidate from one of those two ordinary classes; that candidate then competes on + /// the same fee/weight scale with the lowest-priority Stake and Unstake transactions. Equal + /// priorities are resolved deterministically in favor of retaining Unstake, then Stake. pub fn set_total_weight_limit( &mut self, weight_limit: u64, @@ -2533,10 +3447,27 @@ impl TransactionsPool { ) -> Vec { self.weight_limit = weight_limit; self.vt_to_dr_factor = vt_to_dr_factor; - // TODO: take into account stake tx + // `remove_transactions_for_size_limit` accounts for value-transfer, data-request, stake, + // and unstake weights through `total_transactions_weight`. self.remove_transactions_for_size_limit() } + /// Bound Wit/2.1 Commit candidates per request from the configured data-request block weight. + /// + /// Every consensus-valid data request has a transaction weight greater than or equal to + /// `witnesses * COMMIT_WEIGHT`, so this conservative quotient cannot be smaller than the + /// request's required Commit group. Other positive DR weight components only make the bound + /// more permissive. The default and an explicit zero weight reject all Wit/2.1 Commit work; + /// this keeps a pool fail-closed until configuration is installed. Pre-Wit/2.1 insertion + /// deliberately ignores this local admission policy. + /// This separately bounds retention, physical asynchronous verification, and the miner's later + /// greedy-first, work-bounded group search. The miner owns its independent per-block search + /// budget, so configuring this admission cap cannot make proposal construction unbounded. + pub fn set_wit21_commit_candidates_per_dr_limit(&mut self, max_dr_weight: u32) { + self.wit21_commit_candidates_per_dr_limit = + usize::try_from(max_dr_weight / COMMIT_WEIGHT).unwrap_or(usize::MAX); + } + /// As a miner, require a minimum flat fee to include a VTT into the `TransactionsPool` and /// blocks. /// @@ -2546,6 +3477,22 @@ impl TransactionsPool { self.minimum_vtt_fee = minimum_vtt_fee; } + /// As a miner, require a minimum flat fee to retain a Stake transaction in the local pool. + /// + /// This is local admission policy rather than a consensus rule. A zero value keeps the + /// historical accept-all behavior. + pub fn set_minimum_st_fee(&mut self, minimum_st_fee: u64) { + self.minimum_st_fee = minimum_st_fee; + } + + /// As a miner, require a minimum flat fee to retain an Unstake transaction in the local pool. + /// + /// This is local admission policy rather than a consensus rule. A zero value keeps the + /// historical accept-all behavior. + pub fn set_minimum_ut_fee(&mut self, minimum_ut_fee: u64) { + self.minimum_ut_fee = minimum_ut_fee; + } + /// Set the collateral minimum from consensus constants pub fn set_collateral_minimum(&mut self, collateral_minimum: u64) { // The 20x factor accounts for the transitional collateral minimum enforced during the transition to 2.0 @@ -2594,6 +3541,7 @@ impl TransactionsPool { total_st_weight, total_ut_weight, weight_limit: _, + wit21_commit_candidates_per_dr_limit: _, vt_to_dr_factor: _, minimum_vtt_fee: _, minimum_st_fee: _, @@ -2601,10 +3549,19 @@ impl TransactionsPool { collateral_minimum: _, required_reward_collateral_ratio: _, unconfirmed_transactions, + pending_transaction_admissions, + in_flight_wit21_commit_admissions: _, + next_pending_transaction_admission_id: _, st_transactions, sorted_st_index, + st_hash_alias_index, + commission_update_action_index, + pending_commission_update_actions, ut_transactions, sorted_ut_index, + unstake_action_index, + pending_unstake_actions, + next_pending_delegated_action_id: _, } = self; vt_transactions.clear(); @@ -2621,10 +3578,18 @@ impl TransactionsPool { *total_st_weight = 0; *total_ut_weight = 0; unconfirmed_transactions.clear(); + // `clear` invalidates current ownership but cannot cancel verifier jobs that already left + // this actor. Keep their generation tokens counted until their callbacks release them. + pending_transaction_admissions.clear(); st_transactions.clear(); sorted_st_index.clear(); + st_hash_alias_index.clear(); + commission_update_action_index.clear(); + pending_commission_update_actions.clear(); ut_transactions.clear(); sorted_ut_index.clear(); + unstake_action_index.clear(); + pending_unstake_actions.clear(); } /// Returns the number of value transfer transactions in the pool. @@ -2717,6 +3682,52 @@ impl TransactionsPool { self.co_hash_index.clear(); } + /// Retain the lowest transaction hashes for one Wit/2.1 request and remove every excess + /// candidate from both Commit indexes. Hash ordering makes the result independent of arrival + /// order and matches the order consumed by `select_commit_candidates`. + fn enforce_wit21_commit_candidate_limit( + &mut self, + dr_pointer: Hash, + ) -> Vec { + let Some(commits) = self.co_transactions.get(&dr_pointer) else { + return vec![]; + }; + if commits.len() <= self.wit21_commit_candidates_per_dr_limit { + return vec![]; + } + + let mut ranked = commits + .iter() + .map(|(pkh, hash)| (*pkh, *hash)) + .collect::>(); + ranked.sort_unstable_by_key(|(_pkh, hash)| *hash); + let excess = ranked + .into_iter() + .skip(self.wit21_commit_candidates_per_dr_limit) + .collect::>(); + + let mut removed = Vec::with_capacity(excess.len()); + if let Some(commits) = self.co_transactions.get_mut(&dr_pointer) { + for (pkh, hash) in excess { + if commits.get(&pkh) == Some(&hash) { + commits.remove(&pkh); + } + if let Some(commit) = self.co_hash_index.remove(&hash) { + removed.push(Transaction::Commit(commit)); + } + } + } + if self + .co_transactions + .get(&dr_pointer) + .is_some_and(|commits| commits.is_empty()) + { + self.co_transactions.remove(&dr_pointer); + } + + removed + } + /// Clear reveal transactions in TransactionsPool pub fn clear_reveals(&mut self) { self.re_transactions.clear(); @@ -2725,14 +3736,237 @@ impl TransactionsPool { /// Clear stake transactions in TransactionsPool pub fn clear_stake_transactions(&mut self) { - self.st_transactions.clear(); - self.sorted_st_index.clear(); + // Route each entry through the ordinary removal path instead of clearing only the two + // stake maps. Stake transactions also contribute to `total_st_weight` and are registered + // in `output_pointer_map`; leaving either index behind makes a later pool-size decision + // or conflicting-input admission observe transactions that no longer exist. + let keys = self.st_transactions.keys().copied().collect::>(); + for key in keys { + self.st_remove_inner(&key, false); + } } /// Clear unstake transactions in TransactionsPool pub fn clear_unstake_transactions(&mut self) { - self.ut_transactions.clear(); - self.sorted_ut_index.clear(); + // Keep the aggregate weight counter in sync with the map. Unstake transactions do not + // spend UTXOs, so the existing removal helper is sufficient here. + let keys = self.ut_transactions.keys().copied().collect::>(); + for key in keys { + self.ut_remove_inner(&key); + } + } + + /// Reserve ownership of one asynchronous admission and register its body hash as + /// unconfirmed. Returning `None` means the same consensus body is already being verified; + /// signature-only variants intentionally share the existing attempt instead of spawning + /// duplicate verifier work. If the process-local generation counter is exhausted, this + /// compatibility-shaped API also returns `None`; protocol-aware callers should use + /// `reserve_pending_transaction_admission_for_protocol` to receive the typed outcome. + pub fn reserve_pending_transaction_admission( + &mut self, + transaction_hash: Hash, + ) -> Option { + self.reserve_pending_transaction_admission_inner(transaction_hash, None) + } + + /// Reserve asynchronous admission under a pinned protocol and the consensus-derived Wit/2.1 + /// Commit concurrency policy. + /// + /// The Commit bound is the same conservative `max_dr_weight / COMMIT_WEIGHT` quotient used by + /// retained candidates. A full request rejects new work first-seen-first-served rather than + /// replacing a running verifier: the signature manager has no cancellation primitive, so hash + /// replacement would let a stream of decreasing hashes create unbounded physical work. The + /// untrusted request pointer must resolve to a canonical live request in Commit stage before it + /// receives any slot, preventing fabricated pointers from multiplying the per-request budget. + /// Exact bodies are recognized before capacity and remain idempotent while their generation owns + /// the generic admission slot. Pre-Wit/2.1 and non-Commit transactions use the historical path. + pub fn reserve_pending_transaction_admission_for_protocol( + &mut self, + transaction: &Transaction, + protocol_version: ProtocolVersion, + dr_pool: &DataRequestPool, + ) -> PendingTransactionAdmissionOutcome { + // The actor pins the protocol snapshot across asynchronous verification and computes + // its lifecycle identity with the same versioned hash. Using `Transaction::hash()` here + // would silently consult the process-global protocol representation instead, so a + // protocol-boundary reorg could reserve, clear, or retry a different body identity than + // the one that was actually admitted. Keep generic admission ownership aligned with the + // pinned protocol for every transaction family, not just Wit/2.1 Stake sidecars. + let transaction_hash = transaction.versioned_hash(protocol_version); + if self + .pending_transaction_admissions + .contains_key(&transaction_hash) + { + return PendingTransactionAdmissionOutcome::AlreadyPending; + } + + let wit21_commit_dr_pointer = if protocol_version >= ProtocolVersion::V2_1 { + match transaction { + Transaction::Commit(commit) => Some(commit.body.dr_pointer), + _ => None, + } + } else { + None + }; + if let Some(dr_pointer) = wit21_commit_dr_pointer { + if !dr_pool + .data_request_state(&dr_pointer) + .is_some_and(|dr_state| dr_state.stage == DataRequestStage::COMMIT) + { + return PendingTransactionAdmissionOutcome::Wit21CommitRequestUnavailable { + dr_pointer, + }; + } + let in_flight = self + .in_flight_wit21_commit_admissions + .get(&dr_pointer) + .map_or(0, |in_flight| in_flight.len()); + if in_flight >= self.wit21_commit_candidates_per_dr_limit { + return PendingTransactionAdmissionOutcome::Wit21CommitCapacityReached { + dr_pointer, + limit: self.wit21_commit_candidates_per_dr_limit, + }; + } + } + + match self.reserve_pending_transaction_admission_inner( + transaction_hash, + wit21_commit_dr_pointer, + ) { + Some(reservation) => PendingTransactionAdmissionOutcome::Reserved(reservation), + None if self.next_pending_transaction_admission_id == u64::MAX => { + PendingTransactionAdmissionOutcome::GenerationExhausted + } + None => PendingTransactionAdmissionOutcome::AlreadyPending, + } + } + + fn reserve_pending_transaction_admission_inner( + &mut self, + transaction_hash: Hash, + wit21_commit_dr_pointer: Option, + ) -> Option { + if self + .pending_transaction_admissions + .contains_key(&transaction_hash) + { + return None; + } + + let reservation_id = self.next_pending_transaction_admission_id; + let next_reservation_id = reservation_id.checked_add(1)?; + self.next_pending_transaction_admission_id = next_reservation_id; + self.pending_transaction_admissions + .insert(transaction_hash, reservation_id); + self.unconfirmed_transactions.insert(transaction_hash); + if let Some(dr_pointer) = wit21_commit_dr_pointer { + self.in_flight_wit21_commit_admissions + .entry(dr_pointer) + .or_default() + .insert((transaction_hash, reservation_id)); + } + + Some(PendingTransactionAdmissionReservation { + transaction_hash, + reservation_id, + wit21_commit_dr_pointer, + }) + } + + /// Release the physical verifier slot for this exact generation, including after its generic + /// ownership was invalidated. This is deliberately separate from current-owner cleanup: a stale + /// callback must reduce the work count without erasing a newer same-body generation. + fn release_in_flight_wit21_commit_admission( + &mut self, + reservation: &PendingTransactionAdmissionReservation, + ) { + let Some(dr_pointer) = reservation.wit21_commit_dr_pointer else { + return; + }; + let remove_request = self + .in_flight_wit21_commit_admissions + .get_mut(&dr_pointer) + .is_some_and(|in_flight| { + in_flight.remove(&( + reservation.transaction_hash, + reservation.reservation_id, + )); + in_flight.is_empty() + }); + if remove_request { + self.in_flight_wit21_commit_admissions + .remove(&dr_pointer); + } + } + + /// Return whether this exact asynchronous admission still owns the body-hash slot. The + /// generation is required because epoch transitions and state recovery deliberately release + /// old body ownership; if per-request verifier capacity remains, the same body can then acquire + /// a newer generation before its old callback returns. + pub fn owns_pending_transaction_admission( + &self, + reservation: &PendingTransactionAdmissionReservation, + ) -> bool { + self.pending_transaction_admissions + .get(&reservation.transaction_hash) + == Some(&reservation.reservation_id) + } + + /// Complete only the admission owned by `reservation`, retaining its unconfirmed hash for the + /// normal post-admission lifecycle. A stale callback is a no-op. + pub fn complete_pending_transaction_admission( + &mut self, + reservation: PendingTransactionAdmissionReservation, + ) -> bool { + self.release_in_flight_wit21_commit_admission(&reservation); + if !self.owns_pending_transaction_admission(&reservation) { + return false; + } + + self.pending_transaction_admissions + .remove(&reservation.transaction_hash); + true + } + + /// Roll back only the admission owned by `reservation`. Coupling the generation check and + /// unconfirmed-hash removal prevents a stale verifier from erasing the tracking entry of a + /// newer same-body attempt. + pub fn rollback_pending_transaction_admission( + &mut self, + reservation: PendingTransactionAdmissionReservation, + ) -> bool { + if !self.complete_pending_transaction_admission(reservation) { + return false; + } + + self.unconfirmed_transactions + .remove(&reservation.transaction_hash); + true + } + + /// Invalidate all asynchronous admission ownership and remove only its unconfirmed hashes. + /// Retained transactions have already completed their admission and are therefore absent from + /// this map; their ordinary two-generation unconfirmed lifecycle is preserved. Physical + /// Wit/2.1 Commit work cannot be cancelled, so its exact generation tokens remain capacity- + /// counted until stale callbacks call `complete` or `rollback` and release them. + pub fn clear_pending_transaction_admissions(&mut self) { + let pending_hashes = self + .pending_transaction_admissions + .keys() + .copied() + .collect::>(); + self.pending_transaction_admissions.clear(); + for transaction_hash in pending_hashes { + self.unconfirmed_transactions.remove(&transaction_hash); + } + } + + /// Release all semantic slots owned by asynchronous delegated-action verification. A real + /// epoch transition invalidates every such attempt through the actor's pinned-epoch check; + /// their later compare-and-release callbacks therefore become harmless no-ops. + pub fn clear_pending_delegated_actions(&mut self) { + self.pending_commission_update_actions.clear(); + self.pending_unstake_actions.clear(); } /// Returns `Ok(true)` if the pool already contains this transaction. @@ -2742,7 +3976,20 @@ impl TransactionsPool { /// an existing transaction, but different hash. /// * The reveal transaction has the same data request pointer and pkh as /// an existing transaction, but different hash. + /// * A different commission update already uses the same validator-owner policy nonce. + /// * A different unstake transaction already uses the same validator/withdrawer entry nonce. pub fn contains(&self, transaction: &Transaction) -> Result { + self.contains_for_protocol(transaction, get_protocol_version(None)) + } + + /// Check membership and delegated-action conflicts under the caller's admission protocol. + /// Actor admission pins this value across asynchronous signature verification, so a global + /// protocol transition cannot reinterpret a Stake sidecar halfway through the operation. + pub fn contains_for_protocol( + &self, + transaction: &Transaction, + protocol_version: ProtocolVersion, + ) -> Result { let tx_hash = transaction.hash(); match transaction { @@ -2764,8 +4011,328 @@ impl TransactionsPool { // be impossible for nodes to broadcast these kinds of transactions. Transaction::Tally(_tt) => Err(TransactionError::NotValidTransaction), Transaction::Mint(_mt) => Err(TransactionError::NotValidTransaction), - Transaction::Stake(_st) => Ok(self.st_contains(&tx_hash)), - Transaction::Unstake(_ut) => Ok(self.ut_contains(&tx_hash)), + Transaction::Stake(st) => { + let current_key = st.versioned_hash(protocol_version); + // A reorg can temporarily restore a pre-activation protocol snapshot while a + // terms-bearing transaction admitted under Wit/2.1 remains in the pool. Resolve + // the candidate's pinned-protocol identity against every stored transaction's + // current, V2.1, and legacy projection. Deriving all three identities only from a + // legacy-normalized candidate cannot recover the terms-bearing V2.1 hash. + if self.st_has_hash_alias(¤t_key) { + return Ok(true); + } + + if protocol_version >= ProtocolVersion::V2_1 { + if let Some(action_key) = commission_update_action_key(st) { + if self + .commission_update_action_index + .contains_key(&action_key) + { + return Err(action_key.conflict_error(st.body.output.key.withdrawer)); + } + if let Some((pending_hash, _reservation_id)) = + self.pending_commission_update_actions.get(&action_key) + { + if pending_hash == ¤t_key { + return Ok(true); + } + return Err(action_key.conflict_error(st.body.output.key.withdrawer)); + } + } + } + + Ok(false) + } + Transaction::Unstake(ut) => { + if self.ut_contains(&tx_hash) { + return Ok(true); + } + let action_key = unstake_action_key(ut); + if self.unstake_action_index.contains_key(&action_key) { + return Err(TransactionError::PendingUnstakeConflict { + validator: action_key.validator, + withdrawer: action_key.withdrawer, + nonce: action_key.nonce, + }); + } + if let Some((pending_hash, _reservation_id)) = + self.pending_unstake_actions.get(&action_key) + { + if pending_hash == &tx_hash { + return Ok(true); + } + return Err(TransactionError::PendingUnstakeConflict { + validator: action_key.validator, + withdrawer: action_key.withdrawer, + nonce: action_key.nonce, + }); + } + + Ok(false) + } + } + } + + /// Return whether the exact transaction body is retained under the identity used at + /// admission. Unlike [`TransactionsPool::contains_for_protocol`], this deliberately does not + /// treat a protocol-versioned Stake alias as an exact match: an alias can identify a different + /// terms-bearing body after a protocol-boundary reorganization. Actor admission uses this + /// stronger predicate before completing or relaying policy-sensitive transactions. + pub fn contains_exact_for_protocol( + &self, + transaction: &Transaction, + protocol_version: ProtocolVersion, + ) -> bool { + match transaction { + Transaction::ValueTransfer(vt) => self.vt_transactions.contains_key(&vt.hash()), + Transaction::DataRequest(dr) => self.dr_transactions.contains_key(&dr.hash()), + Transaction::Commit(commit) => self.co_hash_index.contains_key(&commit.hash()), + Transaction::Reveal(reveal) => self.re_hash_index.contains_key(&reveal.hash()), + Transaction::Stake(stake) => self + .st_transactions + .get(&stake.versioned_hash(protocol_version)) + .is_some_and(|(_, _, retained)| retained == stake), + Transaction::Unstake(unstake) => self.ut_transactions.contains_key(&unstake.hash()), + Transaction::Tally(_) | Transaction::Mint(_) => false, + } + } + + /// Simulate whether `transaction` would be retained by the current local pool policy without + /// mutating this pool. + /// + /// The clone deliberately includes retained transactions, pending body/semantic admissions, + /// fee floors, aggregate weights, and the shared size-pressure policy. Exact retained or + /// pending bodies remain idempotently admissible, matching `ChainManager::add_transaction`; + /// a new candidate is accepted only when inserting it into the cloned pool leaves that body + /// addressable under the caller's pinned protocol version. As with direct insertion, callers + /// must perform consensus/context validation first; this pool-only simulation has no canonical + /// data-request snapshot with which to reject an invented or non-Commit-stage pointer. + pub fn would_retain_for_protocol( + &self, + transaction: &Transaction, + fee: u64, + protocol_version: ProtocolVersion, + ) -> Result { + if self.contains_for_protocol(transaction, protocol_version)? { + if self.contains_exact_for_protocol(transaction, protocol_version) { + return Ok(true); + } + // A body that is still undergoing delegated-action signature verification is + // idempotently admissible even though it has not reached the retained index yet. A + // protocol alias, by contrast, is not the exact candidate and must not make a + // read-only preview claim that insertion would retain it. + let pending_exact = match transaction { + Transaction::Stake(stake) if protocol_version >= ProtocolVersion::V2_1 => { + commission_update_action_key(stake).is_some_and(|action_key| { + self.pending_commission_update_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| { + pending_hash == &stake.versioned_hash(protocol_version) + }) + }) + } + Transaction::Unstake(unstake) => { + let action_key = unstake_action_key(unstake); + self.pending_unstake_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash == &unstake.hash()) + } + _ => false, + }; + return Ok(pending_exact); + } + if protocol_version >= ProtocolVersion::V2_1 + && let Transaction::Commit(commit) = transaction + { + // Pending-body ownership is keyed by the same protocol snapshot that admitted the + // transaction. Reusing the process-global hash here would miss an exact in-flight + // Commit after a schedule transition and incorrectly apply the per-request capacity + // check to a body that is already owned. + let transaction_hash = transaction.versioned_hash(protocol_version); + if self + .pending_transaction_admissions + .contains_key(&transaction_hash) + { + return Ok(true); + } + let in_flight = self + .in_flight_wit21_commit_admissions + .get(&commit.body.dr_pointer) + .map_or(0, |in_flight| in_flight.len()); + if in_flight >= self.wit21_commit_candidates_per_dr_limit { + return Ok(false); + } + } + + let mut simulated_pool = self.clone(); + let _removed = + simulated_pool.insert_for_protocol(transaction.clone(), fee, protocol_version); + Ok(simulated_pool.contains_exact_for_protocol(transaction, protocol_version)) + } + + /// Reserve the semantic slot for a Wit/2.1 delegated action before asynchronous verification + /// yields. The reservation is compare-by-hash: retries of the same body are idempotent, while + /// a different body for the same consensus nonce is rejected before consuming more verifier + /// work. Pre-Wit/2.1 transactions return no delegated reservation and retain their historical + /// pool behavior. Callers must release the matching reservation on every terminal path. + pub fn reserve_pending_delegated_action_for_protocol( + &mut self, + transaction: &Transaction, + protocol_version: ProtocolVersion, + ) -> Result, TransactionError> { + match transaction { + Transaction::Stake(st) if protocol_version >= ProtocolVersion::V2_1 => { + let Some(terms) = st.body.output.authorization_terms.as_ref() else { + return Ok(None); + }; + let fee_only_action = matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE + | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT + ); + if terms.validate_structure().is_err() + || terms.validator != st.body.output.key.validator + || terms.withdrawer != st.body.output.key.withdrawer + || st.body.inputs.is_empty() + || (fee_only_action && st.body.output.value != 0) + || (!fee_only_action + && (st.body.output.value < DELEGATED_STAKING_MINIMUM_NANOWITS + || st.body.output.value > DELEGATED_STAKING_MAXIMUM_NANOWITS)) + { + // Do not consume a semantic verifier slot for a native body that the + // retained-pool boundary and canonical validation will reject regardless of + // signer validity. Fee positivity is checked later because this helper does + // not receive the transaction fee. + return Ok(None); + } + let Some(action_key) = commission_update_action_key(st) else { + return Ok(None); + }; + let transaction_hash = st.versioned_hash(protocol_version); + if self.st_has_hash_alias(&transaction_hash) { + return Ok(None); + } + if self + .commission_update_action_index + .contains_key(&action_key) + || self + .pending_commission_update_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash != &transaction_hash) + { + return Err(action_key.conflict_error(st.body.output.key.withdrawer)); + } + if self + .pending_commission_update_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash == &transaction_hash) + { + return Ok(None); + } + let reservation_id = self.next_pending_delegated_action_id; + self.next_pending_delegated_action_id = self + .next_pending_delegated_action_id + .checked_add(1) + .ok_or(TransactionError::UtxoReservationGenerationOverflow)?; + self.pending_commission_update_actions + .insert(action_key, (transaction_hash, reservation_id)); + return Ok(Some(PendingDelegatedActionReservation { + key: PendingDelegatedActionKey::CommissionUpdate(action_key), + transaction_hash, + reservation_id, + })); + } + // Unstake semantic reservations are a Wit/2.1 admission concept. Legacy Unstake + // transactions still use the retained action index for ordinary nonce safety, but + // their asynchronous verification must not consume or conflict with the delegated + // pending-action slots introduced by Wit/2.1. + Transaction::Unstake(_ut) if protocol_version < ProtocolVersion::V2_1 => { + return Ok(None); + } + Transaction::Unstake(ut) => { + if ut.body.withdrawal.value == 0 { + // Native consensus requires a positive withdrawal output. The retained-pool + // boundary rejects this shape too; avoid reserving a nonce slot for a body + // that cannot become a valid transaction. + return Ok(None); + } + let action_key = unstake_action_key(ut); + let transaction_hash = ut.hash(); + if self.ut_transactions.contains_key(&transaction_hash) { + return Ok(None); + } + if self.unstake_action_index.contains_key(&action_key) + || self + .pending_unstake_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash != &transaction_hash) + { + return Err(TransactionError::PendingUnstakeConflict { + validator: action_key.validator, + withdrawer: action_key.withdrawer, + nonce: action_key.nonce, + }); + } + if self + .pending_unstake_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash == &transaction_hash) + { + return Ok(None); + } + let reservation_id = self.next_pending_delegated_action_id; + self.next_pending_delegated_action_id = self + .next_pending_delegated_action_id + .checked_add(1) + .ok_or(TransactionError::UtxoReservationGenerationOverflow)?; + self.pending_unstake_actions + .insert(action_key, (transaction_hash, reservation_id)); + return Ok(Some(PendingDelegatedActionReservation { + key: PendingDelegatedActionKey::Unstake(action_key), + transaction_hash, + reservation_id, + })); + } + _ => return Ok(None), + } + } + + /// Release only the delegated-action reservation owned by the supplied opaque token. + pub fn release_pending_delegated_action( + &mut self, + reservation: PendingDelegatedActionReservation, + ) -> bool { + if !self.owns_pending_delegated_action(&reservation) { + return false; + } + + match reservation.key { + PendingDelegatedActionKey::CommissionUpdate(key) => { + self.pending_commission_update_actions.remove(&key); + } + PendingDelegatedActionKey::Unstake(key) => { + self.pending_unstake_actions.remove(&key); + } + } + + true + } + + /// Return whether this exact asynchronous admission token still owns its semantic slot. + /// Hash equality alone is insufficient after an epoch or chain-state reset because the same + /// transaction body can acquire a newer reservation before the old verifier returns. + pub fn owns_pending_delegated_action( + &self, + reservation: &PendingDelegatedActionReservation, + ) -> bool { + let owned = (reservation.transaction_hash, reservation.reservation_id); + match reservation.key { + PendingDelegatedActionKey::CommissionUpdate(key) => { + self.pending_commission_update_actions.get(&key) == Some(&owned) + } + PendingDelegatedActionKey::Unstake(key) => { + self.pending_unstake_actions.get(&key) == Some(&owned) + } } } @@ -2858,7 +4425,8 @@ impl TransactionsPool { .unwrap_or(Ok(false)) } - /// Returns `true` if the pool contains a stake transaction for the specified hash. + /// Returns `true` if the pool contains a stake transaction for the specified hash or one of + /// its protocol-versioned aliases. /// /// The `key` may be any borrowed form of the hash, but `Hash` and /// `Eq` on the borrowed form must match those for the key type. @@ -2878,7 +4446,11 @@ impl TransactionsPool { /// assert!(pool.st_contains(&hash)); /// ``` pub fn st_contains(&self, key: &Hash) -> bool { - self.st_transactions.contains_key(key) + // Stake entries are retained under the protocol identity used at admission, while + // callers may compute a legacy or Wit/2.1 projection after a reorganization. Keep this + // compatibility helper aligned with `get` and `contains_for_protocol`; unlike retrieval, + // membership remains true even when a legacy projection is ambiguous. + self.st_has_hash_alias(key) } /// Returns `true` if the pool contains an unstake transaction for the @@ -2972,7 +4544,9 @@ impl TransactionsPool { .remove(key) .map(|(weight, transaction)| { self.sorted_vt_index.remove(&(weight, *key)); - self.total_vt_weight -= u64::from(transaction.weight()); + self.total_vt_weight = self + .total_vt_weight + .saturating_sub(u64::from(transaction.weight())); if !consolidated { self.remove_tx_from_output_pointer_map(key, &transaction.body.inputs); } @@ -3024,7 +4598,9 @@ impl TransactionsPool { .remove(key) .map(|(weight, transaction)| { self.sorted_dr_index.remove(&(weight, *key)); - self.total_dr_weight -= u64::from(transaction.weight()); + self.total_dr_weight = self + .total_dr_weight + .saturating_sub(u64::from(transaction.weight())); if !consolidated { self.remove_tx_from_output_pointer_map(key, &transaction.body.inputs); } @@ -3045,14 +4621,162 @@ impl TransactionsPool { } } - /// Returns a tuple with a vector of commit transactions that achieve the minimum specify + /// Returns a tuple with a vector of commit transactions that achieve the minimum specified /// by the data request, the value of all the fees obtained with those commits, and /// the list of data requests that have reached the commit limit + /// + /// Unlike [`TransactionsPool::remove_commits`], this method does not mutate the pool. Commit + /// and data-request hashes are considered in ascending order so repeated candidate-building + /// attempts over the same snapshot select the same non-conflicting complete groups. This is + /// keeps commits available when a later fallible step fails. Callers that must replace a + /// candidate rejected by an external eligibility, signature, or state projection should use + /// [`TransactionsPool::select_commit_candidates`] and publish only a complete filtered group. + /// Successful consolidation and the normal epoch-boundary fallback must expire the epoch's + /// commit set with [`TransactionsPool::clear_commits`]. + pub fn select_commits( + &self, + dr_pool: &DataRequestPool, + ) -> (Vec, u64, Vec) { + let mut total_fee = 0u64; + let mut spent_inputs = HashSet::::new(); + let mut selected_commits = Vec::with_capacity(20); + let mut solved_dr_pointers = Vec::new(); + let mut dr_pointers = self.co_transactions.keys().copied().collect::>(); + dr_pointers.sort_unstable(); + + for dr_pointer in dr_pointers { + let Some(dr_output) = dr_pool.get_dr_output(&dr_pointer) else { + continue; + }; + let n_commits = dr_output.witnesses as usize; + let Some(commits) = self.co_transactions.get(&dr_pointer) else { + continue; + }; + if commits.len() < n_commits { + continue; + } + + let mut commit_hashes = commits.values().copied().collect::>(); + commit_hashes.sort_unstable(); + let mut group_commits = Vec::with_capacity(n_commits); + let mut group_spent_inputs = HashSet::::new(); + + for commit_hash in commit_hashes { + if group_commits.len() == n_commits { + break; + } + let Some(commit_tx) = self.co_hash_index.get(&commit_hash) else { + continue; + }; + let mut transaction_inputs = HashSet::::new(); + let inputs_are_available = commit_tx.body.collateral.iter().all(|input| { + !spent_inputs.contains(input) + && !group_spent_inputs.contains(input) + && transaction_inputs.insert(*input) + }); + if inputs_are_available { + group_spent_inputs.extend(transaction_inputs); + group_commits.push(commit_tx.clone()); + } + } + + // A partial group cannot solve the request and must not reserve inputs that could be + // used by a later complete group. + if group_commits.len() != n_commits { + continue; + } + + spent_inputs.extend(group_spent_inputs); + selected_commits.extend(group_commits); + total_fee = total_fee.saturating_add( + dr_output + .commit_and_reveal_fee + .saturating_mul(n_commits as u64), + ); + solved_dr_pointers.push(dr_pointer); + } + + (selected_commits, total_fee, solved_dr_pointers) + } + + /// Return the deterministic Commit candidates for every request that could still form a + /// complete group. + /// + /// This is the read-only selection surface used when candidate construction must perform + /// checks that do not belong to the transaction pool (for example, projected delegated-stake + /// eligibility and VRF verification). Requests and Commit transaction hashes are returned in + /// ascending order. At most the currently configured per-request candidate limit is returned, + /// even if a direct caller populated the pool before lowering that limit. This keeps the mining + /// surface fail-closed independently of admission cleanup while leaving retained indexes + /// untouched. Each selected pool entry is returned by reference at most once; callers may use + /// the finite immutable slice for a bounded deterministic group search. The returned `usize` + /// is the exact number of Commit transactions required to solve the request. + /// + /// No input is reserved and neither Commit index is mutated. Callers must apply their own + /// transaction-local, group-local, and previously-selected input exclusions while scanning + /// the candidates, and must publish state only after selecting a complete group. + pub fn select_commit_candidates<'a>( + &'a self, + dr_pool: &DataRequestPool, + ) -> Vec<(Hash, usize, Vec<&'a CommitTransaction>)> { + let mut candidates_by_request = Vec::new(); + let mut dr_pointers = self.co_transactions.keys().copied().collect::>(); + dr_pointers.sort_unstable(); + + for dr_pointer in dr_pointers { + let Some(dr_output) = dr_pool.get_dr_output(&dr_pointer) else { + continue; + }; + let required_commits = dr_output.witnesses as usize; + let Some(commits) = self.co_transactions.get(&dr_pointer) else { + continue; + }; + let candidate_limit = self.wit21_commit_candidates_per_dr_limit; + if candidate_limit < required_commits || commits.len() < required_commits { + continue; + } + + // Keep only the current lowest-hash prefix while inspecting an older oversized map. + // This bounds temporary selection memory by the configured cap instead of allocating + // and sorting every stale retained hash before truncation. + let mut commit_hashes = BinaryHeap::new(); + for commit_hash in commits.values().copied() { + if commit_hashes.len() < candidate_limit { + commit_hashes.push(commit_hash); + } else if commit_hashes + .peek() + .is_some_and(|largest_retained| commit_hash < *largest_retained) + { + commit_hashes.pop(); + commit_hashes.push(commit_hash); + } + } + let candidates = commit_hashes + .into_sorted_vec() + .into_iter() + .filter_map(|commit_hash| self.co_hash_index.get(&commit_hash)) + .collect::>(); + + // A stale or incomplete hash index cannot supply even the primary group. Do not + // advertise it as solvable merely because the secondary map has enough entries. + if candidates.len() >= required_commits { + candidates_by_request.push((dr_pointer, required_commits, candidates)); + } + } + + candidates_by_request + } + + /// Returns a tuple with a vector of commit transactions that achieve the minimum specified + /// by the data request, the value of all the fees obtained with those commits, and + /// the list of data requests that have reached the commit limit, then expires all commits in + /// the pool. Fallible callers that may need to retry should use + /// [`TransactionsPool::select_commits`] instead. pub fn remove_commits( &mut self, dr_pool: &DataRequestPool, ) -> (Vec, u64, Vec) { - let mut total_fee = 0; + let mut total_fee = 0u64; let mut spent_inputs = HashSet::::new(); let co_hash_index = &mut self.co_hash_index; let mut dr_pointer_vec = vec![]; @@ -3100,7 +4824,14 @@ impl TransactionsPool { // Check once again that after filtering invalid commitments, we still have as many as requested if filtered_commits.len() == n_commits { commits_vec.extend(filtered_commits); - total_fee += dr_output.commit_and_reveal_fee * n_commits as u64; + // Preserve the release-mode Wit/2.0 fee arithmetic. Native + // Wit/2.1 assembly recomputes accepted group fees with checked + // arithmetic before they can enter the delegated reward pool. + total_fee = total_fee.wrapping_add( + dr_output + .commit_and_reveal_fee + .wrapping_mul(n_commits as u64), + ); dr_pointer_vec.push(*dr_pointer); } } @@ -3141,8 +4872,61 @@ impl TransactionsPool { /// assert!(!pool.st_contains(&transaction.hash())); /// ``` pub fn st_remove(&mut self, tx: &StakeTransaction) -> Option { - let key = tx.hash(); - let transaction = self.st_remove_inner(&key, true); + self.st_remove_for_protocol(tx, get_protocol_version(None)) + } + + /// Remove a consolidated stake transaction under the protocol that interpreted its body. + /// This prevents a pre-Wit/2.1 ignored authorization sidecar from being mistaken for a + /// commission action while a pool entry survives a protocol-boundary reorganization. + pub fn st_remove_for_protocol( + &mut self, + tx: &StakeTransaction, + protocol_version: ProtocolVersion, + ) -> Option { + let current_key = tx.versioned_hash(protocol_version); + // A reorg can restore a protocol snapshot before the transaction's admission snapshot. + // Try the current identity first, then the V2.1 identity so a terms-bearing delegated + // transaction can still be removed from a pool populated just before rollback. + let (key, exact_match) = if self.st_transactions.contains_key(¤t_key) { + (current_key, true) + } else if let Some(stored_key) = self.st_key_for_hash(¤t_key) { + // The current identity may be an alias rather than the key used at admission. This + // is the common cross-fork case where a V2.0 consolidated body omits authorization + // terms but the pool still retains the equivalent V2.1 body. Resolve only a unique + // alias; an ambiguous legacy projection must remain untouched until the caller has a + // terms-bearing identity or a policy nonce that identifies the intended entry. + (stored_key, true) + } else { + let v2_key = tx.versioned_hash(ProtocolVersion::V2_1); + if self.st_transactions.contains_key(&v2_key) { + (v2_key, true) + } else { + // The pool may still hold the legacy-normalized key when a node advances its + // protocol snapshot after admission. This is distinct from the V2.1 rollback + // case above and matters when a caller retains the original terms-bearing value. + let legacy = tx.clone().normalized_for_protocol(ProtocolVersion::V2_0); + let legacy_key = legacy.versioned_hash(ProtocolVersion::V2_0); + if self.st_transactions.contains_key(&legacy_key) { + (legacy_key, true) + } else if let Some(stored_key) = (protocol_version >= ProtocolVersion::V2_1) + .then(|| commission_update_action_key(tx)) + .flatten() + .and_then(|action_key| { + self.commission_update_action_index + .get(&action_key) + .copied() + }) + { + // A peer may consolidate a different valid body for the same policy nonce. + // Remove the local choice and unregister its own UTXO inputs before applying + // the consolidated transaction's input conflicts below. + (stored_key, false) + } else { + (current_key, true) + } + } + }; + let transaction = self.st_remove_inner(&key, exact_match); self.remove_inputs(&tx.body.inputs); @@ -3155,27 +4939,41 @@ impl TransactionsPool { /// block. /// If the transaction did get included in a consolidated block, use `st_remove` instead. fn st_remove_inner(&mut self, key: &Hash, consolidated: bool) -> Option { - self.st_transactions - .remove(key) - .map(|(weight, transaction)| { - self.sorted_st_index.remove(&(weight, *key)); - self.total_st_weight -= u64::from(transaction.weight()); - if !consolidated { - self.remove_tx_from_output_pointer_map(key, &transaction.body.inputs); - } - transaction - }) + let stored_key = if self.st_transactions.contains_key(key) { + *key + } else { + self.st_key_for_hash(key)? + }; + let (priority, admission_weight, transaction) = + self.st_transactions.remove(&stored_key)?; + self.sorted_st_index.remove(&(priority, stored_key)); + self.unregister_stake_hash_aliases(stored_key, &transaction); + if let Some(action_key) = commission_update_action_key(&transaction) { + if self.commission_update_action_index.get(&action_key) == Some(&stored_key) { + self.commission_update_action_index.remove(&action_key); + } + } + // The transaction may have been admitted under a different protocol snapshot than the + // one currently active (for example while a reorg crosses activation). Subtract the exact + // weight recorded at admission instead of recomputing it from the current protocol or the + // transaction body, so protocol-boundary cleanup cannot corrupt the aggregate counter. + self.total_st_weight = self + .total_st_weight + .saturating_sub(u64::from(admission_weight)); + if !consolidated { + self.remove_tx_from_output_pointer_map(&stored_key, &transaction.body.inputs); + } + Some(transaction) } /// Remove stake transactions that would result in overstaking on a validator pub fn remove_invalid_stake_transactions(&mut self, transactions: Vec) { for st_tx_hash in transactions.iter() { - if let Some(st_tx) = self - .st_transactions - .get(st_tx_hash) - .map(|(_, st)| st.clone()) - { - self.st_remove(&st_tx); + if let Some(stored_key) = self.st_key_for_hash(st_tx_hash) { + // Periodic tip-relative revalidation is not block consolidation. Remove only the + // invalid pool entry and its own UTXO references; spending conflicts remain valid + // candidates until an accepted block actually consumes their shared input. + self.st_remove_inner(&stored_key, false); } } } @@ -3203,7 +5001,16 @@ impl TransactionsPool { /// `Eq` on the borrowed form must match those for the key type. /// pub fn ut_remove(&mut self, tx: &UnstakeTransaction) -> Option { - self.ut_remove_inner(&tx.hash()) + let exact_key = tx.hash(); + let key = if self.ut_transactions.contains_key(&exact_key) { + exact_key + } else { + self.unstake_action_index + .get(&unstake_action_key(tx)) + .copied() + .unwrap_or(exact_key) + }; + self.ut_remove_inner(&key) } /// Remove an unstake transaction from the pool but do not remove other transactions that @@ -3215,7 +5022,13 @@ impl TransactionsPool { .remove(key) .map(|(weight, transaction)| { self.sorted_ut_index.remove(&(weight, *key)); - self.total_ut_weight -= u64::from(transaction.weight()); + let action_key = unstake_action_key(&transaction); + if self.unstake_action_index.get(&action_key) == Some(key) { + self.unstake_action_index.remove(&action_key); + } + self.total_ut_weight = self + .total_ut_weight + .saturating_sub(u64::from(transaction.weight())); transaction }) @@ -3224,27 +5037,51 @@ impl TransactionsPool { /// Returns a tuple with a vector of reveal transactions and the value /// of all the fees obtained with those reveals pub fn get_reveals(&self, dr_pool: &DataRequestPool) -> (Vec<&RevealTransaction>, u64) { - let mut total_fee = 0; - let re_hash_index = &self.re_hash_index; - let reveals_vector = self - .re_transactions - .iter() - // TODO: Decide with optimal capacity - .fold( - Vec::with_capacity(20), - |mut reveals_vec, (dr_pointer, reveals)| { - if let Some(dr_output) = dr_pool.get_dr_output(dr_pointer) { - let n_reveals = reveals.len(); - reveals_vec.extend(reveals.values().map(|r| re_hash_index.get(r).unwrap())); + self.get_reveals_checked(dr_pool).unwrap_or_else(|error| { + // Preserve the historical infallible shape for compatibility callers, but do not + // unwind when a recovered secondary reveal index points at a missing primary value. + // Consensus-sensitive mining uses the checked counterpart below and fails closed. + log::error!("Cannot enumerate transaction-pool reveals: {error}"); + (Vec::new(), 0) + }) + } - total_fee += dr_output.commit_and_reveal_fee * n_reveals as u64; - } + /// Returns all retained reveals and their legacy aggregate fee, rejecting a broken secondary + /// reveal index instead of panicking while dereferencing a missing primary transaction. Native + /// Wit/2.1 callers should use the checked fee projection after obtaining this exact list. + pub fn get_reveals_checked( + &self, + dr_pool: &DataRequestPool, + ) -> Result<(Vec<&RevealTransaction>, u64), TransactionError> { + let mut total_fee = 0u64; + let re_hash_index = &self.re_hash_index; + let mut reveals_vector = Vec::with_capacity(20); + for (dr_pointer, reveals) in &self.re_transactions { + let Some(dr_output) = dr_pool.get_dr_output(dr_pointer) else { + continue; + }; + let n_reveals = u64::try_from(reveals.len()) + .map_err(|_| TransactionError::OutputValueOverflow)?; + for (reveal_pkh, reveal_hash) in reveals { + let reveal = re_hash_index + .get(reveal_hash) + .ok_or(TransactionError::RevealNotFound)?; + if reveal.body.dr_pointer != *dr_pointer || reveal.body.pkh != *reveal_pkh { + return Err(TransactionError::RevealNotFound); + } + reveals_vector.push(reveal); + } - reveals_vec - }, + // This aggregate feeds the legacy mint projection only. Wit/2.1 pays reveal + // inclusion rewards through its independently checked projection. + total_fee = total_fee.wrapping_add( + dr_output + .commit_and_reveal_fee + .wrapping_mul(n_reveals), ); + } - (reveals_vector, total_fee) + Ok((reveals_vector, total_fee)) } /// Remove one reveal that was included in a consolidated block. This is used to avoid inserting @@ -3257,10 +5094,28 @@ impl TransactionsPool { reveal_pkh: &PublicKeyHash, re_tx_hash: &Hash, ) -> Option { + let retained = self.re_hash_index.get(re_tx_hash)?; + if &retained.body.dr_pointer != data_request_hash || &retained.body.pkh != reveal_pkh { + // This public helper receives redundant identity arguments. Fail closed before either + // index changes when they do not describe the retained transaction; otherwise a + // mismatched caller could remove one primary entry and an unrelated secondary row. + return None; + } if let Some(re_tx) = self.re_hash_index.remove(re_tx_hash) { - let reveals = self.re_transactions.get_mut(data_request_hash).unwrap(); - reveals.remove(reveal_pkh); - if reveals.is_empty() { + // A direct/recovery insertion may have replaced this participant's Reveal after the + // supplied hash was retained. Remove the secondary row only when it still names this + // exact transaction; otherwise deleting it would strand the newer Reveal in the hash + // index and make it unavailable to mining and request cleanup. + let request_is_empty = self + .re_transactions + .get_mut(data_request_hash) + .is_some_and(|reveals| { + if reveals.get(reveal_pkh) == Some(re_tx_hash) { + reveals.remove(reveal_pkh); + } + reveals.is_empty() + }); + if request_is_empty { self.re_transactions.remove(data_request_hash); } @@ -3290,32 +5145,95 @@ impl TransactionsPool { // Try to split the memory between value transfer and data requests using the same // ratio as the one used in blocks // The ratio of vt to dr in blocks is currently 1:4 - // TODO: What the criteria to delete st? It should be 1:8 #[allow(clippy::cast_precision_loss)] let more_vtts_than_drs = self.total_vt_weight as f64 >= self.total_dr_weight as f64 * self.vt_to_dr_factor; - if more_vtts_than_drs { - // Remove the value transfer transaction with the lowest fee/weight - let tx_hash = self - .sorted_vt_index - .iter() - .map(|(_fee_weight, tx_hash)| *tx_hash) - .next() - // There must be at least one transaction because the total weight is not zero - .unwrap(); - let tx = self.vt_remove_inner(&tx_hash, false).unwrap(); - removed_transactions.push(Transaction::ValueTransfer(tx)); + + // Preserve the established VT/DR occupancy invariant by selecting exactly one + // ordinary candidate from the ratio-preferred class (falling back to the other when + // empty). Stake and Unstake then compete with that candidate on the same fee/weight + // priority scale instead of being exhausted only after every ordinary transaction. + let lowest_vt = self + .sorted_vt_index + .iter() + .next() + .map(|(priority, hash)| { + (*priority, PoolEvictionClass::ValueTransfer, *hash) + }); + let lowest_dr = self + .sorted_dr_index + .iter() + .next() + .map(|(priority, hash)| { + (*priority, PoolEvictionClass::DataRequest, *hash) + }); + let ordinary_candidate = if more_vtts_than_drs { + lowest_vt.or(lowest_dr) } else { - // Remove the data request transaction with the lowest fee/weight - let tx_hash = self - .sorted_dr_index - .iter() - .map(|(_fee_weight, tx_hash)| *tx_hash) - .next() - // There must be at least one transaction because the total weight is not zero - .unwrap(); - let tx = self.dr_remove_inner(&tx_hash, false).unwrap(); - removed_transactions.push(Transaction::DataRequest(tx)); + lowest_dr.or(lowest_vt) + }; + let stake_candidate = self + .sorted_st_index + .iter() + .next() + .map(|(priority, hash)| (*priority, PoolEvictionClass::Stake, *hash)); + let unstake_candidate = self + .sorted_ut_index + .iter() + .next() + .map(|(priority, hash)| (*priority, PoolEvictionClass::Unstake, *hash)); + + let candidate: Option = + [ordinary_candidate, stake_candidate, unstake_candidate] + .into_iter() + .flatten() + .min(); + let Some((priority, class, tx_hash)) = candidate else { + // If the total remains nonzero with every sorted index empty, the counters and + // maps are already inconsistent; avoid a panic and terminate cleanup so the + // caller can inspect the remaining state. + break; + }; + + let transaction = match class { + PoolEvictionClass::ValueTransfer => self + .vt_remove_inner(&tx_hash, false) + .map(Transaction::ValueTransfer), + PoolEvictionClass::DataRequest => self + .dr_remove_inner(&tx_hash, false) + .map(Transaction::DataRequest), + PoolEvictionClass::Stake => self + .st_remove_inner(&tx_hash, false) + .map(Transaction::Stake), + PoolEvictionClass::Unstake => self + .ut_remove_inner(&tx_hash) + .map(Transaction::Unstake), + }; + if let Some(transaction) = transaction { + removed_transactions.push(transaction); + continue; + } + + // A sorted entry without its authoritative map value is already-corrupted local + // state. Remove only that stale index tuple and try another candidate; never panic or + // decrement an aggregate counter without the transaction whose admitted weight owns + // it. If even the exact tuple disappeared, stop rather than spinning forever. + let stale_index_removed = match class { + PoolEvictionClass::ValueTransfer => { + self.sorted_vt_index.remove(&(priority, tx_hash)) + } + PoolEvictionClass::DataRequest => { + self.sorted_dr_index.remove(&(priority, tx_hash)) + } + PoolEvictionClass::Stake => { + self.sorted_st_index.remove(&(priority, tx_hash)) + } + PoolEvictionClass::Unstake => { + self.sorted_ut_index.remove(&(priority, tx_hash)) + } + }; + if !stale_index_removed { + break; } } @@ -3341,19 +5259,37 @@ impl TransactionsPool { /// /// assert!(!pool.is_empty()); /// ``` - #[allow(clippy::cast_precision_loss)] pub fn insert(&mut self, transaction: Transaction, fee: u64) -> Vec { - let key = transaction.hash(); + self.insert_for_protocol(transaction, fee, get_protocol_version(None)) + } + /// Insert a transaction using the protocol identity established by its successful validation. + /// + /// Actor callers that yield for asynchronous signature verification must not re-read the + /// process-global protocol while publishing the validated transaction: an activation or reorg + /// in between could otherwise normalize, hash, and weight a Stake differently from the body + /// whose signatures were just checked. The compatibility `insert` wrapper remains available for + /// synchronous callers and fixtures. + #[allow(clippy::cast_precision_loss)] + pub fn insert_for_protocol( + &mut self, + transaction: Transaction, + fee: u64, + protocol_version: ProtocolVersion, + ) -> Vec { + let mut removed_transactions = vec![]; match transaction { Transaction::ValueTransfer(vt_tx) => { + let key = vt_tx.hash(); let weight = f64::from(vt_tx.weight()); let priority = OrderedFloat(fee as f64 / weight); if fee < self.minimum_vtt_fee { return vec![Transaction::ValueTransfer(vt_tx)]; } else { - self.total_vt_weight += u64::from(vt_tx.weight()); + self.total_vt_weight = self + .total_vt_weight + .saturating_add(u64::from(vt_tx.weight())); for input in &vt_tx.body.inputs { self.output_pointer_map @@ -3367,6 +5303,7 @@ impl TransactionsPool { } } Transaction::DataRequest(dr_tx) => { + let key = dr_tx.hash(); let dr_tx_reward_collateral_ratio = calculate_reward_collateral_ratio( self.collateral_minimum, dr_tx.body.dr_output.collateral, @@ -3378,7 +5315,9 @@ impl TransactionsPool { let weight = f64::from(dr_tx.weight()); let priority = OrderedFloat(fee as f64 / weight); - self.total_dr_weight += u64::from(dr_tx.weight()); + self.total_dr_weight = self + .total_dr_weight + .saturating_add(u64::from(dr_tx.weight())); for input in &dr_tx.body.inputs { self.output_pointer_map @@ -3392,58 +5331,203 @@ impl TransactionsPool { } } Transaction::Commit(co_tx) => { + // Consensus accepts exactly one signature and requires it to identify the same + // validator as the VRF proof. Keep direct/recovery callers on that same boundary: + // indexing `signatures[0]` can panic, while indexing a mismatched signer makes + // insertion disagree with `contains_for_protocol`, which uses the proof identity. + let [commit_signature] = co_tx.signatures.as_slice() else { + return vec![Transaction::Commit(co_tx)]; + }; let dr_pointer = co_tx.body.dr_pointer; - let pkh = PublicKeyHash::from_public_key(&co_tx.signatures[0].public_key); + let pkh = co_tx.body.proof.proof.pkh(); + if commit_signature.public_key.pkh() != pkh { + return vec![Transaction::Commit(co_tx)]; + } let tx_hash = co_tx.hash(); self.co_hash_index.insert(tx_hash, co_tx); - self.co_transactions + let replaced_hash = self + .co_transactions .entry(dr_pointer) .or_default() .insert(pkh, tx_hash); + // Keep both Commit indexes one-to-one for every protocol. A direct/recovery + // caller can replace the body for one request/validator without first running + // the actor conflict check; retaining the old primary hash would make `get` + // return a transaction that membership and selection can no longer address. + if let Some(replaced_hash) = replaced_hash.filter(|hash| hash != &tx_hash) + && let Some(replaced) = self.co_hash_index.remove(&replaced_hash) + { + removed_transactions.push(Transaction::Commit(replaced)); + } + if protocol_version >= ProtocolVersion::V2_1 { + removed_transactions + .extend(self.enforce_wit21_commit_candidate_limit(dr_pointer)); + } } Transaction::Reveal(re_tx) => { + // Reveal validation has the same exact one-signature and signer/body identity + // contract as Commit validation. Reject malformed direct insertions before either + // index is changed, while retaining the body identity used by membership checks. + let [reveal_signature] = re_tx.signatures.as_slice() else { + return vec![Transaction::Reveal(re_tx)]; + }; let dr_pointer = re_tx.body.dr_pointer; let pkh = re_tx.body.pkh; + if reveal_signature.public_key.pkh() != pkh { + return vec![Transaction::Reveal(re_tx)]; + } let tx_hash = re_tx.hash(); self.re_hash_index.insert(tx_hash, re_tx); - self.re_transactions + let replaced_hash = self + .re_transactions .entry(dr_pointer) .or_default() .insert(pkh, tx_hash); + // Keep both Reveal indexes one-to-one for direct/recovery callers that do not + // perform the ordinary actor admission conflict check. This mirrors Commit + // replacement: a distinct body for the same request/participant replaces and + // returns the prior transaction instead of leaving an unreachable hash entry. + if let Some(replaced_hash) = replaced_hash.filter(|hash| hash != &tx_hash) + && let Some(replaced) = self.re_hash_index.remove(&replaced_hash) + { + removed_transactions.push(Transaction::Reveal(replaced)); + } } Transaction::Stake(st_tx) => { - let weight = f64::from(st_tx.weight()); + let st_tx = st_tx.normalized_for_protocol(protocol_version); + if protocol_version >= ProtocolVersion::V2_1 { + // Pool insertion is normally reached after full consensus validation, but + // recovery, replay, and direct callers can invoke this boundary on their own. + // Do not retain a Wit/2.1 body that can never be mined: missing or malformed + // terms, a sidecar bound to a different output key, an input-free body, or + // an action-incompatible value/fee shape must fail closed before any index is + // changed. Signature identity remains with asynchronous admission + // verification, which intentionally reserves slots by body terms. + let Some(terms) = st_tx.body.output.authorization_terms.as_ref() else { + return vec![Transaction::Stake(st_tx)]; + }; + if terms.validate_structure().is_err() + || terms.validator != st_tx.body.output.key.validator + || terms.withdrawer != st_tx.body.output.key.withdrawer + || st_tx.body.inputs.is_empty() + { + return vec![Transaction::Stake(st_tx)]; + } + let fee_only_action = matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE + | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT + ); + if (fee_only_action + && (st_tx.body.output.value != 0 || fee == 0)) + || (!fee_only_action + && (st_tx.body.output.value < DELEGATED_STAKING_MINIMUM_NANOWITS + || st_tx.body.output.value > DELEGATED_STAKING_MAXIMUM_NANOWITS)) + { + // Keep obviously unmineable native bodies out of the retained pool. The + // aggregate validator cap, owner activity, nonce, and signer/state checks + // remain canonical validation concerns; this lower boundary only enforces + // action-specific value and fee shape. + return vec![Transaction::Stake(st_tx)]; + } + } + let key = st_tx.versioned_hash(protocol_version); + let stake_weight = st_tx.weight_for_protocol(protocol_version); + let weight = f64::from(stake_weight); let priority = OrderedFloat(fee as f64 / weight); - - if fee < self.minimum_st_fee { + let commission_action_key = (protocol_version >= ProtocolVersion::V2_1) + .then(|| commission_update_action_key(&st_tx)) + .flatten(); + + // A reorganization can move the active protocol snapshot across Wit/2.1 while + // this transaction remains in the pool. Treat equivalent current, V2.1, and + // legacy-normalized identities as the same admission rather than retaining two + // entries for one wire transaction under different versioned hashes. + if self.st_has_hash_alias(&key) { + return vec![]; + } else if commission_action_key.is_some_and(|action_key| { + self.commission_update_action_index + .contains_key(&action_key) + || self + .pending_commission_update_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash != &key) + }) { + // First-seen wins for one validator policy nonce, including while the first + // body is still being verified. The same pending hash is allowed through so + // its owning actor can promote it into the retained index. Fee replacement + // needs an explicit relay/admission contract and is deliberately not inferred + // here. + return vec![Transaction::Stake(st_tx)]; + } else if fee < self.minimum_st_fee { return vec![Transaction::Stake(st_tx)]; } else { - self.total_st_weight += u64::from(st_tx.weight()); + self.total_st_weight = self + .total_st_weight + .saturating_add(u64::from(stake_weight)); for input in &st_tx.body.inputs { self.output_pointer_map .entry(input.output_pointer) .or_default() - .push(st_tx.hash()); + // Stake transactions are keyed in the pool by their protocol- + // versioned hash. Keep the input-conflict index on that same key; + // using the raw hash can leave a Wit/2.1 transaction stranded when + // an input conflict removes it through `remove_inputs`. + .push(key); } - self.st_transactions.insert(key, (priority, st_tx)); + self.register_stake_hash_aliases(key, &st_tx); + self.st_transactions + .insert(key, (priority, stake_weight, st_tx)); self.sorted_st_index.insert((priority, key)); + if let Some(action_key) = commission_action_key { + self.commission_update_action_index + .insert(action_key, key); + } } } Transaction::Unstake(ut_tx) => { + if protocol_version >= ProtocolVersion::V2_1 && ut_tx.body.withdrawal.value == 0 { + // Native consensus requires a positive withdrawal. Pool insertion normally + // follows full validation, but direct/recovery callers must not retain an + // unmineable Wit/2.1 Unstake body. Signature identity remains with + // asynchronous admission verification so body-keyed signer variants retain + // their one-slot semantics. + return vec![Transaction::Unstake(ut_tx)]; + } + let key = ut_tx.hash(); let weight = f64::from(ut_tx.weight()); let priority = OrderedFloat(fee as f64 / weight); - - if fee < self.minimum_ut_fee { + let action_key = unstake_action_key(&ut_tx); + + if self.ut_transactions.contains_key(&key) { + // Direct pool callers must not inflate the aggregate weight by inserting the + // exact same hash more than once. + return vec![]; + } else if self.unstake_action_index.contains_key(&action_key) + || self + .pending_unstake_actions + .get(&action_key) + .is_some_and(|(pending_hash, _)| pending_hash != &key) + { + // Distinct withdrawals at the same entry nonce cannot both be valid after one + // is consolidated. This also protects a first body that is still being + // verified; its own hash remains promotable. Retain the first choice until + // explicit RBF exists. + return vec![Transaction::Unstake(ut_tx)]; + } else if fee < self.minimum_ut_fee { return vec![Transaction::Unstake(ut_tx)]; } else { - self.total_ut_weight += u64::from(ut_tx.weight()); + self.total_ut_weight = self + .total_ut_weight + .saturating_add(u64::from(ut_tx.weight())); self.ut_transactions.insert(key, (priority, ut_tx)); self.sorted_ut_index.insert((priority, key)); + self.unstake_action_index.insert(action_key, key); } } tx => { @@ -3451,7 +5535,8 @@ impl TransactionsPool { } } - self.remove_transactions_for_size_limit() + removed_transactions.extend(self.remove_transactions_for_size_limit()); + removed_transactions } /// An iterator visiting all the value transfer transactions @@ -3497,7 +5582,15 @@ impl TransactionsPool { self.sorted_st_index .iter() .rev() - .filter_map(move |(_, h)| self.st_transactions.get(h).map(|(_, t)| t)) + .filter_map(move |(_, h)| self.st_transactions.get(h).map(|(_, _, t)| t)) + } + + /// Iterate over the authoritative protocol-versioned stake transaction keys in priority + /// order. The pool can briefly retain a transaction across a protocol rollback, so callers + /// that expose or look up mempool identities must use these stored keys rather than + /// recomputing a hash under the current global protocol snapshot. + pub fn st_hash_iter(&self) -> impl Iterator { + self.sorted_st_index.iter().rev().map(|(_, hash)| hash) } /// An iterator visiting all the unstake transactions @@ -3576,7 +5669,8 @@ impl TransactionsPool { vt_transactions.retain(|hash, (weight, vt_transaction)| { let retain = f(vt_transaction); if !retain { - *total_vt_weight -= 1; + *total_vt_weight = (*total_vt_weight) + .saturating_sub(u64::from(vt_transaction.weight())); sorted_index.remove(&(*weight, *hash)); } @@ -3584,6 +5678,67 @@ impl TransactionsPool { }); } + fn register_stake_hash_aliases(&mut self, stored_key: Hash, transaction: &StakeTransaction) { + for alias in stake_transaction_hash_aliases(transaction) { + self.st_hash_alias_index + .entry(alias) + .or_default() + .insert(stored_key); + } + } + + fn unregister_stake_hash_aliases( + &mut self, + stored_key: Hash, + transaction: &StakeTransaction, + ) { + for alias in stake_transaction_hash_aliases(transaction) { + let remove_alias = self + .st_hash_alias_index + .get_mut(&alias) + .is_some_and(|stored_keys| { + stored_keys.remove(&stored_key); + stored_keys.is_empty() + }); + if remove_alias { + self.st_hash_alias_index.remove(&alias); + } + } + } + + /// Return whether at least one retained Stake exposes this protocol identity. Membership is + /// true even when a terms-free legacy projection corresponds to several distinct V2.1 bodies; + /// only retrieval/removal requires the alias to resolve uniquely. + fn st_has_hash_alias(&self, hash: &Hash) -> bool { + self.st_transactions.contains_key(hash) + || self + .st_hash_alias_index + .get(hash) + .is_some_and(|stored_keys| !stored_keys.is_empty()) + } + + /// Resolve a stake transaction's stored key across protocol-versioned identities. + /// + /// Stake entries are admitted under the protocol snapshot that was active at insertion. A + /// reorganization can temporarily move the global snapshot across Wit/2.1 while the entry is + /// still retained, so callers handling a hash returned by the pool need to recognize the + /// current, Wit/2.1, and legacy-normalized identities. + fn st_key_for_hash(&self, hash: &Hash) -> Option { + if self.st_transactions.contains_key(hash) { + return Some(*hash); + } + + let stored_keys = self.st_hash_alias_index.get(hash)?; + if stored_keys.len() != 1 { + return None; + } + stored_keys + .iter() + .next() + .copied() + .filter(|stored_key| self.st_transactions.contains_key(stored_key)) + } + /// Get transaction by hash pub fn get(&self, hash: &Hash) -> Option { self.vt_transactions @@ -3605,9 +5760,9 @@ impl TransactionsPool { .map(|rt| Transaction::Reveal(rt.clone())) }) .or_else(|| { - self.st_transactions - .get(hash) - .map(|(_, st)| Transaction::Stake(st.clone())) + self.st_key_for_hash(hash) + .and_then(|key| self.st_transactions.get(&key)) + .map(|(_, _, st)| Transaction::Stake(st.clone())) }) .or_else(|| { self.ut_transactions @@ -3616,6 +5771,28 @@ impl TransactionsPool { }) } + /// Get a transaction together with the weight recorded at mempool admission. + /// + /// Stake weight is protocol-versioned, so recomputing it from a later process-global protocol + /// snapshot can disagree with both the stored priority index and the transaction identity. The + /// other transaction families have protocol-independent pool weights and can use their normal + /// weight calculation. + pub fn get_with_admission_weight(&self, hash: &Hash) -> Option<(Transaction, u32)> { + let transaction = self.get(hash)?; + let weight = match &transaction { + Transaction::Stake(_) => { + let key = self.st_key_for_hash(hash)?; + self.st_transactions + .get(&key) + .map(|(_, admission_weight, _)| *admission_weight)? + } + Transaction::Unstake(transaction) => transaction.weight(), + _ => transaction.weight(), + }; + + Some((transaction, weight)) + } + /// Update unconfirmed transactions pub fn update_unconfirmed_transactions(&mut self) { self.unconfirmed_transactions.update(); @@ -3625,6 +5802,11 @@ impl TransactionsPool { self.unconfirmed_transactions.insert(h); } + /// Remove one transaction from the two-generation unconfirmed tracker after admission fails. + pub fn remove_unconfirmed_transaction(&mut self, h: &Hash) { + self.unconfirmed_transactions.remove(h); + } + pub fn remove_unconfirmed_transactions(&mut self) -> Vec { let unconfirmed_hashes = self.unconfirmed_transactions.remove_all(); @@ -3659,7 +5841,10 @@ impl TransactionsPool { } pub fn total_transactions_weight(&self) -> u64 { - self.total_vt_weight + self.total_dr_weight + self.total_st_weight + self.total_vt_weight + .saturating_add(self.total_dr_weight) + .saturating_add(self.total_st_weight) + .saturating_add(self.total_ut_weight) } } @@ -3906,17 +6091,36 @@ impl DataRequestState { } } - /// Advance to the next stage. + /// Advance to the next stage, rejecting a round configuration whose terminal round cannot be + /// represented by the persisted `u16` counters or a completed TALLY that incorrectly remains + /// in the active pool. + /// /// Since the data requests are updated by looking at the transactions from a valid block, - /// the only issue would be that there were no commits in that block. - pub fn update_stage(&mut self, extra_rounds: u16, too_many_witnesses: bool) { + /// the only ordinary issue would be that there were no commits in that block. Callers that + /// consume arbitrary or persisted values must nevertheless use this checked entry point so + /// `extra_rounds == u16::MAX` and restored-state invariant failures cannot panic the process. + pub fn try_update_stage( + &mut self, + extra_rounds: u16, + too_many_witnesses: bool, + ) -> Result<(), DataRequestError> { + let terminal_round = extra_rounds.checked_add(1).ok_or( + DataRequestError::UnrepresentableExtraRounds { extra_rounds }, + )?; + self.stage = match self.stage { DataRequestStage::COMMIT => { if self.info.commits.is_empty() { if too_many_witnesses { DataRequestStage::TALLY - } else if self.info.current_commit_round <= extra_rounds { - self.info.current_commit_round += 1; + } else if self.info.current_commit_round < terminal_round { + self.info.current_commit_round = self + .info + .current_commit_round + .checked_add(1) + .ok_or(DataRequestError::UnrepresentableExtraRounds { + extra_rounds, + })?; DataRequestStage::COMMIT } else { DataRequestStage::TALLY @@ -3928,9 +6132,15 @@ impl DataRequestState { } DataRequestStage::REVEAL => { if self.info.reveals.len() < self.data_request.witnesses as usize - && self.info.current_reveal_round <= extra_rounds + && self.info.current_reveal_round < terminal_round { - self.info.current_reveal_round += 1; + self.info.current_reveal_round = self + .info + .current_reveal_round + .checked_add(1) + .ok_or(DataRequestError::UnrepresentableExtraRounds { + extra_rounds, + })?; DataRequestStage::REVEAL } else { DataRequestStage::TALLY @@ -3940,11 +6150,25 @@ impl DataRequestState { if self.info.tally.is_none() { DataRequestStage::TALLY } else { - panic!("Data request in tally stage should have been removed from the pool"); + return Err(DataRequestError::FinishedDataRequestStillActive); } } }; self.info.current_stage = Some(self.stage); + + Ok(()) + } + + /// Advance to the next stage through the historical infallible API. + /// + /// Valid configurations retain their exact legacy behavior. An unrepresentable terminal round + /// is failed closed by moving the request to tally instead of panicking or wrapping a counter. + /// Production callers that can reject malformed state should use [`Self::try_update_stage`]. + pub fn update_stage(&mut self, extra_rounds: u16, too_many_witnesses: bool) { + if let Err(error) = self.try_update_stage(extra_rounds, too_many_witnesses) { + log::error!("Failing data request stage progression closed: {error}"); + self.set_stage(DataRequestStage::TALLY); + } } /// Function to set the stage in case we forced it to advance during validations @@ -4029,6 +6253,9 @@ pub struct ChainState { /// Tracks stakes for every validator in the network #[serde(default)] pub stakes: Stakes, + /// Tracks persistent validator ownership, commission, and exact delegated collateral debits. + #[serde(default)] + pub delegation_state: DelegationState, /// Unspent Outputs Pool #[serde(skip)] pub unspent_outputs_pool: UnspentOutputsPool, @@ -4038,6 +6265,33 @@ pub struct ChainState { } impl ChainState { + /// Clone the complete chain state while propagating a poisoned stake-entry lock. + /// + /// Snapshot and persistence paths use this instead of the legacy derived [`Clone`] boundary so + /// an unreadable stake ledger is reported before a partial durable operation can begin. + pub fn try_clone_checked( + &self, + ) -> StakesResult { + Ok(Self { + chain_info: self.chain_info.clone(), + unspent_outputs_pool_old_migration_db: self + .unspent_outputs_pool_old_migration_db + .clone(), + data_request_pool: self.data_request_pool.clone(), + block_chain: self.block_chain.clone(), + own_utxos: self.own_utxos.clone(), + reputation_engine: self.reputation_engine.clone(), + node_stats: self.node_stats.clone(), + alt_keys: self.alt_keys.clone(), + superblock_state: self.superblock_state.clone(), + tapi_engine: self.tapi_engine.clone(), + stakes: self.stakes.try_clone_checked()?, + delegation_state: self.delegation_state.clone(), + unspent_outputs_pool: self.unspent_outputs_pool.clone(), + rad_hashes_index: self.rad_hashes_index.clone(), + }) + } + /// Return the number of consolidated blocks pub fn block_number(&self) -> u32 { u32::try_from(self.block_chain.len()).unwrap() @@ -4097,6 +6351,22 @@ where pub chain_state: BoxFuture<'static, Result>, /// The full history of superblocks. pub superblocks: CollectionGenerator, + /// Historical data-request reports as `(logical key, serialized value)` records. Keeping the + /// serialized value preserves the exact on-disk report bytes across portable snapshot export + /// and import instead of reserializing maps with nondeterministic iteration order. + pub reports: BoxFuture<'static, Result, Vec)>, E>>, + /// Whether the snapshot format promises complete historical report coverage. Version-2 and + /// version-3 manifests set this flag; legacy and version-1 snapshots intentionally remain + /// report-less. + /// This is an import compatibility contract, not a persisted export capability marker: every + /// new version-2 or version-3 export must prove complete coverage again from canonical + /// blocks and Reports. + pub reports_required: bool, + /// Protocol schedule used to authenticate the snapshot's complete historical block stream. + /// New portable manifests provide this value so an importer can replay without relying on a + /// matching process-global schedule. Legacy imports leave it absent and retain their prior + /// runtime compatibility behavior. + pub protocol_info: Option, } /// Alternative public key mapping: maps each secp256k1 public key hash to @@ -4112,6 +6382,24 @@ impl AltKeys { pub fn get_bn256(&self, k: &PublicKeyHash) -> Option<&Bn256PublicKey> { self.bn256.get(k) } + + /// Validate every persisted alternate key before it becomes authoritative state. + /// + /// Runtime insertion already filters invalid keys, but deserialization can populate the + /// private map without going through [`Self::insert_bn256`]. Persistence and snapshot import + /// therefore use this explicit boundary so live BN256 vote authentication cannot depend on a + /// malformed key surviving a state reload. + pub fn validate(&self) -> Result<(), anyhow::Error> { + for (identity, public_key) in &self.bn256 { + anyhow::ensure!( + public_key.is_valid(), + "invalid BN256 alternate key for identity {identity:?}" + ); + } + + Ok(()) + } + /// Insert a new BN256 public key, if valid, for a given identity. If the identity /// already had an associated BN256 public key, it will be overwritten. /// Returns the old BN256 public key for this identity, if any @@ -4712,36 +7000,72 @@ impl Default for EpochConstants { } impl EpochConstants { + /// Construct the legacy clock plus an optional Wit/2 clock without unchecked activation-time + /// arithmetic. + /// + /// An absent activation preserves the historical sentinel timestamp. When an activation is + /// present, [`EpochConstants::set_values_for_wit2`] derives its timestamp through the same + /// checked epoch conversion used by runtime consumers. Any clock that would be active must + /// have a nonzero period. + pub fn try_new( + checkpoint_zero_timestamp: i64, + checkpoints_period: u16, + activation_epoch_wit2: Option, + checkpoints_period_wit2: u16, + ) -> Result { + if checkpoints_period == 0 + || (activation_epoch_wit2.is_some() && checkpoints_period_wit2 == 0) + { + return Err(EpochCalculationError::Overflow); + } + let mut epoch_constants = Self { + checkpoint_zero_timestamp, + checkpoints_period, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2, + }; + if let Some(activation_epoch_wit2) = activation_epoch_wit2 { + epoch_constants + .set_values_for_wit2(checkpoints_period_wit2, activation_epoch_wit2)?; + } + + Ok(epoch_constants) + } + /// Calculate the last checkpoint (current epoch) at the supplied timestamp pub fn epoch_at(&self, timestamp: i64) -> Result { + let epochs_in_span = |seconds: i64, period: u16| { + let seconds = u64::try_from(seconds).map_err(|_| { + EpochCalculationError::CheckpointZeroInTheFuture( + self.checkpoint_zero_timestamp, + ) + })?; + seconds + .checked_div(u64::from(period)) + .and_then(|epochs| Epoch::try_from(epochs).ok()) + .ok_or(EpochCalculationError::Overflow) + }; + if timestamp >= self.checkpoint_zero_timestamp_wit2 { - let epochs_pre_v2 = match Epoch::try_from( - self.checkpoint_zero_timestamp_wit2 - self.checkpoint_zero_timestamp, - ) { - Ok(epoch) => epoch / Epoch::from(self.checkpoints_period), - Err(_) => { - return Err(EpochCalculationError::CheckpointZeroInTheFuture( - self.checkpoint_zero_timestamp, - )); - } - }; + let seconds_pre_v2 = self + .checkpoint_zero_timestamp_wit2 + .checked_sub(self.checkpoint_zero_timestamp) + .ok_or(EpochCalculationError::Overflow)?; + let epochs_pre_v2 = epochs_in_span(seconds_pre_v2, self.checkpoints_period)?; + let seconds_post_v2 = timestamp + .checked_sub(self.checkpoint_zero_timestamp_wit2) + .ok_or(EpochCalculationError::Overflow)?; let epochs_post_v2 = - match Epoch::try_from(timestamp - self.checkpoint_zero_timestamp_wit2) { - Ok(epoch) => epoch / Epoch::from(self.checkpoints_period_wit2), - Err(_) => { - return Err(EpochCalculationError::CheckpointZeroInTheFuture( - self.checkpoint_zero_timestamp, - )); - } - }; + epochs_in_span(seconds_post_v2, self.checkpoints_period_wit2)?; - Ok(epochs_pre_v2 + epochs_post_v2) + epochs_pre_v2 + .checked_add(epochs_post_v2) + .ok_or(EpochCalculationError::Overflow) } else { - Epoch::try_from(timestamp - self.checkpoint_zero_timestamp) - .map(|epoch| epoch / Epoch::from(self.checkpoints_period)) - .map_err(|_| { - EpochCalculationError::CheckpointZeroInTheFuture(self.checkpoint_zero_timestamp) - }) + timestamp + .checked_sub(self.checkpoint_zero_timestamp) + .ok_or(EpochCalculationError::Overflow) + .and_then(|seconds| epochs_in_span(seconds, self.checkpoints_period)) } } @@ -4751,10 +7075,8 @@ impl EpochConstants { /// start of the queried protocol epoch, and the second item is a flag that tells if the epoch /// will happen after the 2_x hard fork. pub fn epoch_timestamp(&self, epoch: Epoch) -> Result<(i64, bool), EpochCalculationError> { - let epoch_timestamp = Epoch::from(self.checkpoints_period) - .checked_mul(epoch) - .filter(|&x| x <= Epoch::MAX as Epoch) - .map(i64::from) + let epoch_timestamp = i64::from(self.checkpoints_period) + .checked_mul(i64::from(epoch)) .and_then(|x| x.checked_add(self.checkpoint_zero_timestamp)) .ok_or(EpochCalculationError::Overflow)?; @@ -4762,14 +7084,24 @@ impl EpochConstants { let timestamp = if epoch_timestamp >= self.checkpoint_zero_timestamp_wit2 { in_v2 = true; - let epochs_pre_v2: u32 = u32::try_from( - (self.checkpoint_zero_timestamp_wit2 - self.checkpoint_zero_timestamp) - / i64::from(self.checkpoints_period), - ) - .map_err(|_| EpochCalculationError::Overflow)?; + let seconds_pre_v2 = self + .checkpoint_zero_timestamp_wit2 + .checked_sub(self.checkpoint_zero_timestamp) + .ok_or(EpochCalculationError::Overflow)?; + let epochs_pre_v2 = seconds_pre_v2 + .checked_div(i64::from(self.checkpoints_period)) + .and_then(|epochs| u32::try_from(epochs).ok()) + .ok_or(EpochCalculationError::Overflow)?; + let epochs_post_v2 = epoch + .checked_sub(epochs_pre_v2) + .ok_or(EpochCalculationError::Overflow)?; + let seconds_post_v2 = i64::from(epochs_post_v2) + .checked_mul(i64::from(self.checkpoints_period_wit2)) + .ok_or(EpochCalculationError::Overflow)?; self.checkpoint_zero_timestamp_wit2 - + i64::from((epoch - epochs_pre_v2) * Epoch::from(self.checkpoints_period_wit2)) + .checked_add(seconds_post_v2) + .ok_or(EpochCalculationError::Overflow)? } else { epoch_timestamp }; @@ -4809,11 +7141,17 @@ impl EpochConstants { period: u16, epoch: Epoch, ) -> Result<(), EpochCalculationError> { - self.checkpoints_period_wit2 = period; - match self.epoch_timestamp(epoch) { - Ok((timestamp, _)) => self.checkpoint_zero_timestamp_wit2 = timestamp, - Err(e) => return Err(e), - }; + if self.checkpoints_period == 0 || period == 0 { + return Err(EpochCalculationError::Overflow); + } + // Derivation can fail at either the legacy activation span or the post-Wit/2 projection. + // Stage both fields on a copy so callers holding live actor state never observe only the + // new period after a failed timestamp conversion. + let mut candidate = *self; + candidate.checkpoints_period_wit2 = period; + let checkpoint_zero_timestamp_wit2 = candidate.epoch_timestamp(epoch)?.0; + candidate.checkpoint_zero_timestamp_wit2 = checkpoint_zero_timestamp_wit2; + *self = candidate; Ok(()) } @@ -4827,7 +7165,12 @@ pub enum SignaturesToVerify { dr_hash: Hash, target_hash: Hash, withdrawer: Option, - inclusion_epoch: Option, + /// Protocol that selects the exact V1/V2 proof message. + /// + /// Resolve this when the work item is queued. Deferring the lookup until an asynchronous + /// verifier runs would let a concurrent protocol-schedule publication reinterpret an + /// already validated request under a different message format. + request_protocol_version: ProtocolVersion, }, VrfBlock { proof: BlockEligibilityClaim, @@ -4846,6 +7189,10 @@ pub enum SignaturesToVerify { }, SuperBlockVote { superblock_vote: SuperBlockVote, + /// BN256 public key resolved from the chain's alternate-key mapping for the vote signer. + /// The key is not serialized in the vote; it is supplied by the node that is validating + /// the in-memory network message. + bn256_public_key: Bn256PublicKey, }, } @@ -4899,7 +7246,10 @@ pub fn block_example() -> Block { mod tests { use witnet_crypto::{ merkle::{InclusionProof, merkle_tree_root}, - secp256k1::{PublicKey as Secp256k1_PublicKey, SecretKey as Secp256k1_SecretKey}, + secp256k1::{ + PublicKey as Secp256k1_PublicKey, Secp256k1, + SecretKey as Secp256k1_SecretKey, + }, signature::sign, }; @@ -4907,10 +7257,193 @@ mod tests { proto::versioning::{ProtocolVersion, VersionedHashable}, superblock::{ARSIdentities, mining_build_superblock}, transaction::{CommitTransactionBody, RevealTransactionBody, VTTransactionBody}, + vrf::VrfCtx, }; use super::*; + fn pool_keyed_signature(marker: u8) -> KeyedSignature { + let secp = Secp256k1::new(); + let secret_key = Secp256k1_SecretKey::from_slice(&[marker; 32]).unwrap(); + let public_key = Secp256k1_PublicKey::from_secret_key(&secp, &secret_key); + + KeyedSignature { + signature: Default::default(), + public_key: PublicKey::from(public_key), + } + } + + fn pool_commit(dr_pointer: Hash, commitment: Hash, marker: u8) -> CommitTransaction { + let secret_key = + SecretKey::from(Secp256k1_SecretKey::from_slice(&[marker; 32]).unwrap()); + let proof = DataRequestEligibilityClaim::create( + &mut VrfCtx::secp256k1().unwrap(), + &secret_key, + CheckpointVRF::default(), + dr_pointer, + ) + .unwrap(); + + CommitTransaction { + body: CommitTransactionBody::without_collateral(dr_pointer, commitment, proof), + signatures: vec![pool_keyed_signature(marker)], + } + } + + fn pool_reveal(dr_pointer: Hash, reveal: Vec, marker: u8) -> RevealTransaction { + let signature = pool_keyed_signature(marker); + let pkh = signature.public_key.pkh(); + + RevealTransaction { + body: RevealTransactionBody::new(dr_pointer, reveal, pkh), + signatures: vec![signature], + } + } + + #[test] + fn explicit_protocol_replication_factor_handles_sparse_epochs_and_forks() { + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 103, + 100, + ProtocolVersion::V2_0, + ), + 32, + ); + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 103, + 100, + ProtocolVersion::V2_1, + ), + 32, + ); + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 100, + 100, + ProtocolVersion::V2_0, + ), + 4, + ); + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 200, + 100, + ProtocolVersion::V2_0, + ), + u16::MAX, + ); + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 103, + 100, + ProtocolVersion::V1_8, + ), + 0, + ); + assert_eq!( + ConsensusConstantsWit2::get_replication_factor_for_protocol( + 99, + 100, + ProtocolVersion::V2_1, + ), + 0, + ); + } + + #[test] + fn stake_authorization_v2_body_matches_sdk_literal_vector() { + // Shared with `witnet-sdk/tests/delegated-staking/fixtures.ts`. Keep this literal rather + // than rebuilding the expectation field by field so an endian, width, or offset drift in + // either implementation is visible as a cross-language fixture failure. + const SDK_CANONICAL_V2_BODY_HEX: &str = concat!( + "0000000200001234", + "000102030405060708090a0b0c0d0e0f10111213", + "1415161718191a1b1c1d1e1f2021222324252627", + "0000a4100000000f00000001ffffffffffffffff", + ); + const SDK_CANONICAL_V2_DIGEST_HEX: &str = + "f0c3605b0e932801e86e1221d1b2fa7fea25c59a663783bb1b39bbeb2d6d1c7d"; + const SDK_V2_DOMAIN: &[u8] = b"WITNET_STAKE_AUTHORIZATION_V2\0"; + let validator = PublicKeyHash::from_bytes(&[ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, + ]) + .unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[ + 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, + ]) + .unwrap(); + let terms = StakeAuthorization::commission_update( + 0x1234, + validator, + withdrawer, + 42_000, + 15, + u64::MAX, + ); + let expected_body = hex::decode(SDK_CANONICAL_V2_BODY_HEX).unwrap(); + assert_eq!(expected_body.len(), 68); + assert_eq!(STAKE_AUTHORIZATION_DOMAIN, SDK_V2_DOMAIN); + assert_eq!(STAKE_AUTHORIZATION_CODE_PREFIX, b"WIT2"); + + let signing_bytes = terms.signing_bytes().unwrap(); + assert_eq!( + signing_bytes.len(), + STAKE_AUTHORIZATION_DOMAIN.len() + expected_body.len() + ); + assert_eq!( + &signing_bytes[..STAKE_AUTHORIZATION_DOMAIN.len()], + SDK_V2_DOMAIN + ); + assert_eq!( + &signing_bytes[STAKE_AUTHORIZATION_DOMAIN.len()..], + expected_body.as_slice() + ); + assert_eq!( + hex::encode(terms.signing_digest().unwrap()), + SDK_CANONICAL_V2_DIGEST_HEX + ); + + let code_header = terms.code_header_bytes().unwrap(); + assert_eq!(&code_header[..4], STAKE_AUTHORIZATION_CODE_PREFIX); + assert_eq!(&code_header[4..], expected_body.as_slice()); + } + + #[test] + fn owner_registration_and_exit_bodies_match_sdk_literal_vectors() { + let validator = PublicKeyHash::from_bytes(&(0_u8..20).collect::>()).unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&(20_u8..40).collect::>()).unwrap(); + let registration = StakeAuthorization::register_owner(0x1234, validator, withdrawer, 42_000, 15); + let exit = StakeAuthorization::request_exit(0x1234, validator, withdrawer, 42_000, u64::MAX); + let registration_body = concat!( + "0000000200001234", + "000102030405060708090a0b0c0d0e0f10111213", + "1415161718191a1b1c1d1e1f2021222324252627", + "0000a4100000000f000000020000000000000000", + ); + let exit_body = concat!( + "0000000200001234", + "000102030405060708090a0b0c0d0e0f10111213", + "1415161718191a1b1c1d1e1f2021222324252627", + "0000a4100000000000000003ffffffffffffffff", + ); + for (terms, expected) in [(®istration, registration_body), (&exit, exit_body)] { + assert_eq!(hex::encode(&terms.signing_bytes().unwrap()[STAKE_AUTHORIZATION_DOMAIN.len()..]), expected); + } + assert_eq!(registration.authorizer(), validator); + assert_eq!(exit.authorizer(), withdrawer); + assert_ne!(registration.signing_digest().unwrap(), exit.signing_digest().unwrap()); + let mut malformed = exit; + malformed.commission_percent = 1; + assert!(malformed.validate_structure().is_err()); + let mut malformed = registration; + malformed.nonce = 1; + assert!(malformed.validate_structure().is_err()); + } + fn dr_root_superblock_loop_test( sb: SuperBlock, expected_indices: Vec, @@ -5525,30 +8058,316 @@ mod tests { } #[test] - fn transactions_pool_contains_reveal_no_signatures() { - let transactions_pool = TransactionsPool::default(); - let transaction = Transaction::Reveal(RevealTransaction { - body: Default::default(), - signatures: vec![], - }); + fn transactions_pool_rejects_commit_without_a_signature_before_insertion() { + let mut commit = pool_commit(Hash::default(), Hash::SHA256([1; 32]), 1); + commit.signatures.clear(); + let transaction = Transaction::Commit(commit); + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); - assert_eq!(transactions_pool.contains(&transaction), Ok(false)); + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], + ); + assert!(transactions_pool.co_transactions.is_empty()); + assert!(transactions_pool.co_hash_index.is_empty()); } #[test] - fn transactions_pool_remove_all_transactions_with_same_output_pointer() { - let input = Input::default(); - let vt_1 = VTTransaction::new(VTTransactionBody::new(vec![input], vec![]), vec![]); - let vt_2 = VTTransaction::new( - VTTransactionBody::new(vec![input], vec![ValueTransferOutput::default()]), - vec![], - ); + fn transactions_pool_rejects_commit_with_multiple_signatures_before_insertion() { + let mut commit = pool_commit(Hash::default(), Hash::SHA256([2; 32]), 1); + commit.signatures.push(pool_keyed_signature(1)); + let transaction = Transaction::Commit(commit); + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); - let dr_1 = DRTransaction::new( - DRTransactionBody::new(vec![input], DataRequestOutput::default(), vec![]), - vec![], + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], ); - let dr_2 = DRTransaction::new( + assert!(transactions_pool.co_transactions.is_empty()); + assert!(transactions_pool.co_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_rejects_commit_signer_proof_mismatch_before_insertion() { + let mut commit = pool_commit(Hash::default(), Hash::SHA256([3; 32]), 1); + commit.signatures = vec![pool_keyed_signature(2)]; + let transaction = Transaction::Commit(commit); + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); + + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], + ); + assert!(transactions_pool.co_transactions.is_empty()); + assert!(transactions_pool.co_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_inserts_one_signature_commit_under_its_proof_identity() { + let transaction = Transaction::Commit(pool_commit( + Hash::default(), + Hash::SHA256([4; 32]), + 1, + )); + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); + + assert!( + transactions_pool + .insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert_eq!( + transactions_pool.contains_for_protocol(&transaction, ProtocolVersion::V2_1), + Ok(true), + ); + assert_eq!(transactions_pool.co_transactions.len(), 1); + assert_eq!(transactions_pool.co_hash_index.len(), 1); + } + + #[test] + fn transactions_pool_contains_reveal_no_signatures() { + let transactions_pool = TransactionsPool::default(); + let transaction = Transaction::Reveal(RevealTransaction { + body: Default::default(), + signatures: vec![], + }); + + assert_eq!(transactions_pool.contains(&transaction), Ok(false)); + } + + #[test] + fn transactions_pool_rejects_reveal_without_a_signature_before_insertion() { + let mut reveal = pool_reveal(Hash::default(), vec![1], 1); + reveal.signatures.clear(); + let transaction = Transaction::Reveal(reveal); + let mut transactions_pool = TransactionsPool::default(); + + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], + ); + assert!(transactions_pool.re_transactions.is_empty()); + assert!(transactions_pool.re_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_rejects_reveal_with_multiple_signatures_before_insertion() { + let mut reveal = pool_reveal(Hash::default(), vec![2], 1); + reveal.signatures.push(pool_keyed_signature(1)); + let transaction = Transaction::Reveal(reveal); + let mut transactions_pool = TransactionsPool::default(); + + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], + ); + assert!(transactions_pool.re_transactions.is_empty()); + assert!(transactions_pool.re_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_rejects_reveal_signer_body_mismatch_before_insertion() { + let mut reveal = pool_reveal(Hash::default(), vec![3], 1); + reveal.signatures = vec![pool_keyed_signature(2)]; + let transaction = Transaction::Reveal(reveal); + let mut transactions_pool = TransactionsPool::default(); + + assert_eq!( + transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![transaction], + ); + assert!(transactions_pool.re_transactions.is_empty()); + assert!(transactions_pool.re_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_inserts_one_signature_reveal_under_its_body_identity() { + let transaction = Transaction::Reveal(pool_reveal(Hash::default(), vec![4], 1)); + let mut transactions_pool = TransactionsPool::default(); + + assert!( + transactions_pool + .insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert_eq!( + transactions_pool.contains_for_protocol(&transaction, ProtocolVersion::V2_1), + Ok(true), + ); + assert_eq!(transactions_pool.re_transactions.len(), 1); + assert_eq!(transactions_pool.re_hash_index.len(), 1); + } + + #[test] + fn transactions_pool_reveal_replacement_keeps_indexes_one_to_one() { + let dr_pointer = Hash::default(); + let first = pool_reveal(dr_pointer, vec![5], 1); + let replacement = pool_reveal(dr_pointer, vec![6], 1); + let pkh = first.body.pkh; + let first_hash = first.hash(); + let replacement_hash = replacement.hash(); + assert_eq!(replacement.body.pkh, pkh); + assert_ne!(replacement_hash, first_hash); + + let mut transactions_pool = TransactionsPool::default(); + assert!(transactions_pool + .insert_for_protocol( + Transaction::Reveal(first.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty()); + assert_eq!( + transactions_pool.insert_for_protocol( + Transaction::Reveal(replacement.clone()), + 0, + ProtocolVersion::V2_1, + ), + vec![Transaction::Reveal(first)], + ); + + assert_eq!(transactions_pool.re_transactions.len(), 1); + assert_eq!(transactions_pool.re_hash_index.len(), 1); + assert!(!transactions_pool.re_hash_index.contains_key(&first_hash)); + assert_eq!( + transactions_pool + .re_transactions + .get(&dr_pointer) + .and_then(|reveals| reveals.get(&pkh)), + Some(&replacement_hash), + ); + assert_eq!( + transactions_pool.reveal_contains(&dr_pointer, &pkh, &replacement_hash), + Ok(true), + ); + } + + #[test] + fn transactions_pool_stale_reveal_removal_preserves_newer_mapping() { + let dr_pointer = Hash::default(); + let stale = pool_reveal(dr_pointer, vec![7], 1); + let current = pool_reveal(dr_pointer, vec![8], 1); + let pkh = current.body.pkh; + let stale_hash = stale.hash(); + let current_hash = current.hash(); + let mut transactions_pool = TransactionsPool::default(); + + assert!(transactions_pool + .insert_for_protocol( + Transaction::Reveal(current.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty()); + // Reconstruct the stale primary-only entry that the old replacement path could leave. + transactions_pool + .re_hash_index + .insert(stale_hash, stale.clone()); + + assert_eq!( + transactions_pool.remove_one_reveal(&dr_pointer, &pkh, &stale_hash), + Some(stale), + ); + assert_eq!(transactions_pool.re_hash_index.len(), 1); + assert!(transactions_pool.re_hash_index.contains_key(¤t_hash)); + assert_eq!( + transactions_pool + .re_transactions + .get(&dr_pointer) + .and_then(|reveals| reveals.get(&pkh)), + Some(¤t_hash), + ); + assert_eq!( + transactions_pool.reveal_contains(&dr_pointer, &pkh, ¤t_hash), + Ok(true), + ); + } + + #[test] + fn transactions_pool_exact_reveal_removal_clears_both_indexes() { + let dr_pointer = Hash::default(); + let reveal = pool_reveal(dr_pointer, vec![9], 1); + let pkh = reveal.body.pkh; + let reveal_hash = reveal.hash(); + let mut transactions_pool = TransactionsPool::default(); + + assert!(transactions_pool + .insert_for_protocol( + Transaction::Reveal(reveal.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty()); + let wrong_dr_pointer = Hash::SHA256([0xAA; 32]); + let wrong_pkh = pool_keyed_signature(2).public_key.pkh(); + assert_eq!( + transactions_pool.remove_one_reveal(&wrong_dr_pointer, &pkh, &reveal_hash), + None, + ); + assert_eq!( + transactions_pool.remove_one_reveal(&dr_pointer, &wrong_pkh, &reveal_hash), + None, + ); + assert_eq!(transactions_pool.re_transactions.len(), 1); + assert_eq!(transactions_pool.re_hash_index.len(), 1); + assert_eq!( + transactions_pool.remove_one_reveal(&dr_pointer, &pkh, &reveal_hash), + Some(reveal), + ); + assert!(transactions_pool.re_transactions.is_empty()); + assert!(transactions_pool.re_hash_index.is_empty()); + } + + #[test] + fn transactions_pool_remove_all_transactions_with_same_output_pointer() { + let input = Input::default(); + let vt_1 = VTTransaction::new(VTTransactionBody::new(vec![input], vec![]), vec![]); + let vt_2 = VTTransaction::new( + VTTransactionBody::new(vec![input], vec![ValueTransferOutput::default()]), + vec![], + ); + + let dr_1 = DRTransaction::new( + DRTransactionBody::new(vec![input], DataRequestOutput::default(), vec![]), + vec![], + ); + let dr_2 = DRTransaction::new( DRTransactionBody::new( vec![input], DataRequestOutput::default(), @@ -5774,130 +8593,1620 @@ mod tests { assert_eq!(vt1.hash(), vt2.hash()); assert_ne!(vt1, vt2); - let mut transactions_pool = TransactionsPool::default(); - transactions_pool.insert(vt1.clone(), 1); - transactions_pool.insert(vt2.clone(), 1); - // Removing vt_1 actually returns vt_2, because they have the same hash, but this is fine - // because they both have a valid signature - let t = transactions_pool.vt_remove(&vt_1).unwrap(); - assert_eq!(Transaction::ValueTransfer(t), vt2); - assert!(!transactions_pool.contains(&vt1).unwrap()); - assert!(!transactions_pool.contains(&vt2).unwrap()); + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.insert(vt1.clone(), 1); + transactions_pool.insert(vt2.clone(), 1); + // Removing vt_1 actually returns vt_2, because they have the same hash, but this is fine + // because they both have a valid signature + let t = transactions_pool.vt_remove(&vt_1).unwrap(); + assert_eq!(Transaction::ValueTransfer(t), vt2); + assert!(!transactions_pool.contains(&vt1).unwrap()); + assert!(!transactions_pool.contains(&vt2).unwrap()); + } + + #[test] + fn transactions_pool_malleability_dr() { + let input = Input::default(); + let mut dr_1 = DRTransaction::new( + DRTransactionBody::new(vec![input], DataRequestOutput::default(), vec![]), + vec![KeyedSignature::default()], + ); + // Add dummy signature, but pretend it is valid + match &mut dr_1.signatures[0].signature { + Signature::Secp256k1(sig) => sig.der = vec![0; 32], + } + + let mut dr_2 = dr_1.clone(); + // Flip one bit in the signature, but pretend that the signature is still valid (malleability). + match &mut dr_2.signatures[0].signature { + Signature::Secp256k1(secp_sig) => { + // Flip 1 bit + secp_sig.der[10] ^= 0x01; + } + } + + let dr1 = Transaction::DataRequest(dr_1.clone()); + let dr2 = Transaction::DataRequest(dr_2.clone()); + assert_eq!(dr1.hash(), dr2.hash()); + assert_ne!(dr1, dr2); + + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.insert(dr1.clone(), 1); + transactions_pool.insert(dr2.clone(), 1); + // Removing dr_1 actually returns dr_2, because they have the same hash, but this is fine + // because they both have a valid signature + let t = transactions_pool.dr_remove(&dr_1).unwrap(); + assert_eq!(Transaction::DataRequest(t), dr2); + assert!(!transactions_pool.contains(&dr1).unwrap()); + assert!(!transactions_pool.contains(&dr2).unwrap()); + } + + #[test] + fn transactions_pool_malleability_co() { + let c1 = Hash::SHA256([1; 32]); + let mut t1 = Transaction::Commit(CommitTransaction { + body: CommitTransactionBody::without_collateral( + Default::default(), + c1, + Default::default(), + ), + signatures: vec![KeyedSignature::default()], + }); + // Add dummy signature, but pretend it is valid + match &mut t1 { + Transaction::Commit(co1) => match &mut co1.signatures[0].signature { + Signature::Secp256k1(sig) => sig.der = vec![0; 32], + }, + _ => unreachable!(), + } + + let mut t2 = t1.clone(); + // Flip one bit in the signature, but pretend that the signature is still valid (malleability). + match &mut t2 { + Transaction::Commit(co2) => { + match &mut co2.signatures[0].signature { + Signature::Secp256k1(secp_sig) => { + // Flip 1 bit + secp_sig.der[10] ^= 0x01; + } + } + } + _ => unreachable!(), + } + + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.insert(t1, 0); + transactions_pool.insert(t2.clone(), 0); + let mut expected = TransactionsPool::default(); + expected.insert(t2, 0); + assert_eq!(transactions_pool.co_transactions, expected.co_transactions); + } + + #[test] + fn transactions_pool_malleability_re() { + let r1 = vec![1]; + let mut t1 = Transaction::Reveal(pool_reveal(Hash::default(), r1, 1)); + // Add dummy signature, but pretend it is valid + match &mut t1 { + Transaction::Reveal(re1) => match &mut re1.signatures[0].signature { + Signature::Secp256k1(sig) => sig.der = vec![0; 32], + }, + _ => unreachable!(), + } + + let mut t2 = t1.clone(); + // Flip one bit in the signature, but pretend that the signature is still valid (malleability). + match &mut t2 { + Transaction::Reveal(re2) => { + match &mut re2.signatures[0].signature { + Signature::Secp256k1(secp_sig) => { + // Flip 1 bit + secp_sig.der[10] ^= 0x01; + } + } + } + _ => unreachable!(), + } + + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.insert(t1, 0); + transactions_pool.insert(t2.clone(), 0); + let mut expected = TransactionsPool::default(); + expected.insert(t2, 0); + assert_eq!(transactions_pool.re_transactions, expected.re_transactions); + } + + fn pending_unstake(nonce: u64, withdrawal_value: u64) -> UnstakeTransaction { + UnstakeTransaction::new( + crate::transaction::UnstakeTransactionBody::new( + PublicKeyHash::default(), + ValueTransferOutput { + value: withdrawal_value, + ..Default::default() + }, + 0, + nonce, + ), + KeyedSignature::default(), + ) + } + + fn pending_commission_update( + nonce: u64, + commission_percent: u32, + input_index: u32, + ) -> StakeTransaction { + let validator = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let output = StakeOutput { + authorization: KeyedSignature::default(), + authorization_terms: Some(StakeAuthorization::commission_update( + 0, + validator, + withdrawer, + Epoch::MAX, + commission_percent, + nonce, + )), + key: StakeKey { + validator, + withdrawer, + }, + value: 0, + }; + StakeTransaction::new( + crate::transaction::StakeTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: Hash::SHA256([3; 32]), + output_index: input_index, + })], + output, + None, + ), + Vec::new(), + ) + } + + #[test] + fn stake_output_json_value_is_lossless_without_changing_binary_layout() { + let output = StakeOutput { + authorization: KeyedSignature::default(), + authorization_terms: Some(StakeAuthorization::default()), + key: StakeKey { + validator: PublicKeyHash::default(), + withdrawer: PublicKeyHash::default(), + }, + value: u64::MAX, + }; + + let json = serde_json::to_value(&output).unwrap(); + assert_eq!( + json["value"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!(serde_json::from_value::(json).unwrap(), output); + + let binary = bincode::serialize(&output).unwrap(); + assert_eq!(bincode::deserialize::(&binary).unwrap(), output); + } + + fn four_class_pressure_transactions() -> [Transaction; 4] { + let value_transfer = Transaction::ValueTransfer(VTTransaction::new( + VTTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: Hash::SHA256([11; 32]), + output_index: 0, + })], + vec![ValueTransferOutput::default()], + ), + vec![], + )); + let data_request = Transaction::DataRequest(DRTransaction::new( + DRTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: Hash::SHA256([12; 32]), + output_index: 0, + })], + DataRequestOutput::default(), + vec![], + ), + vec![], + )); + let stake = Transaction::Stake(pending_commission_update(11, 4, 4)); + let unstake = Transaction::Unstake(pending_unstake(12, 1)); + + [value_transfer, data_request, stake, unstake] + } + + fn pressure_transaction_weight(transaction: &Transaction) -> u64 { + match transaction { + Transaction::Stake(transaction) => { + u64::from(transaction.weight_for_protocol(ProtocolVersion::V2_1)) + } + Transaction::Unstake(transaction) => u64::from(transaction.weight()), + transaction => u64::from(transaction.weight()), + } + } + + #[test] + fn delegated_action_fee_floors_are_independent_local_policy() { + let stake = Transaction::Stake(pending_commission_update(1, 5, 0)); + let unstake = Transaction::Unstake(pending_unstake(1, 1)); + let mut pool = TransactionsPool::default(); + pool.set_minimum_st_fee(2); + pool.set_minimum_ut_fee(3); + + assert_eq!( + pool.insert_for_protocol(stake.clone(), 1, ProtocolVersion::V2_1), + vec![stake.clone()] + ); + assert!(!pool + .contains_for_protocol(&stake, ProtocolVersion::V2_1) + .unwrap()); + assert!(pool + .insert_for_protocol(stake.clone(), 2, ProtocolVersion::V2_1) + .is_empty()); + assert!(pool + .contains_for_protocol(&stake, ProtocolVersion::V2_1) + .unwrap()); + + assert_eq!( + pool.insert_for_protocol(unstake.clone(), 2, ProtocolVersion::V2_1), + vec![unstake.clone()] + ); + assert!(!pool + .contains_for_protocol(&unstake, ProtocolVersion::V2_1) + .unwrap()); + assert!(pool + .insert_for_protocol(unstake.clone(), 3, ProtocolVersion::V2_1) + .is_empty()); + assert!(pool + .contains_for_protocol(&unstake, ProtocolVersion::V2_1) + .unwrap()); + } + + #[test] + fn v21_stake_pool_rejects_missing_or_misbound_authorization_terms() { + let missing_terms = Transaction::Stake(StakeTransaction::default()); + let mut pool = TransactionsPool::default(); + assert_eq!( + pool.insert_for_protocol(missing_terms.clone(), 1, ProtocolVersion::V2_1), + vec![missing_terms] + ); + assert_eq!(pool.st_len(), 0); + + let mut misbound = pending_commission_update(1, 5, 0); + let other_validator = PublicKeyHash::from_bytes(&[31; 20]).unwrap(); + misbound.body.output.key.validator = other_validator; + let misbound = Transaction::Stake(misbound); + assert_eq!( + pool.insert_for_protocol(misbound.clone(), 1, ProtocolVersion::V2_1), + vec![misbound] + ); + assert_eq!(pool.st_len(), 0); + } + + #[test] + fn v21_unstake_pool_rejects_zero_output() { + let zero_output = Transaction::Unstake(pending_unstake(1, 0)); + let mut pool = TransactionsPool::default(); + assert_eq!( + pool.insert_for_protocol(zero_output.clone(), 1, ProtocolVersion::V2_1), + vec![zero_output] + ); + assert_eq!(pool.ut_len(), 0); + } + + #[test] + fn v21_stake_pool_rejects_invalid_action_value_and_fee_shapes() { + let mut policy_with_stake = pending_commission_update(1, 5, 0); + policy_with_stake.body.output.value = 1; + let mut pool = TransactionsPool::default(); + let policy_with_stake = Transaction::Stake(policy_with_stake); + assert_eq!( + pool.insert_for_protocol(policy_with_stake.clone(), 1, ProtocolVersion::V2_1), + vec![policy_with_stake] + ); + assert_eq!(pool.st_len(), 0); + + let mut input_free_policy = pending_commission_update(1, 5, 0); + input_free_policy.body.inputs.clear(); + let input_free_policy = Transaction::Stake(input_free_policy); + assert_eq!( + pool.insert_for_protocol(input_free_policy.clone(), 1, ProtocolVersion::V2_1), + vec![input_free_policy] + ); + assert_eq!(pool.st_len(), 0); + + let zero_fee_policy = Transaction::Stake(pending_commission_update(1, 5, 0)); + assert_eq!( + pool.insert_for_protocol(zero_fee_policy.clone(), 0, ProtocolVersion::V2_1), + vec![zero_fee_policy] + ); + assert_eq!(pool.st_len(), 0); + + let mut subminimum_deposit = pending_commission_update(0, 5, 0); + subminimum_deposit.body.output.authorization_terms.as_mut().unwrap().action = + STAKE_AUTHORIZATION_ACTION_DELEGATE; + subminimum_deposit.body.output.authorization_terms.as_mut().unwrap().nonce = 0; + subminimum_deposit.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS - 1; + let subminimum_deposit = Transaction::Stake(subminimum_deposit); + assert_eq!( + pool.insert_for_protocol(subminimum_deposit.clone(), 1, ProtocolVersion::V2_1), + vec![subminimum_deposit] + ); + assert_eq!(pool.st_len(), 0); + } + + #[test] + fn v21_pending_action_reservation_ignores_unmineable_shapes() { + let mut pool = TransactionsPool::default(); + + let zero_output = Transaction::Unstake(pending_unstake(1, 0)); + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol( + &zero_output, + ProtocolVersion::V2_1, + ), + Ok(None) + ); + + let mut policy_with_stake = pending_commission_update(1, 5, 0); + policy_with_stake.body.output.value = 1; + let policy_with_stake = Transaction::Stake(policy_with_stake); + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol( + &policy_with_stake, + ProtocolVersion::V2_1, + ), + Ok(None) + ); + + let mut input_free_policy = pending_commission_update(1, 5, 0); + input_free_policy.body.inputs.clear(); + let input_free_policy = Transaction::Stake(input_free_policy); + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol( + &input_free_policy, + ProtocolVersion::V2_1, + ), + Ok(None) + ); + + let mut subminimum_deposit = pending_commission_update(0, 5, 0); + subminimum_deposit.body.output.authorization_terms.as_mut().unwrap().action = + STAKE_AUTHORIZATION_ACTION_DELEGATE; + subminimum_deposit.body.output.authorization_terms.as_mut().unwrap().nonce = 0; + subminimum_deposit.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS - 1; + let subminimum_deposit = Transaction::Stake(subminimum_deposit); + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol( + &subminimum_deposit, + ProtocolVersion::V2_1, + ), + Ok(None) + ); + } + + #[test] + fn delegated_action_read_only_admission_simulates_policy_without_mutating_the_pool() { + let stake = Transaction::Stake(pending_commission_update(1, 5, 0)); + let unstake = Transaction::Unstake(pending_unstake(1, 1)); + let mut fee_pool = TransactionsPool::default(); + fee_pool.set_minimum_st_fee(2); + fee_pool.set_minimum_ut_fee(3); + + assert!(!fee_pool + .would_retain_for_protocol(&stake, 1, ProtocolVersion::V2_1) + .unwrap()); + assert!(!fee_pool + .would_retain_for_protocol(&unstake, 2, ProtocolVersion::V2_1) + .unwrap()); + assert_eq!(fee_pool.st_len(), 0); + assert_eq!(fee_pool.ut_len(), 0); + + assert!(fee_pool + .would_retain_for_protocol(&stake, 2, ProtocolVersion::V2_1) + .unwrap()); + assert!(fee_pool + .would_retain_for_protocol(&unstake, 3, ProtocolVersion::V2_1) + .unwrap()); + // Successful simulations are read-only too. + assert_eq!(fee_pool.st_len(), 0); + assert_eq!(fee_pool.ut_len(), 0); + + let mut pressure_pool = TransactionsPool::default(); + let _removed = pressure_pool.set_total_weight_limit(0, 1.0); + assert!(!pressure_pool + .would_retain_for_protocol(&stake, u64::MAX, ProtocolVersion::V2_1) + .unwrap()); + assert!(!pressure_pool + .would_retain_for_protocol(&unstake, u64::MAX, ProtocolVersion::V2_1) + .unwrap()); + assert_eq!(pressure_pool.st_len(), 0); + assert_eq!(pressure_pool.ut_len(), 0); + + let mut retained_pool = TransactionsPool::default(); + assert!(retained_pool + .insert_for_protocol(stake.clone(), 2, ProtocolVersion::V2_1) + .is_empty()); + retained_pool.set_minimum_st_fee(u64::MAX); + assert!(retained_pool + .would_retain_for_protocol(&stake, 0, ProtocolVersion::V2_1) + .unwrap()); + assert_eq!(retained_pool.st_len(), 1); + + let mut pending_pool = TransactionsPool::default(); + pending_pool.set_minimum_st_fee(u64::MAX); + let pending = pending_pool + .reserve_pending_delegated_action_for_protocol(&stake, ProtocolVersion::V2_1) + .unwrap() + .unwrap(); + // Exact retained/pending bodies are idempotent even when a fresh insertion would fail + // local policy, matching ordinary `add_transaction` behavior. + assert!(pending_pool + .would_retain_for_protocol(&stake, 0, ProtocolVersion::V2_1) + .unwrap()); + assert!(pending_pool.owns_pending_delegated_action(&pending)); + assert_eq!(pending_pool.st_len(), 0); + } + + #[test] + fn pending_transaction_admission_is_body_bounded_and_generation_owned() { + let transaction_hash = Hash::SHA256([7; 32]); + let mut pool = TransactionsPool::default(); + + let stale_token = pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + assert!(pool.owns_pending_transaction_admission(&stale_token)); + // The body hash intentionally collapses exact retries and signature-only variants onto + // one verifier slot and one unconfirmed entry. + assert_eq!( + pool.reserve_pending_transaction_admission(transaction_hash), + None + ); + assert_eq!(pool.pending_transaction_admissions.len(), 1); + assert_eq!(pool.unconfirmed_transactions.current, vec![transaction_hash]); + + // Epoch/state invalidation removes only the still-pending attempt and permits an immediate + // same-body retry. Its opaque generation must differ even though the hash is identical. + pool.clear_pending_transaction_admissions(); + assert!(!pool.owns_pending_transaction_admission(&stale_token)); + assert!(pool.unconfirmed_transactions.current.is_empty()); + let current_token = pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + assert_ne!(stale_token, current_token); + assert!(pool.owns_pending_transaction_admission(¤t_token)); + + // A late callback cannot roll back either the new ownership slot or its unconfirmed hash. + assert!(!pool.rollback_pending_transaction_admission(stale_token)); + assert!(pool.owns_pending_transaction_admission(¤t_token)); + assert_eq!(pool.unconfirmed_transactions.current, vec![transaction_hash]); + + // Successful admission releases the verifier slot but deliberately retains normal + // unconfirmed tracking for later chain-state recovery. + assert!(pool.complete_pending_transaction_admission(current_token)); + assert!(pool.pending_transaction_admissions.is_empty()); + assert_eq!(pool.unconfirmed_transactions.current, vec![transaction_hash]); + } + + #[test] + fn pending_transaction_admission_generation_exhaustion_fails_closed() { + let transaction = Transaction::ValueTransfer(VTTransaction::default()); + let transaction_hash = transaction.hash(); + let mut pool = TransactionsPool::default(); + pool.next_pending_transaction_admission_id = u64::MAX; + + assert_eq!( + pool.reserve_pending_transaction_admission_for_protocol( + &transaction, + ProtocolVersion::V2_1, + &DataRequestPool::default(), + ), + PendingTransactionAdmissionOutcome::GenerationExhausted + ); + assert!(pool.pending_transaction_admissions.is_empty()); + assert!(pool.unconfirmed_transactions.current.is_empty()); + assert!(pool.in_flight_wit21_commit_admissions.is_empty()); + assert_eq!(pool.next_pending_transaction_admission_id, u64::MAX); + + // The compatibility-shaped helper also refuses to publish a wrapped generation. + assert!(pool + .reserve_pending_transaction_admission(transaction_hash) + .is_none()); + assert!(pool.pending_transaction_admissions.is_empty()); + assert!(pool.unconfirmed_transactions.current.is_empty()); + } + + #[test] + fn protocol_pinned_admission_uses_versioned_transaction_identity() { + let transaction = Transaction::Stake(pending_commission_update(9, 4, 0)); + let expected_hash = transaction.versioned_hash(ProtocolVersion::V2_1); + let mut pool = TransactionsPool::default(); + + let reservation = match pool.reserve_pending_transaction_admission_for_protocol( + &transaction, + ProtocolVersion::V2_1, + &DataRequestPool::default(), + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected protocol-pinned admission outcome: {outcome:?}"), + }; + + assert_eq!(reservation.transaction_hash, expected_hash); + assert_eq!(pool.unconfirmed_transactions.current, vec![expected_hash]); + assert!(pool.rollback_pending_transaction_admission(reservation)); + assert!(pool.unconfirmed_transactions.current.is_empty()); + } + + #[test] + fn pending_wit21_commit_admission_is_consensus_bounded_and_idempotent() { + let request = DRTransaction::new( + DRTransactionBody::new( + vec![], + DataRequestOutput { + witnesses: 2, + ..Default::default() + }, + vec![], + ), + vec![KeyedSignature::default()], + ); + let dr_pointer = request.versioned_hash(ProtocolVersion::from_epoch(0)); + let mut dr_pool = DataRequestPool::default(); + dr_pool.add_data_request(0, request, None).unwrap(); + let commits = (1u8..=3) + .map(|marker| { + Transaction::Commit(CommitTransaction { + body: CommitTransactionBody::without_collateral( + dr_pointer, + Hash::SHA256([marker; 32]), + Default::default(), + ), + // Direct insertion derives the retained validator key from the first signer. + // Keep this fixture structurally complete even though admission itself does + // not perform signature verification. + signatures: vec![KeyedSignature::default()], + }) + }) + .collect::>(); + + // A pool that has not yet received its consensus-derived DR weight bound must not expose + // an unlimited Wit/2.1 verifier or retention window during actor startup (or to a direct + // standalone caller). The same default remains inert for the legacy protocol below. + let mut unconfigured = TransactionsPool::default(); + assert_eq!( + unconfigured.reserve_pending_transaction_admission_for_protocol( + &commits[0], + ProtocolVersion::V2_1, + &dr_pool, + ), + PendingTransactionAdmissionOutcome::Wit21CommitCapacityReached { + dr_pointer, + limit: 0, + } + ); + assert!(unconfigured.pending_transaction_admissions.is_empty()); + assert!(unconfigured.in_flight_wit21_commit_admissions.is_empty()); + assert_eq!( + unconfigured.insert_for_protocol( + commits[0].clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![commits[0].clone()], + ); + assert!(unconfigured.co_hash_index.is_empty()); + assert!(unconfigured.co_transactions.is_empty()); + + let mut pool = TransactionsPool::default(); + // Use an exact two-slot boundary to exercise admission mechanics. The separate + // `transactions_pool_wit21_commit_bound_cannot_undercut_request_witnesses` fixture proves + // that a real consensus `max_dr_weight` quotient cannot undercut this two-witness group. + pool.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + + let first = match pool.reserve_pending_transaction_admission_for_protocol( + &commits[0], + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected first admission outcome: {outcome:?}"), + }; + assert_eq!( + pool.reserve_pending_transaction_admission_for_protocol( + &commits[0], + ProtocolVersion::V2_1, + &dr_pool, + ), + PendingTransactionAdmissionOutcome::AlreadyPending + ); + let second = match pool.reserve_pending_transaction_admission_for_protocol( + &commits[1], + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected second admission outcome: {outcome:?}"), + }; + assert_eq!( + pool.reserve_pending_transaction_admission_for_protocol( + &commits[2], + ProtocolVersion::V2_1, + &dr_pool, + ), + PendingTransactionAdmissionOutcome::Wit21CommitCapacityReached { + dr_pointer, + limit: 2, + } + ); + assert_eq!(pool.pending_transaction_admissions.len(), 2); + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 2); + assert!( + pool.would_retain_for_protocol(&commits[0], 0, ProtocolVersion::V2_1) + .unwrap() + ); + assert!( + !pool + .would_retain_for_protocol(&commits[2], 0, ProtocolVersion::V2_1) + .unwrap() + ); + + assert!(pool.complete_pending_transaction_admission(first)); + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 1); + let third = match pool.reserve_pending_transaction_admission_for_protocol( + &commits[2], + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected replacement admission outcome: {outcome:?}"), + }; + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 2); + assert!(pool.rollback_pending_transaction_admission(second)); + assert!(pool.rollback_pending_transaction_admission(third)); + + // The local concurrency policy is activation-gated. Even a zero configured Wit/2.1 bound + // does not alter historical Commit admission before Wit/2.1. + let mut legacy = TransactionsPool::default(); + legacy.set_wit21_commit_candidates_per_dr_limit(0); + let legacy_reservation = match legacy.reserve_pending_transaction_admission_for_protocol( + &commits[0], + ProtocolVersion::V2_0, + &DataRequestPool::default(), + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected legacy admission outcome: {outcome:?}"), + }; + assert!(legacy.in_flight_wit21_commit_admissions.is_empty()); + assert!(legacy.rollback_pending_transaction_admission(legacy_reservation)); + + // An untrusted pointer cannot manufacture an independent per-request verifier budget. + let mut unknown_pool = TransactionsPool::default(); + unknown_pool.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + let empty_dr_pool = DataRequestPool::default(); + for marker in 0x80u8..=0x9F { + let unknown_pointer = Hash::SHA256([marker; 32]); + let unknown_commit = Transaction::Commit(CommitTransaction { + body: CommitTransactionBody::without_collateral( + unknown_pointer, + Hash::SHA256([marker.wrapping_add(1); 32]), + Default::default(), + ), + signatures: vec![], + }); + assert_eq!( + unknown_pool.reserve_pending_transaction_admission_for_protocol( + &unknown_commit, + ProtocolVersion::V2_1, + &empty_dr_pool, + ), + PendingTransactionAdmissionOutcome::Wit21CommitRequestUnavailable { + dr_pointer: unknown_pointer, + } + ); + } + assert!(unknown_pool.pending_transaction_admissions.is_empty()); + assert!(unknown_pool.in_flight_wit21_commit_admissions.is_empty()); + assert!(unknown_pool.unconfirmed_transactions.current.is_empty()); + + let mut reveal_stage_pool = dr_pool.clone(); + reveal_stage_pool + .data_request_state_mutable(&dr_pointer) + .unwrap() + .stage = DataRequestStage::REVEAL; + assert!(matches!( + unknown_pool.reserve_pending_transaction_admission_for_protocol( + &commits[0], + ProtocolVersion::V2_1, + &reveal_stage_pool, + ), + PendingTransactionAdmissionOutcome::Wit21CommitRequestUnavailable { .. } + )); + assert!(unknown_pool.pending_transaction_admissions.is_empty()); + assert!(unknown_pool.in_flight_wit21_commit_admissions.is_empty()); + assert!(unknown_pool.unconfirmed_transactions.current.is_empty()); + } + + #[test] + fn invalidated_wit21_commit_work_remains_bounded_until_stale_callback() { + let request = DRTransaction::new( + DRTransactionBody::new( + vec![], + DataRequestOutput { + witnesses: 1, + ..Default::default() + }, + vec![], + ), + vec![KeyedSignature::default()], + ); + let dr_pointer = request.versioned_hash(ProtocolVersion::from_epoch(0)); + let mut dr_pool = DataRequestPool::default(); + dr_pool.add_data_request(0, request, None).unwrap(); + let commit = |marker: u8| { + Transaction::Commit(CommitTransaction { + body: CommitTransactionBody::without_collateral( + dr_pointer, + Hash::SHA256([marker; 32]), + Default::default(), + ), + signatures: vec![], + }) + }; + let first = commit(1); + let competing = commit(2); + let mut pool = TransactionsPool::default(); + pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); + let stale = match pool.reserve_pending_transaction_admission_for_protocol( + &first, + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected stale admission outcome: {outcome:?}"), + }; + + pool.clear_pending_transaction_admissions(); + assert!(pool.pending_transaction_admissions.is_empty()); + assert!(pool.unconfirmed_transactions.current.is_empty()); + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 1); + assert!(matches!( + pool.reserve_pending_transaction_admission_for_protocol( + &competing, + ProtocolVersion::V2_1, + &dr_pool, + ), + PendingTransactionAdmissionOutcome::Wit21CommitCapacityReached { .. } + )); + + // The stale generation no longer owns generic admission, but its callback releases its own + // physical-work token without touching any future same-body generation. + assert!(!pool.rollback_pending_transaction_admission(stale)); + assert!(pool.in_flight_wit21_commit_admissions.is_empty()); + let current = match pool.reserve_pending_transaction_admission_for_protocol( + &first, + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected current admission outcome: {outcome:?}"), + }; + assert_ne!(stale, current); + assert!(!pool.rollback_pending_transaction_admission(stale)); + assert!(pool.owns_pending_transaction_admission(¤t)); + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 1); + assert!(pool.rollback_pending_transaction_admission(current)); + assert!(pool.in_flight_wit21_commit_admissions.is_empty()); + + let clear_token = match pool.reserve_pending_transaction_admission_for_protocol( + &first, + ProtocolVersion::V2_1, + &dr_pool, + ) { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + outcome => panic!("unexpected pre-clear admission outcome: {outcome:?}"), + }; + pool.clear(); + assert!(!pool.owns_pending_transaction_admission(&clear_token)); + assert_eq!(pool.in_flight_wit21_commit_admissions[&dr_pointer].len(), 1); + assert!(!pool.rollback_pending_transaction_admission(clear_token)); + assert!(pool.in_flight_wit21_commit_admissions.is_empty()); + } + + #[test] + fn transaction_pool_clear_preserves_admission_generation_monotonicity() { + let transaction_hash = Hash::SHA256([8; 32]); + let mut pool = TransactionsPool::default(); + let stale_token = pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + + pool.clear(); + let current_token = pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + + assert_ne!(stale_token, current_token); + assert!(!pool.rollback_pending_transaction_admission(stale_token)); + assert!(pool.owns_pending_transaction_admission(¤t_token)); + } + + #[test] + fn pending_delegated_action_reservation_is_bounded_and_token_owned() { + let first = Transaction::Unstake(pending_unstake(7, 1)); + let competing = Transaction::Unstake(pending_unstake(7, 2)); + let mut pool = TransactionsPool::default(); + + let stale_token = pool + .reserve_pending_delegated_action_for_protocol(&first, ProtocolVersion::V2_1) + .unwrap() + .unwrap(); + assert!(pool.owns_pending_delegated_action(&stale_token)); + assert!(matches!( + pool.reserve_pending_delegated_action_for_protocol( + &competing, + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingUnstakeConflict { nonce: 7, .. }) + )); + + // An epoch transition can release the old verifier slot and admit a fresh body. The late + // callback carrying the old token must not remove even an identical-body replacement. + pool.clear_pending_delegated_actions(); + assert!(!pool.owns_pending_delegated_action(&stale_token)); + let current_token = pool + .reserve_pending_delegated_action_for_protocol( + &first, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + assert!(pool.owns_pending_delegated_action(¤t_token)); + assert!(!pool.release_pending_delegated_action(stale_token)); + assert_eq!(pool.pending_unstake_actions.len(), 1); + assert!(pool.release_pending_delegated_action(current_token)); + assert!(pool.pending_unstake_actions.is_empty()); + } + + #[test] + fn pending_unstake_reservation_is_body_keyed_across_signer_variants() { + let mut first_unstake = pending_unstake(7, 1); + first_unstake.body.withdrawal.pkh = first_unstake.signature.public_key.pkh(); + let first = Transaction::Unstake(first_unstake); + let mut malformed_signer_variant = first.clone(); + let Transaction::Unstake(transaction) = &mut malformed_signer_variant else { + unreachable!(); + }; + transaction.signature.public_key = PublicKey { + compressed: 2, + bytes: [9; 32], + }; + + // Signatures are outside the transaction body hash. Pre-verification semantic admission + // must nevertheless collapse signer variants onto the withdrawer committed in the + // withdrawal output, which signature validation later requires the signer to match. + assert_eq!(first.hash(), malformed_signer_variant.hash()); + assert_ne!(first, malformed_signer_variant); + let Transaction::Unstake(first_unstake) = &first else { + unreachable!(); + }; + let Transaction::Unstake(variant_unstake) = &malformed_signer_variant else { + unreachable!(); + }; + assert_eq!( + unstake_action_key(first_unstake), + unstake_action_key(variant_unstake) + ); + + let mut pool = TransactionsPool::default(); + let reservation = pool + .reserve_pending_delegated_action_for_protocol(&first, ProtocolVersion::V2_1) + .unwrap() + .unwrap(); + assert_eq!( + pool.contains_for_protocol(&malformed_signer_variant, ProtocolVersion::V2_1), + Ok(true) + ); + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol( + &malformed_signer_variant, + ProtocolVersion::V2_1, + ), + Ok(None) + ); + assert_eq!(pool.pending_unstake_actions.len(), 1); + assert!(pool.release_pending_delegated_action(reservation)); + assert!(pool.pending_unstake_actions.is_empty()); + + // The retained hash-first path is intentionally body-idempotent too: a signature-only + // variant must neither add weight nor create a second action-index entry, and removing + // that body identity must unregister the one retained action. + assert!( + pool.insert_for_protocol(first.clone(), 0, ProtocolVersion::V2_1) + .is_empty() + ); + let admitted_weight = pool.total_ut_weight; + assert_eq!( + pool.contains_for_protocol(&malformed_signer_variant, ProtocolVersion::V2_1), + Ok(true) + ); + assert!( + pool.insert_for_protocol( + malformed_signer_variant.clone(), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert_eq!(pool.ut_len(), 1); + assert_eq!(pool.total_ut_weight, admitted_weight); + assert_eq!(pool.ut_remove(variant_unstake), Some(first_unstake.clone())); + assert!(pool.unstake_action_index.is_empty()); + } + + #[test] + fn direct_insert_cannot_displace_pending_delegated_action() { + let first_unstake = Transaction::Unstake(pending_unstake(7, 1)); + let competing_unstake = Transaction::Unstake(pending_unstake(7, 2)); + let mut unstake_pool = TransactionsPool::default(); + let unstake_reservation = unstake_pool + .reserve_pending_delegated_action_for_protocol( + &first_unstake, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + + assert_eq!( + unstake_pool.insert_for_protocol( + competing_unstake.clone(), + 0, + ProtocolVersion::V2_1, + ), + vec![competing_unstake] + ); + assert_eq!(unstake_pool.ut_len(), 0); + assert_eq!(unstake_pool.pending_unstake_actions.len(), 1); + assert!( + unstake_pool + .insert_for_protocol(first_unstake, 0, ProtocolVersion::V2_1) + .is_empty() + ); + assert_eq!(unstake_pool.ut_len(), 1); + assert!( + unstake_pool.release_pending_delegated_action(unstake_reservation) + ); + + let first_update = Transaction::Stake(pending_commission_update(3, 4, 0)); + let competing_update = Transaction::Stake(pending_commission_update(3, 5, 1)); + let mut stake_pool = TransactionsPool::default(); + let update_reservation = stake_pool + .reserve_pending_delegated_action_for_protocol( + &first_update, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + + assert_eq!( + stake_pool.insert_for_protocol( + competing_update.clone(), + 1, + ProtocolVersion::V2_1, + ), + vec![competing_update] + ); + assert_eq!(stake_pool.st_len(), 0); + assert_eq!(stake_pool.pending_commission_update_actions.len(), 1); + assert!( + stake_pool + .insert_for_protocol(first_update, 1, ProtocolVersion::V2_1) + .is_empty() + ); + assert_eq!(stake_pool.st_len(), 1); + assert!(stake_pool.release_pending_delegated_action(update_reservation)); + } + + #[test] + fn commission_update_slots_are_validator_nonce_scoped_not_withdrawer_scoped() { + let first = pending_commission_update(3, 4, 0); + let mut different_claimed_owner = pending_commission_update(3, 5, 1); + let claimed_owner = PublicKeyHash::from_bytes(&[8; 20]).unwrap(); + different_claimed_owner.body.output.key.withdrawer = claimed_owner; + different_claimed_owner + .body + .output + .authorization_terms + .as_mut() + .unwrap() + .withdrawer = claimed_owner; + + // Commission replay protection belongs to the validator policy. A body that claims a + // different owner will later fail owner validation, but it must not acquire a second + // signature-verification slot for the same validator nonce before that check runs. + assert_eq!( + commission_update_action_key(&first), + commission_update_action_key(&different_claimed_owner) + ); + assert_ne!( + first.versioned_hash(ProtocolVersion::V2_1), + different_claimed_owner.versioned_hash(ProtocolVersion::V2_1) + ); + + let first_transaction = Transaction::Stake(first.clone()); + let different_owner_transaction = + Transaction::Stake(different_claimed_owner.clone()); + let mut pending_pool = TransactionsPool::default(); + let first_reservation = pending_pool + .reserve_pending_delegated_action_for_protocol( + &first_transaction, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + assert!(matches!( + pending_pool.contains_for_protocol( + &different_owner_transaction, + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingCommissionUpdateConflict { + withdrawer, + nonce: 3, + .. + }) if withdrawer == claimed_owner + )); + assert!(matches!( + pending_pool.reserve_pending_delegated_action_for_protocol( + &different_owner_transaction, + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingCommissionUpdateConflict { nonce: 3, .. }) + )); + + // The scope is still per validator: another validator may reserve the same nonce. + let mut other_validator = pending_commission_update(3, 6, 2); + let validator = PublicKeyHash::from_bytes(&[9; 20]).unwrap(); + other_validator.body.output.key.validator = validator; + other_validator + .body + .output + .authorization_terms + .as_mut() + .unwrap() + .validator = validator; + let other_reservation = pending_pool + .reserve_pending_delegated_action_for_protocol( + &Transaction::Stake(other_validator), + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + assert_eq!(pending_pool.pending_commission_update_actions.len(), 2); + assert!(pending_pool.release_pending_delegated_action(first_reservation)); + assert!(pending_pool.release_pending_delegated_action(other_reservation)); + + // Direct insertion observes the same validator-scoped identity; malformed owner variants + // cannot bypass the retained first-seen choice either. + let mut retained_pool = TransactionsPool::default(); + assert!( + retained_pool + .insert_for_protocol(first_transaction, 1, ProtocolVersion::V2_1) + .is_empty() + ); + assert!(matches!( + retained_pool.contains_for_protocol( + &different_owner_transaction, + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingCommissionUpdateConflict { nonce: 3, .. }) + )); + assert_eq!( + retained_pool.insert_for_protocol( + different_owner_transaction.clone(), + 1, + ProtocolVersion::V2_1, + ), + vec![different_owner_transaction] + ); + assert_eq!(retained_pool.st_len(), 1); + assert_eq!(retained_pool.commission_update_action_index.len(), 1); + } + + #[test] + fn pre_v2_1_stake_does_not_consume_commission_action_slots() { + let update = Transaction::Stake(pending_commission_update(3, 4, 0)); + let mut pool = TransactionsPool::default(); + + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol(&update, ProtocolVersion::V2_0), + Ok(None) + ); + assert!( + pool.insert_for_protocol(update, 1, ProtocolVersion::V2_0) + .is_empty() + ); + assert_eq!(pool.st_len(), 1); + assert!(pool.commission_update_action_index.is_empty()); + assert!(pool.pending_commission_update_actions.is_empty()); + } + + #[test] + fn pre_v2_1_unstake_does_not_consume_delegated_action_slots() { + let withdrawal = Transaction::Unstake(pending_unstake(7, 1)); + let mut pool = TransactionsPool::default(); + + assert_eq!( + pool.reserve_pending_delegated_action_for_protocol(&withdrawal, ProtocolVersion::V2_0), + Ok(None) + ); + assert!(pool.pending_unstake_actions.is_empty()); + + // Legacy pool insertion remains available and keeps the ordinary retained nonce index; + // only the asynchronous Wit/2.1 semantic reservation is suppressed above. + assert!( + pool.insert_for_protocol(withdrawal, 0, ProtocolVersion::V2_0) + .is_empty() + ); + assert_eq!(pool.ut_len(), 1); + assert_eq!(pool.unstake_action_index.len(), 1); + assert!(pool.pending_unstake_actions.is_empty()); + } + + #[test] + fn stake_pool_resolves_v2_entry_from_its_legacy_projection() { + let update = pending_commission_update(3, 4, 0); + let expected_v2 = update.clone(); + let legacy = update + .clone() + .normalized_for_protocol(ProtocolVersion::V2_0); + let mut pool = TransactionsPool::default(); + assert!( + pool.insert_for_protocol( + Transaction::Stake(update), + 1, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + + assert_eq!( + pool.contains_for_protocol( + &Transaction::Stake(legacy.clone()), + ProtocolVersion::V2_0, + ), + Ok(true) + ); + assert!(pool.st_contains(&legacy.versioned_hash(ProtocolVersion::V2_0))); + assert_eq!( + pool.st_remove_for_protocol(&legacy, ProtocolVersion::V2_0), + Some(expected_v2), + ); + assert_eq!(pool.st_len(), 0); + } + + #[test] + fn stake_alias_lookup_fails_closed_while_a_legacy_projection_is_ambiguous() { + let first = pending_commission_update(3, 4, 0); + let second = pending_commission_update(4, 4, 0); + let first_key = first.versioned_hash(ProtocolVersion::V2_1); + let second_key = second.versioned_hash(ProtocolVersion::V2_1); + let first_legacy_key = first + .clone() + .normalized_for_protocol(ProtocolVersion::V2_0) + .versioned_hash(ProtocolVersion::V2_0); + let second_legacy_key = second + .clone() + .normalized_for_protocol(ProtocolVersion::V2_0) + .versioned_hash(ProtocolVersion::V2_0); + assert_ne!(first_key, second_key); + assert_eq!(first_legacy_key, second_legacy_key); + + let mut pool = TransactionsPool::default(); + assert!(pool + .insert_for_protocol( + Transaction::Stake(first.clone()), + 1, + ProtocolVersion::V2_1, + ) + .is_empty()); + assert!(pool + .insert_for_protocol( + Transaction::Stake(second.clone()), + 1, + ProtocolVersion::V2_1, + ) + .is_empty()); + + assert_eq!(pool.get(&first_key), Some(Transaction::Stake(first.clone()))); + assert_eq!(pool.get(&second_key), Some(Transaction::Stake(second.clone()))); + assert_eq!(pool.get(&first_legacy_key), None); + assert!(pool.st_contains(&first_legacy_key)); + assert_eq!( + pool.contains_for_protocol( + &Transaction::Stake( + first.clone().normalized_for_protocol(ProtocolVersion::V2_0), + ), + ProtocolVersion::V2_0, + ), + Ok(true) + ); + + assert_eq!(pool.st_remove_inner(&first_key, false), Some(first)); + assert_eq!(pool.get(&first_legacy_key), Some(Transaction::Stake(second))); + } + + #[test] + fn registration_and_exit_have_action_scoped_pending_slots() { + for action in [STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT] { + let mut first = pending_commission_update(3, 0, 0); + let mut competing = pending_commission_update(3, 0, 1); + for transaction in [&mut first, &mut competing] { + let terms = transaction.body.output.authorization_terms.as_mut().unwrap(); + terms.action = action; + if action == STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER { + terms.nonce = 0; + transaction.body.output.value = 10_000_000_000_000; + } + } + let first = Transaction::Stake(first); + let competing = Transaction::Stake(competing); + let mut pool = TransactionsPool::default(); + let reservation = pool.reserve_pending_delegated_action_for_protocol( + &first, ProtocolVersion::V2_1, + ).unwrap().unwrap(); + assert!(matches!( + pool.reserve_pending_delegated_action_for_protocol(&competing, ProtocolVersion::V2_1), + Err(TransactionError::PendingStakePolicyActionConflict { action: found, .. }) if found == action, + )); + assert!(pool.insert_for_protocol(first.clone(), 1, ProtocolVersion::V2_1).is_empty()); + assert_eq!(pool.insert_for_protocol(competing.clone(), 1, ProtocolVersion::V2_1), vec![competing]); + assert!(pool.release_pending_delegated_action(reservation)); + assert_eq!(pool.st_len(), 1); + } + + let mut first = pending_commission_update(0, 0, 0); + first.body.output.authorization_terms.as_mut().unwrap().action = STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER; + let mut other = first.clone(); + let other_withdrawer = PublicKeyHash::from_bytes(&[9; 20]).unwrap(); + other.body.output.key.withdrawer = other_withdrawer; + other.body.output.authorization_terms.as_mut().unwrap().withdrawer = other_withdrawer; + assert_eq!(commission_update_action_key(&first), commission_update_action_key(&other)); + first.body.output.authorization_terms.as_mut().unwrap().action = STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT; + other.body.output.authorization_terms.as_mut().unwrap().action = STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT; + assert_ne!(commission_update_action_key(&first), commission_update_action_key(&other)); + } + + #[test] + fn ordinary_delegations_do_not_consume_commission_action_slots() { + let mut first = pending_commission_update(1, 4, 0); + let mut second = pending_commission_update(1, 4, 1); + for transaction in [&mut first, &mut second] { + let terms = transaction + .body + .output + .authorization_terms + .as_mut() + .unwrap(); + terms.action = STAKE_AUTHORIZATION_ACTION_DELEGATE; + terms.nonce = 0; + transaction.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + } + + let mut pool = TransactionsPool::default(); + assert!( + pool.insert_for_protocol( + Transaction::Stake(first), + 1, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert!( + pool.insert_for_protocol( + Transaction::Stake(second), + 1, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert_eq!(pool.st_len(), 2); + assert!(pool.commission_update_action_index.is_empty()); + assert!(pool.pending_commission_update_actions.is_empty()); + } + + #[test] + fn transactions_pool_allows_only_one_pending_unstake_per_entry_nonce() { + let first = pending_unstake(7, 1); + let competing = pending_unstake(7, 2); + assert_ne!(first.hash(), competing.hash()); + + let mut pool = TransactionsPool::default(); + assert!( + pool.insert_for_protocol( + Transaction::Unstake(first.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + let admitted_weight = pool.total_ut_weight; + + // Exact re-insertion is idempotent at the pool boundary. + assert!( + pool.insert_for_protocol( + Transaction::Unstake(first.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert_eq!(pool.ut_len(), 1); + assert_eq!(pool.total_ut_weight, admitted_weight); + assert!(matches!( + pool.contains_for_protocol( + &Transaction::Unstake(competing.clone()), + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingUnstakeConflict { nonce: 7, .. }) + )); + assert_eq!( + pool.insert_for_protocol( + Transaction::Unstake(competing.clone()), + 0, + ProtocolVersion::V2_1, + ), + vec![Transaction::Unstake(competing.clone())] + ); + + // Consolidating a peer's competing choice expires the local body and its action index. + assert_eq!(pool.ut_remove(&competing), Some(first)); + assert!(pool.unstake_action_index.is_empty()); + assert_eq!(pool.total_ut_weight, 0); } #[test] - fn transactions_pool_malleability_dr() { - let input = Input::default(); - let mut dr_1 = DRTransaction::new( - DRTransactionBody::new(vec![input], DataRequestOutput::default(), vec![]), - vec![KeyedSignature::default()], + fn transactions_pool_allows_only_one_pending_commission_update_per_policy_nonce() { + let first = pending_commission_update(3, 4, 0); + let competing = pending_commission_update(3, 5, 1); + assert_ne!( + first.versioned_hash(ProtocolVersion::V2_1), + competing.versioned_hash(ProtocolVersion::V2_1) ); - // Add dummy signature, but pretend it is valid - match &mut dr_1.signatures[0].signature { - Signature::Secp256k1(sig) => sig.der = vec![0; 32], - } - - let mut dr_2 = dr_1.clone(); - // Flip one bit in the signature, but pretend that the signature is still valid (malleability). - match &mut dr_2.signatures[0].signature { - Signature::Secp256k1(secp_sig) => { - // Flip 1 bit - secp_sig.der[10] ^= 0x01; - } - } - let dr1 = Transaction::DataRequest(dr_1.clone()); - let dr2 = Transaction::DataRequest(dr_2.clone()); - assert_eq!(dr1.hash(), dr2.hash()); - assert_ne!(dr1, dr2); + let mut pool = TransactionsPool::default(); + assert!( + pool.insert_for_protocol( + Transaction::Stake(first.clone()), + 1, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + assert!(matches!( + pool.contains_for_protocol( + &Transaction::Stake(competing.clone()), + ProtocolVersion::V2_1, + ), + Err(TransactionError::PendingCommissionUpdateConflict { nonce: 3, .. }) + )); + assert_eq!( + pool.insert_for_protocol( + Transaction::Stake(competing.clone()), + 2, + ProtocolVersion::V2_1, + ), + vec![Transaction::Stake(competing.clone())] + ); - let mut transactions_pool = TransactionsPool::default(); - transactions_pool.insert(dr1.clone(), 1); - transactions_pool.insert(dr2.clone(), 1); - // Removing dr_1 actually returns dr_2, because they have the same hash, but this is fine - // because they both have a valid signature - let t = transactions_pool.dr_remove(&dr_1).unwrap(); - assert_eq!(Transaction::DataRequest(t), dr2); - assert!(!transactions_pool.contains(&dr1).unwrap()); - assert!(!transactions_pool.contains(&dr2).unwrap()); + // A peer can choose a different valid body for the nonce. Consolidation removes the + // local choice and its own (different) UTXO-conflict reference. + assert_eq!( + pool.st_remove_for_protocol(&competing, ProtocolVersion::V2_1), + Some(first) + ); + assert!(pool.commission_update_action_index.is_empty()); + assert!(pool.output_pointer_map.is_empty()); + assert_eq!(pool.total_st_weight, 0); } #[test] - fn transactions_pool_malleability_co() { - let c1 = Hash::SHA256([1; 32]); - let mut t1 = Transaction::Commit(CommitTransaction { - body: CommitTransactionBody::without_collateral( - Default::default(), - c1, - Default::default(), + fn delegated_action_rbf_is_disabled_and_first_seen_fee_is_retained() { + let first_update = pending_commission_update(3, 4, 0); + let competing_update = pending_commission_update(3, 5, 1); + let first_update_key = first_update.versioned_hash(ProtocolVersion::V2_1); + let competing_update_key = competing_update.versioned_hash(ProtocolVersion::V2_1); + let mut stake_pool = TransactionsPool::default(); + + assert!(stake_pool + .insert_for_protocol( + Transaction::Stake(first_update.clone()), + 1, + ProtocolVersion::V2_1, + ) + .is_empty()); + let first_update_priority = stake_pool.st_transactions[&first_update_key].0; + assert_eq!( + stake_pool.insert_for_protocol( + Transaction::Stake(competing_update.clone()), + u64::MAX, + ProtocolVersion::V2_1, ), - signatures: vec![KeyedSignature::default()], - }); - // Add dummy signature, but pretend it is valid - match &mut t1 { - Transaction::Commit(co1) => match &mut co1.signatures[0].signature { - Signature::Secp256k1(sig) => sig.der = vec![0; 32], - }, - _ => unreachable!(), - } - - let mut t2 = t1.clone(); - // Flip one bit in the signature, but pretend that the signature is still valid (malleability). - match &mut t2 { - Transaction::Commit(co2) => { - match &mut co2.signatures[0].signature { - Signature::Secp256k1(secp_sig) => { - // Flip 1 bit - secp_sig.der[10] ^= 0x01; - } - } - } - _ => unreachable!(), - } + vec![Transaction::Stake(competing_update.clone())] + ); + // An exact retry is idempotent too: a higher fee does not silently mutate the retained + // priority without a relay-wide RBF contract. + assert!(stake_pool + .insert_for_protocol( + Transaction::Stake(first_update.clone()), + u64::MAX, + ProtocolVersion::V2_1, + ) + .is_empty()); + assert_eq!( + stake_pool.st_transactions[&first_update_key].0, + first_update_priority + ); + assert_eq!( + stake_pool.get(&first_update_key), + Some(Transaction::Stake(first_update)) + ); + assert_eq!(stake_pool.get(&competing_update_key), None); + + let first_unstake = pending_unstake(7, 1); + let competing_unstake = pending_unstake(7, 2); + let first_unstake_key = first_unstake.hash(); + let competing_unstake_key = competing_unstake.hash(); + let mut unstake_pool = TransactionsPool::default(); + assert!(unstake_pool + .insert_for_protocol( + Transaction::Unstake(first_unstake.clone()), + 1, + ProtocolVersion::V2_1, + ) + .is_empty()); + let first_unstake_priority = unstake_pool.ut_transactions[&first_unstake_key].0; + assert_eq!( + unstake_pool.insert_for_protocol( + Transaction::Unstake(competing_unstake.clone()), + u64::MAX, + ProtocolVersion::V2_1, + ), + vec![Transaction::Unstake(competing_unstake.clone())] + ); + assert!(unstake_pool + .insert_for_protocol( + Transaction::Unstake(first_unstake.clone()), + u64::MAX, + ProtocolVersion::V2_1, + ) + .is_empty()); + assert_eq!( + unstake_pool.ut_transactions[&first_unstake_key].0, + first_unstake_priority + ); + assert_eq!( + unstake_pool.get(&first_unstake_key), + Some(Transaction::Unstake(first_unstake)) + ); + assert_eq!(unstake_pool.get(&competing_unstake_key), None); + } - let mut transactions_pool = TransactionsPool::default(); - transactions_pool.insert(t1, 0); - transactions_pool.insert(t2.clone(), 0); - let mut expected = TransactionsPool::default(); - expected.insert(t2, 0); - assert_eq!(transactions_pool.co_transactions, expected.co_transactions); + #[test] + fn transactions_pool_pressure_compares_delegated_and_ordinary_priorities() { + let [value_transfer, data_request, stake, unstake] = + four_class_pressure_transactions(); + let value_transfer_weight = pressure_transaction_weight(&value_transfer); + let data_request_weight = pressure_transaction_weight(&data_request); + let stake_weight = pressure_transaction_weight(&stake); + let unstake_weight = pressure_transaction_weight(&unstake); + let mut pool = TransactionsPool::default(); + + // All four indexes use fee/weight. The ordinary ratio still nominates only one of VT/DR, + // but a cheaper delegated entry must compete with and beat that ordinary candidate. + assert!(pool + .insert_for_protocol( + value_transfer.clone(), + value_transfer_weight.saturating_mul(40), + ProtocolVersion::V2_1, + ) + .is_empty()); + assert!(pool + .insert_for_protocol( + data_request.clone(), + data_request_weight.saturating_mul(30), + ProtocolVersion::V2_1, + ) + .is_empty()); + assert!(pool + .insert_for_protocol( + stake.clone(), + stake_weight, + ProtocolVersion::V2_1, + ) + .is_empty()); + assert!(pool + .insert_for_protocol( + unstake.clone(), + unstake_weight.saturating_mul(20), + ProtocolVersion::V2_1, + ) + .is_empty()); + + let total_weight = pool.total_transactions_weight(); + let removed = pool.set_total_weight_limit(total_weight - stake_weight, 1.0); + assert_eq!(removed, vec![stake.clone()]); + assert_eq!(pool.total_transactions_weight(), total_weight - stake_weight); + assert!(!pool + .contains_for_protocol(&stake, ProtocolVersion::V2_1) + .unwrap()); + assert!(pool + .contains_for_protocol(&unstake, ProtocolVersion::V2_1) + .unwrap()); + assert!(pool.commission_update_action_index.is_empty()); + assert!(pool.sorted_st_index.is_empty()); } #[test] - fn transactions_pool_malleability_re() { - let r1 = vec![1]; - let mut t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(Default::default(), r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); - // Add dummy signature, but pretend it is valid - match &mut t1 { - Transaction::Reveal(re1) => match &mut re1.signatures[0].signature { - Signature::Secp256k1(sig) => sig.der = vec![0; 32], - }, - _ => unreachable!(), + fn transactions_pool_pressure_ties_are_deterministic_and_retain_exit_last() { + let [value_transfer, data_request, stake, unstake] = + four_class_pressure_transactions(); + let value_transfer_weight = pressure_transaction_weight(&value_transfer); + let data_request_weight = pressure_transaction_weight(&data_request); + let stake_weight = pressure_transaction_weight(&stake); + let unstake_weight = pressure_transaction_weight(&unstake); + let mut pool = TransactionsPool::default(); + + for (transaction, weight) in [ + (value_transfer.clone(), value_transfer_weight), + (data_request.clone(), data_request_weight), + (stake.clone(), stake_weight), + (unstake.clone(), unstake_weight), + ] { + assert!(pool + .insert_for_protocol( + transaction, + weight.saturating_mul(7), + ProtocolVersion::V2_1, + ) + .is_empty()); } - let mut t2 = t1.clone(); - // Flip one bit in the signature, but pretend that the signature is still valid (malleability). - match &mut t2 { - Transaction::Reveal(re2) => { - match &mut re2.signatures[0].signature { - Signature::Secp256k1(secp_sig) => { - // Flip 1 bit - secp_sig.der[10] ^= 0x01; - } - } - } - _ => unreachable!(), - } + // At equal priority the existing VT/DR ratio chooses the first ordinary candidate. The + // remaining ordinary candidate is next, followed by Stake and finally Unstake. This + // preserves the ordinary occupancy rule and deterministically favors retaining an exit. + let (first_ordinary, second_ordinary) = + if value_transfer_weight >= data_request_weight { + (value_transfer, data_request) + } else { + (data_request, value_transfer) + }; + assert_eq!( + pool.set_total_weight_limit(0, 1.0), + vec![first_ordinary, second_ordinary, stake, unstake] + ); + assert_eq!(pool.total_transactions_weight(), 0); + assert!(pool.output_pointer_map.is_empty()); + assert!(pool.commission_update_action_index.is_empty()); + assert!(pool.unstake_action_index.is_empty()); + } - let mut transactions_pool = TransactionsPool::default(); - transactions_pool.insert(t1, 0); - transactions_pool.insert(t2.clone(), 0); - let mut expected = TransactionsPool::default(); - expected.insert(t2, 0); - assert_eq!(transactions_pool.re_transactions, expected.re_transactions); + #[test] + fn transactions_pool_pressure_drops_stale_sorted_entries_without_panicking() { + let mut pool = TransactionsPool::default(); + let stale_hash = Hash::SHA256([19; 32]); + pool.sorted_st_index + .insert((OrderedFloat(0.0), stale_hash)); + pool.total_st_weight = 1; + + // The aggregate counter is deliberately inconsistent with the authoritative map. Pressure + // cleanup can discard the stale priority tuple but must neither fabricate a transaction + // removal nor decrement weight it cannot attribute to an admitted body. + assert!(pool.set_total_weight_limit(0, 1.0).is_empty()); + assert!(pool.sorted_st_index.is_empty()); + assert_eq!(pool.total_st_weight, 1); } #[test] @@ -6147,14 +10456,9 @@ mod tests { fn transactions_pool_contains_reveal_same_pkh() { let r1 = vec![1]; let r2 = vec![2]; - let t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(Default::default(), r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); - let t2 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(Default::default(), r2, Default::default()), - signatures: vec![KeyedSignature::default()], - }); + let pkh = pool_keyed_signature(1).public_key.pkh(); + let t1 = Transaction::Reveal(pool_reveal(Hash::default(), r1, 1)); + let t2 = Transaction::Reveal(pool_reveal(Hash::default(), r2, 1)); let mut transactions_pool = TransactionsPool::default(); assert_eq!(transactions_pool.contains(&t1), Ok(false)); transactions_pool.insert(t1.clone(), 0); @@ -6162,7 +10466,7 @@ mod tests { assert_eq!( transactions_pool.contains(&t2), Err(TransactionError::DuplicatedReveal { - pkh: Default::default(), + pkh, dr_pointer: Hash::default(), }) ); @@ -6191,25 +10495,27 @@ mod tests { signatures: vec![KeyedSignature::default()], }); let mut transactions_pool = TransactionsPool::default(); - transactions_pool.insert(t1, 0); - transactions_pool.insert(t2.clone(), 0); + let t1_hash = t1.hash(); + assert!(transactions_pool + .insert_for_protocol(t1.clone(), 0, ProtocolVersion::V2_0) + .is_empty()); + let removed = + transactions_pool.insert_for_protocol(t2.clone(), 0, ProtocolVersion::V2_0); + assert_eq!(removed, vec![t1]); let mut expected = TransactionsPool::default(); - expected.insert(t2, 0); + expected.insert_for_protocol(t2.clone(), 0, ProtocolVersion::V2_0); assert_eq!(transactions_pool.co_transactions, expected.co_transactions); + assert_eq!(transactions_pool.co_hash_index, expected.co_hash_index); + assert!(transactions_pool.get(&t1_hash).is_none()); + assert_eq!(transactions_pool.get(&t2.hash()), Some(t2)); } #[test] fn transactions_pool_insert_reveal_overwrites() { let r1 = vec![1]; let r2 = vec![2]; - let t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(Default::default(), r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); - let t2 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(Default::default(), r2, Default::default()), - signatures: vec![KeyedSignature::default()], - }); + let t1 = Transaction::Reveal(pool_reveal(Hash::default(), r1, 1)); + let t2 = Transaction::Reveal(pool_reveal(Hash::default(), r2, 1)); let mut transactions_pool = TransactionsPool::default(); transactions_pool.insert(t1, 0); @@ -6248,12 +10554,211 @@ mod tests { transactions_pool.insert(t1, 0); assert_eq!(transactions_pool.co_transactions.len(), 1); assert_eq!(transactions_pool.co_hash_index.len(), 1); + let (selected_once, selected_fees, selected_pointers) = + transactions_pool.select_commits(&dr_pool); + let selected_twice = transactions_pool.select_commits(&dr_pool); + assert_eq!(selected_once.len(), 1); + assert_eq!(selected_fees, 0); + assert_eq!(selected_pointers, vec![dr_pointer]); + assert_eq!(selected_twice.0, selected_once); + assert_eq!(transactions_pool.co_transactions.len(), 1); + assert_eq!(transactions_pool.co_hash_index.len(), 1); let (commits_vec, _commit_fees, _) = transactions_pool.remove_commits(&dr_pool); assert_eq!(commits_vec.len(), 1); assert_eq!(transactions_pool.co_transactions, HashMap::new()); assert_eq!(transactions_pool.co_hash_index, HashMap::new()); } + #[test] + fn transactions_pool_commit_candidates_are_complete_sorted_and_non_mutating() { + let dr_output = DataRequestOutput { + witnesses: 2, + ..Default::default() + }; + let dr_transaction = DRTransaction::new( + DRTransactionBody::new(vec![], dr_output, vec![]), + vec![KeyedSignature::default()], + ); + let dr_pointer = dr_transaction.hash(); + let mut dr_pool = DataRequestPool::default(); + dr_pool + .add_data_request(0, dr_transaction, None) + .unwrap(); + + let commits = (1u8..=5) + .map(|marker| pool_commit(dr_pointer, Hash::SHA256([marker; 32]), marker)) + .collect::>(); + + let mut transactions_pool = TransactionsPool::default(); + transactions_pool.set_wit21_commit_candidates_per_dr_limit(5 * COMMIT_WEIGHT); + for index in [4usize, 0, 3, 1, 2] { + transactions_pool.insert(Transaction::Commit(commits[index].clone()), 0); + } + + let mut expected_hashes = commits.iter().map(|commit| commit.hash()).collect::>(); + expected_hashes.sort_unstable(); + let selected_once = transactions_pool.select_commit_candidates(&dr_pool); + let selected_twice = transactions_pool.select_commit_candidates(&dr_pool); + + assert_eq!(selected_once, selected_twice); + assert_eq!(selected_once.len(), 1); + assert_eq!(selected_once[0].0, dr_pointer); + assert_eq!(selected_once[0].1, 2); + assert_eq!(selected_once[0].2.len(), commits.len()); + assert_eq!( + selected_once[0] + .2 + .iter() + .map(|commit| commit.hash()) + .collect::>(), + expected_hashes + ); + assert_eq!(transactions_pool.co_transactions.len(), 1); + assert_eq!(transactions_pool.co_hash_index.len(), commits.len()); + + // Selection independently observes a later lowered cap without mutating retained indexes. + transactions_pool.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + let lowered = transactions_pool.select_commit_candidates(&dr_pool); + assert_eq!(lowered.len(), 1); + assert_eq!(lowered[0].2.len(), 2); + assert_eq!( + lowered[0] + .2 + .iter() + .map(|commit| commit.hash()) + .collect::>(), + expected_hashes[..2].to_vec(), + ); + assert_eq!(transactions_pool.co_transactions.len(), 1); + assert_eq!(transactions_pool.co_hash_index.len(), commits.len()); + + transactions_pool.set_wit21_commit_candidates_per_dr_limit(COMMIT_WEIGHT); + assert!(transactions_pool.select_commit_candidates(&dr_pool).is_empty()); + assert_eq!(transactions_pool.co_hash_index.len(), commits.len()); + } + + #[test] + fn transactions_pool_wit21_commit_bound_retains_lowest_hashes_only() { + let dr_pointer = Hash::SHA256([0x55; 32]); + let commits = (1u8..=5) + .map(|marker| pool_commit(dr_pointer, Hash::SHA256([marker; 32]), marker)) + .collect::>(); + let mut all_hashes = commits.iter().map(|commit| commit.hash()).collect::>(); + all_hashes.sort_unstable(); + let mut expected_hashes = all_hashes.clone(); + expected_hashes.truncate(2); + let expected_removed_hashes = all_hashes.into_iter().skip(2).collect::>(); + + let retained_hashes = |pool: &TransactionsPool| { + let mut hashes = pool + .co_transactions + .get(&dr_pointer) + .into_iter() + .flat_map(|commits| commits.values()) + .copied() + .collect::>(); + hashes.sort_unstable(); + hashes + }; + + let mut forward = TransactionsPool::default(); + forward.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + let mut removed_hashes = vec![]; + for index in [4usize, 0, 3, 1, 2] { + removed_hashes.extend( + forward + .insert_for_protocol( + Transaction::Commit(commits[index].clone()), + 0, + ProtocolVersion::V2_1, + ) + .into_iter() + .map(|transaction| transaction.hash()), + ); + } + removed_hashes.sort_unstable(); + assert_eq!(retained_hashes(&forward), expected_hashes); + assert_eq!(removed_hashes, expected_removed_hashes); + assert_eq!(forward.co_hash_index.len(), 2); + assert!(expected_hashes + .iter() + .all(|hash| forward.co_hash_index.contains_key(hash))); + + let mut reverse = TransactionsPool::default(); + reverse.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + for index in [2usize, 1, 3, 0, 4] { + reverse.insert_for_protocol( + Transaction::Commit(commits[index].clone()), + 0, + ProtocolVersion::V2_1, + ); + } + assert_eq!(retained_hashes(&reverse), expected_hashes); + assert_eq!(reverse.co_hash_index.len(), 2); + + // Insertion reports an incoming high-hash candidate as the exact removed transaction. The + // actor uses this existing contract to reject rather than relay a Wit/2.1 Commit that lost + // the cap race and is not addressable in the pool. + let mut ranked_commits = commits.clone(); + ranked_commits.sort_unstable_by_key(|commit| commit.hash()); + let mut self_eviction = TransactionsPool::default(); + self_eviction.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + for commit in &ranked_commits[..2] { + assert!( + self_eviction + .insert_for_protocol( + Transaction::Commit(commit.clone()), + 0, + ProtocolVersion::V2_1, + ) + .is_empty() + ); + } + let removed = self_eviction.insert_for_protocol( + Transaction::Commit(ranked_commits[4].clone()), + 0, + ProtocolVersion::V2_1, + ); + assert_eq!(removed, vec![Transaction::Commit(ranked_commits[4].clone())]); + assert_eq!(retained_hashes(&self_eviction), expected_hashes); + + // The dedicated cap is post-activation local policy. A pre-Wit/2.1 pool retains the + // historical unbounded Commit insertion behavior even when the node configured the cap. + let mut legacy = TransactionsPool::default(); + legacy.set_wit21_commit_candidates_per_dr_limit(2 * COMMIT_WEIGHT); + for commit in commits { + legacy.insert_for_protocol(Transaction::Commit(commit), 0, ProtocolVersion::V2_0); + } + assert_eq!(legacy.co_transactions[&dr_pointer].len(), 5); + assert_eq!(legacy.co_hash_index.len(), 5); + } + + #[test] + fn transactions_pool_wit21_commit_bound_cannot_undercut_request_witnesses() { + for witnesses in [1u16, 2, 126, u16::MAX] { + let request = DRTransaction::new( + DRTransactionBody::new( + vec![], + DataRequestOutput { + witnesses, + ..Default::default() + }, + vec![], + ), + vec![], + ); + let mut transactions_pool = TransactionsPool::default(); + + transactions_pool + .set_wit21_commit_candidates_per_dr_limit(request.weight()); + + assert!( + transactions_pool.wit21_commit_candidates_per_dr_limit + >= usize::from(witnesses) + ); + } + } + #[test] fn transactions_pool_return_only_with_different_inputs() { let public_key = PublicKey::default(); @@ -6430,10 +10935,7 @@ mod tests { dr_pool.add_data_request(0, drt, None).unwrap(); let r1 = vec![1]; - let t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(dr_pointer, r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); + let t1 = Transaction::Reveal(pool_reveal(dr_pointer, r1, 1)); let mut transactions_pool = TransactionsPool::default(); transactions_pool.insert(t1, 0); assert_eq!(transactions_pool.re_transactions.len(), 1); @@ -6467,10 +10969,7 @@ mod tests { dr_pool.add_data_request(0, drt, None).unwrap(); let r1 = vec![1]; - let t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(dr_pointer, r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); + let t1 = Transaction::Reveal(pool_reveal(dr_pointer, r1, 1)); let mut transactions_pool = TransactionsPool::default(); transactions_pool.insert(t1, 0); assert_eq!(transactions_pool.re_transactions.len(), 1); @@ -6503,10 +11002,7 @@ mod tests { dr_pool.add_data_request(0, drt, None).unwrap(); let r1 = vec![1]; - let t1 = Transaction::Reveal(RevealTransaction { - body: RevealTransactionBody::new(dr_pointer, r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }); + let t1 = Transaction::Reveal(pool_reveal(dr_pointer, r1, 1)); let mut transactions_pool = TransactionsPool::default(); transactions_pool.insert(t1, 0); assert_eq!(transactions_pool.re_transactions.len(), 1); @@ -6539,10 +11035,7 @@ mod tests { dr_pool.add_data_request(0, drt, None).unwrap(); let r1 = vec![1]; - let re_tx = RevealTransaction { - body: RevealTransactionBody::new(dr_pointer, r1, Default::default()), - signatures: vec![KeyedSignature::default()], - }; + let re_tx = pool_reveal(dr_pointer, r1, 1); let t1 = Transaction::Reveal(re_tx.clone()); let mut transactions_pool = TransactionsPool::default(); transactions_pool.insert(t1, 0); @@ -6565,11 +11058,7 @@ mod tests { // Now, a different reveal is inserted let r2 = vec![1]; - let re_pkh2 = PublicKeyHash::from_bytes(&[0x01; 20]).unwrap(); - let re_tx2 = RevealTransaction { - body: RevealTransactionBody::new(dr_pointer, r2, re_pkh2), - signatures: vec![KeyedSignature::default()], - }; + let re_tx2 = pool_reveal(dr_pointer, r2, 2); let t2 = Transaction::Reveal(re_tx2.clone()); transactions_pool.insert(t2, 0); assert_eq!(transactions_pool.re_transactions.len(), 1); @@ -7623,14 +12112,9 @@ mod tests { fn remove_unconfirmed_transactions_clear_commits_and_reveals() { let mut tx_pool = TransactionsPool::default(); let dr_pointer = Hash::with_first_u32(0x03ff_ffff); - let sk: [u8; 32] = [0xcd; 32]; - - // Create commit - let mut c = CommitTransaction::default(); - c.body.dr_pointer = dr_pointer; - let ks = sign_tx(sk, &c.body); - let pkh = ks.public_key.pkh(); - c.signatures = vec![ks]; + // Create a structurally valid Commit whose signer matches the VRF proof identity. + let c = pool_commit(dr_pointer, Hash::default(), 0xcd); + let pkh = c.signatures[0].public_key.pkh(); // Insert commit tx_pool.insert(Transaction::Commit(c.clone()), 1); @@ -7641,9 +12125,8 @@ mod tests { ); // Create reveal - let mut r = RevealTransaction::default(); - r.body.dr_pointer = dr_pointer; - r.body.pkh = pkh; + let r = pool_reveal(dr_pointer, vec![], 0xcd); + assert_eq!(r.body.pkh, pkh); // Insert reveal tx_pool.insert(Transaction::Reveal(r.clone()), 1); @@ -7677,41 +12160,97 @@ mod tests { assert_eq!(state.stage, DataRequestStage::COMMIT); assert_eq!(state.info.current_commit_round, 0); // First commitment round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::COMMIT); assert_eq!(state.info.current_commit_round, 1); // Second commitment round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::COMMIT); assert_eq!(state.info.current_commit_round, 2); // Third commitment round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::COMMIT); assert_eq!(state.info.current_commit_round, 3); // Fourth and last commitment round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::COMMIT); assert_eq!(state.info.current_commit_round, 4); // We introduce at least 1 commit to force the stage to move to reveal let _ = state.add_commit(Default::default(), Default::default()); // First reveal round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::REVEAL); assert_eq!(state.info.current_reveal_round, 1); // Second reveal round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::REVEAL); assert_eq!(state.info.current_reveal_round, 2); // Third reveal round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::REVEAL); assert_eq!(state.info.current_reveal_round, 3); // Fourth reveal round - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); assert_eq!(state.stage, DataRequestStage::REVEAL); assert_eq!(state.info.current_reveal_round, 4); // Jumped to tally stage - state.update_stage(extra_rounds, false); + state.try_update_stage(extra_rounds, false).unwrap(); + assert_eq!(state.stage, DataRequestStage::TALLY); + } + + #[test] + fn checked_data_request_stage_rejects_unrepresentable_terminal_round_without_mutation() { + let mut commit_state = DataRequestState::default(); + commit_state.info.current_commit_round = u16::MAX; + let commit_before = commit_state.clone(); + + assert_eq!( + commit_state.try_update_stage(u16::MAX, false), + Err(DataRequestError::UnrepresentableExtraRounds { + extra_rounds: u16::MAX, + }) + ); + assert_eq!(commit_state, commit_before); + + let mut reveal_state = DataRequestState::default(); + reveal_state.stage = DataRequestStage::REVEAL; + reveal_state.info.current_stage = Some(DataRequestStage::REVEAL); + reveal_state.info.current_reveal_round = u16::MAX; + let reveal_before = reveal_state.clone(); + + assert_eq!( + reveal_state.try_update_stage(u16::MAX, false), + Err(DataRequestError::UnrepresentableExtraRounds { + extra_rounds: u16::MAX, + }) + ); + assert_eq!(reveal_state, reveal_before); + } + + #[test] + fn checked_data_request_stage_rejects_finished_active_tally_without_mutation() { + let mut state = DataRequestState::default(); + state.stage = DataRequestStage::TALLY; + state.info.current_stage = Some(DataRequestStage::TALLY); + state.info.tally = Some(TallyTransaction::default()); + let before = state.clone(); + + assert_eq!( + state.try_update_stage(0, false), + Err(DataRequestError::FinishedDataRequestStillActive) + ); + assert_eq!(state, before); + } + + #[test] + fn infallible_data_request_stage_fails_unrepresentable_round_closed() { + let mut state = DataRequestState::default(); + state.info.current_commit_round = u16::MAX; + + state.update_stage(u16::MAX, false); + assert_eq!(state.stage, DataRequestStage::TALLY); + assert_eq!(state.info.current_stage, Some(DataRequestStage::TALLY)); + assert_eq!(state.info.current_commit_round, u16::MAX); } } diff --git a/data_structures/src/data_request.rs b/data_structures/src/data_request.rs index 1f64314064..59add5a359 100644 --- a/data_structures/src/data_request.rs +++ b/data_structures/src/data_request.rs @@ -14,7 +14,7 @@ use crate::{ Hash, Hashable, PublicKeyHash, ValueTransferOutput, tapi::ActiveWips, }, error::{DataRequestError, TransactionError}, - get_environment, get_protocol_version_activation_epoch, + get_environment, get_protocol_info, proto::versioning::{ProtocolVersion, VersionedHashable}, radon_report::{RadonReport, Stage, TypeLike}, transaction::{CommitTransaction, DRTransaction, RevealTransaction, TallyTransaction}, @@ -94,20 +94,71 @@ impl DataRequestPool { validator_count: Option, epoch: Option, ) -> HashMap> { - let validator_count = validator_count.unwrap_or(crate::DEFAULT_VALIDATOR_COUNT_FOR_TESTS); + self.get_all_reveals_with_legacy_validator_count( + active_wips, + validator_count, + None, + epoch, + ) + } + + /// Get all the reveals, using the legacy validator population for pre-Wit/2.1 + /// data requests that remain active after delegated staking starts. + pub fn get_all_reveals_with_legacy_validator_count( + &self, + active_wips: &ActiveWips, + validator_count: Option, + legacy_validator_count: Option, + epoch: Option, + ) -> HashMap> { + let protocol_info = get_protocol_info(); + let containing_protocol = epoch.map_or(protocol_info.current_version, |epoch| { + protocol_info.all_versions.version_for_epoch(epoch) + }); + let wit2_activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); + self.get_all_reveals_for_protocol_with_legacy_validator_count( + active_wips, + validator_count, + legacy_validator_count, + containing_protocol, + wit2_activation_epoch, + ) + } + + /// Protocol-explicit tally-ready reveal enumeration for callers that already pinned the + /// containing block's schedule. This prevents candidate construction from classifying the + /// same block against two process-registry generations. + pub fn get_all_reveals_for_protocol_with_legacy_validator_count( + &self, + active_wips: &ActiveWips, + validator_count: Option, + legacy_validator_count: Option, + containing_protocol: ProtocolVersion, + wit2_activation_epoch: Epoch, + ) -> HashMap> { + let legacy_validator_count = legacy_validator_count.unwrap_or_else(|| { + validator_count.unwrap_or(crate::DEFAULT_VALIDATOR_COUNT_FOR_TESTS) + }); self.data_request_pool .iter() .filter_map(|(dr_pointer, dr_state)| { if let DataRequestStage::TALLY = dr_state.stage { - // Do not resolve data requests requiring too many witnesses using the normal - // tally creation path, but resolve them when building a block. - let wit2_activation_epoch = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - if dr_state.epoch >= wit2_activation_epoch - && data_request_has_too_many_witnesses( + // Preserve the deployed Wit/2.0 behavior, which had no valid non-same-block + // successor for a post-activation request while the population remained below + // its witness threshold. Wit/2.1 repairs that liveness hole by returning every + // TALLY request to the caller, which applies the shared causal classifier before + // deciding between an ordinary result and the TooManyWitnesses sentinel. + if containing_protocol < ProtocolVersion::V2_1 + // Preserve the exact V2.0 activation-epoch compatibility exception: only + // requests born strictly later were historically suppressed here. + && dr_state.epoch > wit2_activation_epoch + && data_request_has_too_many_witnesses_for_protocol( &dr_state.data_request, - validator_count, - epoch, + legacy_validator_count, + containing_protocol, ) { None @@ -154,7 +205,28 @@ impl DataRequestPool { data_request: DRTransaction, block_hash: Option, ) -> Result<(), anyhow::Error> { - let dr_hash = data_request.versioned_hash(ProtocolVersion::from_epoch(epoch)); + self.add_data_request_for_protocol( + epoch, + data_request, + block_hash, + get_protocol_info() + .all_versions + .version_for_epoch(epoch), + ) + } + + /// Add a data request using an already selected containing-block protocol. + /// + /// Consensus replay uses this variant so the request pointer cannot be re-hashed under a + /// different process-global schedule generation while the enclosing block is being projected. + pub fn add_data_request_for_protocol( + &mut self, + epoch: Epoch, + data_request: DRTransaction, + block_hash: Option, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let dr_hash = data_request.versioned_hash(protocol_version); if data_request.signatures.is_empty() { return Err(TransactionError::SignatureNotFound.into()); } @@ -179,7 +251,26 @@ impl DataRequestPool { commit: CommitTransaction, block_hash: &Hash, ) -> Result<(), anyhow::Error> { - let tx_hash = commit.versioned_hash(ProtocolVersion::from_epoch(epoch)); + self.add_commit_for_protocol( + epoch, + pkh, + commit, + block_hash, + get_protocol_info() + .all_versions + .version_for_epoch(epoch), + ) + } + + fn add_commit_for_protocol( + &mut self, + epoch: Epoch, + pkh: PublicKeyHash, + commit: CommitTransaction, + block_hash: &Hash, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let tx_hash = commit.versioned_hash(protocol_version); log::debug!("Adding commit tx {tx_hash}: {commit:?}"); // For a commit output, we need to get the corresponding data request input let dr_pointer = commit.body.dr_pointer; @@ -206,7 +297,26 @@ impl DataRequestPool { reveal: RevealTransaction, block_hash: &Hash, ) -> Result<(), anyhow::Error> { - let tx_hash = reveal.versioned_hash(ProtocolVersion::from_epoch(epoch)); + self.add_reveal_for_protocol( + epoch, + pkh, + reveal, + block_hash, + get_protocol_info() + .all_versions + .version_for_epoch(epoch), + ) + } + + fn add_reveal_for_protocol( + &mut self, + epoch: Epoch, + pkh: PublicKeyHash, + reveal: RevealTransaction, + block_hash: &Hash, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let tx_hash = reveal.versioned_hash(protocol_version); // For a commit output, we need to get the corresponding data request input let dr_pointer = reveal.body.dr_pointer; // The data request must be from a previous block, and must not be timelocked. @@ -230,7 +340,12 @@ impl DataRequestPool { tally: TallyTransaction, block_hash: &Hash, ) -> Result<(), anyhow::Error> { - let dr_info = Self::resolve_data_request(&mut self.data_request_pool, tally, block_hash)?; + let dr_info = Self::resolve_data_request( + &mut self.data_request_pool, + tally, + block_hash, + None, + )?; // Since this method does not have access to the storage, we save the // "to be stored" inside a vector and provide another method to store them @@ -245,21 +360,36 @@ impl DataRequestPool { data_request_pool: &mut HashMap, tally_tx: TallyTransaction, block_hash: &Hash, + protocol_version: Option, ) -> Result { let dr_pointer = tally_tx.dr_pointer; - - let dr_state: Result = - data_request_pool.remove(&dr_pointer).ok_or_else(|| { + let tx_hash = protocol_version + .map_or_else(|| tally_tx.hash(), |version| tally_tx.versioned_hash(version)); + // Validate the consuming transition against a clone before removing the live request. + // `DataRequestState::add_tally` takes ownership of its receiver, so removing first would + // lose an out-of-stage request whenever that method rejects the tally. Keeping the source + // entry in the map until the transition succeeds makes both the compatibility and + // protocol-pinned paths failure-atomic. + let dr_state = data_request_pool + .get(&dr_pointer) + .cloned() + .ok_or_else(|| { DataRequestError::AddTallyFail { block_hash: *block_hash, - tx_hash: tally_tx.hash(), + tx_hash, dr_pointer, } - .into() - }); - let dr_state = dr_state?; + })?; + let dr_info = dr_state.add_tally(tally_tx, block_hash)?; + data_request_pool.remove(&dr_pointer).ok_or_else(|| { + DataRequestError::AddTallyFail { + block_hash: *block_hash, + tx_hash, + dr_pointer, + } + })?; - dr_state.add_tally(tally_tx, block_hash) + Ok(dr_info) } /// Return the list of data requests in which this node has participated and are ready @@ -271,86 +401,218 @@ impl DataRequestPool { validator_count: Option, epoch: Option, ) -> Vec { + match self.try_update_data_request_stages(validator_count, epoch) { + Ok(reveals) => reveals, + Err(error) => self.fail_stage_progression_closed(error), + } + } + + /// Checked counterpart to [`Self::update_data_request_stages`]. + /// + /// This rejects an unrepresentable terminal round before mutating any request or secondary + /// pool index and propagates typed per-request state errors. Production callers should use this + /// entry point when malformed state can abort the enclosing operation. + pub fn try_update_data_request_stages( + &mut self, + validator_count: Option, + epoch: Option, + ) -> Result, DataRequestError> { + self.try_update_data_request_stages_with_legacy_validator_count( + validator_count, + None, + epoch, + ) + } + + /// Update active data request stages, using legacy validator counts for + /// pre-Wit/2.1 requests during the cross-fork period. + pub fn update_data_request_stages_with_legacy_validator_count( + &mut self, + validator_count: Option, + legacy_validator_count: Option, + epoch: Option, + ) -> Vec { + match self.try_update_data_request_stages_with_legacy_validator_count( + validator_count, + legacy_validator_count, + epoch, + ) { + Ok(reveals) => reveals, + Err(error) => self.fail_stage_progression_closed(error), + } + } + + /// Prove that this pool's configured terminal commit/reveal round can be represented. + /// + /// This is intentionally independent of the number of active requests: an empty pool with an + /// invalid configuration must not become accepted state merely because there is nothing to + /// advance yet. + pub fn validate_stage_round_configuration(&self) -> Result<(), DataRequestError> { + self.extra_rounds + .checked_add(1) + .map(|_| ()) + .ok_or(DataRequestError::UnrepresentableExtraRounds { + extra_rounds: self.extra_rounds, + }) + } + + /// Checked stage update using legacy validator counts for pre-Wit/2.1 requests during the + /// cross-fork period. + pub fn try_update_data_request_stages_with_legacy_validator_count( + &mut self, + validator_count: Option, + legacy_validator_count: Option, + epoch: Option, + ) -> Result, DataRequestError> { + let protocol_info = get_protocol_info(); + self.try_update_data_request_stages_with_legacy_validator_count_for_protocol( + validator_count, + legacy_validator_count, + epoch, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) + } + + /// Checked stage update using a request-protocol resolver pinned by the caller. + /// + /// Consensus block replay supplies one schedule snapshot for the whole transition so a + /// mutable process-global registry cannot change the witness-threshold branch halfway through + /// the pool scan. Structural and persisted-state preflight runs before any request or index is + /// advanced, so a rejected pass does not leave a partially progressed active pool. The + /// epoch-derived method above remains the compatibility entry point. + pub fn try_update_data_request_stages_with_legacy_validator_count_for_protocol( + &mut self, + validator_count: Option, + legacy_validator_count: Option, + epoch: Option, + request_protocol_for: F, + ) -> Result, DataRequestError> + where + F: Fn(Epoch) -> ProtocolVersion, + { + // Validate before borrowing or mutating any constituent map. Once this succeeds, + // `DataRequestState::try_update_stage` cannot encounter a round overflow for this pass. + self.validate_stage_round_configuration()?; + + // A completed request should have been removed by `add_tally`. Preflight this invariant so + // a malformed request later in the map cannot leave earlier requests or their secondary + // indexes partially advanced before the checked pass returns an error. + if self.data_request_pool.values().any(|dr_state| { + dr_state.stage == DataRequestStage::TALLY && dr_state.info.tally.is_some() + }) { + return Err(DataRequestError::FinishedDataRequestStillActive); + } + let validator_count = validator_count.unwrap_or(crate::DEFAULT_VALIDATOR_COUNT_FOR_TESTS); + let legacy_validator_count = legacy_validator_count.unwrap_or(validator_count); let waiting_for_reveal = &mut self.waiting_for_reveal; let data_requests_by_epoch = &mut self.data_requests_by_epoch; let extra_rounds = self.extra_rounds; + let mut reveals = Vec::new(); // Update the stage of the active data requests - self.data_request_pool - .iter_mut() - .filter_map(|(dr_pointer, dr_state)| { - // We can notify the user that a data request from "my_claims" is available - // for reveal. - let too_many_witnesses = data_request_has_too_many_witnesses( - &dr_state.data_request, - validator_count, - epoch, - ); - dr_state.update_stage(extra_rounds, too_many_witnesses); - match dr_state.stage { - DataRequestStage::REVEAL => { - // When a data request changes from commit stage to reveal stage, it should - // be removed from the "data_requests_by_epoch" map, which stores the data - // requests potentially available for commitment - if dr_state.info.current_reveal_round == 1 - && let Some(hs) = data_requests_by_epoch.get_mut(&dr_state.epoch) - { - let present = hs.remove(dr_pointer); - if hs.is_empty() { - data_requests_by_epoch.remove(&dr_state.epoch); - } - if !present { - log::error!( - "Data request {:?} was not present in the \ - data_requests_by_epoch map (epoch #{})", - dr_pointer, - dr_state.epoch - ); - } + for (dr_pointer, dr_state) in &mut self.data_request_pool { + // We can notify the user that a data request from "my_claims" is available + // for reveal. + // Resolve the request's protocol exactly once for this transition. Production + // callers supply a captured schedule, but keeping one local value also prevents a + // stateful/custom resolver from selecting different fork rules for the same request + // in the population and witness-threshold branches below. + let request_protocol = request_protocol_for(dr_state.epoch); + let too_many_witnesses = data_request_has_too_many_witnesses_for_protocol( + &dr_state.data_request, + if request_protocol < ProtocolVersion::V2_1 { + legacy_validator_count + } else { + validator_count + }, + request_protocol, + ); + dr_state.try_update_stage(extra_rounds, too_many_witnesses)?; + match dr_state.stage { + DataRequestStage::REVEAL => { + // When a data request changes from commit stage to reveal stage, it should + // be removed from the "data_requests_by_epoch" map, which stores the data + // requests potentially available for commitment + if dr_state.info.current_reveal_round == 1 + && let Some(hs) = data_requests_by_epoch.get_mut(&dr_state.epoch) + { + let present = hs.remove(dr_pointer); + if hs.is_empty() { + data_requests_by_epoch.remove(&dr_state.epoch); + } + if !present { + log::error!( + "Data request {:?} was not present in the \ + data_requests_by_epoch map (epoch #{})", + dr_pointer, + dr_state.epoch + ); } + } - if let Some(transaction) = waiting_for_reveal.get(dr_pointer) { - // We submitted a commit for this data request! - // But has it been included into the block? - let pkh = PublicKeyHash::from_public_key( - &transaction.signatures[0].public_key, + if let Some(transaction) = waiting_for_reveal.get(dr_pointer).cloned() { + // We submitted a commit for this data request! + // But has it been included into the block? + let [signature] = transaction.signatures.as_slice() else { + log::error!( + "Pending reveal for data request {dr_pointer:?} does not contain exactly one signature" + ); + waiting_for_reveal.remove(dr_pointer); + continue; + }; + let pkh = PublicKeyHash::from_public_key(&signature.public_key); + if dr_state.info.commits.contains_key(&pkh) + && !dr_state.info.reveals.contains_key(&pkh) + { + // We found our commit, return the reveal transaction to be sent + // until it would be included in a block. + reveals.push(transaction); + } else if !dr_state.info.commits.contains_key(&pkh) + && dr_state.info.current_reveal_round == 1 + { + log::info!( + "The sent commit transaction has not been \ + selected to be part of the data request {dr_pointer:?}" ); - if dr_state.info.commits.contains_key(&pkh) - && !dr_state.info.reveals.contains_key(&pkh) - { - // We found our commit, return the reveal transaction to be sent - // until it would be included in a block. - return Some(transaction.clone()); - } else if !dr_state.info.commits.contains_key(&pkh) - && dr_state.info.current_reveal_round == 1 - { - log::info!( - "The sent commit transaction has not been \ - selected to be part of the data request {dr_pointer:?}" - ); - } } } + } - DataRequestStage::TALLY => { - // When a data request changes from commit stage to tally stage - // (not enough commits to go into reveal stage) - // it should be removed from the "data_requests_by_epoch" map - if let Some(hs) = data_requests_by_epoch.get_mut(&dr_state.epoch) { - hs.remove(dr_pointer); - if hs.is_empty() { - data_requests_by_epoch.remove(&dr_state.epoch); - } + DataRequestStage::TALLY => { + // When a data request changes from commit stage to tally stage + // (not enough commits to go into reveal stage) + // it should be removed from the "data_requests_by_epoch" map + if let Some(hs) = data_requests_by_epoch.get_mut(&dr_state.epoch) { + hs.remove(dr_pointer); + if hs.is_empty() { + data_requests_by_epoch.remove(&dr_state.epoch); } - // Remove pending reveals in Tally stage - waiting_for_reveal.remove(dr_pointer); } - _ => {} + // Remove pending reveals in Tally stage + waiting_for_reveal.remove(dr_pointer); } + _ => {} + } + } - None - }) - .collect() + Ok(reveals) + } + + /// Preserve the historical infallible API without permitting an invalid round counter to + /// remain commit- or reveal-eligible. This path is reserved for compatibility callers; + /// production state transitions use the checked methods above and reject the enclosing work. + fn fail_stage_progression_closed( + &mut self, + error: DataRequestError, + ) -> Vec { + log::error!("Failing all active data request stages closed: {error}"); + for dr_state in self.data_request_pool.values_mut() { + dr_state.set_stage(DataRequestStage::TALLY); + } + self.data_requests_by_epoch.clear(); + self.waiting_for_reveal.clear(); + + Vec::new() } /// New commitments are added to their respective data requests, updating the stage to reveal @@ -360,10 +622,36 @@ impl DataRequestPool { epoch: Epoch, block_hash: &Hash, ) -> Result<(), anyhow::Error> { - let pkh = PublicKeyHash::from_public_key(&commit_transaction.signatures[0].public_key); + let signature = commit_transaction + .signatures + .first() + .ok_or(TransactionError::SignatureNotFound)?; + let pkh = PublicKeyHash::from_public_key(&signature.public_key); self.add_commit(epoch, pkh, commit_transaction.clone(), block_hash) } + /// Process a commit using an already selected containing-block protocol. + pub fn process_commit_for_protocol( + &mut self, + commit_transaction: &CommitTransaction, + epoch: Epoch, + block_hash: &Hash, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let signature = commit_transaction + .signatures + .first() + .ok_or(TransactionError::SignatureNotFound)?; + let pkh = PublicKeyHash::from_public_key(&signature.public_key); + self.add_commit_for_protocol( + epoch, + pkh, + commit_transaction.clone(), + block_hash, + protocol_version, + ) + } + /// New reveals are added to their respective data requests, updating the stage to tally pub fn process_reveal( &mut self, @@ -371,10 +659,36 @@ impl DataRequestPool { epoch: Epoch, block_hash: &Hash, ) -> Result<(), anyhow::Error> { - let pkh = PublicKeyHash::from_public_key(&reveal_transaction.signatures[0].public_key); + let signature = reveal_transaction + .signatures + .first() + .ok_or(TransactionError::SignatureNotFound)?; + let pkh = PublicKeyHash::from_public_key(&signature.public_key); self.add_reveal(epoch, pkh, reveal_transaction.clone(), block_hash) } + /// Process a reveal using an already selected containing-block protocol. + pub fn process_reveal_for_protocol( + &mut self, + reveal_transaction: &RevealTransaction, + epoch: Epoch, + block_hash: &Hash, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let signature = reveal_transaction + .signatures + .first() + .ok_or(TransactionError::SignatureNotFound)?; + let pkh = PublicKeyHash::from_public_key(&signature.public_key); + self.add_reveal_for_protocol( + epoch, + pkh, + reveal_transaction.clone(), + block_hash, + protocol_version, + ) + } + /// New data requests are inserted and wait for commitments /// The epoch is needed as the key to the available data requests map pub fn process_data_request( @@ -389,6 +703,24 @@ impl DataRequestPool { self.add_data_request(epoch, dr_transaction.clone(), block_hash) } + /// Process a data request using an already selected containing-block protocol. + pub fn process_data_request_for_protocol( + &mut self, + dr_transaction: &DRTransaction, + epoch: Epoch, + block_hash: Option, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + // A data request output should have a valid value transfer input, which is assumed to have + // been checked by the enclosing transaction validator. + self.add_data_request_for_protocol( + epoch, + dr_transaction.clone(), + block_hash, + protocol_version, + ) + } + /// New tallies are added to their respective data requests and finish them pub fn process_tally( &mut self, @@ -398,6 +730,28 @@ impl DataRequestPool { self.add_tally(tally_transaction.clone(), block_hash) } + /// Process a tally using an already selected containing-block protocol. + /// + /// The successful state transition is protocol-independent, but its structured missing-state + /// error carries the tally transaction identity. Consensus replay must derive that diagnostic + /// identity from the same pinned protocol as the enclosing block rather than consulting the + /// process-global hash compatibility wrapper. + pub fn process_tally_for_protocol( + &mut self, + tally_transaction: &TallyTransaction, + block_hash: &Hash, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + let dr_info = Self::resolve_data_request( + &mut self.data_request_pool, + tally_transaction.clone(), + block_hash, + Some(protocol_version), + )?; + self.to_be_stored.push(dr_info); + Ok(()) + } + /// Get the detailed state of a data request. pub fn data_request_state(&self, dr_pointer: &Hash) -> Option<&DataRequestState> { self.data_request_pool.get(dr_pointer) @@ -418,10 +772,35 @@ impl DataRequestPool { /// Return the sum of all the wits that is currently being used to resolve data requests pub fn locked_wits_by_requests(&self, collateral_minimum: u64) -> u64 { - let mut total = 0; + let protocol_info = get_protocol_info(); + self.locked_wits_by_requests_for_protocol( + collateral_minimum, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) + } + + /// Return locked request funds using an already selected request-protocol resolver. + /// + /// The compatibility method above is suitable for live diagnostics. Consensus projections + /// that need this accounting during a pinned replay can use this variant to avoid consulting a + /// mutable global schedule while iterating the pool. + pub fn locked_wits_by_requests_for_protocol( + &self, + collateral_minimum: u64, + request_protocol_for: F, + ) -> u64 + where + F: Fn(Epoch) -> ProtocolVersion, + { + let mut total: u64 = 0; for dr_state in self.data_request_pool.values() { let dr_collateral = dr_state.data_request.collateral; - let dr_collateral = if dr_collateral == 0 { + // The request's inclusion protocol controls zero-collateral semantics. A Wit/2.0 + // request keeps its serialized zero owner-only no-op even while it remains in flight + // after activation; native Wit/2.1 and pre-Wit/2.0 requests retain the minimum default. + let dr_collateral = if dr_collateral == 0 + && request_protocol_for(dr_state.epoch) != ProtocolVersion::V2_0 + { collateral_minimum } else { dr_collateral @@ -431,14 +810,23 @@ impl DataRequestPool { DataRequestStage::COMMIT => { // In commit stage, there are not collateralized wits yet, // but the requester has locked wits to pay fees and rewards - dr_state.data_request.checked_total_value().unwrap() + // Treat an invalid total conservatively so a malformed local state cannot + // under-report locked funds or panic the node's balance path. + dr_state + .data_request + .checked_total_value() + .unwrap_or(u64::MAX) } DataRequestStage::REVEAL | DataRequestStage::TALLY => { // In reveal and tally stage, there are some wits collateralized by the witnesses, // and there are some fees that were already paid - let dr_total_value = dr_state.data_request.checked_total_value().unwrap(); - let n_commits = u64::try_from(dr_state.info.commits.len()).unwrap(); - let n_reveals = u64::try_from(dr_state.info.reveals.len()).unwrap(); + // Treat invalid totals/count conversions conservatively for the same reason. + let dr_total_value = dr_state + .data_request + .checked_total_value() + .unwrap_or(u64::MAX); + let n_commits = u64::try_from(dr_state.info.commits.len()).unwrap_or(u64::MAX); + let n_reveals = u64::try_from(dr_state.info.reveals.len()).unwrap_or(u64::MAX); let collateralized_wits = n_commits.saturating_mul(dr_collateral); let commit_fees = @@ -453,7 +841,7 @@ impl DataRequestPool { } }; - total += locked_wits; + total = total.saturating_add(locked_wits); } total @@ -483,9 +871,53 @@ pub fn calculate_tally_change( let honests_count = honests_count as u64; let witnesses = u64::from(dr_output.witnesses); - dr_output.witness_reward * (witnesses - honests_count) - + dr_output.commit_and_reveal_fee * (witnesses - reveals_count) - + dr_output.commit_and_reveal_fee * (witnesses - commits_count) + // This is the release-arithmetic compatibility helper used by pre-Wit/2.1 requests. + dr_output + .witness_reward + .wrapping_mul(witnesses.wrapping_sub(honests_count)) + .wrapping_add( + dr_output + .commit_and_reveal_fee + .wrapping_mul(witnesses.wrapping_sub(reveals_count)), + ) + .wrapping_add( + dr_output + .commit_and_reveal_fee + .wrapping_mul(witnesses.wrapping_sub(commits_count)), + ) +} + +/// Strict tally-change arithmetic for native Wit/2.1 requests. +pub fn calculate_tally_change_checked( + commits_count: usize, + reveals_count: usize, + honests_count: usize, + dr_output: &DataRequestOutput, +) -> Option { + let commits_count = u64::try_from(commits_count).ok()?; + let reveals_count = u64::try_from(reveals_count).ok()?; + let honests_count = u64::try_from(honests_count).ok()?; + let witnesses = u64::from(dr_output.witnesses); + + let missing_honests = witnesses.checked_sub(honests_count)?; + let missing_reveals = witnesses.checked_sub(reveals_count)?; + let missing_commits = witnesses.checked_sub(commits_count)?; + + dr_output + .witness_reward + .checked_mul(missing_honests) + .and_then(|value| { + dr_output + .commit_and_reveal_fee + .checked_mul(missing_reveals) + .and_then(|reveal_fee| value.checked_add(reveal_fee)) + }) + .and_then(|value| { + dr_output + .commit_and_reveal_fee + .checked_mul(missing_commits) + .and_then(|commit_fee| value.checked_add(commit_fee)) + }) } pub fn calculate_witness_reward_before_second_hard_fork( @@ -499,20 +931,82 @@ pub fn calculate_witness_reward_before_second_hard_fork( reward: u64, collateral: u64, ) -> (u64, u64) { + calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + reward, + collateral, + ) + .expect("legacy pre-second-hard-fork witness reward has no honest recipients") +} + +/// Preserve release-wrapping pre-second-hard-fork reward arithmetic while representing the +/// release binary's zero-denominator trap as an error. +pub fn calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count: usize, + reveals_count: usize, + liars_count: usize, + errors_count: usize, + reward: u64, + collateral: u64, +) -> Option<(u64, u64)> { if commits_count == 0 { - (0, 0) + Some((0, 0)) } else if reveals_count == 0 { - (collateral, 0) + Some((collateral, 0)) } else { - let honests_count = (commits_count - liars_count - errors_count) as u64; + let honests_count = commits_count + .wrapping_sub(liars_count) + .wrapping_sub(errors_count) as u64; + if honests_count == 0 { + return None; + } let liars_count = liars_count as u64; - let slashed_collateral_reward = collateral * liars_count / honests_count; - let slashed_collateral_remainder = (collateral * liars_count) % honests_count; - - ( - reward + collateral + slashed_collateral_reward, + let slashed_total = collateral.wrapping_mul(liars_count); + let slashed_collateral_reward = slashed_total / honests_count; + let slashed_collateral_remainder = slashed_total % honests_count; + + Some(( + reward + .wrapping_add(collateral) + .wrapping_add(slashed_collateral_reward), slashed_collateral_remainder, - ) + )) + } +} + +/// Strict pre-second-hard-fork reward arithmetic for native Wit/2.1 tally projections. +pub fn calculate_witness_reward_before_second_hard_fork_checked( + commits_count: usize, + reveals_count: usize, + liars_count: usize, + errors_count: usize, + reward: u64, + collateral: u64, +) -> Option<(u64, u64)> { + if commits_count == 0 { + Some((0, 0)) + } else if reveals_count == 0 { + Some((collateral, 0)) + } else { + let honests_count = commits_count + .checked_sub(liars_count)? + .checked_sub(errors_count)?; + let honests_count = u64::try_from(honests_count).ok()?; + if honests_count == 0 { + return None; + } + let liars_count = u64::try_from(liars_count).ok()?; + let slashed_total = collateral.checked_mul(liars_count)?; + let slashed_collateral_reward = slashed_total / honests_count; + let slashed_collateral_remainder = slashed_total % honests_count; + let total_reward = reward + .checked_add(collateral)? + .checked_add(slashed_collateral_reward)?; + + Some((total_reward, slashed_collateral_remainder)) } } @@ -527,21 +1021,25 @@ pub fn calculate_witness_reward( collateral: u64, wip0023_active: bool, ) -> (u64, u64) { - let honests_count = (commits_count - liars_count - errors_count) as u64; + let honests_count = commits_count + .wrapping_sub(liars_count) + .wrapping_sub(errors_count) as u64; if commits_count == 0 { (0, 0) } else { let liars_count = liars_count as u64; - let slashed_total = collateral * liars_count; + let slashed_total = collateral.wrapping_mul(liars_count); match slashed_total.checked_div(honests_count) { None => (collateral, 0), - Some(_) if wip0023_active => (reward + collateral, 0), + Some(_) if wip0023_active => (reward.wrapping_add(collateral), 0), Some(slashed_collateral_reward) => { let slashed_collateral_remainder = slashed_total % honests_count; ( - reward + collateral + slashed_collateral_reward, + reward + .wrapping_add(collateral) + .wrapping_add(slashed_collateral_reward), slashed_collateral_remainder, ) } @@ -549,6 +1047,39 @@ pub fn calculate_witness_reward( } } +/// Strict witness-reward arithmetic for native Wit/2.1 requests. +pub fn calculate_witness_reward_checked( + commits_count: usize, + liars_count: usize, + errors_count: usize, + reward: u64, + collateral: u64, + wip0023_active: bool, +) -> Option<(u64, u64)> { + if commits_count == 0 { + return Some((0, 0)); + } + + let honests_count = commits_count + .checked_sub(liars_count)? + .checked_sub(errors_count)?; + let honests_count = u64::try_from(honests_count).ok()?; + let liars_count = u64::try_from(liars_count).ok()?; + let slashed_total = collateral.checked_mul(liars_count)?; + + match slashed_total.checked_div(honests_count) { + None => Some((collateral, 0)), + Some(_) if wip0023_active => Some((reward.checked_add(collateral)?, 0)), + Some(slashed_collateral_reward) => { + let slashed_collateral_remainder = slashed_total % honests_count; + let total_reward = reward + .checked_add(collateral)? + .checked_add(slashed_collateral_reward)?; + Some((total_reward, slashed_collateral_remainder)) + } + } +} + /// Function to calculate the data request reward to collateral ratio /// /// The ratio is rounded up to the next integer. This is because the validation checks for ratios @@ -575,7 +1106,24 @@ pub fn data_request_has_too_many_witnesses( validator_count: usize, epoch: Option, ) -> bool { - if ProtocolVersion::from_epoch_opt(epoch) < ProtocolVersion::V2_0 { + data_request_has_too_many_witnesses_for_protocol( + dr_output, + validator_count, + ProtocolVersion::from_epoch_opt(epoch), + ) +} + +/// Protocol-explicit variant of [`data_request_has_too_many_witnesses`]. +/// +/// Historical replay pins one immutable protocol schedule before reading any blocks, so it must +/// not consult the mutable process-global schedule again while reconstructing an old stage +/// transition. Ordinary live consensus should keep using the epoch-based wrapper above. +pub fn data_request_has_too_many_witnesses_for_protocol( + dr_output: &DataRequestOutput, + validator_count: usize, + protocol_version: ProtocolVersion, +) -> bool { + if protocol_version < ProtocolVersion::V2_0 { false } else { let divider = if let Environment::Mainnet = get_environment() { @@ -588,6 +1136,118 @@ pub fn data_request_has_too_many_witnesses( } } +/// How a tally-ready request must interpret an exceeded witness/population threshold. +/// +/// `UnsupportedLegacyCrossing` preserves the deployed Wit/2.0 acceptance surface: requests born +/// after activation could be moved to TALLY by consolidation, but no later tally transaction was +/// valid while the threshold remained exceeded. Wit/2.1 introduces an explicit causal escape for +/// those states without retroactively changing Wit/2.0 blocks. `InvalidEarlyTallyPopulation` +/// rejects a restored early state whose immutable successor population cannot prove that cause. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum TooManyWitnessesTallyMode { + Ordinary, + Sentinel, + UnsupportedLegacyCrossing, + InvalidEarlyTallyPopulation, +} + +/// Classify the tally result for a live request using the process protocol schedule. +pub fn classify_too_many_witnesses_tally( + dr_state: &DataRequestState, + validator_count: usize, + extra_rounds: u16, + containing_protocol: ProtocolVersion, + included_in_containing_block: bool, +) -> Result { + let protocol_info = get_protocol_info(); + classify_too_many_witnesses_tally_for_protocols( + dr_state, + validator_count, + extra_rounds, + protocol_info + .all_versions + .version_for_epoch(dr_state.epoch), + containing_protocol, + protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX), + included_in_containing_block, + ) +} + +/// Protocol-explicit variant of [`classify_too_many_witnesses_tally`]. +/// +/// The delayed Wit/2.1 sentinel is deliberately causal: it is available only when an exceeded +/// population threshold was the reason a no-commit request entered TALLY before its terminal +/// round. A population drop cannot overwrite completed Commit/Reveal evidence or relabel an +/// ordinary terminal timeout. Same-block sentinels and the exact Wit/2.0 activation-epoch +/// compatibility case retain their historical behavior. +#[allow(clippy::too_many_arguments)] +pub fn classify_too_many_witnesses_tally_for_protocols( + dr_state: &DataRequestState, + validator_count: usize, + extra_rounds: u16, + request_protocol: ProtocolVersion, + containing_protocol: ProtocolVersion, + wit2_activation_epoch: Epoch, + included_in_containing_block: bool, +) -> Result { + let terminal_round = extra_rounds.checked_add(1).ok_or( + DataRequestError::UnrepresentableExtraRounds { extra_rounds }, + )?; + + let threshold_caused_early_tally_shape = containing_protocol >= ProtocolVersion::V2_1 + && request_protocol >= ProtocolVersion::V2_0 + && wit2_activation_epoch != Epoch::MAX + && dr_state.epoch > wit2_activation_epoch + && !included_in_containing_block + && dr_state.stage == DataRequestStage::TALLY + && dr_state.info.current_stage == Some(DataRequestStage::TALLY) + && dr_state.info.tally.is_none() + && dr_state.info.block_hash_tally_tx.is_none() + && dr_state.info.commits.is_empty() + && dr_state.info.reveals.is_empty() + && dr_state.info.current_reveal_round == 0 + && dr_state.info.current_commit_round < terminal_round; + + if !data_request_has_too_many_witnesses_for_protocol( + &dr_state.data_request, + validator_count, + request_protocol, + ) { + return Ok(if threshold_caused_early_tally_shape { + // The predecessor's post-block ledger is this block's immutable opening ledger, so a + // canonical early transition cannot lose its threshold cause before the mandatory + // successor. Reject an inconsistent restored state instead of relabelling it ordinary. + TooManyWitnessesTallyMode::InvalidEarlyTallyPopulation + } else { + TooManyWitnessesTallyMode::Ordinary + }); + } + + if included_in_containing_block && containing_protocol >= ProtocolVersion::V2_0 { + return Ok(TooManyWitnessesTallyMode::Sentinel); + } + + if containing_protocol >= ProtocolVersion::V2_0 + && wit2_activation_epoch != Epoch::MAX + && dr_state.epoch == wit2_activation_epoch + { + return Ok(TooManyWitnessesTallyMode::Sentinel); + } + + if containing_protocol < ProtocolVersion::V2_1 { + return Ok(TooManyWitnessesTallyMode::UnsupportedLegacyCrossing); + } + + Ok(if threshold_caused_early_tally_shape { + TooManyWitnessesTallyMode::Sentinel + } else { + TooManyWitnessesTallyMode::Ordinary + }) +} + /// Saturating version of `u64::div_ceil`. /// /// Calculates the quotient of `lhs` and `rhs`, rounding the result towards positive infinity. @@ -613,24 +1273,20 @@ fn calculate_errors_and_liars_count(errors: &[bool], liars: &[bool]) -> (usize, .zip(errors.iter()) .fold((0, 0), |(l_count, e_count), x| match x { // If it is out of consensus and it is an error we consider as a error - (true, true) => (l_count, e_count + 1), + (true, true) => (l_count, e_count.saturating_add(1)), // If it is out of consensus and it is not an error we consider as a lie - (true, false) => (l_count + 1, e_count), + (true, false) => (l_count.saturating_add(1), e_count), // Rest of cases is an honest node _ => (l_count, e_count), }) } -/// Create tally transaction to reward honest witnesses and penalize liars. -/// -/// # Panics +/// Fallibly create a tally transaction to reward honest witnesses and penalize liars. /// -/// This function panics if the `RadonReport` is not in tally stage. -/// -/// And also if `tally_metadata.liars` and `tally_metadata.errors` do not have the same length as -/// `revealers` +/// Consensus-facing callers should use this checked constructor so malformed report metadata is +/// rejected as a transaction error instead of aborting the validator or miner process. #[allow(clippy::too_many_arguments)] -pub fn create_tally( +pub fn create_tally_checked( dr_pointer: Hash, dr_output: &DataRequestOutput, pkh: PublicKeyHash, @@ -641,29 +1297,48 @@ pub fn create_tally( tally_bytes_on_encode_error: Vec, active_wips: &ActiveWips, protocol_version: ProtocolVersion, -) -> TallyTransaction +) -> Result where RT: TypeLike, { - if let Stage::Tally(tally_metadata) = &report.context.stage { + let Stage::Tally(tally_metadata) = &report.context.stage else { + return Err(TransactionError::NoTallyStage.into()); + }; + + { let commits_count = committers.len(); let reveals_count = revealers.len(); + // A reveal is keyed by `(data request, participant)` in the authoritative pool. Keep the + // checked constructor aligned with that identity invariant even for direct callers: a + // duplicate revealer would otherwise be counted twice while only removing one entry from + // `out_of_consensus`, and a revealer without a matching commitment could receive a reward + // that is not backed by any collateral. The historical wrapper still preserves its API, + // but malformed input now fails before any tally outputs are assembled. + let mut seen_revealers = HashSet::with_capacity(revealers.len()); + for revealer in &revealers { + anyhow::ensure!( + seen_revealers.insert(*revealer), + "Duplicate revealer {revealer} in data request {dr_pointer}" + ); + anyhow::ensure!( + committers.contains(revealer), + "Revealer {revealer} has no matching commitment in data request {dr_pointer}" + ); + } let mut out_of_consensus = committers; let mut error_committers = vec![]; let liars = &tally_metadata.liars; let errors = &tally_metadata.errors; - assert_eq!( - reveals_count, - liars.len(), + anyhow::ensure!( + reveals_count == liars.len(), "Length of liars vector collected from tally ({}) does not match actual count of reveals ({})", reveals_count, liars.len() ); - assert_eq!( - reveals_count, - errors.len(), + anyhow::ensure!( + reveals_count == errors.len(), "Length of errors vector collected from tally ({}) does not match actual count of reveals ({})", reveals_count, errors.len() @@ -671,37 +1346,114 @@ where let (liars_count, errors_count) = calculate_errors_and_liars_count(errors, liars); - let collateral = if dr_output.collateral == 0 { + // A serialized zero means the network minimum for pre-Wit/2.0 and native Wit/2.1, but it + // remains a raw zero owner-only no-op for Wit/2.0 requests that may cross activation. The + // expected tally must use the request protocol passed by its caller, not the block epoch. + let collateral = if dr_output.collateral == 0 + && protocol_version != ProtocolVersion::V2_0 + { collateral_minimum } else { dr_output.collateral }; // Collateral division rest goes for the miner - let non_reveals_count = commits_count - reveals_count; + let strict_arithmetic = protocol_version >= ProtocolVersion::V2_1; + let non_reveals_count = if strict_arithmetic { + commits_count.checked_sub(reveals_count).ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 tally has {} reveals but only {} commits for data request {dr_pointer}", + reveals_count, + commits_count + ) + })? + } else { + commits_count.wrapping_sub(reveals_count) + }; + let liars_and_non_reveals = if strict_arithmetic { + liars_count.checked_add(non_reveals_count).ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 liar/non-reveal count overflow for data request {dr_pointer}" + ) + })? + } else { + liars_count.wrapping_add(non_reveals_count) + }; let is_after_second_hard_fork = active_wips.wips_0009_0011_0012(); let (reward, _rest) = if is_after_second_hard_fork { - calculate_witness_reward( - commits_count, - liars_count + non_reveals_count, - errors_count, - dr_output.witness_reward, - collateral, - active_wips.wip0023(), - ) + if strict_arithmetic { + calculate_witness_reward_checked( + commits_count, + liars_and_non_reveals, + errors_count, + dr_output.witness_reward, + collateral, + active_wips.wip0023(), + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 witness reward arithmetic overflow for data request {dr_pointer}" + ) + })? + } else { + calculate_witness_reward( + commits_count, + liars_and_non_reveals, + errors_count, + dr_output.witness_reward, + collateral, + active_wips.wip0023(), + ) + } } else { - calculate_witness_reward_before_second_hard_fork( - commits_count, - reveals_count, - liars_count + non_reveals_count, - errors_count, - dr_output.witness_reward, - collateral, - ) + if strict_arithmetic { + calculate_witness_reward_before_second_hard_fork_checked( + commits_count, + reveals_count, + liars_and_non_reveals, + errors_count, + dr_output.witness_reward, + collateral, + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 pre-second-hard-fork witness reward arithmetic overflow for data request {dr_pointer}" + ) + })? + } else { + calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count, + reveals_count, + liars_and_non_reveals, + errors_count, + dr_output.witness_reward, + collateral, + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 legacy witness reward arithmetic overflow for data request {dr_pointer}" + ) + })? + } }; // Check if we need to reward revealers let any_honest_revealers = if is_after_second_hard_fork { - let honests_count = commits_count - liars_count - errors_count - non_reveals_count; + let honests_count = if strict_arithmetic { + commits_count + .checked_sub(liars_count) + .and_then(|count| count.checked_sub(errors_count)) + .and_then(|count| count.checked_sub(non_reveals_count)) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 honest-witness count underflow for data request {dr_pointer}" + ) + })? + } else { + commits_count + .wrapping_sub(liars_count) + .wrapping_sub(errors_count) + .wrapping_sub(non_reveals_count) + }; honests_count > 0 } else { @@ -799,9 +1551,35 @@ where vec![] }; - let honests_count = reveals_count - liars_count - errors_count; - let tally_change = - calculate_tally_change(commits_count, reveals_count, honests_count, dr_output); + let honests_count = if strict_arithmetic { + reveals_count + .checked_sub(liars_count) + .and_then(|count| count.checked_sub(errors_count)) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 honest-reveal count underflow for data request {dr_pointer}" + ) + })? + } else { + reveals_count + .wrapping_sub(liars_count) + .wrapping_sub(errors_count) + }; + let tally_change = if strict_arithmetic { + calculate_tally_change_checked( + commits_count, + reveals_count, + honests_count, + dr_output, + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 tally-change arithmetic overflow for data request {dr_pointer}" + ) + })? + } else { + calculate_tally_change(commits_count, reveals_count, honests_count, dr_output) + }; if tally_change > 0 { let vt_output_change = ValueTransferOutput { pkh, @@ -818,24 +1596,372 @@ where }); let out_of_consensus = out_of_consensus.into_iter().collect(); - TallyTransaction::new( + Ok(TallyTransaction::new( dr_pointer, tally_bytes, outputs, out_of_consensus, error_committers, - ) - } else { - panic!("{}", TransactionError::NoTallyStage) + )) } } +/// Create tally transaction to reward honest witnesses and penalize liars. +/// +/// This compatibility wrapper preserves the historical infallible API for callers that already +/// validate report shape. Consensus and mining paths should use [`create_tally_checked`] instead. +#[allow(clippy::too_many_arguments)] +pub fn create_tally( + dr_pointer: Hash, + dr_output: &DataRequestOutput, + pkh: PublicKeyHash, + report: &RadonReport, + revealers: Vec, + committers: HashSet, + collateral_minimum: u64, + tally_bytes_on_encode_error: Vec, + active_wips: &ActiveWips, + protocol_version: ProtocolVersion, +) -> TallyTransaction +where + RT: TypeLike, +{ + create_tally_checked( + dr_pointer, + dr_output, + pkh, + report, + revealers, + committers, + collateral_minimum, + tally_bytes_on_encode_error, + active_wips, + protocol_version, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + #[cfg(test)] mod tests { use crate::{chain::*, transaction::*, vrf::*}; use super::*; + fn tally_state_for_too_many_witnesses(epoch: Epoch, commit_round: u16) -> DataRequestState { + let mut state = DataRequestState { + data_request: DataRequestOutput { + witnesses: 1, + ..DataRequestOutput::default() + }, + epoch, + ..DataRequestState::default() + }; + state.set_stage(DataRequestStage::TALLY); + state.info.current_commit_round = commit_round; + state + } + + #[test] + fn too_many_witnesses_tally_classifier_is_v21_gated_and_causal() { + let activation_epoch = 100; + let state = tally_state_for_too_many_witnesses(activation_epoch + 1, 1); + + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &state, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_0, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::UnsupportedLegacyCrossing), + ); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &state, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::Sentinel), + ); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &state, + 100, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::InvalidEarlyTallyPopulation), + ); + + let legacy_crossing = tally_state_for_too_many_witnesses(activation_epoch - 1, 1); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &legacy_crossing, + 0, + 2, + ProtocolVersion::V1_8, + ProtocolVersion::V2_1, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::Ordinary), + ); + + let terminal = tally_state_for_too_many_witnesses(activation_epoch + 1, 3); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &terminal, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::Ordinary), + ); + + let mut reveal_derived = state.clone(); + reveal_derived.info.current_reveal_round = 1; + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &reveal_derived, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::Ordinary), + ); + } + + #[test] + fn too_many_witnesses_tally_classifier_preserves_existing_exceptions() { + let activation_epoch = 100; + let activation_state = + tally_state_for_too_many_witnesses(activation_epoch, 3); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &activation_state, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_0, + activation_epoch, + false, + ), + Ok(TooManyWitnessesTallyMode::Sentinel), + ); + + let same_block = tally_state_for_too_many_witnesses(activation_epoch + 1, 0); + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &same_block, + 0, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_0, + activation_epoch, + true, + ), + Ok(TooManyWitnessesTallyMode::Sentinel), + ); + + assert_eq!( + classify_too_many_witnesses_tally_for_protocols( + &same_block, + 100, + 2, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + activation_epoch, + true, + ), + Ok(TooManyWitnessesTallyMode::Ordinary), + ); + } + + #[test] + fn tally_ready_enumeration_preserves_activation_epoch_compatibility() { + let activation_epoch = 100; + let activation_pointer = Hash::with_first_u32(1); + let later_pointer = Hash::with_first_u32(2); + let mut pool = DataRequestPool::new(2); + pool.data_request_pool.insert( + activation_pointer, + tally_state_for_too_many_witnesses(activation_epoch, 1), + ); + pool.data_request_pool.insert( + later_pointer, + tally_state_for_too_many_witnesses(activation_epoch + 1, 1), + ); + let active_wips = ActiveWips::default(); + + let v20 = pool.get_all_reveals_for_protocol_with_legacy_validator_count( + &active_wips, + Some(0), + Some(0), + ProtocolVersion::V2_0, + activation_epoch, + ); + assert!(v20.contains_key(&activation_pointer)); + assert!(!v20.contains_key(&later_pointer)); + + let v21 = pool.get_all_reveals_for_protocol_with_legacy_validator_count( + &active_wips, + Some(0), + Some(0), + ProtocolVersion::V2_1, + activation_epoch, + ); + assert!(v21.contains_key(&activation_pointer)); + assert!(v21.contains_key(&later_pointer)); + } + + #[test] + fn checked_pool_stage_update_rejects_unrepresentable_round_before_mutation() { + let mut pool = DataRequestPool::new(u16::MAX); + let before = pool.clone(); + + assert_eq!( + pool.try_update_data_request_stages(None, None), + Err(DataRequestError::UnrepresentableExtraRounds { + extra_rounds: u16::MAX, + }) + ); + assert_eq!(pool, before); + } + + #[test] + fn checked_pool_stage_update_rejects_finished_active_tally_without_mutation() { + let pointer = Hash::default(); + let mut state = DataRequestState::default(); + state.set_stage(DataRequestStage::TALLY); + state.info.tally = Some(TallyTransaction::default()); + let mut pool = DataRequestPool::default(); + pool.data_request_pool.insert(pointer, state); + let before = pool.clone(); + + assert_eq!( + pool.try_update_data_request_stages(Some(1), Some(0)), + Err(DataRequestError::FinishedDataRequestStillActive) + ); + assert_eq!(pool, before); + } + + #[test] + fn checked_pool_stage_update_preflights_finished_state_before_other_requests() { + let mut pool = DataRequestPool::default(); + pool.data_request_pool + .insert(Hash::with_first_u32(1), DataRequestState::default()); + + let mut finished_state = DataRequestState::default(); + finished_state.set_stage(DataRequestStage::TALLY); + finished_state.info.tally = Some(TallyTransaction::default()); + pool.data_request_pool + .insert(Hash::with_first_u32(2), finished_state); + let before = pool.clone(); + + assert_eq!( + pool.try_update_data_request_stages(Some(1), Some(0)), + Err(DataRequestError::FinishedDataRequestStillActive) + ); + assert_eq!(pool, before); + } + + #[test] + fn checked_pool_stage_update_discards_pending_reveal_with_invalid_signature_shape() { + let pointer = Hash::default(); + let mut state = DataRequestState::default(); + state.data_request.witnesses = 1; + state + .info + .commits + .insert(PublicKeyHash::default(), CommitTransaction::default()); + let mut pool = DataRequestPool::default(); + pool.data_request_pool.insert(pointer, state); + pool.data_requests_by_epoch + .entry(0) + .or_default() + .insert(pointer); + pool.waiting_for_reveal + .insert(pointer, RevealTransaction::default()); + + assert!( + pool.try_update_data_request_stages(Some(1), Some(0)) + .unwrap() + .is_empty() + ); + assert_eq!( + pool.data_request_pool[&pointer].stage, + DataRequestStage::REVEAL + ); + assert!(!pool.waiting_for_reveal.contains_key(&pointer)); + } + + #[test] + fn infallible_pool_stage_update_fails_unrepresentable_round_closed() { + let mut pool = DataRequestPool::new(u16::MAX); + let pointer = Hash::default(); + pool.data_request_pool + .insert(pointer, DataRequestState::default()); + pool.data_requests_by_epoch + .entry(0) + .or_default() + .insert(pointer); + + assert!(pool.update_data_request_stages(None, None).is_empty()); + assert_eq!( + pool.data_request_pool[&pointer].stage, + DataRequestStage::TALLY + ); + assert!(pool.data_requests_by_epoch.is_empty()); + assert!(pool.waiting_for_reveal.is_empty()); + } + + #[test] + fn legacy_tally_arithmetic_wraps_while_wit2_1_arithmetic_rejects_overflow() { + let dr_output = DataRequestOutput { + witness_reward: u64::MAX, + witnesses: 2, + ..DataRequestOutput::default() + }; + + assert_eq!(calculate_tally_change(0, 0, 0, &dr_output), u64::MAX - 1); + assert_eq!(calculate_tally_change_checked(0, 0, 0, &dr_output), None); + + assert_eq!( + calculate_witness_reward(2, 1, 0, u64::MAX, u64::MAX, false), + (u64::MAX - 2, 0), + ); + assert_eq!( + calculate_witness_reward_checked(2, 1, 0, u64::MAX, u64::MAX, false), + None, + ); + assert_eq!( + calculate_witness_reward_before_second_hard_fork_legacy_checked( + 1, 1, 1, 0, 1, 1, + ), + None, + ); + assert_eq!( + calculate_witness_reward_before_second_hard_fork_checked(1, 1, 1, 0, 1, 1), + None, + ); + } + fn add_data_requests() -> (u32, Hash, DataRequestPool, Hash) { let fake_block_hash = Hash::SHA256([1; 32]); let epoch = 0; @@ -1066,6 +2192,21 @@ mod tests { add_data_requests_with_3_reveal_stages(); } + #[test] + fn rejected_tally_preserves_the_active_request() { + let (_epoch, fake_block_hash, p, dr_pointer) = add_data_requests(); + let mut p = p; + let before = p + .data_request_state(&dr_pointer) + .cloned() + .expect("fixture request must be present"); + let tally_transaction = TallyTransaction::new(dr_pointer, vec![], vec![], vec![], vec![]); + + assert!(p.process_tally(&tally_transaction, &fake_block_hash).is_err()); + assert_eq!(p.data_request_state(&dr_pointer), Some(&before)); + assert!(p.to_be_stored.is_empty()); + } + #[test] fn test_from_commit_to_reveal() { let (epoch, fake_block_hash, p, dr_pointer) = add_data_requests(); diff --git a/data_structures/src/error.rs b/data_structures/src/error.rs index 3ee072c181..87d36f43fc 100644 --- a/data_structures/src/error.rs +++ b/data_structures/src/error.rs @@ -39,6 +39,12 @@ pub enum TransactionError { InputValueOverflow, #[error("Sum of output values overflows")] OutputValueOverflow, + /// The local pending-transaction timeout cannot be represented by the timestamp type. + #[error("Pending transaction timeout timestamp overflows")] + PendingTimeoutOverflow, + /// The process-local owner space for asynchronous UTXO reservations is exhausted. + #[error("UTXO reservation generation overflows")] + UtxoReservationGenerationOverflow, /// The transaction creates value #[error("Transaction creates value (its fee is negative)")] NegativeFee, @@ -49,6 +55,13 @@ pub enum TransactionError { DataRequestNotFound { hash: Hash }, #[error("Too many witnesses data request not found in the same block: {hash}")] TooManyWitnessesDataRequestNotFound { hash: Hash }, + /// A post-Wit/2 request can enter tally before its terminal round only because its witness + /// target exceeded the relevant validator population. The next accepted block must observe + /// the same opening population and cannot reinterpret that state as an ordinary tally. + #[error( + "Data request is in an early tally state without its required TooManyWitnesses population: {hash}" + )] + InvalidTooManyWitnessesTallyState { hash: Hash }, #[error("Commit transaction has an invalid Proof of Eligibility")] InvalidDataRequestPoe, #[error("Validator {validator} is not eligible to commit to a data request")] @@ -196,6 +209,11 @@ pub enum TransactionError { /// Incorrect collateral in commit transaction #[error("Incorrect collateral. Expected: {expected}, found: {found}")] IncorrectCollateral { expected: u64, found: u64 }, + /// Stake-backed Wit/2 commits reserve collateral from the delegated stake ledger and cannot + /// carry legacy UTXO collateral inputs, even when an equal change output would make the net + /// difference zero. The historical variant name remains for wire/API compatibility. + #[error("stake-backed Wit/2 commits cannot include UTXO collateral inputs")] + NativeCommitWithUtxoCollateral, /// Collateral in commit transaction is not mature enough #[error( "Output {output} used as input for collateralized commitment is not mature enough. Inputs of commitment transactions must be older than {must_be_older_than} blocks, but this one was only {found} blocks old" @@ -244,6 +262,53 @@ pub enum TransactionError { /// Stake amount above maximum #[error("The amount of coins in stake ({stake}) is more than the maximum allowed {max_stake})")] StakeAboveMaximum { max_stake: u64, stake: u64 }, + /// A Wit/2.1 stake transaction omitted its versioned authorization terms. + #[error("Wit/2.1 stake transactions require versioned authorization terms")] + MissingStakeAuthorizationTerms, + /// A stake authorization failed structural, network, expiry, identity, or signature validation. + #[error("Invalid stake authorization: {reason}")] + InvalidStakeAuthorization { reason: String }, + /// Delegation terms disagree with the validator-wide commission effective at inclusion. + #[error( + "Validator {validator} authorization uses {authorized}% commission, but {effective}% is effective" + )] + DelegationCommissionMismatch { + validator: PublicKeyHash, + authorized: u32, + effective: u8, + }, + /// A third party tried to delegate to a validator without an active owner stake entry. + #[error("Validator {validator} owner {owner} does not have the minimum active stake")] + InactiveValidatorOwner { + validator: PublicKeyHash, + owner: PublicKeyHash, + }, + /// A commission-update action carried stake value instead of being policy-only. + #[error("Commission update for validator {validator} must have zero stake value, got {value}")] + CommissionUpdateWithStake { + validator: PublicKeyHash, + value: u64, + }, + /// A commission update must pay the normal stake-transaction fee. + #[error("Commission update for validator {validator} must pay a positive fee")] + CommissionUpdateWithoutFee { validator: PublicKeyHash }, + /// A commission update was not addressed to the validator's persistent owner. + #[error( + "Commission update for validator {validator} uses withdrawer {used}, expected owner {expected}" + )] + InvalidCommissionOwner { + validator: PublicKeyHash, + used: PublicKeyHash, + expected: PublicKeyHash, + }, + /// The validator owner attempted to remove its mandatory self-bond while delegations remain. + #[error( + "Validator {validator} owner {owner} must retain the minimum self-stake while delegations or collateral reservations exist" + )] + ValidatorOwnerStakeRequired { + validator: PublicKeyHash, + owner: PublicKeyHash, + }, /// Stake weight limit exceeded #[error("Stake Transaction weight ({weight}) exceeds the limit {max_weight})")] StakeWeightLimitExceeded { weight: u32, max_weight: u32 }, @@ -251,8 +316,37 @@ pub enum TransactionError { #[error("Tried to unstake more coins than the current stake ({unstake} > {stake})")] UnstakingMoreThanStaked { stake: u64, unstake: u64 }, /// Tried to perform an unstake action with an invalid nonce. - #[error("Cannot unstake with an invalid nonce: {used} < {current}")] + #[error("Cannot unstake with an invalid nonce: used {used}, expected {current}")] UnstakeInvalidNonce { used: u64, current: u64 }, + /// A different registration or exit already consumes this stake-policy semantic slot. + #[error("A different stake policy action {action} is pending for validator {validator}, withdrawer {withdrawer}, nonce {nonce}")] + PendingStakePolicyActionConflict { + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + action: u32, + nonce: u64, + }, + /// A different pending commission update already consumes this validator policy nonce. + #[error( + "A different commission update is already pending for validator {validator}, owner {withdrawer}, nonce {nonce}" + )] + PendingCommissionUpdateConflict { + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + nonce: u64, + }, + /// A different pending withdrawal already consumes this stake-entry nonce. + #[error( + "A different unstake transaction is already pending for validator {validator}, withdrawer {withdrawer}, nonce {nonce}" + )] + PendingUnstakeConflict { + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + nonce: u64, + }, + /// A zero-valued unstake output cannot transfer any withdrawn principal. + #[error("Unstake transactions must have a positive withdrawal value")] + ZeroValueUnstakeOutput, /// An stake output with zero value does not make sense #[error("Transaction {tx_hash} has a zero value stake output")] ZeroValueStakeOutput { tx_hash: Hash }, @@ -276,6 +370,14 @@ pub enum TransactionError { time_lock: u64, unstaking_delay_seconds: u64, }, + /// An absolute unstake lock must extend at least the configured delay past inclusion. + #[error( + "The absolute unstake timelock ({time_lock}) is lower than the minimum inclusion-relative timestamp ({minimum_time_lock})" + )] + InvalidAbsoluteUnstakeTimelock { + time_lock: u64, + minimum_time_lock: u64, + }, /// Invalid unstake request #[error("No stake found for validator and withdrawer pair ({validator}, {withdrawer})")] NoStakeFound { @@ -413,6 +515,15 @@ pub enum BlockError { /// Repeated operator Unstake #[error("A single operator is withdrawing stake more than once in a block: ({pkh})")] RepeatedUnstakeOperator { pkh: PublicKeyHash }, + /// A delegated entry cannot be credited and debited in one Wit/2.1 block because both + /// transitions derive their replay nonce from that block's epoch. + #[error( + "Stake entry ({validator}, {withdrawer}) cannot receive stake and withdraw stake in the same Wit/2.1 block" + )] + ConflictingStakeAndUnstakeEntry { + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + }, /// Missing expected tallies #[error("{count} expected tally transactions are missing in block candidate {block_hash}")] MissingExpectedTallies { count: usize, block_hash: Hash }, @@ -501,6 +612,14 @@ pub enum DataRequestError { NotTallyStage, #[error("Cannot persist unfinished data request (with no Tally)")] UnfinishedDataRequest, + /// A completed request must be removed from the active pool before the next stage pass. + #[error("Finished data request remained active in the data request pool")] + FinishedDataRequestStillActive, + /// The terminal commit/reveal round is represented as `extra_rounds + 1`. + #[error( + "Data request stage cannot advance because extra_rounds {extra_rounds} has no representable terminal round" + )] + UnrepresentableExtraRounds { extra_rounds: u16 }, #[error("The data request is not valid since it has no retrieval sources")] NoRetrievalSources, #[error("The data request has not a valid RadType")] diff --git a/data_structures/src/lib.rs b/data_structures/src/lib.rs index a8dcbde0e3..a550f75553 100644 --- a/data_structures/src/lib.rs +++ b/data_structures/src/lib.rs @@ -13,13 +13,16 @@ extern crate protobuf_convert; use std::sync::RwLock; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; + use lazy_static::lazy_static; pub use strum; use crate::{ - chain::{Environment, Epoch}, - proto::versioning::{ProtocolInfo, ProtocolVersion}, + chain::{ConsensusConstants, Environment, Epoch, EpochConstants}, + proto::versioning::{ProtocolInfo, ProtocolRegistrationError, ProtocolVersion}, }; pub const DEFAULT_VALIDATOR_COUNT_FOR_TESTS: usize = 1000; @@ -76,6 +79,9 @@ pub mod witnessing; /// strings or bytes depending on the serializer. pub mod serialization_helpers; +/// Compatibility decoders for legacy bincode persistence layouts. +pub mod bincode_compatibility; + /// Provides convenient constants, structs and methods for handling values denominated in Wit. pub mod wit; @@ -89,9 +95,86 @@ lazy_static! { // can work without having to manually set the environment. // The default environment will also be used in tests. static ref ENVIRONMENT: RwLock = RwLock::new(Environment::Mainnet); - /// Protocol version that we are running. - /// default to legacy for now — it's the v2 bootstrapping module's responsibility to upgrade it. - static ref PROTOCOL: RwLock = RwLock::new(ProtocolInfo::default()); + /// Process-wide protocol schedule and the epoch clock that interprets that schedule. + /// + /// Keeping these in one lock is important: a restored or dynamically discovered Wit/2 + /// activation changes both values. Publishing them through independent globals lets readers + /// observe a new protocol schedule with the legacy clock (or the inverse) while actor + /// mailboxes are being updated. + static ref PROTOCOL: RwLock = RwLock::new(ProtocolRuntime::default()); +} + +#[derive(Default)] +struct ProtocolRuntime { + protocol_info: ProtocolInfo, + epoch_constants: Option, + generation: u64, +} + +#[cfg(test)] +static TEST_PROTOCOL_RUNTIME_LOCK: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn test_protocol_runtime_guard() -> MutexGuard<'static, ()> { + TEST_PROTOCOL_RUNTIME_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("protocol runtime test lock must not be poisoned") +} + +impl ProtocolRuntime { + fn next_generation(&self) -> u64 { + self.generation + .checked_add(1) + .expect("protocol runtime generation exhausted") + } +} + +fn epoch_constants_for_protocol_info( + info: &ProtocolInfo, + consensus_constants: &ConsensusConstants, +) -> Result { + info.validate_timing_topology(consensus_constants)?; + let activation_epoch = info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoints_period_wit2 = activation_epoch + .and_then(|_| { + info.all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + }) + .unwrap_or(u16::MAX); + + EpochConstants::try_new( + consensus_constants.checkpoint_zero_timestamp, + consensus_constants.checkpoints_period, + activation_epoch, + checkpoints_period_wit2, + ) + .map_err(|_| ProtocolRegistrationError::UnrepresentableRuntimeClock) +} + +fn validate_protocol_runtime( + info: &ProtocolInfo, + epoch_constants: EpochConstants, +) -> Result<(), ProtocolRegistrationError> { + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: epoch_constants.checkpoint_zero_timestamp, + checkpoints_period: epoch_constants.checkpoints_period, + ..Default::default() + }; + let expected = epoch_constants_for_protocol_info(info, &consensus_constants)?; + if expected.checkpoint_zero_timestamp != epoch_constants.checkpoint_zero_timestamp + || expected.checkpoints_period != epoch_constants.checkpoints_period + || expected.checkpoint_zero_timestamp_wit2 + != epoch_constants.checkpoint_zero_timestamp_wit2 + || expected.checkpoints_period_wit2 != epoch_constants.checkpoints_period_wit2 + { + return Err(ProtocolRegistrationError::RuntimeClockMismatch); + } + + Ok(()) } /// Environment in which we are running: mainnet or testnet. @@ -128,7 +211,8 @@ pub fn set_environment(environment: Environment) { pub fn get_protocol_info() -> ProtocolInfo { // This unwrap is safe as long as the lock is not poisoned. // The lock can only become poisoned when a writer panics. - let protocol_info = PROTOCOL.read().unwrap(); + let protocol = PROTOCOL.read().unwrap(); + let protocol_info = &protocol.protocol_info; ProtocolInfo { current_version: protocol_info.current_version, @@ -137,11 +221,24 @@ pub fn get_protocol_info() -> ProtocolInfo { } } +/// Return one atomic snapshot of the process-wide protocol schedule and its matching epoch clock. +/// The clock is absent only for context-free library/test registration that did not supply +/// consensus timing information. +pub fn get_protocol_runtime() -> (ProtocolInfo, Option, u64) { + let protocol = PROTOCOL.read().unwrap(); + ( + protocol.protocol_info.clone(), + protocol.epoch_constants, + protocol.generation, + ) +} + /// Protocol version that we are running. pub fn get_protocol_version(epoch: Option) -> ProtocolVersion { // This unwrap is safe as long as the lock is not poisoned. // The lock can only become poisoned when a writer panics. - let protocol_info = PROTOCOL.read().unwrap(); + let protocol = PROTOCOL.read().unwrap(); + let protocol_info = &protocol.protocol_info; if let Some(epoch) = epoch { protocol_info.all_versions.version_for_epoch(epoch) @@ -156,54 +253,216 @@ pub fn register_protocol_version( epoch: Epoch, checkpoint_period: u16, ) { - log::info!( - "Registering protocol version {protocol_version}, which enters into force at epoch {epoch}" + try_register_protocol_version(protocol_version, epoch, checkpoint_period).unwrap_or_else( + |error| { + panic!( + "invalid protocol registration for {protocol_version} at epoch {epoch}: {error}" + ) + }, ); +} + +/// Let the protocol versions controller know about a protocol version without allowing a +/// conflicting or non-monotonic schedule to partially mutate the process-global registry. +pub fn try_register_protocol_version( + protocol_version: ProtocolVersion, + epoch: Epoch, + checkpoint_period: u16, +) -> Result<(), ProtocolRegistrationError> { + proto::versioning::ensure_production_protocol_version_ready(protocol_version)?; // This unwrap is safe as long as the lock is not poisoned. // The lock can only become poisoned when a writer panics. - let mut protocol_info = PROTOCOL.write().unwrap(); - protocol_info.register(epoch, protocol_version, checkpoint_period); + let mut protocol = PROTOCOL.write().unwrap(); + let next_generation = protocol.next_generation(); + protocol + .protocol_info + .try_register(epoch, protocol_version, checkpoint_period)?; + // A context-free schedule mutation cannot prove that an already paired clock still matches. + // Readers must fall back to their local clock until a caller publishes an exact pair. + protocol.epoch_constants = None; + protocol.generation = next_generation; + log::info!( + "Registered protocol version {protocol_version}, which enters into force at epoch {epoch}" + ); + Ok(()) } /// Set the protocol version that we are running. pub fn set_protocol_version(protocol_version: ProtocolVersion) { + proto::versioning::ensure_production_protocol_version_ready(protocol_version) + .unwrap_or_else(|error| panic!("refusing to set an unreleased protocol: {error}")); log::info!("Setting current protocol version to {protocol_version}"); // The lock can only become poisoned when a writer panics. let mut protocol = PROTOCOL.write().unwrap(); - protocol.current_version = protocol_version; + if protocol.protocol_info.current_version != protocol_version { + let next_generation = protocol.next_generation(); + protocol.protocol_info.current_version = protocol_version; + protocol.generation = next_generation; + } } pub fn load_protocol_info(info: ProtocolInfo) { + try_load_protocol_info(info) + .unwrap_or_else(|error| panic!("refusing to load an invalid protocol schedule: {error}")); +} + +/// Atomically replace the process-global protocol schedule after validating both indexes, the +/// implicit/explicit initial epoch, strict version ordering, and checkpoint-period coverage. This +/// context-free form is retained for restoring a previously validated runtime schedule; startup +/// configuration should use [`try_load_protocol_info_with_consensus_constants`]. +pub fn try_load_protocol_info(info: ProtocolInfo) -> Result<(), ProtocolRegistrationError> { + info.validate()?; + proto::versioning::ensure_production_protocol_schedule_ready(&info)?; log::info!("Loading protocol versions data in bulk: {info:?}"); - let mut protocol_info = PROTOCOL.write().unwrap(); - *protocol_info = info; + let mut protocol = PROTOCOL.write().unwrap(); + let next_generation = protocol.next_generation(); + protocol.protocol_info = info; + protocol.epoch_constants = None; + protocol.generation = next_generation; + Ok(()) +} + +/// Atomically replace the process-global protocol schedule after validating that it is structurally +/// sound and that its explicit periods fit the pre-/post-Wit/2 timing regimes constructed from the +/// supplied consensus constants. +pub fn try_load_protocol_info_with_consensus_constants( + info: ProtocolInfo, + consensus_constants: &ConsensusConstants, +) -> Result<(), ProtocolRegistrationError> { + let epoch_constants = epoch_constants_for_protocol_info(&info, consensus_constants)?; + try_load_protocol_runtime(info, epoch_constants) +} + +/// Atomically replace the protocol schedule and the exact epoch clock that interprets it. +pub fn try_load_protocol_runtime( + info: ProtocolInfo, + epoch_constants: EpochConstants, +) -> Result<(), ProtocolRegistrationError> { + validate_protocol_runtime(&info, epoch_constants)?; + proto::versioning::ensure_production_protocol_schedule_ready(&info)?; + log::info!("Loading protocol versions and epoch clock in bulk: {info:?}"); + let mut protocol = PROTOCOL.write().unwrap(); + let next_generation = protocol.next_generation(); + protocol.protocol_info = info; + protocol.epoch_constants = Some(epoch_constants); + protocol.generation = next_generation; + Ok(()) +} + +/// Replace a validated protocol schedule and its clock, panicking only for programming errors in +/// callers that already authenticated both values. +pub fn load_protocol_runtime(info: ProtocolInfo, epoch_constants: EpochConstants) { + try_load_protocol_runtime(info, epoch_constants).unwrap_or_else(|error| { + panic!("refusing to load a protocol schedule with a mismatched epoch clock: {error}") + }); +} + +/// Register one protocol activation and publish its matching clock in the same write-lock turn. +/// Neither value changes if the registration or pair validation fails. +pub fn try_register_protocol_version_with_epoch_constants( + protocol_version: ProtocolVersion, + epoch: Epoch, + checkpoint_period: u16, + epoch_constants: EpochConstants, + current_epoch: Epoch, +) -> Result<(), ProtocolRegistrationError> { + try_register_protocol_version_with_epoch_constants_and_generation( + protocol_version, + epoch, + checkpoint_period, + epoch_constants, + current_epoch, + ) + .map(|_| ()) +} + +/// Register one protocol activation and return the exact runtime generation published with its +/// matching clock. The generation is returned from the same write-lock turn as the schedule and +/// clock update so callers can distinguish their own publication from a concurrent one. +pub fn try_register_protocol_version_with_epoch_constants_and_generation( + protocol_version: ProtocolVersion, + epoch: Epoch, + checkpoint_period: u16, + epoch_constants: EpochConstants, + current_epoch: Epoch, +) -> Result { + proto::versioning::ensure_production_protocol_version_ready(protocol_version)?; + let mut protocol = PROTOCOL.write().unwrap(); + let next_generation = protocol.next_generation(); + let mut next_info = protocol.protocol_info.clone(); + next_info.try_register(epoch, protocol_version, checkpoint_period)?; + next_info.current_version = next_info.all_versions.version_for_epoch(current_epoch); + validate_protocol_runtime(&next_info, epoch_constants)?; + proto::versioning::ensure_production_protocol_schedule_ready(&next_info)?; + protocol.protocol_info = next_info; + protocol.epoch_constants = Some(epoch_constants); + protocol.generation = next_generation; + log::info!( + "Registered protocol version {protocol_version} with its epoch clock; activation epoch {epoch}" + ); + Ok(next_generation) } pub fn clear_protocol_info() { log::info!("Clearing all protocol versions"); - let mut protocol_info = PROTOCOL.write().unwrap(); - protocol_info.clear_versions(); + let mut protocol = PROTOCOL.write().unwrap(); + let next_generation = protocol.next_generation(); + protocol.protocol_info.clear_versions(); + protocol.epoch_constants = None; + protocol.generation = next_generation; } /// Refresh the protocol version, i.e. derive the current version from the current epoch, and update `current_version` /// accordingly. pub fn refresh_protocol_version(current_epoch: Epoch) { - let current_version = ProtocolVersion::from_epoch(current_epoch); - set_protocol_version(current_version) + // Derive and publish under one write lock. Splitting this into a read followed by + // `set_protocol_version` allowed an atomic schedule/clock replacement to interleave and leave + // the new schedule carrying a `current_version` derived from the old one. + let mut protocol = PROTOCOL.write().unwrap(); + let current_version = protocol + .protocol_info + .all_versions + .version_for_epoch(current_epoch); + // Epoch refresh is another production publication boundary. Without this check, a schedule + // that entered the process through a future compatibility path could bypass the explicit + // release gate when the clock advanced into V2.1, leaving the runtime active even though + // `set_protocol_version` and schedule-loading APIs reject the same version. + // Reject an unreleased version without unwinding through the write guard. Panicking while + // holding `PROTOCOL` would poison the global lock and turn a deliberately rejected refresh + // into a process-wide runtime failure on every subsequent protocol read. + if let Err(error) = + proto::versioning::ensure_production_protocol_version_ready(current_version) + { + drop(protocol); + panic!("refusing to refresh to an unreleased protocol: {error}"); + } + log::info!("Setting current protocol version to {current_version}"); + if protocol.protocol_info.current_version != current_version { + let next_generation = protocol.next_generation(); + protocol.protocol_info.current_version = current_version; + protocol.generation = next_generation; + } } pub fn get_protocol_version_activation_epoch(protocol_version: ProtocolVersion) -> Epoch { // This unwrap is safe as long as the lock is not poisoned. // The lock can only become poisoned when a writer panics. - let protocol = PROTOCOL.write().unwrap(); - protocol.all_versions.get_activation_epoch(protocol_version) + let protocol = PROTOCOL.read().unwrap(); + protocol + .protocol_info + .all_versions + .get_activation_epoch(protocol_version) } pub fn get_protocol_version_period(protocol_version: ProtocolVersion) -> u16 { // This unwrap is safe as long as the lock is not poisoned. // The lock can only become poisoned when a writer panics. - let protocol = PROTOCOL.write().unwrap(); - match protocol.all_checkpoints_periods.get(&protocol_version) { + let protocol = PROTOCOL.read().unwrap(); + match protocol + .protocol_info + .all_checkpoints_periods + .get(&protocol_version) + { Some(period) => *period, None => u16::MAX, } @@ -222,11 +481,13 @@ mod tests { #[test] fn protocol_versions() { + let _protocol_runtime_guard = test_protocol_runtime_guard(); + clear_protocol_info(); let protocol_version = ProtocolVersion::guess(); assert_eq!(protocol_version, ProtocolVersion::V1_7); // Register the different protocol versions - register_protocol_version(ProtocolVersion::V1_7, 100, 45); + register_protocol_version(ProtocolVersion::V1_7, 0, 45); register_protocol_version(ProtocolVersion::V1_8, 200, 45); register_protocol_version(ProtocolVersion::V2_0, 300, 20); @@ -248,5 +509,159 @@ mod tests { let version = ProtocolVersion::guess(); assert_eq!(version, ProtocolVersion::V2_0); + + clear_protocol_info(); + } + + #[test] + fn context_aware_protocol_load_is_failure_atomic() { + let _protocol_runtime_guard = test_protocol_runtime_guard(); + clear_protocol_info(); + let consensus_constants = ConsensusConstants { + checkpoints_period: 45, + ..Default::default() + }; + let mut baseline = ProtocolInfo::default(); + baseline + .try_register(0, ProtocolVersion::V1_7, 45) + .unwrap(); + baseline + .try_register(200, ProtocolVersion::V1_8, 45) + .unwrap(); + baseline + .try_register(300, ProtocolVersion::V2_0, 20) + .unwrap(); + try_load_protocol_info_with_consensus_constants( + baseline.clone(), + &consensus_constants, + ) + .unwrap(); + let published_baseline = get_protocol_info(); + + let mut invalid = ProtocolInfo::default(); + invalid + .try_register(0, ProtocolVersion::V1_7, 30) + .unwrap(); + assert_eq!(invalid.validate(), Ok(())); + assert_eq!( + try_load_protocol_info_with_consensus_constants(invalid, &consensus_constants), + Err(ProtocolRegistrationError::TimingPeriodMismatch { + version: ProtocolVersion::V1_7, + expected_period: 45, + actual_period: 30, + }) + ); + assert_eq!(get_protocol_info(), published_baseline); + + clear_protocol_info(); + } + + #[test] + fn protocol_schedule_and_epoch_clock_publish_as_one_generation() { + let _protocol_runtime_guard = test_protocol_runtime_guard(); + clear_protocol_info(); + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: 1_000, + checkpoints_period: 45, + ..Default::default() + }; + let mut schedule = ProtocolInfo::default(); + schedule + .try_register(0, ProtocolVersion::V1_7, 45) + .unwrap(); + schedule + .try_register(200, ProtocolVersion::V1_8, 45) + .unwrap(); + schedule + .try_register(300, ProtocolVersion::V2_0, 20) + .unwrap(); + try_load_protocol_info_with_consensus_constants( + schedule.clone(), + &consensus_constants, + ) + .unwrap(); + + let (published_schedule, published_clock, generation) = get_protocol_runtime(); + let published_clock = published_clock.expect("context-aware load must pair its clock"); + assert_eq!(published_schedule, schedule); + assert_eq!(published_clock.checkpoint_zero_timestamp, 1_000); + assert_eq!(published_clock.checkpoint_zero_timestamp_wit2, 14_500); + assert_eq!(published_clock.checkpoints_period_wit2, 20); + + let mismatched_clock = EpochConstants { + checkpoint_zero_timestamp_wit2: 14_501, + ..published_clock + }; + assert_eq!( + try_load_protocol_runtime(schedule.clone(), mismatched_clock), + Err(ProtocolRegistrationError::RuntimeClockMismatch) + ); + let (unchanged_schedule, unchanged_clock, unchanged_generation) = get_protocol_runtime(); + assert_eq!(unchanged_schedule, schedule); + assert_eq!( + unchanged_clock + .expect("failed pair replacement must retain the clock") + .checkpoint_zero_timestamp_wit2, + 14_500 + ); + assert_eq!(unchanged_generation, generation); + + let registered_generation = + try_register_protocol_version_with_epoch_constants_and_generation( + ProtocolVersion::V2_0, + 300, + 20, + published_clock, + 400, + ) + .unwrap(); + let (extended, paired_clock, extended_generation) = get_protocol_runtime(); + // Re-registering the exact released activation is idempotent in the schedule, while + // publishing its paired current-version/clock view still receives one new generation. + assert_eq!(extended.current_version, ProtocolVersion::V2_0); + assert_eq!( + paired_clock + .expect("paired registration must retain the clock") + .checkpoint_zero_timestamp_wit2, + 14_500 + ); + assert!(extended_generation > generation); + assert_eq!(extended_generation, registered_generation); + + // A future native schedule remains valid as a pure offline value, but no production + // publication entry point may install it while the release guard is closed. Rejection + // must preserve the complete paired runtime, including its generation. + let mut unreleased = extended.clone(); + unreleased.try_register(400, ProtocolVersion::V2_1, 20).unwrap(); + unreleased.current_version = ProtocolVersion::V2_1; + validate_protocol_runtime(&unreleased, published_clock).unwrap(); + let expected = ProtocolRegistrationError::ReleaseNotReady { version: ProtocolVersion::V2_1 }; + assert_eq!( + try_register_protocol_version(ProtocolVersion::V2_1, 400, 20), + Err(expected.clone()), + ); + assert_eq!( + try_register_protocol_version_with_epoch_constants_and_generation( + ProtocolVersion::V2_1, 400, 20, published_clock, 400, + ), + Err(expected.clone()), + ); + assert_eq!(try_load_protocol_info(unreleased.clone()), Err(expected.clone())); + assert_eq!( + try_load_protocol_info_with_consensus_constants(unreleased.clone(), &consensus_constants), + Err(expected.clone()), + ); + assert_eq!(try_load_protocol_runtime(unreleased, published_clock), Err(expected)); + assert!(std::panic::catch_unwind(|| set_protocol_version(ProtocolVersion::V2_1)).is_err()); + let (retained_schedule, retained_clock, retained_generation) = get_protocol_runtime(); + assert_eq!(retained_schedule, extended); + let retained_clock = retained_clock.expect("release rejection must retain the paired clock"); + assert_eq!(retained_clock.checkpoint_zero_timestamp, published_clock.checkpoint_zero_timestamp); + assert_eq!(retained_clock.checkpoints_period, published_clock.checkpoints_period); + assert_eq!(retained_clock.checkpoint_zero_timestamp_wit2, published_clock.checkpoint_zero_timestamp_wit2); + assert_eq!(retained_clock.checkpoints_period_wit2, published_clock.checkpoints_period_wit2); + assert_eq!(retained_generation, extended_generation); + + clear_protocol_info(); } } diff --git a/data_structures/src/proto/versioning.rs b/data_structures/src/proto/versioning.rs index 62c9462b8b..3545826fe3 100644 --- a/data_structures/src/proto/versioning.rs +++ b/data_structures/src/proto/versioning.rs @@ -25,18 +25,237 @@ use crate::{ types::Message, }; -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct ProtocolInfo { pub current_version: ProtocolVersion, pub all_versions: VersionsMap, pub all_checkpoints_periods: HashMap, } +/// Deliberately closed for this unverified delegated-staking draft. Opening this production gate +/// requires a reviewed deterministic activation/migration boundary, real historical compatibility +/// evidence, an explicit snapshot trust policy, and separate release/activation authorization. +/// This is not a configurable epoch or a current-tip preflight result. +pub const DELEGATED_STAKING_RELEASE_READY: bool = false; + +/// Reject installation of an unreleased protocol in a production runtime. Pure schedule and state +/// transition types remain able to represent it for offline review and future verification. +pub fn ensure_production_protocol_version_ready( + version: ProtocolVersion, +) -> Result<(), ProtocolRegistrationError> { + if !DELEGATED_STAKING_RELEASE_READY && version >= ProtocolVersion::V2_1 { + return Err(ProtocolRegistrationError::ReleaseNotReady { version }); + } + Ok(()) +} + +/// Apply the same release gate to a complete schedule, including an unreached future activation +/// or an explicit current-version projection. A portable snapshot cannot bypass this boundary. +pub fn ensure_production_protocol_schedule_ready( + info: &ProtocolInfo, +) -> Result<(), ProtocolRegistrationError> { + ensure_production_protocol_version_ready(info.current_version)?; + for &version in info.all_versions.efv.keys() { + ensure_production_protocol_version_ready(version)?; + } + Ok(()) +} + +#[test] +fn delegated_staking_release_gate_is_closed_but_pure_schedules_remain_representable() { + let mut schedule = ProtocolInfo::default(); + schedule.try_register(10, ProtocolVersion::V2_0, 15).unwrap(); + assert!(ensure_production_protocol_schedule_ready(&schedule).is_ok()); + schedule.try_register(20, ProtocolVersion::V2_1, 15).unwrap(); + assert!(schedule.validate().is_ok()); + assert!(matches!( + ensure_production_protocol_schedule_ready(&schedule), + Err(ProtocolRegistrationError::ReleaseNotReady { version: ProtocolVersion::V2_1 }) + )); + let projected = ProtocolInfo { + current_version: ProtocolVersion::V2_1, + ..Default::default() + }; + assert!(ensure_production_protocol_schedule_ready(&projected).is_err()); +} + impl ProtocolInfo { - pub fn register(&mut self, epoch: Epoch, version: ProtocolVersion, checkpoint_period: u16) { - self.all_versions.register(epoch, version); + /// Serialize the protocol schedule into deterministic bytes for snapshot trust-anchor hashing. It sorts the `HashMap` fields used by `ProtocolInfo` and is consumed by `validate_snapshot_trust_anchor` in the node. + pub fn canonical_schedule_bytes(&self) -> Result, bincode::Error> { + #[derive(Serialize)] + struct CanonicalSchedule { + current_version: ProtocolVersion, + version_to_epoch: Vec<(ProtocolVersion, Epoch)>, + epoch_to_version: Vec<(Epoch, ProtocolVersion)>, + checkpoint_periods: Vec<(ProtocolVersion, u16)>, + } + + let mut version_to_epoch = self + .all_versions + .efv + .iter() + .map(|(&version, &epoch)| (version, epoch)) + .collect::>(); + version_to_epoch.sort_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .expect("protocol versions have a total order") + }); + + let epoch_to_version = self + .all_versions + .vfe + .iter() + .map(|(&epoch, &version)| (epoch, version)) + .collect::>(); + + let mut checkpoint_periods = self + .all_checkpoints_periods + .iter() + .map(|(&version, &period)| (version, period)) + .collect::>(); + checkpoint_periods.sort_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .expect("protocol versions have a total order") + }); + + bincode::serialize(&CanonicalSchedule { + current_version: self.current_version, + version_to_epoch, + epoch_to_version, + checkpoint_periods, + }) + } + + /// Validate that the protocol schedule is an exact, monotonic bijection, that an explicit + /// initial version agrees with its epoch-zero fallback, and that every registered version has + /// one usable checkpoint period. + pub fn validate(&self) -> Result<(), ProtocolRegistrationError> { + self.all_versions.validate()?; + + for (&version, &epoch) in &self.all_versions.efv { + let checkpoint_period = self + .all_checkpoints_periods + .get(&version) + .copied() + .ok_or(ProtocolRegistrationError::MissingCheckpointPeriod { version, epoch })?; + if checkpoint_period == 0 { + return Err(ProtocolRegistrationError::ZeroCheckpointPeriod { version }); + } + } + + for &version in self.all_checkpoints_periods.keys() { + if !self.all_versions.efv.contains_key(&version) { + return Err(ProtocolRegistrationError::OrphanCheckpointPeriod { version }); + } + } + + Ok(()) + } + + /// Validate the configured schedule against the two timing regimes representable by + /// [`crate::chain::EpochConstants`]. Generic registry validation intentionally remains + /// context-free: this check is for composing a complete node schedule with its consensus + /// constants immediately before publication. + pub fn validate_timing_topology( + &self, + consensus_constants: &ConsensusConstants, + ) -> Result<(), ProtocolRegistrationError> { + self.validate()?; + + let consensus_period = consensus_constants.checkpoints_period; + if consensus_period == 0 { + return Err(ProtocolRegistrationError::ZeroConsensusCheckpointPeriod); + } + + // EpochConstants has one clock before Wit/2.0. An omitted V1.7 registration remains the + // intentional epoch-zero fallback, but every explicit pre-Wit/2 version must agree with + // the consensus clock used by epoch/timestamp conversion. + for version in [V1_7, V1_8] { + if let Some(&actual_period) = self.all_checkpoints_periods.get(&version) + && actual_period != consensus_period + { + return Err(ProtocolRegistrationError::TimingPeriodMismatch { + version, + expected_period: consensus_period, + actual_period, + }); + } + } + + // EpochConstants likewise has one clock from Wit/2.0 onward. A V2.1 registration without + // an explicit V2.0 predecessor cannot identify that clock, and a second post-V2 period + // cannot be represented by the runtime timing model. + if let Some(&actual_period) = self.all_checkpoints_periods.get(&V2_1) { + let expected_period = self + .all_checkpoints_periods + .get(&V2_0) + .copied() + .ok_or(ProtocolRegistrationError::MissingTimingPredecessor { + version: V2_1, + predecessor: V2_0, + })?; + if actual_period != expected_period { + return Err(ProtocolRegistrationError::TimingPeriodMismatch { + version: V2_1, + expected_period, + actual_period, + }); + } + } + + Ok(()) + } + + /// Register one protocol activation without allowing either side of the epoch/version index + /// or its checkpoint period to diverge. Repeating the exact tuple is idempotent; every other + /// conflict is rejected before mutation. + pub fn try_register( + &mut self, + epoch: Epoch, + version: ProtocolVersion, + checkpoint_period: u16, + ) -> Result<(), ProtocolRegistrationError> { + self.validate()?; + if checkpoint_period == 0 { + return Err(ProtocolRegistrationError::ZeroCheckpointPeriod { version }); + } + + if let Some(®istered_epoch) = self.all_versions.efv.get(&version) { + if registered_epoch != epoch { + return Err(ProtocolRegistrationError::VersionEpochConflict { + version, + registered_epoch, + requested_epoch: epoch, + }); + } + + let registered_period = self.all_checkpoints_periods[&version]; + if registered_period != checkpoint_period { + return Err(ProtocolRegistrationError::VersionPeriodConflict { + version, + registered_period, + requested_period: checkpoint_period, + }); + } + + return Ok(()); + } + + self.all_versions.try_register(epoch, version)?; self.all_checkpoints_periods .insert(version, checkpoint_period); + Ok(()) + } + + pub fn register(&mut self, epoch: Epoch, version: ProtocolVersion, checkpoint_period: u16) { + self.try_register(epoch, version, checkpoint_period) + .unwrap_or_else(|error| { + panic!( + "invalid protocol registration for {version} at epoch {epoch}: {error}" + ) + }); } pub fn clear_versions(&mut self) { @@ -52,33 +271,78 @@ impl ProtocolInfo { consensus_constants: &ConsensusConstants, ) -> Option { if version == ProtocolVersion::MIN { - Some(consensus_constants.checkpoint_zero_timestamp) - } else { - let previous_version = version.prev(); - let previous_version_activation_timestamp = - self.derive_activation_timestamp(previous_version, consensus_constants); - let previous_version_checkpoint_period = - u32::from(if previous_version == ProtocolVersion::MIN { - consensus_constants.checkpoints_period - } else { - *self.all_checkpoints_periods.get(&previous_version)? - }); - let previous_version_span_epochs = self - .all_versions - .try_get_activation_epoch(version)? - .saturating_sub( - self.all_versions - .try_get_activation_epoch(previous_version) - .unwrap_or(0), - ); - let previous_version_span_seconds = - i64::from(previous_version_span_epochs * previous_version_checkpoint_period); - - Some(previous_version_activation_timestamp? + previous_version_span_seconds) + return Some(consensus_constants.checkpoint_zero_timestamp); + } + + let target_epoch = self.all_versions.try_get_activation_epoch(version)?; + let mut timestamp = consensus_constants.checkpoint_zero_timestamp; + let mut previous_epoch = 0; + let mut previous_period = consensus_constants.checkpoints_period; + + // Walk the schedule that is actually registered instead of recursively requiring every + // enum predecessor. Version gaps are structurally valid, and the consensus base clock + // covers the whole pre-Wit/2 span when V1.8 is intentionally absent. + for (&activation_epoch, ®istered_version) in + self.all_versions.vfe.range(..=target_epoch) + { + if registered_version == ProtocolVersion::MIN { + previous_epoch = activation_epoch; + previous_period = consensus_constants.checkpoints_period; + continue; + } + + let span_epochs = activation_epoch.checked_sub(previous_epoch)?; + let span_seconds = i64::from(span_epochs) + .checked_mul(i64::from(previous_period))?; + timestamp = timestamp.checked_add(span_seconds)?; + if registered_version == version { + return Some(timestamp); + } + previous_epoch = activation_epoch; + previous_period = *self.all_checkpoints_periods.get(®istered_version)?; } + + None } } +/// Verify that equivalent protocol schedules produce identical canonical bytes. It exercises `ProtocolInfo::canonical_schedule_bytes` with different `HashMap` insertion orders for snapshot-anchor stability. +#[test] +fn canonical_schedule_bytes_are_independent_of_hash_map_insertion_order() { + let mut first = ProtocolInfo { + current_version: ProtocolVersion::V2_1, + ..Default::default() + }; + first.all_versions.efv.insert(ProtocolVersion::V1_7, 0); + first.all_versions.efv.insert(ProtocolVersion::V2_0, 20); + first.all_versions.efv.insert(ProtocolVersion::V2_1, 30); + first.all_versions.vfe.insert(0, ProtocolVersion::V1_7); + first.all_versions.vfe.insert(20, ProtocolVersion::V2_0); + first.all_versions.vfe.insert(30, ProtocolVersion::V2_1); + first.all_checkpoints_periods.insert(ProtocolVersion::V1_7, 45); + first.all_checkpoints_periods.insert(ProtocolVersion::V2_0, 15); + first.all_checkpoints_periods.insert(ProtocolVersion::V2_1, 15); + + let mut second = ProtocolInfo { + current_version: ProtocolVersion::V2_1, + ..Default::default() + }; + second.all_versions.efv.insert(ProtocolVersion::V2_1, 30); + second.all_versions.efv.insert(ProtocolVersion::V2_0, 20); + second.all_versions.efv.insert(ProtocolVersion::V1_7, 0); + second.all_versions.vfe.insert(30, ProtocolVersion::V2_1); + second.all_versions.vfe.insert(20, ProtocolVersion::V2_0); + second.all_versions.vfe.insert(0, ProtocolVersion::V1_7); + second.all_checkpoints_periods.insert(ProtocolVersion::V2_1, 15); + second.all_checkpoints_periods.insert(ProtocolVersion::V2_0, 15); + second.all_checkpoints_periods.insert(ProtocolVersion::V1_7, 45); + + assert_eq!( + first.canonical_schedule_bytes().unwrap(), + second.canonical_schedule_bytes().unwrap() + ); +} + #[test] fn test_activation_timestamp_derivation() { let mut protocols = ProtocolInfo::default(); @@ -112,18 +376,371 @@ fn test_activation_timestamp_derivation() { protocols.derive_activation_timestamp(V2_0, &consensus_constants), None ); + + // A valid sparse schedule uses the base clock until V2.0 rather than requiring an otherwise + // unnecessary V1.8 registration, then uses the registered post-V2 clock through V2.1. + let mut sparse_protocols = ProtocolInfo::default(); + sparse_protocols.register(200, V2_0, 15); + sparse_protocols.register(300, V2_1, 15); + assert_eq!( + sparse_protocols.validate_timing_topology(&consensus_constants), + Ok(()) + ); + assert_eq!( + sparse_protocols.derive_activation_timestamp(V2_0, &consensus_constants), + Some(19_000) + ); + assert_eq!( + sparse_protocols.derive_activation_timestamp(V2_1, &consensus_constants), + Some(20_500) + ); } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[test] +fn protocol_registration_is_idempotent_monotonic_and_failure_atomic() { + let mut protocols = ProtocolInfo::default(); + protocols.try_register(0, V1_7, 45).unwrap(); + protocols.try_register(20, V1_8, 45).unwrap(); + protocols.try_register(30, V2_0, 20).unwrap(); + + let baseline = protocols.clone(); + assert_eq!(protocols.try_register(30, V2_0, 20), Ok(())); + assert_eq!(protocols, baseline); + + assert!(matches!( + protocols.try_register(40, V2_0, 20), + Err(ProtocolRegistrationError::VersionEpochConflict { .. }) + )); + assert_eq!(protocols, baseline); + + assert!(matches!( + protocols.try_register(30, V2_1, 20), + Err(ProtocolRegistrationError::EpochCollision { .. }) + )); + assert_eq!(protocols, baseline); + + assert!(matches!( + protocols.try_register(25, V2_1, 20), + Err(ProtocolRegistrationError::NonMonotonicSchedule { .. }) + )); + assert_eq!(protocols, baseline); + + assert!(matches!( + protocols.try_register(30, V2_0, 15), + Err(ProtocolRegistrationError::VersionPeriodConflict { .. }) + )); + assert_eq!(protocols, baseline); + + assert!(matches!( + protocols.try_register(40, V2_1, 0), + Err(ProtocolRegistrationError::ZeroCheckpointPeriod { .. }) + )); + assert_eq!(protocols, baseline); +} + +#[test] +fn protocol_registration_rejects_a_nonzero_explicit_initial_epoch_atomically() { + // Omitting V1_7 is intentional: version lookup treats it as active from epoch zero. + let mut protocols = ProtocolInfo::default(); + protocols.try_register(20, V1_8, 45).unwrap(); + assert_eq!(protocols.all_versions.version_for_epoch(0), V1_7); + let baseline = protocols.clone(); + + assert_eq!( + protocols.try_register(10, V1_7, 45), + Err(ProtocolRegistrationError::InitialVersionEpoch { epoch: 10 }) + ); + assert_eq!(protocols, baseline); + + // Bulk loading reaches `validate`, so a structurally bijective but contradictory decoded + // schedule is rejected as well. + let mut invalid = ProtocolInfo::default(); + invalid.all_versions.efv.insert(V1_7, 10); + invalid.all_versions.vfe.insert(10, V1_7); + invalid.all_checkpoints_periods.insert(V1_7, 45); + assert_eq!( + invalid.validate(), + Err(ProtocolRegistrationError::InitialVersionEpoch { epoch: 10 }) + ); + + let mut explicit = ProtocolInfo::default(); + assert_eq!(explicit.try_register(0, V1_7, 45), Ok(())); + let explicit_baseline = explicit.clone(); + assert_eq!(explicit.try_register(0, V1_7, 45), Ok(())); + assert_eq!(explicit, explicit_baseline); +} + +#[test] +fn protocol_validation_rejects_divergent_indexes_and_periods() { + let mut protocols = ProtocolInfo::default(); + protocols.try_register(0, V1_7, 45).unwrap(); + protocols.all_versions.vfe.insert(0, V1_8); + assert!(matches!( + protocols.validate(), + Err(ProtocolRegistrationError::InconsistentReverseEntry { .. }) + )); + + let mut protocols = ProtocolInfo::default(); + protocols.try_register(0, V1_7, 45).unwrap(); + protocols.all_checkpoints_periods.remove(&V1_7); + assert!(matches!( + protocols.validate(), + Err(ProtocolRegistrationError::MissingCheckpointPeriod { .. }) + )); +} + +#[test] +fn protocol_timing_topology_accepts_matching_custom_periods_and_implicit_initial_version() { + let consensus_constants = ConsensusConstants { + checkpoints_period: 30, + ..Default::default() + }; + + let mut explicit = ProtocolInfo::default(); + explicit.try_register(0, V1_7, 30).unwrap(); + explicit.try_register(10, V1_8, 30).unwrap(); + explicit.try_register(20, V2_0, 15).unwrap(); + explicit.try_register(30, V2_1, 15).unwrap(); + assert_eq!( + explicit.validate_timing_topology(&consensus_constants), + Ok(()) + ); + + let mut implicit_initial = ProtocolInfo::default(); + implicit_initial.try_register(10, V1_8, 30).unwrap(); + implicit_initial.try_register(20, V2_0, 15).unwrap(); + implicit_initial.try_register(30, V2_1, 15).unwrap(); + assert_eq!( + implicit_initial.all_versions.version_for_epoch(0), + V1_7 + ); + assert_eq!( + implicit_initial.validate_timing_topology(&consensus_constants), + Ok(()) + ); +} + +#[test] +fn protocol_timing_topology_rejects_each_unrepresentable_period() { + let consensus_constants = ConsensusConstants { + checkpoints_period: 30, + ..Default::default() + }; + + let mut mismatched_v1_7 = ProtocolInfo::default(); + mismatched_v1_7.try_register(0, V1_7, 45).unwrap(); + assert_eq!(mismatched_v1_7.validate(), Ok(())); + assert_eq!( + mismatched_v1_7.validate_timing_topology(&consensus_constants), + Err(ProtocolRegistrationError::TimingPeriodMismatch { + version: V1_7, + expected_period: 30, + actual_period: 45, + }) + ); + + let mut mismatched_v1_8 = ProtocolInfo::default(); + mismatched_v1_8.try_register(10, V1_8, 45).unwrap(); + assert_eq!(mismatched_v1_8.validate(), Ok(())); + assert_eq!( + mismatched_v1_8.validate_timing_topology(&consensus_constants), + Err(ProtocolRegistrationError::TimingPeriodMismatch { + version: V1_8, + expected_period: 30, + actual_period: 45, + }) + ); + + let mut mismatched_v2_1 = ProtocolInfo::default(); + mismatched_v2_1.try_register(0, V1_7, 30).unwrap(); + mismatched_v2_1.try_register(10, V1_8, 30).unwrap(); + mismatched_v2_1.try_register(20, V2_0, 15).unwrap(); + mismatched_v2_1.try_register(30, V2_1, 10).unwrap(); + assert_eq!(mismatched_v2_1.validate(), Ok(())); + assert_eq!( + mismatched_v2_1.validate_timing_topology(&consensus_constants), + Err(ProtocolRegistrationError::TimingPeriodMismatch { + version: V2_1, + expected_period: 15, + actual_period: 10, + }) + ); +} + +#[test] +fn protocol_timing_topology_rejects_v2_1_without_its_timing_predecessor() { + let consensus_constants = ConsensusConstants { + checkpoints_period: 30, + ..Default::default() + }; + let mut protocols = ProtocolInfo::default(); + protocols.try_register(10, V1_8, 30).unwrap(); + protocols.try_register(30, V2_1, 15).unwrap(); + + // Skipping a version remains valid for an isolated registry; it becomes invalid only when the + // schedule is composed with EpochConstants' single post-Wit/2 timing regime. + assert_eq!(protocols.validate(), Ok(())); + assert_eq!( + protocols.validate_timing_topology(&consensus_constants), + Err(ProtocolRegistrationError::MissingTimingPredecessor { + version: V2_1, + predecessor: V2_0, + }) + ); +} + +#[test] +fn protocol_timing_topology_rejects_a_zero_consensus_period() { + let consensus_constants = ConsensusConstants { + checkpoints_period: 0, + ..Default::default() + }; + assert_eq!(ProtocolInfo::default().validate(), Ok(())); + assert_eq!( + ProtocolInfo::default().validate_timing_topology(&consensus_constants), + Err(ProtocolRegistrationError::ZeroConsensusCheckpointPeriod) + ); +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct VersionsMap { efv: HashMap, vfe: BTreeMap, } impl VersionsMap { - pub fn register(&mut self, epoch: Epoch, version: ProtocolVersion) { + /// Whether the schedule contains no explicit epoch/version registrations. + pub fn is_empty(&self) -> bool { + self.efv.is_empty() + } + + /// Validate that the epoch-to-version and version-to-epoch maps are an exact bijection whose + /// activation epochs increase strictly with protocol version. The original protocol is + /// implicitly active from epoch zero when it is absent, so an explicit entry for it must name + /// that same epoch instead of contradicting the lookup fallback. + pub fn validate(&self) -> Result<(), ProtocolRegistrationError> { + if let Some(&epoch) = self.efv.get(&ProtocolVersion::MIN) + && epoch != 0 + { + return Err(ProtocolRegistrationError::InitialVersionEpoch { epoch }); + } + + for (&version, &epoch) in &self.efv { + match self.vfe.get(&epoch) { + Some(&reverse_version) if reverse_version == version => {} + Some(&reverse_version) => { + return Err(ProtocolRegistrationError::InconsistentReverseEntry { + version, + epoch, + reverse_version, + }); + } + None => { + return Err(ProtocolRegistrationError::MissingReverseEntry { + version, + epoch, + }); + } + } + } + + for (&epoch, &version) in &self.vfe { + match self.efv.get(&version) { + Some(&forward_epoch) if forward_epoch == epoch => {} + Some(&forward_epoch) => { + return Err(ProtocolRegistrationError::InconsistentForwardEntry { + version, + epoch, + forward_epoch, + }); + } + None => { + return Err(ProtocolRegistrationError::MissingForwardEntry { + version, + epoch, + }); + } + } + } + + let mut previous = None; + for (&epoch, &version) in &self.vfe { + if let Some((previous_epoch, previous_version)) = previous + && previous_version >= version + { + return Err(ProtocolRegistrationError::NonMonotonicSchedule { + earlier_version: previous_version, + earlier_epoch: previous_epoch, + later_version: version, + later_epoch: epoch, + }); + } + previous = Some((epoch, version)); + } + + Ok(()) + } + + /// Register an activation only if it preserves the bijective monotonic schedule. An exact + /// duplicate is idempotent. Validation is completed before either map is mutated. + pub fn try_register( + &mut self, + epoch: Epoch, + version: ProtocolVersion, + ) -> Result<(), ProtocolRegistrationError> { + self.validate()?; + + if version == ProtocolVersion::MIN && epoch != 0 { + return Err(ProtocolRegistrationError::InitialVersionEpoch { epoch }); + } + + if let Some(®istered_epoch) = self.efv.get(&version) { + if registered_epoch == epoch { + return Ok(()); + } + return Err(ProtocolRegistrationError::VersionEpochConflict { + version, + registered_epoch, + requested_epoch: epoch, + }); + } + + if let Some(®istered_version) = self.vfe.get(&epoch) { + return Err(ProtocolRegistrationError::EpochCollision { + epoch, + registered_version, + requested_version: version, + }); + } + + for (®istered_version, ®istered_epoch) in &self.efv { + let violates_order = (registered_version < version && registered_epoch >= epoch) + || (registered_version > version && registered_epoch <= epoch); + if violates_order { + let (earlier_version, earlier_epoch, later_version, later_epoch) = + if registered_epoch < epoch { + (registered_version, registered_epoch, version, epoch) + } else { + (version, epoch, registered_version, registered_epoch) + }; + return Err(ProtocolRegistrationError::NonMonotonicSchedule { + earlier_version, + earlier_epoch, + later_version, + later_epoch, + }); + } + } + self.efv.insert(version, epoch); self.vfe.insert(epoch, version); + Ok(()) + } + + pub fn register(&mut self, epoch: Epoch, version: ProtocolVersion) { + self.try_register(epoch, version).unwrap_or_else(|error| { + panic!("invalid protocol registration for {version} at epoch {epoch}: {error}") + }); } pub fn version_for_epoch(&self, queried_epoch: Epoch) -> ProtocolVersion { @@ -154,6 +771,104 @@ impl VersionsMap { } } +/// A protocol schedule registration would make the global epoch/version mapping ambiguous or +/// unusable. These errors are deterministic configuration/consensus errors, never replacement +/// instructions for an already registered tuple. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ProtocolRegistrationError { + #[error("protocol {version} is not release-ready: delegated-staking activation is disabled pending reviewed boundary migration, historical compatibility evidence, and snapshot trust policy")] + ReleaseNotReady { version: ProtocolVersion }, + #[error("initial protocol version must be registered at epoch zero, not epoch {epoch}")] + InitialVersionEpoch { epoch: Epoch }, + #[error("checkpoint period for protocol version {version} must be greater than zero")] + ZeroCheckpointPeriod { version: ProtocolVersion }, + #[error("consensus checkpoint period must be greater than zero")] + ZeroConsensusCheckpointPeriod, + #[error("protocol schedule cannot be represented by the runtime epoch clock")] + UnrepresentableRuntimeClock, + #[error("protocol schedule and runtime epoch clock do not describe the same activation")] + RuntimeClockMismatch, + #[error( + "protocol version {version} has checkpoint period {actual_period}, but its runtime timing regime requires {expected_period}" + )] + TimingPeriodMismatch { + version: ProtocolVersion, + expected_period: u16, + actual_period: u16, + }, + #[error("protocol version {version} requires explicit timing predecessor {predecessor}")] + MissingTimingPredecessor { + version: ProtocolVersion, + predecessor: ProtocolVersion, + }, + #[error( + "protocol version {version} is already registered at epoch {registered_epoch}, not epoch {requested_epoch}" + )] + VersionEpochConflict { + version: ProtocolVersion, + registered_epoch: Epoch, + requested_epoch: Epoch, + }, + #[error( + "protocol version {version} already has checkpoint period {registered_period}, not {requested_period}" + )] + VersionPeriodConflict { + version: ProtocolVersion, + registered_period: u16, + requested_period: u16, + }, + #[error( + "activation epoch {epoch} is already registered for protocol version {registered_version}, not {requested_version}" + )] + EpochCollision { + epoch: Epoch, + registered_version: ProtocolVersion, + requested_version: ProtocolVersion, + }, + #[error( + "protocol schedule is not monotonic: {earlier_version} at epoch {earlier_epoch} is followed by {later_version} at epoch {later_epoch}" + )] + NonMonotonicSchedule { + earlier_version: ProtocolVersion, + earlier_epoch: Epoch, + later_version: ProtocolVersion, + later_epoch: Epoch, + }, + #[error( + "protocol registry has {version} -> epoch {epoch}, but that epoch points to {reverse_version}" + )] + InconsistentReverseEntry { + version: ProtocolVersion, + epoch: Epoch, + reverse_version: ProtocolVersion, + }, + #[error("protocol registry has {version} -> epoch {epoch} without a reverse entry")] + MissingReverseEntry { + version: ProtocolVersion, + epoch: Epoch, + }, + #[error( + "protocol registry has epoch {epoch} -> {version}, but that version points to epoch {forward_epoch}" + )] + InconsistentForwardEntry { + version: ProtocolVersion, + epoch: Epoch, + forward_epoch: Epoch, + }, + #[error("protocol registry has epoch {epoch} -> {version} without a forward entry")] + MissingForwardEntry { + version: ProtocolVersion, + epoch: Epoch, + }, + #[error("protocol version {version} at epoch {epoch} has no checkpoint period")] + MissingCheckpointPeriod { + version: ProtocolVersion, + epoch: Epoch, + }, + #[error("checkpoint-period registry contains unregistered protocol version {version}")] + OrphanCheckpointPeriod { version: ProtocolVersion }, +} + impl IntoIterator for VersionsMap { type Item = (ProtocolVersion, Epoch); type IntoIter = std::collections::hash_map::IntoIter; @@ -427,6 +1142,25 @@ impl Versioned for crate::transaction::MintTransaction { } impl Versioned for crate::transaction::StakeTransactionBody { type LegacyType = ::ProtoStruct; + + fn to_versioned_pb( + &self, + version: ProtocolVersion, + ) -> Result, Error> + where + ::ProtoStruct: protobuf::Message, + { + let mut pb = self.to_pb(); + + // Unknown protobuf fields are ignored by old nodes. Explicitly omit + // Wit/2.1 authorization terms when deriving pre-2.1 consensus hashes, + // so upgraded nodes reproduce the exact legacy representation too. + if version < V2_1 && pb.has_output() { + pb.mut_output().clear_authorization_terms(); + } + + Ok(Box::new(pb)) + } } impl Versioned for crate::transaction::UnstakeTransactionBody { type LegacyType = ::ProtoStruct; diff --git a/data_structures/src/serialization_helpers.rs b/data_structures/src/serialization_helpers.rs index 33c03f1052..10a21360ca 100644 --- a/data_structures/src/serialization_helpers.rs +++ b/data_structures/src/serialization_helpers.rs @@ -633,6 +633,20 @@ where } } +/// Serialize a number as a decimal string for human-readable formats while preserving its native +/// representation in binary formats. +pub fn number_to_string(value: &T, serializer: S) -> Result +where + T: Display + Serialize, + S: Serializer, +{ + if serializer.is_human_readable() { + serializer.collect_str(value) + } else { + value.serialize(serializer) + } +} + #[test] fn test_number_from_string() { use serde_json; diff --git a/data_structures/src/staking/delegation.rs b/data_structures/src/staking/delegation.rs new file mode 100644 index 0000000000..2ec7712cf7 --- /dev/null +++ b/data_structures/src/staking/delegation.rs @@ -0,0 +1,7302 @@ +use std::{borrow::Cow, collections::{BTreeMap, BTreeSet}}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ + capabilities::Capability, + chain::{ + CheckpointBeacon, Epoch, Hash, PublicKeyHash, COMMISSION_INCREASE_NOTICE_SECONDS, + }, + serialization_helpers::number_from_string, + staking::{ + errors::StakesError, + helpers::{StakeKey, StakesResult}, + stakes::{QueryStakesKey, StakesTracker}, + }, + wit::Wit, +}; + +/// Maximum validator commission accepted by the delegated staking protocol. +pub const MAX_COMMISSION_PERCENT: u8 = 15; + +/// Hard minimum balance for every live Wit/2.1 stake entry: 10,000 WIT in nanowits. +pub const DELEGATED_STAKING_MINIMUM_NANOWITS: u64 = 10_000_000_000_000; + +/// Hard admission cap for one validator's live-plus-reserved stake: 10,000,000 WIT in nanowits. +pub const DELEGATED_STAKING_MAXIMUM_NANOWITS: u64 = 10_000_000_000_000_000; + +/// Percentage of a collateral penalty that is assigned to the validator owner first. +pub const OWNER_COLLATERAL_PERCENT: u8 = 70; + +/// Result type used by delegated staking state transitions. +pub type DelegationResult = StakesResult; + +mod activation; +pub use activation::{ + ActivationBoundaryCommitment, ActivationBoundaryEntry, ActivationBoundaryReport, + ActivationBoundaryValidator, ActivationMigrationDisposition, ActivationOwnerStatus, +}; + +/// Calculate the first epoch at which a requested validator commission becomes effective. +/// +/// Commission decreases (including no-op changes) take effect in the next epoch. Increases use +/// the fixed wall-clock notice period converted to whole checkpoint periods, rounded up and +/// clamped to at least one epoch. `None` means that the checkpoint period is zero, the delay does +/// not fit in an [`Epoch`], or adding the delay would overflow the current epoch. +pub fn commission_effective_epoch( + current_epoch: Epoch, + current_commission_percent: u8, + requested_commission_percent: u8, + checkpoint_period_seconds: u64, +) -> Option { + // Keep the hard commission ceiling at this shared arithmetic boundary as well as at the + // authorization/policy validators. Without this guard, a malformed persisted current value + // above 15% could still schedule a downward change because decreases do not need the + // wall-clock calculation. Returning `None` makes every caller fail closed before it records a + // new pending transition. + if current_commission_percent > MAX_COMMISSION_PERCENT + || requested_commission_percent > MAX_COMMISSION_PERCENT + { + return None; + } + + let delay = if requested_commission_percent <= current_commission_percent { + 1 + } else if checkpoint_period_seconds == 0 { + return None; + } else { + u32::try_from( + COMMISSION_INCREASE_NOTICE_SECONDS.div_ceil(checkpoint_period_seconds), + ) + .ok()? + .max(1) + }; + + current_epoch.checked_add(delay) +} + +/// Consensus parameters that govern delegated staking accounting. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DelegationPolicy { + /// Highest whole-number commission percentage a validator may charge. + pub maximum_commission_percent: u8, + /// Serialized compatibility field for the mandatory owner collateral split. + /// + /// Consensus allocation uses `OWNER_COLLATERAL_PERCENT` below rather than trusting a + /// persisted value, so malformed state cannot change the protocol's 70/30 rule. + pub owner_collateral_percent: u8, +} + +impl Default for DelegationPolicy { + fn default() -> Self { + Self { + maximum_commission_percent: MAX_COMMISSION_PERCENT, + owner_collateral_percent: OWNER_COLLATERAL_PERCENT, + } + } +} + +/// A validator commission change waiting for its activation epoch. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PendingCommission { + /// New whole-number commission percentage. + pub commission_percent: u8, + /// First epoch in which the new commission applies. + pub effective_epoch: Epoch, + /// Validator-scoped sequence number covered by the authorization signature. + pub nonce: u64, +} + +/// Persistent validator-wide delegation settings. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ValidatorPolicy { + /// Withdrawer of the first stake entry registered for the validator. + /// + /// This is the validator owner for reward and slashing purposes. It intentionally supports a + /// cold withdrawer different from the validator's operational key. + pub owner_withdrawer: PublicKeyHash, + /// Active whole-number commission percentage. + pub commission_percent: u8, + /// Sequence number of the last activated commission change. + pub commission_nonce: u64, + /// At most one commission change may be pending at a time. + pub pending_commission: Option, +} + +impl ValidatorPolicy { + /// Construct a zero-commission policy for a newly observed validator. + pub fn new(owner_withdrawer: PublicKeyHash) -> Self { + Self::with_commission(owner_withdrawer, 0) + } + + /// Construct a policy with the commission established by the validator's first authorization. + pub fn with_commission( + owner_withdrawer: PublicKeyHash, + commission_percent: u8, + ) -> Self { + Self { + owner_withdrawer, + commission_percent, + commission_nonce: 0, + pending_commission: None, + } + } + + /// Return the stake key that receives owner rewards and commission. + pub fn owner_key(self, validator: PublicKeyHash) -> StakeKey { + StakeKey { + validator, + withdrawer: self.owner_withdrawer, + } + } +} + +/// Uniquely identifies collateral reserved for one validator's commitment. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct CollateralReservationKey { + /// Data request for which collateral was reserved. + pub data_request: Hash, + /// Validator that made the commitment. + pub validator: PublicKeyHash, +} + +/// An exact debit from one stake entry made while reserving collateral. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CollateralDebit { + /// Stake entry from which collateral was removed. + pub key: StakeKey, + /// Amount removed from the entry. + pub amount: Wit, +} + +/// Exact stake-entry debits backing one oracle commitment. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CollateralReservation { + /// Total collateral reserved. + pub total: Wit, + /// Deterministically ordered entry debits whose sum equals `total`. + pub debits: Vec, + /// Native witness reward weights fixed before this commitment's collateral debit. Legacy + /// owner-only reservations have no weights and retain their original settlement rules. + pub reward_weights: Vec<(StakeKey, Wit)>, +} + +/// Settlement outcome retained for a pre-activation obligation whose historical debit or +/// beneficiary cannot be proven from the activation image alone. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LegacyActivationSettlement { + /// The request is still active and has not reached a terminal tally outcome. + Pending, + /// The validator was slashed; no beneficiary credit is due. + Slashed, + /// The request settled without minting an unproven amount. The terms remain recorded for a + /// future, separately authorized recovery/claim policy. + Deferred { + /// Nominal collateral not credited because the old debit is not proven. + collateral: Wit, + /// Witness reward not credited because the beneficiary is not proven or the payout is + /// coupled to the unresolved historical collateral decision. + witness_reward: Wit, + }, + /// A raw-zero legacy obligation had a canonical live owner and its witness reward was paid + /// through the old owner-only settlement rule. This is not historical collateral proof. + SettledToCanonicalOwner { + /// Reward credited to the canonical owner entry. + witness_reward: Wit, + }, +} + +/// Non-spendable record for a pre-activation Wit/2.0 commitment crossing the activation boundary. +/// +/// The canonical live owner is only a diagnostic recipient hint. It is intentionally not a +/// spendable claim authorization and does not prove that `raw_collateral` was debited by the old +/// release-arithmetic wrapper. Records remain after terminal settlement until an independently +/// approved historical recovery policy resolves them. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LegacyActivationObligation { + /// Raw serialized collateral from the historical request, including zero. + pub raw_collateral: Wit, + /// Raw witness reward term from the historical request. + pub witness_reward: Wit, + /// Sole live withdrawer observed at activation, if one existed. This is not proof of the + /// historical payer and cannot authorize a later claim. + pub canonical_live_owner: Option, + /// Current non-spendable settlement state. + pub settlement: LegacyActivationSettlement, +} + +/// An exact credit produced when distributing a validator reward. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RewardCredit { + /// Stake entry receiving the credit. + pub key: StakeKey, + /// Amount credited to the entry. + pub amount: Wit, +} + +/// Auditable result of a delegated reward distribution. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct RewardDistribution { + /// Total commission transferred from delegators to the owner. + pub owner_commission: Wit, + /// Deterministically ordered credits whose sum equals the gross reward. + pub credits: Vec, +} + +/// Stable machine-readable reasons reported by a Wit/2.1 activation preflight. +/// +/// The cached-sidecar variants are diagnostics only. They describe optional upgrade-history +/// records and do not make the report unsafe; the transition rebuilds authoritative obligations +/// from the canonical request pool. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActivationPreflightErrorCode { + /// The one-time activation transition has already been applied to this state. + ActivationAlreadyInitialized, + /// The live legacy stake ledger could not be read for this validator. + LiveStakeLedgerUnreadable, + /// A live legacy stake entry is below the Wit/2.1 minimum and is therefore settlement-only. + /// This is a diagnostic classification; it is not a native eligibility grant. + LiveStakeBelowMinimum, + /// No canonical live validator has an entry at the Wit/2.1 minimum. + /// Activation cannot safely cross the boundary without a proposer-capable validator. + NoEligibleValidator, + /// A legacy validator has more than one live withdrawer, so activation cannot infer its + /// immutable owner from canonical preactivation state. + AmbiguousLiveOwner, + /// The canonical activation projection fails checked live/reservation accounting. + ActivationAccountingInvalid, + /// An outstanding legacy commitment has no live stake entry from which to derive its owner. + MissingLiveOwner, + /// A cached sidecar total differs from the authoritative data-request collateral. + CachedTotalMismatch, + /// A cached legacy sidecar does not contain exactly one owner-only debit. + CachedDebitCountMismatch, + /// The validator in the cached debit differs from the commitment validator. + CachedDebitValidatorMismatch, + /// The cached debit amount differs from the sidecar total. + CachedDebitAmountMismatch, + /// A cached pre-activation reservation has no matching outstanding Wit/2.0 commitment. + OrphanedCachedReservation, +} + +impl ActivationPreflightErrorCode { + /// Whether this diagnostic describes only optional preactivation cache state. + /// + /// These records can differ by node upgrade height and are deliberately excluded from the + /// activation safety decision. The authoritative live stake ledger and outstanding request + /// pool are rebuilt into the first Wit/2.1 image instead. + pub fn is_cache_diagnostic(self) -> bool { + matches!( + self, + Self::CachedTotalMismatch + | Self::CachedDebitCountMismatch + | Self::CachedDebitValidatorMismatch + | Self::CachedDebitAmountMismatch + | Self::OrphanedCachedReservation + ) + } +} + +/// A live legacy stake entry that cannot cross the Wit/2.1 activation boundary. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationPreflightLiveStake { + /// Exact validator and withdrawer pair found in the live stake ledger. + pub key: StakeKey, + /// Live balance, in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub stake_nanowits: u64, +} + +/// Read-only description of a cached owner-only collateral sidecar. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationPreflightSidecar { + /// Total collateral recorded by the sidecar, in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub total_nanowits: u64, + /// Number of exact debits recorded by the sidecar. + pub debit_count: usize, + /// Whether every cached debit has a zero amount (vacuously true for no debits). + /// + /// Multi-debit diagnostics intentionally omit individual entries, so clients need this bit to + /// distinguish an inert legacy zero-collateral cache from a malformed zero-total sidecar that + /// still contains a nonzero debit. + pub all_debits_zero: bool, + /// Historical withdrawer named by a single cached debit, if the shape permits reading it. + /// + /// This field is diagnostic only and is never accepted as activation-owner provenance. + pub historical_withdrawer: Option, + /// Validator named by a single cached debit, if the shape permits reading it. + pub debit_validator: Option, + /// Amount named by a single cached debit, in nanowits, if one exists. + #[serde( + serialize_with = "serialize_optional_u64_as_string", + deserialize_with = "deserialize_optional_u64_from_string", + )] + pub debit_amount_nanowits: Option, +} + +/// Activation-preflight result for one authoritative outstanding Wit/2.0 commitment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationPreflightCommitment { + /// Data request whose collateral is still outstanding. + pub data_request: Hash, + /// Validator that submitted the commitment. + pub validator: PublicKeyHash, + /// Collateral required by the authoritative data-request state, in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub expected_collateral_nanowits: u64, + /// Owner derived exclusively from the current live legacy stake ledger. + pub canonical_live_owner: Option, + /// Number of live stake entries found for this validator. Wit/2.0 normally has exactly one; + /// more than one is reported as ambiguous and blocks activation. + pub live_stake_entry_count: usize, + /// Cached owner-only sidecar, if this node recorded one before activation. + pub cached_sidecar: Option, + /// Deterministic owner or optional-cache diagnostics found for this commitment. + pub errors: Vec, + /// Whether this individual commitment can cross the activation boundary safely after ignoring + /// optional cache diagnostics. + pub safe: bool, +} + +/// Cached reservation that is not backed by an authoritative outstanding Wit/2.0 commitment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationPreflightOrphanedSidecar { + /// Key under which the unexpected reservation is cached. + pub key: CollateralReservationKey, + /// Cached reservation details. Its withdrawer remains diagnostic-only data. + pub sidecar: ActivationPreflightSidecar, + /// Deterministic cache diagnostics found for this reservation. + pub errors: Vec, +} + +/// Deterministic, read-only go/no-go report for the one-time Wit/2.1 activation transition. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationPreflightReport { + /// True when at least one canonical owner can propose a native block, every outstanding legacy + /// commitment has a live canonical owner, the reconstructed live/reserved aggregates pass + /// checked accounting, and the activation transition has not already run. Below-minimum + /// legacy owners are preserved as settlement-only entries and remain listed diagnostically; + /// they do not contribute native power. Optional preactivation sidecar mismatches and orphans + /// remain in the diagnostic fields but do not affect this decision. + pub safe: bool, + /// Exact chain tip whose active requests and stake ledger were inspected. + /// + /// Operators and multi-node clients must require this beacon to agree before combining + /// reports; an individually synced node can still be on a different fork or lag another node. + pub inspected_beacon: CheckpointBeacon, + /// Persisted one-time transition marker from the inspected chain state. + pub activation_initialized: bool, + /// Exact minimum live entry balance enforced by the projected activation epoch, in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub minimum_stake_nanowits: u64, + /// Number of live stake entries inspected, or `None` when the ledger could not be read. + pub live_stake_entry_count: Option, + /// Validators with more than one canonical live legacy withdrawer. Their ownership is + /// ambiguous and blocks activation; no lexicographic fallback is consensus-safe. + pub ambiguous_live_validators: Vec, + /// Canonically ordered live entries whose balance is below the activation minimum. + pub below_minimum_live_stakes: Vec, + /// Whether the otherwise-canonical activation projection failed its checked accounting gate. + pub activation_accounting_error: bool, + /// Number of authoritative outstanding Wit/2.0 commitments inspected. + pub outstanding_legacy_commitment_count: usize, + /// Number of those commitments for which this node has a cached sidecar. + pub cached_legacy_sidecar_count: usize, + /// Number of those commitments that activation will need to reconstruct from live state. + pub missing_legacy_sidecar_count: usize, + /// One result per authoritative commitment, ordered by `(data_request, validator)`. + pub commitments: Vec, + /// Cached reservations with no authoritative outstanding Wit/2.0 commitment, in key order. + pub orphaned_cached_sidecars: Vec, + /// Report-wide unsafe conditions. + pub errors: Vec, +} + +/// Serialize a u64 as a decimal string for human-readable activation diagnostics. It is used by the serde fields in ActivationPreflightReport and related boundary records. +fn serialize_u64_as_string(value: &u64, serializer: S) -> Result +where + S: Serializer, +{ + if serializer.is_human_readable() { + serializer.serialize_str(&value.to_string()) + } else { + serializer.serialize_u64(*value) + } +} + +/// Serialize an optional u64 as a decimal string while preserving absent values. It is used by ActivationPreflightSidecar for lossless optional debit amounts. +fn serialize_optional_u64_as_string( + value: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match value { + Some(value) if serializer.is_human_readable() => { + serializer.serialize_some(&value.to_string()) + } + Some(value) => serializer.serialize_some(value), + None => serializer.serialize_none(), + } +} + +/// Deserialize an optional human-readable decimal u64 without losing its exact value. It is paired with serialize_optional_u64_as_string on activation-sidecar fields. +fn deserialize_optional_u64_from_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + if deserializer.is_human_readable() { + Option::::deserialize(deserializer)? + .map(|value| value.parse::().map_err(serde::de::Error::custom)) + .transpose() + } else { + Option::::deserialize(deserializer) + } +} + +/// Classify a cached legacy collateral sidecar without allowing it to affect activation. It compares sidecar shape and totals with ActivationPreflightErrorCode while DelegationState::activation_preflight builds diagnostics. +fn inspect_activation_sidecar( + reservation_key: CollateralReservationKey, + reservation: &CollateralReservation, + expected_total: Option, +) -> (ActivationPreflightSidecar, Vec) { + let mut errors = Vec::new(); + // A serialized zero Wit/2.0 collateral is a protocol-level no-op. Older upgraded nodes could + // still cache an all-zero sidecar for it. All sidecar shapes are retained as diagnostics only; + // activation drops them before rebuilding owner policies and authoritative obligations. + let all_debits_zero = reservation + .debits + .iter() + .all(|debit| debit.amount.nanowits() == 0); + let inert_zero_sidecar = expected_total.is_some_and(|expected| expected.nanowits() == 0) + && reservation.total.nanowits() == 0 + && all_debits_zero; + let debit = reservation.debits.first().filter(|_| reservation.debits.len() == 1); + if !inert_zero_sidecar { + if expected_total.is_some_and(|expected| expected != reservation.total) { + errors.push(ActivationPreflightErrorCode::CachedTotalMismatch); + } + if reservation.debits.len() != 1 { + errors.push(ActivationPreflightErrorCode::CachedDebitCountMismatch); + } + + if debit.is_some_and(|debit| debit.key.validator != reservation_key.validator) { + errors.push(ActivationPreflightErrorCode::CachedDebitValidatorMismatch); + } + if debit.is_some_and(|debit| debit.amount != reservation.total) { + errors.push(ActivationPreflightErrorCode::CachedDebitAmountMismatch); + } + } + + ( + ActivationPreflightSidecar { + total_nanowits: reservation.total.nanowits(), + debit_count: reservation.debits.len(), + all_debits_zero, + historical_withdrawer: debit.map(|debit| debit.key.withdrawer), + debit_validator: debit.map(|debit| debit.key.validator), + debit_amount_nanowits: debit.map(|debit| debit.amount.nanowits()), + }, + errors, + ) +} + +/// Validate the consensus-critical shape shared by native collateral refund and slash paths. +/// +/// Runtime reservation construction already emits this canonical form, and persisted-state load +/// validates it before publication. Rechecking at the final consumption boundary prevents a +/// malformed in-memory sidecar from crediting a different amount on refund or being silently +/// consumed on slash. Pre-activation Wit/2.0 sidecars remain deliberately exempt through +/// [`DelegationState::slash_legacy_collateral`], because they are advisory upgrade-history caches +/// and must not affect legacy block validity. +fn validate_collateral_reservation_shape( + reservation_key: CollateralReservationKey, + reservation: &CollateralReservation, +) -> DelegationResult<()> { + if reservation.total.nanowits() == 0 || reservation.debits.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "collateral reservation is empty", + }); + } + + let mut total = 0_u64; + let mut previous_key: Option<&StakeKey> = None; + for debit in &reservation.debits { + if debit.key.validator != reservation_key.validator + || debit.amount.nanowits() == 0 + || previous_key.is_some_and(|key| key >= &debit.key) + { + return Err(StakesError::InvalidDelegationState { + reason: "collateral debits are not canonical for their reservation", + }); + } + total = total + .checked_add(debit.amount.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + previous_key = Some(&debit.key); + } + if total != reservation.total.nanowits() { + return Err(StakesError::InvalidDelegationState { + reason: "collateral debit sum does not match reservation total", + }); + } + + let mut previous_reward_key: Option<&StakeKey> = None; + let mut _reward_total = 0_u64; + for (key, coins) in &reservation.reward_weights { + if key.validator != reservation_key.validator || coins.nanowits() == 0 + || previous_reward_key.is_some_and(|previous| previous >= key) + { + return Err(StakesError::InvalidDelegationState { + reason: "reservation reward weights are not canonical", + }); + } + _reward_total = _reward_total.checked_add(coins.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + previous_reward_key = Some(key); + } + if !reservation.reward_weights.is_empty() && reservation.debits.iter().any(|debit| { + reservation.reward_weights.binary_search_by(|(key, _)| key.cmp(&debit.key)) + .map_or(true, |index| reservation.reward_weights[index].1 < debit.amount) + }) { + return Err(StakesError::InvalidDelegationState { + reason: "native collateral debit lacks its committed reward weight", + }); + } + + Ok(()) +} + +/// Return the one hard minimum used by every native Wit/2.1 transition. +/// +/// The public transition helpers retain their minimum argument for source compatibility with the +/// pre-delegation APIs, but native consensus must not become configurable through a caller that +/// supplies a different value. Callers therefore normalize to this constant before doing any +/// accounting; legacy Wit/2.0 wrappers remain parameterized elsewhere. +fn hard_v21_minimum_stake() -> Wit { + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS) +} + +/// Persistent state needed in addition to the stake-entry ledger for delegation. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct DelegationState { + /// Consensus accounting policy. + pub policy: DelegationPolicy, + /// Whether the one-time Wit/2.1 legacy-owner canonicalization has completed. + pub activation_initialized: bool, + /// Validator owner and commission settings. + pub validators: BTreeMap, + /// Outstanding oracle collateral reservations. + pub collateral_reservations: + BTreeMap, + /// Pre-activation commitments whose historical debit or beneficiary is not proven by the + /// activation image. These records are not native escrow and do not grant a spendable claim. + pub legacy_activation_obligations: + BTreeMap, + /// Irrevocable withdrawer requests that exclude exact entries from new participation until + /// their previously earned rewards/collateral settle and their final balance is withdrawn. + pub exit_intents: BTreeSet>, +} + +impl DelegationState { + /// Read-only native power view, excluding entries whose withdrawers requested exit. + pub fn participating_stakes_checked<'a>( + &self, + stakes: &'a StakesTracker, + ) -> DelegationResult> { + stakes.validate_validator_index_checked()?; + // Preactivation exit-intent bytes are an upgrade-history sidecar. They are cleared by + // canonical activation and must not suppress legacy power while a caller is constructing + // the boundary projection from authoritative stake/request state. + if !self.activation_initialized || self.exit_intents.is_empty() { + Ok(Cow::Borrowed(stakes)) + } else { + Ok(Cow::Owned(stakes.retain_entries_checked(|key| !self.exit_intents.contains(key))?)) + } + } + + /// Validate a withdrawer-signed exit request against the exact entry and retained owner duty. + pub fn validate_exit_request( + &self, + stakes: &StakesTracker, + key: &StakeKey, + nonce: u64, + ) -> DelegationResult<()> { + self.validate_authoritative_owner_policies(stakes)?; + if self.activation_initialized && self.exit_intents.contains(key) { + return Err(StakesError::StakeEntryExiting { key: key.clone() }); + } + let entry = stakes.query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .into_iter().next().ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + if nonce != entry.value.nonce { + return Err(StakesError::InvalidExitNonce { + key: key.clone(), used: nonce, expected: entry.value.nonce, + }); + } + let policy = self.validator_policy(key.validator)?; + if key.withdrawer == policy.owner_withdrawer + && stakes.query_stakes_by_validator_primary_checked(key.validator)? + .iter() + .any(|entry| entry.key != *key && entry.value.coins.nanowits() > 0) + { + return Err(StakesError::OwnerExitWithDelegators { validator: key.validator }); + } + Ok(()) + } + + /// Persist an irrevocable draining request; old reservations remain eligible for settlement. + pub fn request_exit( + &mut self, + stakes: &StakesTracker, + key: StakeKey, + nonce: u64, + ) -> DelegationResult<()> { + self.validate_exit_request(stakes, &key, nonce)?; + self.exit_intents.insert(key); + Ok(()) + } + + /// Reject a top-up that could keep an exiting withdrawer's balance moving indefinitely. + pub fn ensure_entry_accepts_deposit(&self, key: &StakeKey) -> DelegationResult<()> { + if self.activation_initialized && self.exit_intents.contains(key) { + Err(StakesError::StakeEntryExiting { key: key.clone() }) + } else { + Ok(()) + } + } + + /// Forget a completed exit only after the exact entry has actually been removed and all of + /// its earlier collateral/reward destinations have settled. + pub fn complete_exit_if_removed( + &mut self, + stakes: &StakesTracker, + key: &StakeKey, + ) -> DelegationResult<()> { + // An activated exit completion mutates authoritative policy state. Prove that both + // maintained stake indexes still match the primary ledger before interpreting a missing + // primary row as a completed removal; otherwise a stale secondary row could survive while + // this helper clears the exact-key drain intent. + if self.activation_initialized { + stakes.validate_validator_index_checked()?; + } + match stakes.query_stakes_checked(QueryStakesKey::Key(key.clone())) { + Ok(_) => Ok(()), + Err(StakesError::EntryNotFound { .. }) => { + self.ensure_entry_can_be_removed(key)?; + self.exit_intents.remove(key); + Ok(()) + } + Err(error) => Err(error), + } + } + /// Return a validator's persistent policy. + pub fn validator_policy(&self, validator: PublicKeyHash) -> DelegationResult { + self.validators + .get(&validator) + .copied() + .ok_or(StakesError::ValidatorPolicyNotFound { validator }) + } + + /// Return the persistent owner stake key for a validator. + pub fn owner_key( + &self, + validator: PublicKeyHash, + ) -> DelegationResult> { + Ok(self.validator_policy(validator)?.owner_key(validator)) + } + + /// Return the recorded post-activation owner, or deterministically derive it from the live + /// legacy stake ledger before activation. + /// + /// Persisted Wit/2.0 validators have one withdrawer. Malformed historical snapshots that + /// contain more than one live key are rejected rather than assigned a guessed owner. + pub fn owner_withdrawer_or_legacy( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + ) -> DelegationResult { + if self.activation_initialized { + // Once the marker is persisted, policy state is authoritative and every validator + // that existed at activation (or was registered later) must retain its immutable + // owner record. Deriving a replacement from live stake here would silently reset a + // missing owner's commission and nonce history and could select a delegator as owner. + return self + .validator_policy(validator) + .map(|policy| policy.owner_withdrawer); + } + + // Policy and reservation sidecars are inert compatibility caches before activation. Their + // presence can depend on when a node upgraded, so neither may become an owner-recovery + // source for consensus until activation has rebuilt policies from the live stake ledger. + self.legacy_live_withdrawer(stakes, validator) + } + + /// Derive the current Wit/2.0 owner exclusively from the live one-withdrawer stake ledger. + /// + /// Pre-activation consensus paths must use this view so inert policy and reservation sidecars + /// cannot change Wit/2.0 transaction validity. Read the canonical primary map rather than a + /// maintained secondary index so an omitted validator-index row cannot hide live ownership. + /// A malformed legacy validator with multiple live withdrawers is rejected instead of being + /// assigned an arbitrary owner; activation must never convert ambiguous history into an + /// immutable policy. + pub fn legacy_live_withdrawer( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + ) -> DelegationResult { + let entries = stakes.query_stakes_by_validator_primary_checked(validator)?; + match entries.as_slice() { + [] => Err(StakesError::ValidatorNotFound { validator }), + [entry] => Ok(entry.key.withdrawer), + _ => Err(StakesError::InvalidDelegationState { + reason: "legacy validator has ambiguous live withdrawer ownership", + }), + } + } + + /// Inspect whether the supplied authoritative Wit/2.0 commitments can cross the Wit/2.1 + /// activation boundary without changing this delegation state or the stake ledger. + /// + /// `outstanding_legacy_commitments` must be built from the active data-request pool, not from + /// this state's optional sidecar cache. A live owner is derived exclusively from the current + /// stake ledger. Cached sidecar shape and orphan rows are exposed for diagnostics only; a + /// cached debit withdrawer is deliberately not compared with, or used to replace, that owner + /// because it may depend on node upgrade height. + pub fn activation_preflight( + &self, + stakes: &StakesTracker, + outstanding_legacy_commitments: &BTreeMap, + inspected_beacon: CheckpointBeacon, + _minimum_stake: Wit, + ) -> ActivationPreflightReport { + // Keep this diagnostic report tied to the protocol constant even when a legacy caller + // passes a mutable/runtime value. A preflight that reports a different minimum could + // falsely authorize activation of a state the consensus transition would reject. + let minimum_stake = hard_v21_minimum_stake(); + let mut report_errors = Vec::new(); + if self.activation_initialized { + report_errors.push(ActivationPreflightErrorCode::ActivationAlreadyInitialized); + } + + // Inspect the complete ledger, not only commitment validators. A zero or sub-minimum + // legacy owner is preserved for settlement and reported here, but it is not admitted to + // native eligibility or new collateral. The separate no-eligible-owner check remains a + // hard activation gate. + let (live_stake_entry_count, below_minimum_live_stakes, ambiguous_live_validators) = + match stakes.query_stakes_checked(QueryStakesKey::All) { + Ok(mut entries) => { + entries.sort_by(|left, right| left.key.cmp(&right.key)); + let mut ambiguous_live_validators = Vec::new(); + let mut previous_validator: Option = None; + let mut validator_entry_count = 0usize; + for entry in &entries { + if previous_validator == Some(entry.key.validator) { + validator_entry_count = validator_entry_count.saturating_add(1); + } else { + if validator_entry_count > 1 { + if let Some(validator) = previous_validator { + ambiguous_live_validators.push(validator); + } + } + previous_validator = Some(entry.key.validator); + validator_entry_count = 1; + } + } + if validator_entry_count > 1 { + if let Some(validator) = previous_validator { + ambiguous_live_validators.push(validator); + } + } + let below_minimum = entries + .iter() + .filter(|entry| entry.value.coins < minimum_stake) + .map(|entry| ActivationPreflightLiveStake { + key: entry.key.clone(), + stake_nanowits: entry.value.coins.nanowits(), + }) + .collect::>(); + if !self.activation_initialized + && !entries + .iter() + .any(|entry| entry.value.coins >= minimum_stake) + { + report_errors.push(ActivationPreflightErrorCode::NoEligibleValidator); + } + (Some(entries.len()), below_minimum, ambiguous_live_validators) + } + Err(_) => { + report_errors.push(ActivationPreflightErrorCode::LiveStakeLedgerUnreadable); + (None, Vec::new(), Vec::new()) + } + }; + let ambiguous_live_validators = if self.activation_initialized { + Vec::new() + } else { + ambiguous_live_validators + }; + if !ambiguous_live_validators.is_empty() { + report_errors.push(ActivationPreflightErrorCode::AmbiguousLiveOwner); + } + + let mut cached_legacy_sidecar_count = 0usize; + let mut commitments = Vec::with_capacity(outstanding_legacy_commitments.len()); + for (reservation_key, expected_collateral) in outstanding_legacy_commitments { + let mut errors = Vec::new(); + let (canonical_live_owner, live_stake_entry_count) = + match stakes + .query_stakes_by_validator_primary_checked(reservation_key.validator) + { + Ok(entries) => match entries.as_slice() { + [] => { + errors.push(ActivationPreflightErrorCode::MissingLiveOwner); + (None, 0) + } + [entry] => (Some(entry.key.withdrawer), 1), + _ => { + errors.push(ActivationPreflightErrorCode::AmbiguousLiveOwner); + (None, entries.len()) + } + }, + Err(StakesError::ValidatorNotFound { .. }) => { + errors.push(ActivationPreflightErrorCode::MissingLiveOwner); + (None, 0) + } + Err(_) => { + errors.push(ActivationPreflightErrorCode::LiveStakeLedgerUnreadable); + (None, 0) + } + }; + + let cached_sidecar = self + .collateral_reservations + .get(reservation_key) + .map(|reservation| { + cached_legacy_sidecar_count += 1; + let (sidecar, sidecar_errors) = inspect_activation_sidecar( + *reservation_key, + reservation, + Some(*expected_collateral), + ); + errors.extend(sidecar_errors); + sidecar + }); + + let safe = errors.iter().all(|error| error.is_cache_diagnostic()); + commitments.push(ActivationPreflightCommitment { + data_request: reservation_key.data_request, + validator: reservation_key.validator, + expected_collateral_nanowits: expected_collateral.nanowits(), + canonical_live_owner, + live_stake_entry_count, + cached_sidecar, + // Optional sidecar shape is retained in the report for operator diagnostics but + // cannot make activation unsafe. The authoritative commitment and live-owner + // checks above remain blocking. + safe, + errors, + }); + } + + let mut orphaned_cached_sidecars = Vec::new(); + // Once initialized, this map may legitimately contain native Wit/2.1 reservations, which + // are outside the scope of an activation preflight and cannot be classified as orphans. + if !self.activation_initialized { + for (reservation_key, reservation) in &self.collateral_reservations { + if outstanding_legacy_commitments.contains_key(reservation_key) { + continue; + } + // A zero-value legacy collateral transition is a protocol-level no-op. Older + // binaries could nevertheless leave an empty cache record behind; activation + // removes that exact shape, so it must not make this read-only go/no-go report + // claim the transition is unsafe. Any zero-total record with a nonzero debit is + // intentionally retained as a diagnostic orphan below. It cannot affect the + // activation decision because the transition discards all preactivation caches. + if reservation.total.nanowits() == 0 + && reservation + .debits + .iter() + .all(|debit| debit.amount.nanowits() == 0) + { + continue; + } + let (sidecar, mut errors) = + inspect_activation_sidecar(*reservation_key, reservation, None); + errors.insert( + 0, + ActivationPreflightErrorCode::OrphanedCachedReservation, + ); + orphaned_cached_sidecars.push(ActivationPreflightOrphanedSidecar { + key: *reservation_key, + sidecar, + errors, + }); + } + } + + let missing_legacy_sidecar_count = outstanding_legacy_commitments + .len() + .saturating_sub(cached_legacy_sidecar_count); + + // Once every independently diagnosable prerequisite is clean, build the same canonical + // legacy reservation projection used by the first Wit/2.1 block and run the persistence + // accounting invariant against it. This catches cumulative u64 overflow across several + // individually valid Wit/2.0 commitments before the activation marker can become + // authoritative. Cache-only diagnostics do not skip this projection: initialization + // discards those optional records and reconstructs the authoritative obligations below. + let activation_accounting_error = if report_errors.is_empty() + && commitments.iter().all(|commitment| commitment.safe) + { + let mut projected = self.clone(); + let accounting_result: DelegationResult<()> = (|| { + projected.initialize_legacy_validators_for_activation(stakes, minimum_stake)?; + // The migrated obligation is intentionally not inserted as native escrow, but + // preflight still has to reject an activation image whose live balance plus the + // nominal historical terms cannot be represented. This is an arithmetic envelope + // only: it does not prove that the old release debited that amount and therefore + // must not turn the obligation into spendable collateral. + let mut nominal_collateral_by_validator = + BTreeMap::::new(); + for (reservation_key, expected_collateral) in outstanding_legacy_commitments { + let total = nominal_collateral_by_validator + .entry(reservation_key.validator) + .or_default(); + *total = total + .checked_add(expected_collateral.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + } + for entry in stakes.query_stakes_checked(QueryStakesKey::All)? { + if let Some(nominal_collateral) = + nominal_collateral_by_validator.get(&entry.key.validator) + { + entry + .value + .coins + .nanowits() + .checked_add(*nominal_collateral) + .ok_or(StakesError::ArithmeticOverflow)?; + } + } + // Positive pre-activation collateral is represented by a non-spendable + // obligation during the real transition, not by an exact native reservation. + // The preflight only checks the migrated live/policy image here; request terms + // and terminal outcomes are supplied by the authoritative request pool during + // block projection. + projected.validate_stake_and_reservation_accounting(stakes, minimum_stake) + })(); + accounting_result.is_err() + } else { + false + }; + if activation_accounting_error { + report_errors.push(ActivationPreflightErrorCode::ActivationAccountingInvalid); + } + let safe = report_errors.is_empty() && commitments.iter().all(|commitment| commitment.safe); + + ActivationPreflightReport { + safe, + inspected_beacon, + activation_initialized: self.activation_initialized, + minimum_stake_nanowits: minimum_stake.nanowits(), + live_stake_entry_count, + ambiguous_live_validators, + below_minimum_live_stakes, + activation_accounting_error, + outstanding_legacy_commitment_count: outstanding_legacy_commitments.len(), + cached_legacy_sidecar_count, + missing_legacy_sidecar_count, + commitments, + orphaned_cached_sidecars, + errors: report_errors, + } + } + + /// Return the legacy eligibility view containing only each validator's persistent/lazy owner + /// entry. This prevents post-activation delegations from changing a Wit/2.0 request that is + /// still in flight. + pub fn legacy_owner_view(&self, stakes: &StakesTracker) -> StakesTracker { + stakes.retain_entries(|key| { + self.owner_withdrawer_or_legacy(stakes, key.validator) + .is_ok_and(|owner| owner == key.withdrawer) + }) + } + + /// Return the legacy owner-only view while propagating owner-policy or stake-ledger errors. + /// + /// Cross-fork consensus paths must not use [`Self::legacy_owner_view`], whose infallible + /// compatibility contract intentionally drops entries when owner derivation fails. Resolve + /// every validator first, then retain exactly its canonical owner entry so a malformed + /// post-activation policy cannot be converted into ordinary legacy ineligibility. + pub fn legacy_owner_view_checked( + &self, + stakes: &StakesTracker, + ) -> DelegationResult { + // The filtered view is used for post-activation cross-fork eligibility and is built with + // a checked clone that reindexes from `by_key`. Preserve the authoritative source-ledger + // invariant before that clone can normalize a malformed secondary index. Pre-activation + // legacy callers retain their historical compatibility behavior. + if self.activation_initialized { + stakes.validate_validator_index_checked()?; + } + // Build the validator population from the canonical primary ledger. The maintained + // validator index is a derived cache and an omitted row must not make a live validator + // disappear from a legacy eligibility projection. + let canonical_entries = stakes.query_stakes_primary_checked(QueryStakesKey::All)?; + let validators = canonical_entries + .into_iter() + .map(|entry| entry.key.validator) + .collect::>(); + // Resolve ownership through the same authoritative helper used by eligibility and + // settlement. Before activation it derives the canonical live legacy owner; after the + // marker it returns the immutable persisted policy owner. Picking an arbitrary primary-map + // withdrawer here would allow a delegator to replace the owner in a cross-fork Wit/2.0 + // eligibility view after activation. + let owners = validators + .into_iter() + .map(|validator| { + self.owner_withdrawer_or_legacy(stakes, validator) + .map(|owner| (validator, owner)) + }) + .collect::>>()?; + stakes.retain_entries_checked(|key| { + owners + .get(&key.validator) + .is_some_and(|owner| *owner == key.withdrawer) + && (!self.activation_initialized || !self.exit_intents.contains(key)) + }) + } + + /// Lazily persist the deterministic owner of a validator loaded from pre-delegation state. + pub fn ensure_validator_policy( + &mut self, + stakes: &StakesTracker, + validator: PublicKeyHash, + ) -> DelegationResult { + // After activation, a missing policy is corrupted consensus state. The live ledger is no + // longer an authorized source of owner provenance, so callers must not lazily recreate a + // record with a reset commission/nonce history. + if self.activation_initialized { + return self.validator_policy(validator); + } + + if let Some(policy) = self.validators.get(&validator) { + return Ok(*policy); + } + + let owner = self.owner_withdrawer_or_legacy(stakes, validator)?; + self.register_validator_if_absent(validator, owner, 0) + } + + /// Require authoritative policy and reservation references for every validator in a + /// post-activation ledger. + /// + /// Before the Wit/2.1 activation marker, policy records are compatibility caches and legacy + /// owner derivation remains consensus-authoritative. Once the marker is set, silently treating + /// a missing policy or a non-owner below-minimum live entry as an inactive validator would make + /// eligibility, validator counts, and rewards depend on malformed local state instead of + /// failing closed. A below-minimum owner preserved from activation is explicitly + /// settlement-only. Reservation debits are checked for canonical shape and live-key + /// resolution here because admission and eligibility read their totals before the heavier + /// persisted-state accounting walk. + pub fn validate_authoritative_owner_policies( + &self, + stakes: &StakesTracker, + ) -> DelegationResult<()> { + // Eligibility and validator-wide admission paths consume the maintained validator index + // after this shared policy check. Prove it is the exact canonical-ledger projection first, + // so an omitted or duplicated delegation cannot change aggregate power or capacity. The + // first Wit/2.1 epoch can evaluate a projected legacy policy before the marker is persisted, + // so this ledger invariant deliberately precedes the marker-specific policy checks. + stakes.validate_validator_index_checked()?; + + if !self.activation_initialized { + return Ok(()); + } + + for key in &self.exit_intents { + stakes.query_stakes_checked(QueryStakesKey::Key(key.clone()))?; + self.validator_policy(key.validator)?; + } + + // The serialized policy fields are compatibility metadata, but their values are still + // part of the authoritative post-activation state image. Consensus allocation below uses + // the immutable protocol constants rather than trusting these fields; reject a divergent + // image here as well so hot eligibility/admission cannot operate on state that persistence + // would later refuse to reload. + if self.policy != DelegationPolicy::default() { + return Err(StakesError::InvalidDelegationState { + reason: "delegation policy constants do not match the protocol", + }); + } + + // Every post-activation policy record is authoritative, including records retained after + // a validator has fully exited. Validate the commission domain and pending nonce for the + // complete map here rather than only when a live/reservation-backed validator is visited; + // otherwise a malformed policy-only record could pass activity/ranking reads and remain + // latent until a later reward or persistence boundary happened to inspect it. + for policy in self.validators.values() { + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if pending.nonce <= policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + } + } + + // Post-activation reservations are consensus-authoritative. Delegated admission reads + // their totals while checking a new deposit against the validator aggregate cap, so + // reject a malformed debit vector before any caller can use that total. Every debit must + // also still resolve to a canonical live entry: a syntactically valid vector naming an + // orphaned key would otherwise influence capacity until a later refund/slash boundary + // happened to inspect it. Keep this boundary deliberately narrower than the full + // live-plus-reserved accounting walk below: persisted loads and activation already + // perform that O(state) check, while this helper is also used by hot eligibility and + // admission paths. + // Pre-activation sidecars remain inert and are intentionally not shape-validated here for + // Wit/2.0 compatibility. + let mut reserved_by_key = BTreeMap::, u64>::new(); + for (reservation_key, reservation) in &self.collateral_reservations { + validate_collateral_reservation_shape(*reservation_key, reservation)?; + self.validate_native_reservation_allocation(*reservation_key, reservation)?; + for (key, _) in &reservation.reward_weights { + // Reward weights are immutable pre-debit entitlements, but their exact stake + // identity must remain present until settlement. A plain checked query returning + // an empty vector would otherwise let a malformed snapshot omit an exiting entry + // and defer the failure until reward distribution, after admission/eligibility + // had already accepted the state image. + stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + } + for debit in &reservation.debits { + stakes.query_stakes_checked(QueryStakesKey::Key(debit.key.clone()))?; + let reserved = reserved_by_key.entry(debit.key.clone()).or_default(); + *reserved = reserved + .checked_add(debit.amount.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + } + } + // The hot policy/admission boundary is intentionally lighter than the full persisted-state + // walk, but it must still prove that every escrowed debit resolves to a live entry and that + // restoring the aggregate debit remains representable. The live entry already reflects the + // debit, so subtracting the reservation from it here would count the debit twice and reject + // valid outstanding commitments. + for (key, reserved) in reserved_by_key { + let live_coins = stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })? + .value + .coins + .nanowits(); + live_coins + .checked_add(reserved) + .ok_or(StakesError::ArithmeticOverflow)?; + } + + // Activation-boundary obligations are historical recovery records, never native escrow. + // Keep that distinction explicit at the hot invariant boundary: a key cannot be both a + // spendable reservation and a deferred recovery record, and terminal records must retain + // the exact request terms they describe. + for (reservation_key, obligation) in &self.legacy_activation_obligations { + if self.collateral_reservations.contains_key(reservation_key) { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligation overlaps collateral reservation", + }); + } + match &obligation.settlement { + LegacyActivationSettlement::Pending + | LegacyActivationSettlement::Slashed => {} + LegacyActivationSettlement::Deferred { + collateral, + witness_reward, + } => { + if *collateral != obligation.raw_collateral + || (*witness_reward != obligation.witness_reward + && witness_reward.nanowits() != 0) + { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligation settlement terms changed", + }); + } + } + LegacyActivationSettlement::SettledToCanonicalOwner { witness_reward } => { + if obligation.raw_collateral.nanowits() != 0 + || obligation.canonical_live_owner.is_none() + || *witness_reward != obligation.witness_reward + { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation owner settlement is not raw-zero canonical history", + }); + } + } + } + } + + // Materialize the canonical ledger once, grouped by validator. Repeating a primary-map + // scan for every validator makes this shared admission/reward proof O(validators * + // entries). The checked all-entry query still proves every stored identity and reads + // every balance, while the index proof above rejects omitted or detached index rows. + let mut entries_by_validator = BTreeMap::new(); + for entry in stakes.query_stakes_primary_checked(QueryStakesKey::All)? { + entries_by_validator + .entry(entry.key.validator) + .or_insert_with(Vec::new) + .push(entry); + } + let mut live_or_reserved_validators = entries_by_validator + .keys() + .copied() + .collect::>(); + live_or_reserved_validators.extend( + self.collateral_reservations + .keys() + .map(|key| key.validator), + ); + + let mut validators = live_or_reserved_validators.clone(); + // Retained policies remain authoritative after a validator's live stake has fully exited; + // include them even when no current ledger or reservation entry references the validator. + validators.extend(self.validators.keys().copied()); + for validator in validators { + let policy = match self.validators.get(&validator).copied() { + Some(policy) => policy, + None if live_or_reserved_validators.contains(&validator) => { + // A live or reservation-backed validator cannot be treated as an unknown + // first-registration candidate after activation. Returning the specific + // invalid-state error keeps RPC callers from interpreting a malformed + // reservation-only projection as an ordinary policy-not-found response. + return Err(StakesError::InvalidDelegationState { + reason: "live or reserved validator is missing its owner policy", + }); + } + None => { + // `validators` is built from `self.validators` and the live/reserved union, + // so this branch is defensive only; retain the ordinary lookup error if a + // future source changes that construction. + return Err(StakesError::ValidatorPolicyNotFound { validator }); + } + }; + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if pending.nonce <= policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + } + + // A policy-only record is valid after a validator has fully exited and all of its + // reservations have settled. Any validator that still has live stake or escrowed + // collateral, however, must retain the immutable owner entry. Without this check, + // query and admission paths could silently treat malformed owner state as inactive. + if live_or_reserved_validators.contains(&validator) { + let entries = entries_by_validator + .get(&validator) + .ok_or(StakesError::ValidatorNotFound { validator })?; + if self.exit_intents.contains(&policy.owner_key(validator)) + && entries.iter().any(|entry| { + entry.key.withdrawer != policy.owner_withdrawer + && entry.value.coins.nanowits() > 0 + }) + { + return Err(StakesError::OwnerExitWithDelegators { validator }); + } + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + if let Some(entry) = entries.iter().find(|entry| { + entry.value.coins < minimum + && !(self.activation_initialized + && entry.key.withdrawer == policy.owner_withdrawer) + }) { + return Err(StakesError::LiveStakeEntryBelowMinimum { + key: entry.key.clone(), + amount: entry.value.coins, + minimum, + }); + } + if !entries + .iter() + .any(|entry| entry.key.withdrawer == policy.owner_withdrawer) + { + return Err(StakesError::OwnerStakeNotFound { + validator, + owner: policy.owner_withdrawer, + }); + } + } + } + + Ok(()) + } + + /// Return whether a validator is active, propagating missing post-activation policy state. + pub fn is_validator_active_checked( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + _minimum_self_stake: Wit, + ) -> DelegationResult { + // The owner-activity helpers are consumed by native Wit/2.1 eligibility and ranking. Keep + // their public compatibility parameter from becoming a consensus knob: a caller that + // supplies a lower runtime value must not turn a sub-10,000-WIT owner into an active + // validator during the pre-marker activation window. Legacy owner views use their own + // dedicated helpers and do not pass through this boundary. + let minimum_self_stake = hard_v21_minimum_stake(); + // Keep this shared checked helper fail-closed for direct callers as well as the rank/count + // helpers that already perform the coverage check. Without this guard, a reserved-only + // validator with missing post-activation policy could be silently treated as inactive. + self.validate_authoritative_owner_policies(stakes)?; + + self.is_validator_active_unvalidated(stakes, validator, minimum_self_stake) + } + + /// Evaluate owner activity after the caller has validated authoritative policy coverage. + /// + /// Bulk consensus paths must not repeat the full policy/ledger invariant walk for every + /// validator. Keep that validation at the operation boundary and use this narrow helper for + /// each activity lookup inside the already-validated pass. + fn is_validator_active_unvalidated( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + minimum_self_stake: Wit, + ) -> DelegationResult { + if self.activation_initialized + && self + .validators + .get(&validator) + .is_some_and(|policy| self.exit_intents.contains(&policy.owner_key(validator))) + { + return Ok(false); + } + + // A candidate identity with no stake is simply inactive. Only a live validator missing its + // post-activation policy is malformed and must be surfaced to the caller. + if self.activation_initialized && !self.validators.contains_key(&validator) { + if !stakes + .validator_addresses() + .any(|candidate| *candidate == validator) + { + return Ok(false); + } + } + let owner = self.owner_withdrawer_or_legacy(stakes, validator)?; + // `is_validator_active_checked` has already proved the complete stake projection before + // entering this helper. Use the narrow post-validation lookup here so bulk active-set + // construction does not rescan both secondary indexes for every candidate validator. + stakes.validator_is_active_after_index_validation(validator, owner, minimum_self_stake) + } + + /// Return whether the persistent or legacy-derived owner maintains the minimum self-stake. + pub fn is_validator_active( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + minimum_self_stake: Wit, + ) -> bool { + self.is_validator_active_checked(stakes, validator, minimum_self_stake) + .unwrap_or(false) + } + + /// Return aggregate validator power, or zero when its owner self-stake is inactive. + pub fn active_validator_power( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + capability: Capability, + epoch: Epoch, + minimum_self_stake: Wit, + ) -> DelegationResult { + let is_active = match self + .is_validator_active_checked(stakes, validator, minimum_self_stake) + { + Ok(is_active) => is_active, + // Unknown identities have no self-stake and therefore contribute no power. A live + // post-activation validator with a missing policy returns ValidatorPolicyNotFound and + // remains a hard error. + Err(StakesError::ValidatorNotFound { .. }) => false, + Err(error) => return Err(error), + }; + if is_active { + self.participating_stakes_checked(stakes)?.query_power(validator, capability, epoch) + } else { + Ok(0) + } + } + + /// Return only owner-active validators in deterministic aggregate-power order. + pub fn by_active_validator_rank( + &self, + stakes: &StakesTracker, + capability: Capability, + epoch: Epoch, + minimum_self_stake: Wit, + ) -> std::vec::IntoIter<(PublicKeyHash, u64)> { + self.by_active_validator_rank_checked(stakes, capability, epoch, minimum_self_stake) + .unwrap_or_default() + } + + /// Resolve the owner-active validator population once for a consensus operation. + /// + /// The returned set is safe to reuse for mining, witnessing, ranking, and validator-count + /// calculations in the same immutable snapshot. Authoritative policy coverage is validated + /// before any member is evaluated, avoiding a full policy walk for every callback invocation. + pub fn active_validator_set_checked( + &self, + stakes: &StakesTracker, + _minimum_self_stake: Wit, + ) -> DelegationResult> { + // Native eligibility has one fixed owner minimum. Normalize the retained argument here so + // the bulk set used by mining, witnessing, and validator-count calculations cannot be + // weakened by a mutable caller value before the activation marker is persisted. + let minimum_self_stake = hard_v21_minimum_stake(); + self.validate_authoritative_owner_policies(stakes)?; + stakes + .validator_addresses() + .try_fold(BTreeSet::new(), |mut active, validator| { + if self.is_validator_active_unvalidated(stakes, *validator, minimum_self_stake)? { + active.insert(*validator); + } + Ok(active) + }) + } + + /// Return active validators ordered by aggregate power, propagating arithmetic overflow. + pub fn by_active_validator_rank_checked( + &self, + stakes: &StakesTracker, + capability: Capability, + epoch: Epoch, + minimum_self_stake: Wit, + ) -> DelegationResult> { + // `by_validator_rank_checked_filtered` accepts a boolean predicate because it is also + // used by compatibility callers. Do not pass the infallible activity wrapper here: a + // malformed post-activation policy or a stake-ledger read failure must reject the checked + // ranking, not silently remove that validator from the population. Evaluate the predicate + // through the fallible helper first, then use the resulting immutable set for the checked + // aggregate-power pass. + let active_validators = self.active_validator_set_checked(stakes, minimum_self_stake)?; + self.participating_stakes_checked(stakes)?.by_validator_rank_checked_filtered(capability, epoch, |validator| { + active_validators.contains(validator) + }) + } + + /// Count active validators while rejecting malformed post-activation owner policy state. + pub fn active_validator_count_checked( + &self, + stakes: &StakesTracker, + minimum_self_stake: Wit, + ) -> DelegationResult { + self.active_validator_set_checked(stakes, minimum_self_stake) + .map(|active| active.len()) + } + + /// Count validator identities whose persistent owner meets the minimum self-stake. + pub fn active_validator_count( + &self, + stakes: &StakesTracker, + minimum_self_stake: Wit, + ) -> usize { + self.active_validator_count_checked(stakes, minimum_self_stake) + .unwrap_or_default() + } + + /// Register the first valid stake withdrawer as the persistent validator owner. + /// + /// Calling this for an already registered validator is idempotent and never changes its owner. + pub fn register_validator_if_absent( + &mut self, + validator: PublicKeyHash, + first_withdrawer: PublicKeyHash, + initial_commission_percent: u8, + ) -> DelegationResult { + let maximum = MAX_COMMISSION_PERCENT; + if initial_commission_percent > maximum { + return Err(StakesError::CommissionAboveMaximum { + commission: initial_commission_percent, + maximum, + }); + } + + if let Some(policy) = self.validators.get(&validator).copied() { + if policy.owner_withdrawer != first_withdrawer { + return Err(StakesError::ValidatorOwnerMismatch { + validator, + recorded_owner: policy.owner_withdrawer, + requested_owner: first_withdrawer, + }); + } + return Ok(policy); + } + + let policy = ValidatorPolicy::with_commission(first_withdrawer, initial_commission_percent); + self.validators.insert(validator, policy); + Ok(policy) + } + + /// Populate policies from the deterministically ordered legacy stake ledger before activation, + /// or verify complete policy coverage after activation. + /// + /// Wit/2.0 permits only one withdrawer per validator. If a non-conforming snapshot contains + /// more than one entry, owner derivation fails closed; deterministic key ordering is not + /// evidence that any one of the competing withdrawers is the historical owner. + pub fn initialize_legacy_validators( + &mut self, + stakes: &StakesTracker, + ) -> DelegationResult<()> { + // Once the one-time activation marker is persisted, policy ownership and commission + // history are consensus state. Do not silently reconstruct a missing policy from the + // current stake ledger: a malformed snapshot could otherwise select a delegator as the + // owner or reset the commission/nonce history during block validation or consolidation. + if self.activation_initialized { + let mut validators = stakes + .query_stakes_primary_checked(QueryStakesKey::All)? + .into_iter() + .map(|entry| entry.key.validator) + .collect::>(); + validators.extend(self.collateral_reservations.keys().map(|key| key.validator)); + for validator in validators { + self.validator_policy(validator)?; + } + return Ok(()); + } + + // Resolve one owner per validator through the same deterministic live-ledger rule used by + // eligibility and collateral paths before activation. After activation, owner recovery is + // forbidden: a missing policy is corrupted consensus state, including for a validator + // referenced only by an outstanding reservation. + // + // Stage the compatibility policy image as well. A direct caller may encounter an + // ambiguous or mismatched owner after successfully resolving earlier validators; writing + // those earlier policies into `self` before returning the error would make a failed + // preactivation read alter later owner/commission projections. + let mut updated_state = self.clone(); + // Policy records are also upgrade-history caches before activation. Rebuild the entire + // compatibility image instead of allowing a cached owner, commission, or nonce to + // survive merely because it happens to name the same live validator. Reservation keys + // are deliberately excluded from this population: they are optional preactivation + // sidecars and cannot create a validator or force owner recovery when the canonical live + // ledger has no corresponding entry. The one-time activation transition reconstructs + // outstanding reservations separately from the authenticated request pool. + updated_state.validators.clear(); + let validators = stakes + .query_stakes_primary_checked(QueryStakesKey::All)? + .into_iter() + .map(|entry| entry.key.validator) + .collect::>(); + for validator in validators { + let owner = updated_state.legacy_live_withdrawer(stakes, validator)?; + updated_state.register_validator_if_absent(validator, owner, 0)?; + } + + *self = updated_state; + + Ok(()) + } + + /// Validate every consensus-critical invariant expected of a persisted post-activation state. + /// + /// Runtime transitions construct these records atomically, but bincode decoding alone cannot + /// enforce commission bounds, owner coverage, nonce ordering, or exact reservation accounting. + /// Database and snapshot loaders must call this before publishing a decoded `ChainState`. + pub fn validate_persisted_invariants( + &self, + stakes: &StakesTracker, + current_epoch: Epoch, + _minimum_stake: Wit, + _maximum_validator_stake: Wit, + ) -> DelegationResult<()> { + let minimum_stake = hard_v21_minimum_stake(); + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "post-activation validation requires the activation marker", + }); + } + // Activation is only admitted from a non-empty eligible legacy population, and policy + // records are retained after a validator's live stake fully exits. An activated persisted + // image with no policies therefore cannot be a valid post-Wit/2.1 state: accepting it + // would allow a snapshot/database load to publish a chain with no authenticated owner + // history or proposer population. Keep explicit owner registration available for a live + // block-local transition, but reject the impossible empty image at the persistence + // boundary. + if self.validators.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "post-activation state has no authenticated validator policy", + }); + } + if self.policy != DelegationPolicy::default() { + return Err(StakesError::InvalidDelegationState { + reason: "delegation policy constants do not match the protocol", + }); + } + + for policy in self.validators.values() { + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + let minimum_pending_nonce = policy + .commission_nonce + .checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + if pending.nonce < minimum_pending_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + if pending.effective_epoch <= current_epoch { + return Err(StakesError::InvalidDelegationState { + reason: "mature pending commission was not activated", + }); + } + } + } + + self.validate_stake_and_reservation_accounting(stakes, minimum_stake) + } + + /// Validate the canonical stake/reservation shape and every checked aggregate shared by the + /// activation transition and the post-activation persistence boundary. + /// + /// Calling this immediately after reconstructing legacy reservations prevents the one-time + /// marker from making arithmetic-overflow state authoritative only for a later database load + /// to reject it. + pub fn validate_stake_and_reservation_accounting( + &self, + stakes: &StakesTracker, + _minimum_stake: Wit, + ) -> DelegationResult<()> { + let minimum_stake = hard_v21_minimum_stake(); + self.validate_authoritative_owner_policies(stakes)?; + // Persistence and activation accounting must validate the same validator population later + // consumed by eligibility, reward distribution, and collateral planning. + stakes.validate_validator_index_checked()?; + + let mut reserved_by_key = BTreeMap::, u64>::new(); + let mut reserved_by_validator = BTreeMap::::new(); + for (reservation_key, reservation) in &self.collateral_reservations { + self.validator_policy(reservation_key.validator)?; + validate_collateral_reservation_shape(*reservation_key, reservation)?; + // Persisted post-activation state must satisfy the same owner-floor and legacy + // owner-only classification enforced at refund/slash boundaries. Checking only the + // generic debit-vector shape here would allow a malformed reservation to become + // authoritative and defer rejection until a later oracle settlement. + self.validate_native_reservation_allocation(*reservation_key, reservation)?; + for debit in &reservation.debits { + let reserved = reserved_by_key.entry(debit.key.clone()).or_default(); + *reserved = reserved + .checked_add(debit.amount.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + } + let validator_total = reserved_by_validator + .entry(reservation_key.validator) + .or_default(); + *validator_total = validator_total + .checked_add(reservation.total.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + } + + // Every live or reservation-backed validator must retain its immutable owner policy, every + // native entry must honor the protocol minimum, and live plus escrowed stake must remain + // arithmetically sound. A below-minimum legacy owner is a deterministic settlement-only + // grandfathered entry: its owner policy was rebuilt at activation, it cannot contribute + // native power, and no new native reservation can use it. Reservation-backed identities + // are included explicitly because a + // malformed snapshot could otherwise keep a valid-looking escrow sidecar while omitting + // the owner entry that native collateral planning always requires. + // The validator maximum is an admission cap for new deposits and reservations, not a + // persisted-state ceiling: protocol rewards may legitimately push an existing validator + // above it. + // A persistence/activation accounting pass must not rescan the whole primary map for + // every validator. Group one checked canonical walk; the index validation above still + // proves that the maintained population agrees with this primary-ledger projection. + let mut entries_by_validator = BTreeMap::new(); + for entry in stakes.query_stakes_primary_checked(QueryStakesKey::All)? { + entries_by_validator.entry(entry.key.validator) + .or_insert_with(Vec::new).push(entry); + } + let mut live_or_reserved_validators = entries_by_validator + .keys() + .copied() + .collect::>(); + live_or_reserved_validators.extend(reserved_by_validator.keys().copied()); + for validator in live_or_reserved_validators { + let policy = self.validator_policy(validator)?; + let entries = entries_by_validator.get(&validator) + .ok_or(StakesError::ValidatorNotFound { validator })?; + if !entries + .iter() + .any(|entry| entry.key.withdrawer == policy.owner_withdrawer) + { + return Err(StakesError::OwnerStakeNotFound { + validator, + owner: policy.owner_withdrawer, + }); + } + if let Some(entry) = entries.iter().find(|entry| { + entry.value.coins < minimum_stake + && !(self.activation_initialized + && entry.key.withdrawer == policy.owner_withdrawer) + }) { + return Err(StakesError::LiveStakeEntryBelowMinimum { + key: entry.key.clone(), + amount: entry.value.coins, + minimum: minimum_stake, + }); + } + let live_total = entries + .iter() + .try_fold(0u64, |total, entry| { + total + .checked_add(entry.value.coins.nanowits()) + .ok_or(StakesError::ArithmeticOverflow) + })?; + let reserved_total = reserved_by_validator.get(&validator).copied().unwrap_or(0); + live_total + .checked_add(reserved_total) + .ok_or(StakesError::ArithmeticOverflow)?; + } + + // Reservations are debited from live entries before they are persisted. Validate the + // aggregate debit per exact `(validator, withdrawer)` key so every escrowed debit resolves + // to a real post-debit entry. The entry minimum check above is authoritative for the + // remaining live balance; subtracting the reservation here would count the debit twice. + // Re-adding the escrowed amount is still checked so a malformed snapshot cannot overflow + // the reconstructed pre-reservation balance that will be restored on refund. + for (key, reserved) in reserved_by_key { + let entry = stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .cloned() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + entry + .value + .coins + .nanowits() + .checked_add(reserved) + .ok_or(StakesError::ArithmeticOverflow)?; + } + + Ok(()) + } + + /// Initialize legacy policies and discard every optional reservation cache present at the + /// V2.1 boundary. Authoritative outstanding commitments are rebuilt by the caller. + /// + /// Reservation sidecars and any policy records are deliberately inert under Wit/2.0 and may + /// differ with upgrade history. The canonical live stake ledger at activation is therefore the + /// sole source for a fresh zero-commission policy set. Outstanding reservation validators are + /// validated when the caller reconstructs the authoritative request set; a historical sidecar + /// key is never accepted as provenance. The persisted marker makes this run exactly once, on + /// the first processed V2.1 block even when the chain skipped the activation epoch, before + /// tracking or reserving any post-activation commitment. + pub fn initialize_legacy_validators_for_activation( + &mut self, + stakes: &StakesTracker, + _minimum_stake: Wit, + ) -> DelegationResult<()> { + let minimum_stake = hard_v21_minimum_stake(); + // Activation is the point at which derived policy and reservation metadata become + // authoritative. Reject malformed source indexes before any policy clone or marker + // publication can rebuild them from the primary ledger and erase the evidence. + stakes.validate_validator_index_checked()?; + if self.activation_initialized { + return self.initialize_legacy_validators(stakes); + } + + // The marker may only be set for a ledger that the post-activation persistence invariant + // will accept. Legacy collateral accounting can leave an indexed zero/sub-minimum owner + // entry. Preserve it as settlement-only state, but do not activate a chain with no native + // proposer-capable owner at all. + let mut entries = stakes.query_stakes_checked(QueryStakesKey::All)?; + entries.sort_by(|left, right| left.key.cmp(&right.key)); + let mut previous_validator: Option = None; + let mut validator_entry_count = 0usize; + for entry in &entries { + if previous_validator == Some(entry.key.validator) { + validator_entry_count = validator_entry_count.saturating_add(1); + if validator_entry_count == 2 { + return Err(StakesError::InvalidDelegationState { + reason: "activation encountered ambiguous legacy owner ownership", + }); + } + } else { + previous_validator = Some(entry.key.validator); + validator_entry_count = 1; + } + } + if entries.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "activation has no eligible validator", + }); + } + if !entries + .iter() + .any(|entry| entry.value.coins >= minimum_stake) + { + return Err(StakesError::InvalidDelegationState { + reason: "activation has no eligible validator", + }); + } + + let mut updated_state = self.clone(); + // No Wit/2.0 policy metadata is authoritative. Rebuild it entirely from canonical live + // stake so two nodes that upgraded at different heights enter Wit/2.1 with identical owner, + // commission, and nonce state. Reset the fixed compatibility fields for the same reason. + updated_state.policy = DelegationPolicy::default(); + updated_state.validators.clear(); + updated_state.exit_intents.clear(); + updated_state.legacy_activation_obligations.clear(); + // Pre-activation reservations are upgrade-history caches, not authoritative consensus + // state. Drop the complete cache before rebuilding obligations from the authenticated + // outstanding request pool. This makes activation independent of whether a node was + // upgraded before or after each legacy Commit and prevents malformed/orphaned cache rows + // from becoming post-activation escrow. + updated_state.collateral_reservations.clear(); + + // Resolve every live validator exclusively from the canonical primary ledger. A cached + // reservation validator is never allowed to invent an owner or keep an orphan alive at + // the boundary; the authoritative commitment-aware reconstruction below will add only + // validators that still have a live canonical owner. + let validators = stakes + .query_stakes_primary_checked(QueryStakesKey::All)? + .into_iter() + .map(|entry| entry.key.validator) + .collect::>(); + for validator in validators { + let owner = updated_state.legacy_live_withdrawer(stakes, validator)?; + updated_state.register_validator_if_absent(validator, owner, 0)?; + } + + updated_state.activation_initialized = true; + // Keep marker publication atomic with the complete post-activation accounting invariant. + // The caller will append authoritative legacy reservations afterward, but the freshly + // rebuilt policy/ledger image itself must already be acceptable to persistence; otherwise + // a direct initializer caller could publish a marker that the next load immediately + // rejects. + updated_state.validate_stake_and_reservation_accounting(stakes, minimum_stake)?; + *self = updated_state; + Ok(()) + } + + /// Record a pre-activation commitment without pretending its nominal collateral is exact + /// native escrow. A raw-zero commitment with one canonical live owner has no historical debit + /// ambiguity and therefore remains on the existing owner-only settlement path; missing or + /// ambiguous zero-collateral commitments still receive a non-spendable recovery record so a + /// later reward cannot invent a beneficiary. + pub fn record_legacy_activation_obligation( + &mut self, + stakes: &StakesTracker, + reservation_key: CollateralReservationKey, + raw_collateral: Wit, + witness_reward: Wit, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligations require the activation marker", + }); + } + stakes.validate_validator_index_checked()?; + let entries = match stakes.query_stakes_by_validator_primary_checked(reservation_key.validator) { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(error), + }; + let canonical_live_owner = match entries.as_slice() { + [entry] => Some(entry.key.withdrawer), + _ => None, + }; + if raw_collateral.nanowits() == 0 && canonical_live_owner.is_some() { + return Ok(()); + } + + if let Some(existing) = self.legacy_activation_obligations.get(&reservation_key) { + if existing.raw_collateral != raw_collateral + || existing.witness_reward != witness_reward + { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligation conflicts with its request terms", + }); + } + return Ok(()); + } + + self.legacy_activation_obligations.insert( + reservation_key, + LegacyActivationObligation { + raw_collateral, + witness_reward, + canonical_live_owner, + settlement: LegacyActivationSettlement::Pending, + }, + ); + Ok(()) + } + + /// Settle one persisted activation-boundary obligation without crediting unproven historical + /// collateral or assigning a missing/ambiguous owner. The record remains after settlement so + /// a future authenticated recovery policy can inspect the exact request terms and outcome. + pub fn settle_legacy_activation_obligation( + &mut self, + stakes: &mut StakesTracker, + reservation_key: CollateralReservationKey, + is_error: bool, + is_slashed: bool, + epoch: Epoch, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligations require the activation marker", + }); + } + stakes.validate_validator_index_checked()?; + let obligation = self + .legacy_activation_obligations + .get(&reservation_key) + .cloned() + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + if !matches!(obligation.settlement, LegacyActivationSettlement::Pending) { + return Err(StakesError::InvalidDelegationState { + reason: "legacy activation obligation has already settled", + }); + } + + let mut updated_state = self.clone(); + if is_slashed { + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::Slashed; + *self = updated_state; + return Ok(()); + } + + let unpaid_collateral = obligation.raw_collateral; + let unpaid_reward = if is_error { + Wit::from_nanowits(0) + } else { + obligation.witness_reward + }; + + // A raw-zero obligation with one still-live canonical owner has no historical collateral + // ambiguity. Preserve the old owner-only reward arithmetic for that narrow case. Every + // other case is retained as a non-spendable deferred outcome. + if obligation.raw_collateral.nanowits() == 0 { + if let Some(owner) = obligation.canonical_live_owner { + if !is_error { + let owner_key = StakeKey { + validator: reservation_key.validator, + withdrawer: owner, + }; + if stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key.clone()))? + .is_empty() + { + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::Deferred { + collateral: unpaid_collateral, + witness_reward: unpaid_reward, + }; + } else { + updated_state.ensure_validator_reward_representable( + stakes, + reservation_key.validator, + unpaid_reward, + )?; + let mut updated_stakes = stakes.try_clone_checked()?; + if unpaid_reward.nanowits() > 0 { + updated_stakes.add_stake_to_existing_legacy( + owner_key, + unpaid_reward, + epoch, + false, + Wit::from_nanowits(0), + )?; + } + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::SettledToCanonicalOwner { + witness_reward: unpaid_reward, + }; + *stakes = updated_stakes; + } + } else { + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::Deferred { + collateral: unpaid_collateral, + witness_reward: unpaid_reward, + }; + } + } else { + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::Deferred { + collateral: unpaid_collateral, + witness_reward: unpaid_reward, + }; + } + } else { + updated_state + .legacy_activation_obligations + .get_mut(&reservation_key) + .expect("obligation was cloned from the map") + .settlement = LegacyActivationSettlement::Deferred { + collateral: unpaid_collateral, + witness_reward: unpaid_reward, + }; + } + + *self = updated_state; + Ok(()) + } + + /// Read the commission applicable at an epoch without mutating pending state. + pub fn effective_commission_at( + &self, + validator: PublicKeyHash, + epoch: Epoch, + ) -> DelegationResult { + let validator_policy = self.validator_policy(validator)?; + // Validate both sides of the policy before selecting the value that is effective at this + // epoch. Otherwise a malformed future pending value above the hard cap could remain hidden + // while callers observe the otherwise-valid active commission; later activation would then + // publish an invalid policy. Consensus callers also run the complete persisted-policy + // validator, but keeping this shared lookup fail closed protects direct replay and + // consolidation callsites as well. + if validator_policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: validator_policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = validator_policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + // A pending update may be newer than the active nonce by more than one when it + // replaces an earlier pending update, but it can never reuse or precede the last + // activated sequence. Keep this invariant at the shared read boundary as well as at + // persisted-state validation so direct reward/replay callers cannot consume malformed + // commission history merely because the pending value is otherwise within the cap. + if pending.nonce <= validator_policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + } + let commission = validator_policy + .pending_commission + .filter(|pending| pending.effective_epoch <= epoch) + .map_or(validator_policy.commission_percent, |pending| { + pending.commission_percent + }); + + Ok(commission) + } + + /// Reject a post-activation policy that still carries a commission change which should have + /// been materialized by the supplied epoch. + /// + /// Block projection activates pending changes before processing transactions, but standalone + /// admission and read-only policy queries can observe persisted state directly. Keeping this + /// check separate from [`Self::effective_commission_at`] preserves that method's non-mutating + /// historical semantics while letting consensus-facing boundaries fail closed on stale state. + pub fn validate_pending_commissions_at_epoch( + &self, + current_epoch: Epoch, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Ok(()); + } + for policy in self.validators.values() { + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + // Keep the standalone timing guard fail-closed on the complete commission + // sequence as well. Block projection normally validates this through policy + // coverage and activation, but direct callers of this helper must not accept a + // stale pending nonce merely because its effective epoch is still in the future. + if pending.nonce <= policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + if pending.effective_epoch <= current_epoch { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch has already arrived", + }); + } + } + } + Ok(()) + } + + /// Schedule a validator-wide commission change for an inclusion epoch. + /// + /// `effective_epoch` must be strictly later than `current_epoch`; callers pass the epoch of + /// the block that includes the commission-update authorization as `current_epoch`. + pub fn schedule_commission( + &mut self, + validator: PublicKeyHash, + commission_percent: u8, + current_epoch: Epoch, + effective_epoch: Epoch, + nonce: u64, + ) -> DelegationResult<()> { + let maximum = MAX_COMMISSION_PERCENT; + if commission_percent > maximum { + return Err(StakesError::CommissionAboveMaximum { + commission: commission_percent, + maximum, + }); + } + if effective_epoch <= current_epoch { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch is not in the future", + }); + } + + let validator_policy = self + .validators + .get_mut(&validator) + .ok_or(StakesError::ValidatorPolicyNotFound { validator })?; + + if validator_policy.commission_percent > maximum { + return Err(StakesError::CommissionAboveMaximum { + commission: validator_policy.commission_percent, + maximum, + }); + } + if let Some(pending) = validator_policy.pending_commission + && pending.nonce <= validator_policy.commission_nonce + { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + if let Some(pending) = validator_policy.pending_commission + && pending.effective_epoch <= current_epoch + { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch has already arrived", + }); + } + + let expected = validator_policy + .pending_commission + .map_or(validator_policy.commission_nonce, |pending| pending.nonce) + .checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + if nonce != expected { + return Err(StakesError::InvalidCommissionNonce { + validator, + used: nonce, + expected, + }); + } + + validator_policy.pending_commission = Some(PendingCommission { + commission_percent, + effective_epoch, + nonce, + }); + + Ok(()) + } + + /// Activate all pending commission changes whose effective epoch has arrived. + /// + /// Validate the active and pending policy fields before mutating anything. In particular, a + /// malformed pending nonce must not be copied into `commission_nonce` and then erased: doing + /// so would destroy the evidence needed by the persisted-policy invariant to reject corrupted + /// state. The update is staged in a cloned policy map so an error leaves the receiver exactly + /// unchanged. + pub fn activate_pending_commissions( + &mut self, + current_epoch: Epoch, + ) -> DelegationResult<()> { + let mut updated = self.validators.clone(); + for policy in updated.values_mut() { + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if pending.nonce <= policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + if pending.effective_epoch <= current_epoch { + policy.commission_percent = pending.commission_percent; + policy.commission_nonce = pending.nonce; + policy.pending_commission = None; + } + } + } + self.validators = updated; + Ok(()) + } + + /// Return true when the exact stake entry backs any unresolved oracle commitment. + pub fn has_reservation_for_key(&self, key: &StakeKey) -> bool { + self.collateral_reservations + .values() + .any(|reservation| reservation.debits.iter().any(|debit| &debit.key == key) + || reservation.reward_weights.iter().any(|(reward_key, _)| reward_key == key)) + } + + /// Return true when a validator has any unresolved oracle commitment. + pub fn has_reservations_for_validator(&self, validator: PublicKeyHash) -> bool { + self.collateral_reservations + .keys() + .any(|key| key.validator == validator) + } + + /// Require one outstanding reservation to have its canonical debit shape and the exact + /// collateral total dictated by the authoritative data request. + /// + /// Persistence validates this relationship when state is loaded, while tally projection uses + /// this final boundary check so refund and slash cannot trust a stale or malformed in-memory + /// total. The debit vector itself remains historical and is never reconstructed at tally time. + pub fn validate_collateral_reservation_total( + &self, + reservation_key: CollateralReservationKey, + expected_total: Wit, + ) -> DelegationResult<()> { + let reservation = self + .collateral_reservations + .get(&reservation_key) + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + validate_collateral_reservation_shape(reservation_key, reservation)?; + self.validate_native_reservation_allocation(reservation_key, reservation)?; + if reservation.total != expected_total { + return Err(StakesError::InvalidDelegationState { + reason: "collateral reservation total does not match its data request", + }); + } + + Ok(()) + } + + /// Prove that every immutable native reward-weight identity still exists in the live stake + /// ledger before a reservation is consumed. Reservation shape and owner-floor checks do not + /// carry the stake tracker, so direct tally/refund/slash callers must perform this explicit + /// lookup while the sidecar is still available. Legacy owner-only reservations have no reward + /// weights and remain on their historical path. + pub fn validate_committed_reward_keys( + &self, + stakes: &StakesTracker, + reservation_key: CollateralReservationKey, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Ok(()); + } + let reservation = self + .collateral_reservations + .get(&reservation_key) + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + if reservation.reward_weights.is_empty() { + return Ok(()); + } + for (key, _) in &reservation.reward_weights { + stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + } + Ok(()) + } + + /// Require every activated native reservation to retain the immutable owner reward share and + /// mandatory owner collateral floor. + /// + /// Legacy Wit/2.0 reservations intentionally carry no reward weights and must remain + /// owner-only. Native reservations always include the owner in their non-empty snapshot; + /// keeping both classifications and the native split check at consumption helpers prevents a + /// direct refund/slash caller from relying only on generic vector shape. + fn validate_native_reservation_allocation( + &self, + reservation_key: CollateralReservationKey, + reservation: &CollateralReservation, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Ok(()); + } + if reservation.reward_weights.is_empty() { + // Empty weights identify an activated Wit/2.0 compatibility sidecar. Do not allow a + // malformed multi-entry vector to masquerade as that legacy shape and bypass the + // immutable-owner settlement rule. + return self.validate_active_legacy_collateral_owner(reservation_key); + } + let owner_key = self.owner_key(reservation_key.validator)?; + if reservation + .reward_weights + .binary_search_by(|(key, _)| key.cmp(&owner_key)) + .is_err() + { + return Err(StakesError::InvalidDelegationState { + reason: "native reservation reward weights omit the immutable owner", + }); + } + + // Native collateral is split at admission with a mandatory floor(required * 70 / 100) + // assigned to the immutable owner. Delegators may cover only the remaining 30% target; + // the owner can cover any delegator shortfall, so a valid reservation may contain more + // than the floor but never less. Recheck that allocation at every native reservation + // boundary so restored or externally supplied state cannot turn a later slash into a + // different owner/delegator economic split merely by presenting a well-shaped debit + // vector. + let owner_target = u64::try_from( + u128::from(reservation.total.nanowits()) + * u128::from(OWNER_COLLATERAL_PERCENT) + / 100, + ) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let owner_debit = reservation + .debits + .iter() + .find(|debit| debit.key == owner_key) + .map(|debit| debit.amount.nanowits()) + .unwrap_or(0); + if owner_debit < owner_target { + return Err(StakesError::InvalidDelegationState { + reason: "native reservation owner debit is below the mandatory collateral share", + }); + } + Ok(()) + } + + /// Validate an activation-reconstructed owner-only reservation against authoritative raw + /// Wit/2.0 collateral without treating a historical cache withdrawer as owner provenance. + /// + /// A missing preactivation cache is expected because strict activation tracking reconstructs + /// the record from the active data-request pool and the live owner. The reservation present + /// here must have the exact total, one debit for the commitment validator, and that same exact + /// debit amount. + pub fn validate_cached_legacy_collateral( + &self, + reservation_key: CollateralReservationKey, + expected_total: Wit, + ) -> DelegationResult<()> { + let Some(reservation) = self.collateral_reservations.get(&reservation_key) else { + return Ok(()); + }; + if expected_total.nanowits() == 0 + && reservation.total.nanowits() == 0 + && reservation + .debits + .iter() + .all(|debit| debit.amount.nanowits() == 0) + { + return Ok(()); + } + let [debit] = reservation.debits.as_slice() else { + return Err(StakesError::InvalidDelegationState { + reason: "cached legacy collateral reservation is not owner-only", + }); + }; + if reservation.total != expected_total + || debit.key.validator != reservation_key.validator + || debit.amount != expected_total + { + return Err(StakesError::InvalidDelegationState { + reason: "cached legacy collateral reservation disagrees with its commitment", + }); + } + + Ok(()) + } + + /// Remove one upgrade-history sidecar only when it carries no collateral or debit value. + /// + /// Older callers may use this to remove an inert cache key that has no authoritative live + /// commitment. The activation transition now clears the complete cache atomically, so this + /// helper remains only for compatibility cleanup callers. + pub fn discard_inert_legacy_sidecar( + &mut self, + reservation_key: CollateralReservationKey, + ) -> bool { + let inert = self + .collateral_reservations + .get(&reservation_key) + .is_some_and(|reservation| { + reservation.total.nanowits() == 0 + && reservation + .debits + .iter() + .all(|debit| debit.amount.nanowits() == 0) + }); + if inert { + self.collateral_reservations.remove(&reservation_key); + } + inert + } + + /// After activation, require an outstanding Wit/2.0 reservation to remain keyed to the + /// persistent owner. Native Wit/2.1 reservations may debit delegators, but a legacy sidecar is + /// owner-only by protocol. Keeping this check separate preserves the pre-activation rule that + /// cached historical withdrawers are inert and cannot establish owner provenance. + pub fn validate_active_legacy_collateral_owner( + &self, + reservation_key: CollateralReservationKey, + ) -> DelegationResult<()> { + let Some(reservation) = self.collateral_reservations.get(&reservation_key) else { + // Serialized zero collateral intentionally has no reservation sidecar. + return Ok(()); + }; + // A legacy sidecar is owner-only, but owner binding alone is not enough to establish a + // canonical reservation. Reuse the same checked shape boundary as native refund/slash + // and persisted-state validation so a zero, duplicate, unsorted, wrong-validator, or + // sum-mismatched debit vector cannot pass this post-activation owner check. + validate_collateral_reservation_shape(reservation_key, reservation)?; + if !reservation.reward_weights.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "legacy reservation must not carry native reward weights", + }); + } + let [debit] = reservation.debits.as_slice() else { + return Err(StakesError::InvalidDelegationState { + reason: "active legacy collateral reservation is not owner-only", + }); + }; + let owner_key = self.owner_key(reservation_key.validator)?; + if debit.key != owner_key || debit.amount != reservation.total { + return Err(StakesError::InvalidDelegationState { + reason: "active legacy collateral reservation is not keyed to the owner", + }); + } + + Ok(()) + } + + /// Reject removal of an entry while some of its stake is awaiting an oracle outcome. + pub fn ensure_entry_can_be_removed( + &self, + key: &StakeKey, + ) -> DelegationResult<()> { + if self.has_reservation_for_key(key) { + Err(StakesError::StakeEntryLocked { key: key.clone() }) + } else { + Ok(()) + } + } + + /// Plan collateral without changing state: the owner must cover its 70% share, and may also + /// cover any unavailable part of the delegators' 30%. An owner shortfall never spills outward. + pub fn plan_collateral_reservation( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + collateral: Wit, + _minimum_stake: Wit, + ) -> DelegationResult { + // The planner reads the canonical primary map, while the subsequent reservation mutation + // clones and rebuilds maintained indexes. Prove the source projection here as well so a + // direct planner caller cannot obtain a debit vector from a malformed ledger that would be + // silently canonicalized by the mutating path. + stakes.validate_validator_index_checked()?; + let minimum_stake = hard_v21_minimum_stake(); + // Native Wit/2.1 collateral is always normalized by the protocol boundary before it + // reaches this planner. A zero amount is only a legacy Wit/2.0 no-op and must not be + // representable as an empty native reservation, because such a sidecar cannot satisfy + // the persisted reservation invariants or participate in an exact refund/slash lifecycle. + if collateral.nanowits() == 0 { + return Err(StakesError::InvalidDelegationState { + reason: "native collateral reservation cannot be zero", + }); + } + // Collateral is transferred from live entries into the reservation map. It therefore + // preserves the validator's live-plus-reserved admission total and must not be added as a + // second cap-consuming deposit here. The cap is enforced at the native deposit/top-up + // boundary, where new coins actually enter the validator aggregate. + let owner_withdrawer = self.owner_withdrawer_or_legacy(stakes, validator)?; + let owner_key = StakeKey { + validator, + withdrawer: owner_withdrawer, + }; + self.ensure_entry_accepts_deposit(&owner_key)?; + let entries = stakes.query_stakes_by_validator_primary_checked(validator)?; + let reward_weights = entries.iter() + .filter(|entry| { + !self.activation_initialized || !self.exit_intents.contains(&entry.key) + }) + .map(|entry| (entry.key.clone(), entry.value.coins)) + .collect::>(); + let owner_entry = entries + .iter() + .find(|entry| entry.key == owner_key) + .ok_or(StakesError::OwnerStakeNotFound { + validator, + owner: owner_withdrawer, + })?; + + let minimum = minimum_stake.nanowits(); + let owner_coins = owner_entry.value.coins.nanowits(); + // The native planner is also a consensus-facing eligibility boundary: an owner + // self-bond below the minimum is inactive and must never be supplemented by + // delegator capacity. Persisted-state validation rejects this shape as well, but + // keeping the guard here makes direct planner callers fail closed on malformed state. + if owner_coins < minimum { + return Err(StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(owner_coins), + minimum: Wit::from_nanowits(minimum), + }); + } + // The minimum guard above proves this subtraction is representable. Keep the + // consensus-facing planner checked nevertheless, so a future change to the guard or to + // the fixed minimum cannot turn malformed state into a silently zero-capacity owner. + let owner_capacity = owner_coins + .checked_sub(minimum) + .ok_or(StakesError::ArithmeticOverflow)?; + + let delegators = entries + .into_iter() + .filter(|entry| entry.key != owner_key) + .filter(|entry| { + !self.activation_initialized || !self.exit_intents.contains(&entry.key) + }) + .map(|entry| { + let coins = entry.value.coins.nanowits(); + // Every live entry is required to retain the same 10,000 WIT minimum as the + // owner. Treating a malformed below-minimum delegator as zero-capacity would let + // a native commitment proceed while preserving invalid consensus state; fail + // closed at the collateral boundary instead. + if coins < minimum { + return Err(StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(coins), + minimum: Wit::from_nanowits(minimum), + }); + } + let capacity = coins + .checked_sub(minimum) + .ok_or(StakesError::ArithmeticOverflow)?; + Ok(WeightedCapacity { + key: entry.key, + // Collateral exposure is allocated over excess capacity, not over the + // mandatory 10,000 WIT remainder that cannot be slashed. + weight: capacity, + capacity, + }) + }) + .collect::>>()?; + // The capped proportional pass can consume remaining capacity in iteration order. Use + // the canonical key order rather than the append order of the live stake index so a + // restored/reindexed ledger derives the same exact debit vector. + let mut delegators = delegators; + delegators.sort_by(|left, right| left.key.cmp(&right.key)); + let delegator_capacity = checked_sum(delegators.iter().map(|entry| entry.capacity))?; + let required = collateral.nanowits(); + let total_capacity = owner_capacity + .checked_add(delegator_capacity) + .ok_or(StakesError::ArithmeticOverflow)?; + if total_capacity < required { + return Err(StakesError::InsufficientCollateralCapacity { + validator, + required: collateral, + available: Wit::from_nanowits(total_capacity), + }); + } + + // Keep the 70/30 split immutable at the consensus boundary. The policy field is retained + // for serialized-state and RPC compatibility, but must not be able to change accounting. + let owner_target = u64::try_from( + u128::from(required) * u128::from(OWNER_COLLATERAL_PERCENT) / 100, + ) + .map_err(|_| StakesError::ArithmeticOverflow)?; + if owner_capacity < owner_target { + return Err(StakesError::InsufficientOwnerCollateralCapacity { + validator, + required: Wit::from_nanowits(owner_target), + available: Wit::from_nanowits(owner_capacity), + }); + } + let delegator_target = required + .checked_sub(owner_target) + .ok_or(StakesError::ArithmeticOverflow)?; + + // Delegators never cover an underfunded owner. The owner may cover the unavailable part + // of their target, including the complete collateral for an owner-only validator. The + // total-capacity guard above proves that this remainder fits the owner's capacity. + let delegator_amount = delegator_target.min(delegator_capacity); + let owner_amount = required + .checked_sub(delegator_amount) + .ok_or(StakesError::ArithmeticOverflow)?; + + let allocated = owner_amount + .checked_add(delegator_amount) + .ok_or(StakesError::ArithmeticOverflow)?; + if allocated != required || owner_amount > owner_capacity { + return Err(StakesError::InsufficientCollateralCapacity { + validator, + required: collateral, + available: Wit::from_nanowits(total_capacity), + }); + } + + let mut debits = Vec::with_capacity(delegators.len() + 1); + if owner_amount > 0 { + debits.push(CollateralDebit { + key: owner_key, + amount: Wit::from_nanowits(owner_amount), + }); + } + for (key, amount) in allocate_with_capacities(&delegators, delegator_amount)? { + if amount > 0 { + debits.push(CollateralDebit { + key, + amount: Wit::from_nanowits(amount), + }); + } + } + debits.sort_by(|a, b| a.key.cmp(&b.key)); + + Ok(CollateralReservation { + total: collateral, + debits, + reward_weights, + }) + } + + /// Track collateral that was already debited by a pre-activation Wit/2.0 commit. + /// + /// Wit/2.0 did not persist an exact multi-entry reservation. Retain its deterministic legacy + /// owner-only debit so validation and consolidation can account for the same collateral. On the + /// first Wit/2.1 transition, all such sidecars are canonicalized to the then-live persistent + /// owner before they can affect delegated-staking locks or settlement. A sidecar cannot replace + /// a missing live activation owner. This operation is idempotent. + pub fn track_legacy_collateral( + &mut self, + stakes: &StakesTracker, + data_request: Hash, + validator: PublicKeyHash, + collateral: Wit, + ) -> DelegationResult<()> { + let reservation_key = CollateralReservationKey { + data_request, + validator, + }; + let existing_is_zero_noop = self + .collateral_reservations + .get(&reservation_key) + .map(|existing| { + existing.total.nanowits() == 0 + && existing + .debits + .iter() + .all(|debit| debit.amount.nanowits() == 0) + }); + if existing_is_zero_noop == Some(false) { + // Re-observing the same legacy commitment is idempotent only when the authoritative + // collateral amount is identical. Silently accepting a conflicting amount would let a + // duplicate/malformed Commit rewrite the sidecar's meaning for activation while the + // already-debited legacy balance remained unchanged. + let existing = self + .collateral_reservations + .get(&reservation_key) + .ok_or(StakesError::CollateralReservationNotFound { + validator, + })?; + if existing.total != collateral { + return Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation conflicts with its commitment", + }); + } + return Ok(()); + } + + if existing_is_zero_noop == Some(true) && collateral.nanowits() != 0 { + // An inert all-zero compatibility sidecar may be removed only when the authoritative + // commitment is also zero. Treating it as freely replaceable would let a conflicting + // re-observation of the same `(data_request, validator)` key rewrite the historical + // collateral amount and bypass the exact-repeat guard above. + return Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation conflicts with its commitment", + }); + } + + // Capture the owner that is live when this legacy reservation is first observed. Do not + // let an older outstanding reservation select the owner of a newer Wit/2.0 commitment. + let owner = self.legacy_live_withdrawer(stakes, validator)?; + + if existing_is_zero_noop == Some(true) { + // Clean up sidecars produced by an earlier source-only revision. They represent no + // stake debit and must not become a post-activation lock or invariant failure. Do this + // only after the live-owner check above so an error leaves the caller unchanged. + self.collateral_reservations.remove(&reservation_key); + } + + // Wit/2.0 permits the serialized collateral field to be zero, which is interpreted as the + // network minimum for commit eligibility but remains a zero stake debit in the legacy + // consolidation path. Do not materialize a no-op sidecar: it would survive activation as + // an invalid zero reservation and would unnecessarily lock the owner entry. The live-owner + // lookup intentionally remains before this early return so activation cannot bypass its + // outstanding-commitment owner safety check. + if collateral.nanowits() == 0 { + return Ok(()); + } + + self.collateral_reservations.insert( + reservation_key, + CollateralReservation { + total: collateral, + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: StakeKey { + validator, + withdrawer: owner, + }, + amount: collateral, + }], + }, + ); + Ok(()) + } + + /// Debit and persist collateral for an oracle commitment atomically. + pub fn reserve_collateral( + &mut self, + stakes: &mut StakesTracker, + reservation_key: CollateralReservationKey, + collateral: Wit, + epoch: Epoch, + _minimum_stake: Wit, + ) -> DelegationResult { + let minimum_stake = hard_v21_minimum_stake(); + if self.collateral_reservations.contains_key(&reservation_key) { + return Err(StakesError::CollateralAlreadyReserved { + validator: reservation_key.validator, + }); + } + + let reservation = self.plan_collateral_reservation( + stakes, + reservation_key.validator, + collateral, + minimum_stake, + )?; + let mut updated_stakes = stakes.try_clone_checked()?; + for debit in &reservation.debits { + updated_stakes.remove_stake( + debit.key.clone(), + debit.amount, + epoch, + false, + minimum_stake, + )?; + } + + // Materialize a lazily derived legacy policy only after every debit has succeeded. This + // keeps a failed reservation from leaving delegation metadata behind on the live state. + self.ensure_validator_policy(&updated_stakes, reservation_key.validator)?; + + *stakes = updated_stakes; + self.collateral_reservations + .insert(reservation_key, reservation.clone()); + + Ok(reservation) + } + + /// Debit and persist a legacy owner-only collateral reservation after Wit/2.1 activation. + /// + /// Wit/2.0 requests keep their owner-only economics even when the containing block is + /// post-activation. A positive debit is recorded so a later tally can replay it exactly and so + /// same-block projection can account for the locked owner balance. A serialized zero collateral + /// is a legacy no-op and deliberately produces no reservation record. + pub fn reserve_legacy_collateral( + &mut self, + stakes: &mut StakesTracker, + reservation_key: CollateralReservationKey, + collateral: Wit, + epoch: Epoch, + _minimum_stake: Wit, + ) -> DelegationResult { + // This is the post-activation cross-fork path. Its owner-only debit is projected through + // the native ledger, so do not let checked cloning repair an omitted, duplicated, or + // detached maintained index before the owner lock is recorded. + stakes.validate_validator_index_checked()?; + // This method is used for a legacy request that is being projected in a post-activation + // block. Keep its owner-only economics, but still preserve the hard delegated minimum for + // the live owner entry; accepting a caller-supplied lower value would create state that + // native persistence and eligibility cannot represent. + let minimum_stake = hard_v21_minimum_stake(); + if self.collateral_reservations.contains_key(&reservation_key) { + return Err(StakesError::CollateralAlreadyReserved { + validator: reservation_key.validator, + }); + } + + let owner = self.owner_withdrawer_or_legacy(stakes, reservation_key.validator)?; + let key = StakeKey { + validator: reservation_key.validator, + withdrawer: owner, + }; + self.ensure_entry_accepts_deposit(&key)?; + // Even a zero-collateral legacy request must resolve to a live owner stake after + // activation. Otherwise a malformed request could use the no-op branch to bypass the + // persisted owner-entry invariant that all subsequent legacy settlement paths rely on. + // Check the hard minimum here as well as in the native planner: this helper is called + // directly by cross-fork validation/mining code and must fail closed on malformed state. + let owner_balance = stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .into_iter() + .next() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })? + .value + .coins + .nanowits(); + let minimum = minimum_stake.nanowits(); + if owner_balance < minimum { + return Err(StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(owner_balance), + minimum: Wit::from_nanowits(minimum), + }); + } + + // A zero Wit/2.0 collateral is a valid legacy no-op. The old balance transition does not + // debit stake for it, so retain no reservation record and let tally follow the ordinary + // owner-only legacy settlement branch. The owner stake lookup above intentionally remains + // part of this no-op path so a post-activation request cannot bypass the persisted-owner + // invariant. + if collateral.nanowits() == 0 { + return Ok(CollateralReservation { + total: collateral, + debits: Vec::new(), + reward_weights: Vec::new(), + }); + } + + // Cross-fork Wit/2.0 reservations retain their owner-only economics, but they are + // projected through the post-activation delegated ledger. Preserve the owner's hard + // 10,000 WIT minimum before debiting the collateral; `remove_stake` permits a zero + // remainder for full exits, which is correct for an explicit Unstake but would make an + // outstanding legacy reservation silently deactivate its validator. + let available = owner_balance + .checked_sub(minimum) + .ok_or(StakesError::ArithmeticOverflow)?; + if collateral.nanowits() > available { + return Err(StakesError::InsufficientCollateralCapacity { + validator: reservation_key.validator, + required: collateral, + available: Wit::from_nanowits(available), + }); + } + let mut updated_stakes = stakes.try_clone_checked()?; + updated_stakes.remove_stake( + key.clone(), + collateral, + epoch, + false, + minimum_stake, + )?; + + let reservation = CollateralReservation { + total: collateral, + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key, + amount: collateral, + }], + }; + *stakes = updated_stakes; + self.collateral_reservations + .insert(reservation_key, reservation.clone()); + + Ok(reservation) + } + + /// Refund the exact entries that funded a collateral reservation. + pub fn refund_collateral( + &mut self, + stakes: &mut StakesTracker, + reservation_key: CollateralReservationKey, + epoch: Epoch, + ) -> DelegationResult { + // Native refunds restore exact debit entries on the projected ledger. Validate the source + // index before any reservation lookup or checked clone can turn malformed input into a + // silently reindexed state image. + stakes.validate_validator_index_checked()?; + let reservation = self + .collateral_reservations + .get(&reservation_key) + .cloned() + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + validate_collateral_reservation_shape(reservation_key, &reservation)?; + self.validate_native_reservation_allocation(reservation_key, &reservation)?; + self.validate_committed_reward_keys(stakes, reservation_key)?; + let mut reserved_by_key = BTreeMap::, u64>::new(); + // Include every outstanding reservation for this validator. A valid live entry may back + // several commitments, and checking only the one being refunded could admit an + // individually representable credit that leaves another reservation's aggregate unable + // to be restored without overflowing the u64 balance field. + for (candidate_key, candidate) in &self.collateral_reservations { + if candidate_key.validator != reservation_key.validator { + continue; + } + validate_collateral_reservation_shape(*candidate_key, candidate)?; + self.validate_native_reservation_allocation(*candidate_key, candidate)?; + self.validate_committed_reward_keys(stakes, *candidate_key)?; + for debit in &candidate.debits { + let reserved = reserved_by_key.entry(debit.key.clone()).or_default(); + *reserved = reserved + .checked_add(debit.amount.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + } + } + for (key, reserved) in reserved_by_key { + let live_coins = stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })? + .value + .coins + .nanowits(); + live_coins + .checked_add(reserved) + .ok_or(StakesError::ArithmeticOverflow)?; + } + let mut updated_stakes = stakes.try_clone_checked()?; + for debit in &reservation.debits { + updated_stakes.add_stake_to_existing( + debit.key.clone(), + debit.amount, + epoch, + false, + Wit::from_nanowits(0), + )?; + } + + *stakes = updated_stakes; + self.collateral_reservations.remove(&reservation_key); + + Ok(reservation) + } + + /// Ensure a reward leaves the validator-wide live-plus-reserved reconstruction representable. + /// + /// This invariant applies to both native delegated rewards and Wit/2.0 tallies that settle + /// after activation. A reservation is already absent from live stake, so count its escrowed + /// total exactly once and add only the new reward. + fn ensure_validator_reward_representable( + &self, + stakes: &StakesTracker, + validator: PublicKeyHash, + reward: Wit, + ) -> DelegationResult<()> { + let live_total = stakes + .query_stakes_by_validator_primary_checked(validator)? + .into_iter() + .try_fold(0u64, |total, entry| { + total + .checked_add(entry.value.coins.nanowits()) + .ok_or(StakesError::ArithmeticOverflow) + })?; + let reserved_total = checked_sum( + self.collateral_reservations + .iter() + .filter(|(reservation_key, _)| reservation_key.validator == validator) + .map(|(_, reservation)| reservation.total.nanowits()), + )?; + live_total + .checked_add(reserved_total) + .and_then(|total| total.checked_add(reward.nanowits())) + .ok_or(StakesError::ArithmeticOverflow)?; + + Ok(()) + } + + /// Settle a Wit/2.0 owner-only reservation in one legacy-compatible stake addition. + /// + /// Legacy consolidation combined collateral and witness reward before age averaging. Keeping + /// that single operation is required both before activation and while a Wit/2.0 request crosses + /// into Wit/2.1. At the activation boundary, pre-existing sidecars are canonicalized to the + /// persistent owner selected from the then-live legacy ledger. Later V2.0 reservations already + /// use that persistent owner. Settlement therefore uses policy ownership and the recorded total, + /// not an upgrade-height-dependent historical sidecar key. + pub fn settle_legacy_collateral( + &mut self, + stakes: &mut StakesTracker, + reservation_key: CollateralReservationKey, + reward: Wit, + epoch: Epoch, + ) -> DelegationResult { + // A Wit/2.0 reservation settling after activation still credits the native projected + // ledger. Preserve the same fail-closed index boundary as native refund/reward paths while + // retaining the legacy owner-only arithmetic below. + stakes.validate_validator_index_checked()?; + // Check the aggregate while the reservation is still present. The reservation is about + // to be removed from the sidecar and then added back to its owner entry; checking only the + // post-removal state would omit that amount and could let a later legacy wrapping add + // overflow a live owner balance after intervening rewards. + let reservation_before_settlement = self + .collateral_reservations + .get(&reservation_key) + .cloned() + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + validate_collateral_reservation_shape(reservation_key, &reservation_before_settlement)?; + if self.activation_initialized { + // An activated Wit/2.0 sidecar is always the owner-only compatibility shape. Do not + // let a malformed native reward snapshot enter legacy arithmetic and silently discard + // its delegated entitlements. + self.validate_active_legacy_collateral_owner(reservation_key)?; + } + self.ensure_validator_reward_representable( + stakes, + reservation_key.validator, + reward, + )?; + + let mut updated_state = self.clone(); + let reservation = updated_state.slash_reserved_collateral(reservation_key)?; + let [debit] = reservation.debits.as_slice() else { + return Err(StakesError::ArithmeticOverflow); + }; + let owner_key = updated_state.owner_key(reservation_key.validator)?; + if debit.key != owner_key || debit.amount != reservation.total { + // Legacy reservations are owner-only even when their tally is settled after the + // delegated-staking fork. Do not let a malformed sidecar redirect the lock at commit + // time and then credit a different entry at settlement. + return Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation is not keyed to its owner", + }); + } + let amount = reservation + .total + .nanowits() + .checked_add(reward.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + + // `add_stake_to_existing_legacy` intentionally keeps the historical release arithmetic, + // so preflight the coin addition explicitly before invoking it. This rejects malformed + // post-activation state deterministically without changing valid Wit/2.0 age averaging or + // wrapping behavior. + let owner_entry = stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key.clone()))? + .into_iter() + .next() + .ok_or_else(|| StakesError::EntryNotFound { + key: owner_key.clone(), + })?; + owner_entry + .value + .coins + .nanowits() + .checked_add(amount) + .ok_or(StakesError::ArithmeticOverflow)?; + + let mut updated_stakes = stakes.try_clone_checked()?; + if amount > 0 { + // A legacy reservation was debited from a live owner at commit time. Do not let + // settlement manufacture that entry if state is corrupted or the owner disappeared; + // silently inserting it would mint a new stake identity and bypass the owner lock. + updated_stakes.add_stake_to_existing_legacy( + owner_key, + Wit::from_nanowits(amount), + epoch, + false, + Wit::from_nanowits(0), + )?; + } + + *stakes = updated_stakes; + *self = updated_state; + Ok(reservation) + } + + /// Settle a legacy tally that has no reservation sidecar after activation. + /// + /// A serialized zero collateral intentionally creates no sidecar, but its historical reward + /// still belongs to the persistent owner when the request crosses into Wit/2.1. Keep the + /// release-compatible collateral-plus-reward arithmetic while updating only the existing owner + /// entry; silently selecting the first BTree-ordered entry would pay a delegator instead. A + /// positive collateral must have an exact reservation and is rejected here as malformed state + /// rather than being credited a second time. + pub fn settle_legacy_tally_without_reservation( + &self, + stakes: &mut StakesTracker, + validator: PublicKeyHash, + collateral: Wit, + reward: Wit, + epoch: Epoch, + ) -> DelegationResult<()> { + // This helper is the post-activation cross-fork path for a serialized zero-collateral + // Wit/2.0 tally. Do not let a direct caller apply owner-only settlement while the marker + // is absent, where the same bytes are still governed by legacy stake accounting and any + // policy/exit sidecars remain compatibility metadata rather than consensus state. + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "legacy cross-fork tally settlement requires the activation marker", + }); + } + // This zero-collateral cross-fork transition credits an existing native owner entry. Do + // not permit the checked clone below to repair a malformed secondary index first. + stakes.validate_validator_index_checked()?; + if collateral.nanowits() != 0 { + return Err(StakesError::CollateralReservationNotFound { validator }); + } + let owner_key = self.owner_key(validator)?; + self.ensure_validator_reward_representable(stakes, validator, reward)?; + let amount = collateral + .nanowits() + .checked_add(reward.nanowits()) + .ok_or(StakesError::ArithmeticOverflow)?; + let mut updated_stakes = stakes.try_clone_checked()?; + if amount > 0 { + updated_stakes.add_stake_to_existing_legacy( + owner_key, + Wit::from_nanowits(amount), + epoch, + false, + Wit::from_nanowits(0), + )?; + } else { + // Preserve the legacy missing-validator failure even for a zero-value transition. + updated_stakes.query_stakes_checked(QueryStakesKey::Key(owner_key))?; + } + *stakes = updated_stakes; + Ok(()) + } + + /// Consume a legacy owner-only reservation without changing validator ownership. + /// + /// Before activation, the sidecar is an advisory compatibility cache and legacy block + /// validity must remain independent of its historical withdrawer. Once the activation marker + /// is persisted, the debit must be the exact immutable owner entry before the sidecar is + /// consumed. + pub fn slash_legacy_collateral( + &mut self, + reservation_key: CollateralReservationKey, + ) -> DelegationResult { + // Before activation, a cached Wit/2.0 sidecar is inert upgrade-history metadata. Its + // presence or shape must not change legacy slash validity, so consume it exactly as the + // historical compatibility path did. Once the marker is authoritative, use the strict + // native consumption boundary below and additionally enforce owner-only legacy semantics. + if !self.activation_initialized { + return self.collateral_reservations.remove(&reservation_key).ok_or( + StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + }, + ); + } + + self.validate_active_legacy_collateral_owner(reservation_key)?; + let mut updated_state = self.clone(); + let reservation = updated_state.slash_reserved_collateral(reservation_key)?; + let [debit] = reservation.debits.as_slice() else { + return Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation is not owner-only", + }); + }; + let owner_key = updated_state.owner_key(reservation_key.validator)?; + if debit.key != owner_key || debit.amount != reservation.total { + return Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation is not keyed to its owner", + }); + } + *self = updated_state; + Ok(reservation) + } + + /// Finalize a slash by consuming collateral that was already debited at commit time. + /// + /// Activated native reservations carry immutable reward-weight identities that can only be + /// authenticated against the live stake ledger. Use + /// [`Self::slash_reserved_collateral_checked`] for native settlement. This low-level helper + /// remains available to legacy owner-only callers, but refuses to consume a native weighted + /// reservation without that proof. + pub fn slash_reserved_collateral( + &mut self, + reservation_key: CollateralReservationKey, + ) -> DelegationResult { + if self.activation_initialized + && self + .collateral_reservations + .get(&reservation_key) + .is_some_and(|reservation| !reservation.reward_weights.is_empty()) + { + return Err(StakesError::InvalidDelegationState { + reason: "native collateral slash requires live reward-key validation", + }); + } + self.slash_reserved_collateral_inner(reservation_key) + } + + /// Consume a validated reservation without repeating the public safety boundary. Callers must + /// use [`Self::slash_reserved_collateral`] for legacy owner-only records or + /// [`Self::slash_reserved_collateral_checked`] for native weighted records. + fn slash_reserved_collateral_inner( + &mut self, + reservation_key: CollateralReservationKey, + ) -> DelegationResult { + let reservation = self + .collateral_reservations + .get(&reservation_key) + .ok_or(StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + })?; + validate_collateral_reservation_shape(reservation_key, reservation)?; + self.validate_native_reservation_allocation(reservation_key, reservation)?; + self.collateral_reservations.remove(&reservation_key).ok_or( + StakesError::CollateralReservationNotFound { + validator: reservation_key.validator, + }, + ) + } + + /// Finalize a native slash only after proving every committed reward-weight key still exists + /// in the live stake ledger. The checked proof is performed before the sidecar can be + /// consumed, so a malformed reservation cannot disappear merely because no reward allocation + /// loop runs on a slash outcome. + pub fn slash_reserved_collateral_checked( + &mut self, + stakes: &StakesTracker, + reservation_key: CollateralReservationKey, + ) -> DelegationResult { + // An activated settlement must reject a malformed stake snapshot before either the + // reward-key proof or sidecar consumption. `query_stakes_checked` below authenticates + // presence through the primary map, but it intentionally does not prove that the + // maintained validator/withdrawer indexes still agree with it. The checked slash + // boundary is externally callable, so accepting that partial image here would let a + // direct caller consume a reservation while carrying corrupt delegated-state indexes. + if self.activation_initialized { + stakes.validate_validator_index_checked()?; + } + self.validate_committed_reward_keys(stakes, reservation_key)?; + self.slash_reserved_collateral_inner(reservation_key) + } + + /// Distribute a gross validator reward pro rata and charge commission only to delegators. + pub fn distribute_reward( + &mut self, + stakes: &mut StakesTracker, + validator: PublicKeyHash, + gross_reward: Wit, + epoch: Epoch, + ) -> DelegationResult { + self.distribute_reward_inner(stakes, validator, gross_reward, epoch, None) + } + + /// Credit a cross-fork Wit/2.0 inclusion reward to the immutable owner only. + /// + /// A Wit/2.0 request included after activation keeps its historical owner-only reward + /// economics even though the validator now has delegated entries. Do not route this through + /// [`Self::distribute_reward`], which intentionally allocates native Wit/2.1 rewards across + /// every non-draining entry and charges commission to delegator shares. The checked clone keeps + /// this direct replay boundary failure-atomic and the existing-entry operation prevents a + /// malformed state from manufacturing a new owner row. + pub fn distribute_legacy_owner_reward( + &self, + stakes: &mut StakesTracker, + validator: PublicKeyHash, + reward: Wit, + epoch: Epoch, + ) -> DelegationResult<()> { + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "legacy owner reward distribution requires the activation marker", + }); + } + // Inclusion rewards are a consensus-visible state publication boundary. Prove the source + // projection and authoritative policy image before resolving the owner; otherwise a + // detached index or missing policy could turn a malformed validator into an ordinary + // owner-only reward target. + stakes.validate_validator_index_checked()?; + self.validate_authoritative_owner_policies(stakes)?; + self.validate_pending_commissions_at_epoch(epoch)?; + + let owner_key = self.owner_key(validator)?; + // Cross-fork Wit/2.0 inclusion rewards retain their historical owner-only target, but an + // owner entry that was already marked for exit is no longer eligible for new block or + // inclusion rewards. Committed witness entitlements use the separate tally settlement + // helpers and intentionally remain payable after an exit request. + if self.exit_intents.contains(&owner_key) { + return Ok(()); + } + // Keep the same validator-wide representability guard used by post-activation legacy + // tally settlement. A reward is added to live owner stake while outstanding collateral is + // still escrowed, so both components must remain representable together. + self.ensure_validator_reward_representable(stakes, validator, reward)?; + stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { + key: owner_key.clone(), + })?; + + let mut updated_stakes = stakes.try_clone_checked()?; + if reward.nanowits() > 0 { + updated_stakes.add_stake_to_existing_legacy( + owner_key, + reward, + epoch, + false, + Wit::from_nanowits(0), + )?; + } + *stakes = updated_stakes; + Ok(()) + } + + /// Pay an already committed native witness reward using its immutable pre-debit weights. + /// Exiting entries retain these earned shares, while future block/inclusion rewards use the + /// ordinary non-draining live-balance path. + pub fn distribute_committed_reward( + &mut self, + stakes: &mut StakesTracker, + validator: PublicKeyHash, + gross_reward: Wit, + epoch: Epoch, + reward_weights: &[(StakeKey, Wit)], + ) -> DelegationResult { + // A committed reward snapshot is a native Wit/2.1 entitlement. Do not let a direct + // caller apply those weights while the one-time activation marker is still absent, where + // the same bytes could be interpreted as preactivation compatibility metadata rather than + // consensus-native delegated reward state. + if !self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "native committed reward distribution requires the activation marker", + }); + } + if reward_weights.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "native witness reward is missing committed weights", + }); + } + self.distribute_reward_inner(stakes, validator, gross_reward, epoch, Some(reward_weights)) + } + + /// Apply checked gross reward allocation, commission transfer, and stake credits for one validator. It is called by distribute_reward and distribute_committed_reward after their legacy or native boundary checks. + fn distribute_reward_inner( + &mut self, + stakes: &mut StakesTracker, + validator: PublicKeyHash, + gross_reward: Wit, + epoch: Epoch, + committed_weights: Option<&[(StakeKey, Wit)]>, + ) -> DelegationResult { + // Reward allocation scans the canonical primary ledger, then applies credits through a + // checked clone that rebuilds secondary indexes. Validate the source projection first so a + // direct reward caller cannot turn an omitted, duplicated, or detached index row into a + // silently accepted canonicalization. + stakes.validate_validator_index_checked()?; + // After activation the persisted policy is the sole owner/commission authority. A + // missing record is corrupted consensus state, not a legacy validator that may be + // reconstructed from whichever live withdrawer happens to remain. Fail before reading + // balances or allocating any reward so direct callers cannot lazily mint a zero- + // commission policy and publish a divergent state image. + if self.activation_initialized && !self.validators.contains_key(&validator) { + return Err(StakesError::ValidatorPolicyNotFound { validator }); + } + // Rewards are a state-publication boundary, not merely a calculation helper. A direct + // replay/consolidation caller must prove the complete post-activation policy/reservation + // projection before crediting even one validator; checking only the target policy above + // would let malformed state for another live or reserved validator pass until a later + // persistence load. The proof is a no-op for the pre-activation compatibility path. + if self.activation_initialized { + self.validate_authoritative_owner_policies(stakes)?; + } + // Block projection materializes due pending changes before rewards are distributed. Keep + // the reward transition itself fail-closed as well: a direct replay or consolidation + // caller must not apply a matured commission rate while leaving the pending record in the + // persisted policy image. The pre-activation sidecar remains inert and retains its legacy + // compatibility behavior. + if self.activation_initialized { + self.validate_pending_commissions_at_epoch(epoch)?; + } + // Derive a legacy policy locally first. Persisting it before all reward calculations and + // cloned stake credits succeed would make a failed distribution mutate delegation state. + let policy_was_missing = !self.validators.contains_key(&validator); + let validator_policy = if let Some(policy) = self.validators.get(&validator).copied() { + policy + } else { + let owner = self.owner_withdrawer_or_legacy(stakes, validator)?; + ValidatorPolicy::new(owner) + }; + // Reuse the shared lookup for persisted policies so both the active and pending values + // are validated before selecting the epoch-effective rate. A malformed future pending + // value must not stay hidden merely because this reward settles before its effective epoch. + let commission_percent = if policy_was_missing { + validator_policy.commission_percent + } else { + self.effective_commission_at(validator, epoch)? + }; + let owner_key = validator_policy.owner_key(validator); + // A fresh native block/inclusion reward cannot be earned by a validator whose immutable + // owner is already draining. Committed witness rewards pass `committed_weights` and must + // remain payable after an exit, so keep that earlier entitlement path distinct. + if committed_weights.is_none() + && self.activation_initialized + && self.exit_intents.contains(&owner_key) + { + return Ok(RewardDistribution::default()); + } + let entries = stakes.query_stakes_by_validator_primary_checked(validator)?; + if !entries.iter().any(|entry| entry.key == owner_key) { + return Err(StakesError::OwnerStakeNotFound { + validator, + owner: validator_policy.owner_withdrawer, + }); + } + + let weighted_entries = if let Some(weights) = committed_weights { + let mut previous: Option<&StakeKey> = None; + // The owner is part of every native commitment snapshot even when its balance is + // later drained. Requiring that immutable key here keeps settlement aligned with + // reservation construction after the reservation has been removed from state. + let mut owner_weight_seen = false; + for (key, weight) in weights { + if key.validator != validator || weight.nanowits() == 0 + || previous.is_some_and(|previous| previous >= key) + { + return Err(StakesError::InvalidDelegationState { + reason: "committed witness reward weights are not canonical", + }); + } + if key == &owner_key { + owner_weight_seen = true; + } + // A committed weight is an immutable pre-debit entitlement. The reservation may + // already have been consumed by the time this public settlement helper is called, + // so the earlier reservation-shape proof is no longer available here. Require the + // exact live key explicitly; otherwise a missing entry could be assigned a zero + // rounded reward and disappear without ever reaching the checked credit loop. + stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + previous = Some(key); + } + if !owner_weight_seen { + return Err(StakesError::InvalidDelegationState { + reason: "committed witness reward weights omit the immutable owner", + }); + } + weights.iter().map(|(key, weight)| (key.clone(), weight.nanowits())).collect::>() + } else { + entries.into_iter() + .filter(|entry| { + !self.activation_initialized || !self.exit_intents.contains(&entry.key) + }) + .map(|entry| (entry.key, entry.value.coins.nanowits())) + .collect::>() + }; + + // Rewards may carry a validator above the admission cap, but not beyond the persisted + // u64 representation shared by live stake and outstanding reservations. + self.ensure_validator_reward_representable(stakes, validator, gross_reward)?; + + let gross_credits = allocate_reward_by_weight( + &weighted_entries, + gross_reward.nanowits(), + &owner_key, + )?; + + let delegator_gross = gross_credits + .iter() + .filter(|(key, _)| key != &owner_key) + .map(|(_, amount)| *amount) + .try_fold(0u64, |total, amount| { + total + .checked_add(amount) + .ok_or(StakesError::ArithmeticOverflow) + })?; + let owner_commission = u64::try_from( + u128::from(delegator_gross) * u128::from(commission_percent) / 100, + ) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let commission_weights = gross_credits + .iter() + .filter(|(key, amount)| key != &owner_key && *amount > 0) + .cloned() + .collect::>(); + let commission_debits = allocate_by_weight(&commission_weights, owner_commission)? + .into_iter() + .collect::>(); + + let mut credits = BTreeMap::, u64>::new(); + for (key, gross_credit) in gross_credits { + let commission = commission_debits.get(&key).copied().unwrap_or_default(); + let net_credit = gross_credit + .checked_sub(commission) + .ok_or(StakesError::ArithmeticOverflow)?; + credits.insert(key, net_credit); + } + let owner_credit = credits.entry(owner_key).or_default(); + *owner_credit = owner_credit + .checked_add(owner_commission) + .ok_or(StakesError::ArithmeticOverflow)?; + if checked_sum(credits.values().copied())? != gross_reward.nanowits() { + return Err(StakesError::ArithmeticOverflow); + } + + let mut updated_stakes = stakes.try_clone_checked()?; + let mut distribution = RewardDistribution { + owner_commission: Wit::from_nanowits(owner_commission), + credits: Vec::with_capacity(credits.len()), + }; + for (key, amount) in credits { + if amount == 0 { + continue; + } + + let current = updated_stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))? + .first() + .map(|entry| entry.value.coins.nanowits()) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + current + .checked_add(amount) + .ok_or(StakesError::ArithmeticOverflow)?; + let amount = Wit::from_nanowits(amount); + updated_stakes.add_stake_to_existing( + key.clone(), + amount, + epoch, + false, + Wit::from_nanowits(0), + )?; + distribution.credits.push(RewardCredit { key, amount }); + } + + *stakes = updated_stakes; + if policy_was_missing { + self.validators.insert(validator, validator_policy); + } + + Ok(distribution) + } +} + +mod allocation; +use self::allocation::{ + WeightedCapacity, allocate_by_weight, allocate_reward_by_weight, allocate_with_capacities, + checked_sum, +}; + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use serde::Deserialize; + + use super::*; + + const NANOWITS_PER_WIT: u64 = 1_000_000_000; + const MINIMUM_STAKE_WIT: u64 = 10_000; + const MINIMUM_STAKE_NANOWITS: u64 = MINIMUM_STAKE_WIT * NANOWITS_PER_WIT; + + #[derive(Debug, Deserialize)] + struct LifecycleContractFixture { + protocol: String, + minimum_stake_nanowits: String, + validator: String, + owner: String, + first_delegator: String, + second_delegator: String, + reward: LifecycleRewardFixture, + slash: LifecycleSlashFixture, + exit: LifecycleExitFixture, + } + + #[derive(Debug, Deserialize)] + struct LifecycleRewardFixture { + gross_nanowits: String, + owner_commission_nanowits: String, + } + + #[derive(Debug, Deserialize)] + struct LifecycleSlashFixture { + collateral_nanowits: String, + owner_debit_nanowits: String, + first_delegator_debit_nanowits: String, + second_delegator_debit_nanowits: String, + } + + #[derive(Debug, Deserialize)] + struct LifecycleExitFixture { + exiting_delegator: String, + committed_reward_nanowits: String, + ordinary_reward_excludes_exiting: bool, + } + + fn lifecycle_contract_fixture() -> LifecycleContractFixture { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/delegated-staking/rust-lifecycle.fixture.json" + ))) + .expect("shared Rust/SDK lifecycle contract must decode") + } + + fn repeated_hex(byte: u8) -> String { + format!("{byte:02x}").repeat(20) + } + + fn pkh(byte: u8) -> PublicKeyHash { + PublicKeyHash::from_bytes(&[byte; 20]).unwrap() + } + + fn wits(value: u64) -> Wit { + Wit::from_nanowits(value.checked_mul(NANOWITS_PER_WIT).unwrap()) + } + + fn stake_key( + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + ) -> StakeKey { + StakeKey { + validator, + withdrawer, + } + } + + fn add_entry( + stakes: &mut StakesTracker, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + amount_wit: u64, + ) { + stakes + .add_stake( + stake_key(validator, withdrawer), + wits(amount_wit), + 1, + true, + Wit::from_nanowits(MINIMUM_STAKE_NANOWITS), + ) + .unwrap(); + } + + fn balance_nanowits( + stakes: &StakesTracker, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + ) -> u64 { + stakes + .query_stakes_checked(QueryStakesKey::Key(stake_key(validator, withdrawer))) + .unwrap()[0] + .value + .coins + .nanowits() + } + + fn debit_nanowits( + reservation: &CollateralReservation, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + ) -> u64 { + let key = stake_key(validator, withdrawer); + reservation + .debits + .iter() + .find(|debit| debit.key == key) + .map_or(0, |debit| debit.amount.nanowits()) + } + + #[test] + fn commission_cap_is_inclusive_and_above_cap_is_atomic() { + let validator = pkh(1); + let owner = pkh(2); + let mut state = DelegationState::default(); + assert_eq!( + state.policy, + DelegationPolicy { + maximum_commission_percent: 15, + owner_collateral_percent: 70, + } + ); + + let registered = state + .register_validator_if_absent(validator, owner, MAX_COMMISSION_PERCENT) + .unwrap(); + assert_eq!(registered.commission_percent, 15); + + let before = state.clone(); + assert_eq!( + state.schedule_commission(validator, 16, 9, 10, 1), + Err(StakesError::CommissionAboveMaximum { + commission: 16, + maximum: 15, + }) + ); + assert_eq!(state, before); + + assert_eq!( + state.schedule_commission(validator, 15, 10, 10, 1), + Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch is not in the future", + }) + ); + assert_eq!(state, before); + + let mut unregistered = DelegationState::default(); + assert_eq!( + unregistered.register_validator_if_absent(pkh(3), pkh(4), 16), + Err(StakesError::CommissionAboveMaximum { + commission: 16, + maximum: 15, + }) + ); + assert!(unregistered.validators.is_empty()); + } + + #[test] + fn legacy_collateral_tracking_rejects_conflicting_repeat_atomically() { + let validator = pkh(60); + let owner = pkh(61); + let request = Hash::with_first_u32(60); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + + state + .track_legacy_collateral(&stakes, request, validator, wits(100)) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + state + .track_legacy_collateral(&stakes, request, validator, wits(100)) + .unwrap(); + assert_eq!(state, state_before); + + assert_eq!( + state.track_legacy_collateral(&stakes, request, validator, wits(101)), + Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation conflicts with its commitment", + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_zero_sidecar_cannot_be_replaced_by_positive_commitment() { + let validator = pkh(62); + let owner = pkh(63); + let request = Hash::with_first_u32(62); + let key = CollateralReservationKey { + data_request: request, + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState { + collateral_reservations: BTreeMap::from([( + key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(0), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: wits(0), + }], + }, + )]), + ..DelegationState::default() + }; + let before = state.clone(); + + assert_eq!( + state.track_legacy_collateral(&stakes, request, validator, wits(100)), + Err(StakesError::InvalidDelegationState { + reason: "legacy collateral reservation conflicts with its commitment", + }) + ); + assert_eq!(state, before); + + state + .track_legacy_collateral(&stakes, request, validator, wits(0)) + .unwrap(); + assert!(!state.collateral_reservations.contains_key(&key)); + } + + #[test] + fn authoritative_post_activation_policy_check_rejects_a_below_minimum_live_entry() { + let validator = pkh(31); + let owner = pkh(32); + let delegator = pkh(33); + let delegator_key = stake_key(validator, delegator); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, MINIMUM_STAKE_WIT); + add_entry(&mut stakes, validator, delegator, MINIMUM_STAKE_WIT); + // Runtime transitions cannot create this shape. Corrupt the private fixture directly to + // prove the block/RPC prevalidation boundary does not rely only on persistence loading. + stakes + .by_key + .get(&delegator_key) + .unwrap() + .value + .write() + .unwrap() + .coins = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::LiveStakeEntryBelowMinimum { + key: delegator_key, + amount: Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + minimum: Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + }) + ); + } + + #[test] + fn authoritative_policy_grouped_walk_checks_every_validator_and_retained_policy() { + let mut stakes = StakesTracker::default(); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + let owner = pkh(200); + let delegator = pkh(201); + // Interleave insertions so the grouped canonical view cannot depend on insertion order + // or accidentally reuse the first validator's owner when checking a later group. + for byte in (1_u8..=8).rev() { + let validator = pkh(byte); + add_entry(&mut stakes, validator, delegator, MINIMUM_STAKE_WIT); + add_entry(&mut stakes, validator, owner, MINIMUM_STAKE_WIT); + state.register_validator_if_absent(validator, owner, 0).unwrap(); + } + state.register_validator_if_absent(pkh(9), owner, 0).unwrap(); + assert_eq!(state.validate_authoritative_owner_policies(&stakes), Ok(())); + + let last_validator = pkh(8); + let missing_owner = pkh(202); + state.validators.get_mut(&last_validator).unwrap().owner_withdrawer = missing_owner; + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::OwnerStakeNotFound { + validator: last_validator, + owner: missing_owner, + }) + ); + state.validators.get_mut(&last_validator).unwrap().owner_withdrawer = owner; + + // Empty retained policies are still checked even though they have no canonical group. + state.validators.get_mut(&pkh(9)).unwrap().commission_percent = 16; + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::CommissionAboveMaximum { + commission: 16, + maximum: MAX_COMMISSION_PERCENT, + }) + ); + } + + #[test] + fn checked_legacy_owner_view_uses_persisted_owner_after_activation() { + let validator = pkh(40); + // Keep the delegator's key lower than the owner's key so an arbitrary live-key + // reconstruction would expose the wrong entry. + let delegator = pkh(1); + let owner = pkh(2); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, MINIMUM_STAKE_WIT); + add_entry(&mut stakes, validator, delegator, MINIMUM_STAKE_WIT); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + + let owner_view = state.legacy_owner_view_checked(&stakes).unwrap(); + let entries = owner_view + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, stake_key(validator, owner)); + } + + #[test] + fn active_validator_set_uses_fixed_minimum_before_activation_marker() { + let validator = pkh(35); + let owner = pkh(36); + let owner_key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, MINIMUM_STAKE_WIT); + // A legacy-compatible pre-activation snapshot may still contain a sub-minimum live + // entry. The retained helper argument must not make that owner eligible for the native + // active set while the activation marker is still absent. + stakes + .by_key + .get(&owner_key) + .unwrap() + .value + .write() + .unwrap() + .coins = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + + assert_eq!( + state + .active_validator_count_checked(&stakes, Wit::from_nanowits(0)) + .unwrap(), + 0 + ); + assert!(state + .active_validator_set_checked(&stakes, Wit::from_nanowits(0)) + .unwrap() + .is_empty()); + } + + #[test] + fn grouped_persistence_accounting_covers_every_validator_and_keeps_state_unchanged() { + let mut stakes = StakesTracker::default(); + let mut state = DelegationState { activation_initialized: true, ..Default::default() }; + // Deliberately insert validators out of primary-key order, with different cold owners. + for byte in [31, 11, 21] { + let validator = pkh(byte); + let owner = pkh(byte + 1); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, pkh(byte + 2), 20_000); + state.register_validator_if_absent(validator, owner, 0).unwrap(); + } + for byte in [21, 31, 11] { + state.reserve_collateral( + &mut stakes, + CollateralReservationKey { data_request: Hash::with_first_u32(u32::from(byte)), validator: pkh(byte) }, + wits(1_000), 2, wits(MINIMUM_STAKE_WIT), + ).unwrap(); + } + let before_state = state.clone(); + let before_entries = stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(); + state.validate_stake_and_reservation_accounting(&stakes, wits(MINIMUM_STAKE_WIT)).unwrap(); + assert_eq!(state, before_state); + assert_eq!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(), before_entries); + + // Policy corruption must still fail the prerequisite authority check without mutation. + let mut invalid = state.clone(); + invalid.validators.get_mut(&pkh(31)).unwrap().owner_withdrawer = pkh(99); + let before_invalid = invalid.clone(); + assert!(invalid.validate_stake_and_reservation_accounting(&stakes, wits(MINIMUM_STAKE_WIT)).is_err()); + assert_eq!(invalid, before_invalid); + assert_eq!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(), before_entries); + + // Keep each exact-entry refund representable while overflowing only the final + // validator's aggregate. Unlike a missing owner, this passes the prerequisite authority + // check and therefore proves the grouped accounting loop reaches the last validator. + for withdrawer in [pkh(32), pkh(33)] { + stakes.by_key.get(&stake_key(pkh(31), withdrawer)).unwrap() + .value.write().unwrap().coins = Wit::from_nanowits(u64::MAX / 2 + 1); + } + let before_overflow_entries = stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(); + state.validate_authoritative_owner_policies(&stakes).unwrap(); + assert_eq!( + state.validate_stake_and_reservation_accounting(&stakes, wits(MINIMUM_STAKE_WIT)), + Err(StakesError::ArithmeticOverflow), + ); + assert_eq!(state, before_state); + assert_eq!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(), before_overflow_entries); + } + + #[test] + fn authoritative_post_activation_policy_check_rejects_malformed_reservation_shape() { + let validator = pkh(34); + let owner = pkh(35); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(19), + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(1_000), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: wits(999), + }], + }, + ); + + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::InvalidDelegationState { + reason: "collateral debit sum does not match reservation total", + }) + ); + } + + #[test] + fn authoritative_post_activation_policy_check_rejects_divergent_policy_constants() { + let validator = pkh(42); + let owner = pkh(43); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + // The 70/30 split and 15% ceiling are immutable even though these fields remain in the + // serialized compatibility image. Hot policy consumers must reject a divergent image just + // as the persistence validator does. + state.policy.owner_collateral_percent = 0; + + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::InvalidDelegationState { + reason: "delegation policy constants do not match the protocol", + }) + ); + } + + #[test] + fn authoritative_post_activation_policy_check_rejects_orphaned_reservation_debit() { + let validator = pkh(36); + let owner = pkh(37); + let missing_withdrawer = pkh(38); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(20), + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(1_000), + debits: vec![CollateralDebit { + key: stake_key(validator, missing_withdrawer), + amount: wits(1_000), + }], + }, + ); + + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::InvalidDelegationState { + reason: "active legacy collateral reservation is not keyed to the owner", + }) + ); + } + + #[test] + fn preactivation_policy_check_keeps_malformed_reservation_sidecars_inert() { + let validator = pkh(39); + let owner = pkh(40); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState::default(); + state.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(21), + validator, + }, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(1_000), + // This record is an upgrade-history cache while Wit/2.0 is active. Its malformed + // debit must not change legacy policy/eligibility behavior. + debits: vec![CollateralDebit { + key: stake_key(validator, pkh(41)), + amount: wits(999), + }], + }, + ); + + assert_eq!(state.validate_authoritative_owner_policies(&stakes), Ok(())); + } + + #[test] + fn commission_changes_are_mutable_delayed_and_strictly_sequenced() { + let validator = pkh(1); + let owner = pkh(2); + let inclusion_epoch = 100; + let checkpoint_period_seconds = 20; + let increase_epoch = commission_effective_epoch( + inclusion_epoch, + 5, + 15, + checkpoint_period_seconds, + ) + .unwrap(); + let increase_delay = increase_epoch - inclusion_epoch; + assert_eq!(increase_delay, 30_240); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 5) + .unwrap(); + + state + .schedule_commission(validator, 15, inclusion_epoch, increase_epoch, 1) + .unwrap(); + assert_eq!(state.effective_commission_at(validator, increase_epoch - 1), Ok(5)); + assert_eq!(state.effective_commission_at(validator, increase_epoch), Ok(15)); + state.activate_pending_commissions(increase_epoch - 1).unwrap(); + assert_eq!(state.validator_policy(validator).unwrap().commission_percent, 5); + state.activate_pending_commissions(increase_epoch).unwrap(); + assert_eq!( + state.validator_policy(validator).unwrap(), + ValidatorPolicy { + owner_withdrawer: owner, + commission_percent: 15, + commission_nonce: 1, + pending_commission: None, + } + ); + + let decrease_epoch = increase_epoch.checked_add(1).unwrap(); + state + .schedule_commission(validator, 0, increase_epoch, decrease_epoch, 2) + .unwrap(); + assert_eq!(state.effective_commission_at(validator, increase_epoch), Ok(15)); + assert_eq!(state.effective_commission_at(validator, decrease_epoch), Ok(0)); + assert_eq!( + state.schedule_commission(validator, 1, increase_epoch, decrease_epoch, 2), + Err(StakesError::InvalidCommissionNonce { + validator, + used: 2, + expected: 3, + }) + ); + + state.activate_pending_commissions(decrease_epoch).unwrap(); + assert_eq!( + state.validator_policy(validator).unwrap(), + ValidatorPolicy { + owner_withdrawer: owner, + commission_percent: 0, + commission_nonce: 2, + pending_commission: None, + } + ); + + // A newer valid nonce replaces the one pending change without mutating the active value. + state + .schedule_commission( + validator, + 15, + decrease_epoch, + decrease_epoch + increase_delay, + 3, + ) + .unwrap(); + state + .schedule_commission( + validator, + 3, + decrease_epoch, + decrease_epoch + increase_delay + 1, + 4, + ) + .unwrap(); + let policy = state.validator_policy(validator).unwrap(); + assert_eq!(policy.commission_percent, 0); + assert_eq!(policy.commission_nonce, 2); + assert_eq!( + policy.pending_commission, + Some(PendingCommission { + commission_percent: 3, + effective_epoch: decrease_epoch + increase_delay + 1, + nonce: 4, + }) + ); + } + + #[test] + fn effective_commission_rejects_a_malformed_future_pending_rate() { + let validator = pkh(3); + let owner = pkh(4); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state + .validators + .get_mut(&validator) + .expect("validator policy was just registered") + .pending_commission = Some(PendingCommission { + commission_percent: MAX_COMMISSION_PERCENT + 1, + effective_epoch: 100, + nonce: 1, + }); + + assert_eq!( + state.effective_commission_at(validator, 0), + Err(StakesError::CommissionAboveMaximum { + commission: MAX_COMMISSION_PERCENT + 1, + maximum: MAX_COMMISSION_PERCENT, + }) + ); + } + + #[test] + fn stale_pending_commission_is_rejected_only_after_activation() { + let validator = pkh(3); + let owner = pkh(4); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 5) + .unwrap(); + state + .schedule_commission(validator, 7, 10, 12, 1) + .unwrap(); + + assert_eq!(state.validate_pending_commissions_at_epoch(11), Ok(())); + assert_eq!( + state.validate_pending_commissions_at_epoch(12), + Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch has already arrived", + }) + ); + + state.activation_initialized = false; + assert_eq!(state.validate_pending_commissions_at_epoch(12), Ok(())); + } + + #[test] + fn future_pending_commission_rejects_a_stale_nonce() { + let validator = pkh(7); + let owner = pkh(8); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 5) + .unwrap(); + let policy = state.validators.get_mut(&validator).unwrap(); + policy.commission_nonce = 4; + policy.pending_commission = Some(PendingCommission { + commission_percent: 7, + effective_epoch: 12, + nonce: 4, + }); + + assert_eq!( + state.validate_pending_commissions_at_epoch(11), + Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }) + ); + } + + #[test] + fn pending_commission_activation_rejects_stale_nonce_atomically() { + let validator = pkh(5); + let owner = pkh(6); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 5) + .unwrap(); + let policy = state.validators.get_mut(&validator).unwrap(); + policy.commission_nonce = 4; + policy.pending_commission = Some(PendingCommission { + commission_percent: 7, + effective_epoch: 10, + nonce: 4, + }); + let before = state.clone(); + + assert_eq!( + state.activate_pending_commissions(10), + Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn collateral_plan_enforces_owner_share_and_owner_only_shortfall_cover() { + let validator = pkh(1); + let owner = pkh(2); + let delegator = pkh(3); + let minimum = wits(MINIMUM_STAKE_WIT); + let collateral = wits(10_000); + let scenarios = [ + // Both sides have capacity: use the exact mandatory 70/30 split. + (20_000, 20_000, 7_000, 3_000), + // The exact owner-capacity boundary is accepted. + (17_000, 20_000, 7_000, 3_000), + // Delegator capacity is short: the owner absorbs the delegator shortfall. + (20_000, 12_000, 8_000, 2_000), + ]; + + for (owner_stake, delegated_stake, expected_owner, expected_delegator) in scenarios { + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, owner_stake); + add_entry(&mut stakes, validator, delegator, delegated_stake); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + // Persisted compatibility data cannot weaken the consensus split. + state.policy.owner_collateral_percent = 0; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let reservation = state + .plan_collateral_reservation(&stakes, validator, collateral, minimum) + .unwrap(); + + assert_eq!(reservation.total, collateral); + assert_eq!( + debit_nanowits(&reservation, validator, owner), + wits(expected_owner).nanowits() + ); + assert_eq!( + debit_nanowits(&reservation, validator, delegator), + wits(expected_delegator).nanowits() + ); + assert_eq!( + reservation + .debits + .iter() + .map(|debit| debit.amount.nanowits()) + .sum::(), + collateral.nanowits() + ); + for debit in &reservation.debits { + let before = balance_nanowits( + &stakes, + debit.key.validator, + debit.key.withdrawer, + ); + assert!(before - debit.amount.nanowits() >= minimum.nanowits()); + } + } + + // The collective 30% delegator burden is split deterministically by excess capacity. + let other_delegator = pkh(4); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + add_entry(&mut stakes, validator, other_delegator, 30_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let reservation = state + .plan_collateral_reservation(&stakes, validator, collateral, minimum) + .unwrap(); + assert_eq!(debit_nanowits(&reservation, validator, owner), wits(7_000).nanowits()); + assert_eq!( + debit_nanowits(&reservation, validator, delegator), + wits(1_000).nanowits() + ); + assert_eq!( + debit_nanowits(&reservation, validator, other_delegator), + wits(2_000).nanowits() + ); + assert_eq!( + reservation + .debits + .iter() + .map(|debit| debit.amount.nanowits()) + .sum::(), + collateral.nanowits() + ); + } + + #[test] + fn exit_drains_old_obligations_without_accepting_new_participation() { + let validator = pkh(1); + let owner = pkh(2); + let withdrawer = pkh(3); + let key = stake_key(validator, withdrawer); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, withdrawer, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 15).unwrap(); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(91), validator, + }; + let old = state.reserve_collateral( + &mut stakes, reservation_key, wits(1_000), 2, wits(MINIMUM_STAKE_WIT), + ).unwrap(); + assert_eq!(old.reward_weights, vec![ + (stake_key(validator, owner), wits(20_000)), + (key.clone(), wits(20_000)), + ]); + state.request_exit(&stakes, key.clone(), 1).unwrap(); + assert!(matches!(state.ensure_entry_accepts_deposit(&key), Err(StakesError::StakeEntryExiting { .. }))); + assert!(matches!(state.request_exit(&stakes, key.clone(), 1), Err(StakesError::StakeEntryExiting { .. }))); + assert!(matches!(state.ensure_entry_can_be_removed(&key), Err(StakesError::StakeEntryLocked { .. }))); + + let next = state.plan_collateral_reservation( + &stakes, validator, wits(1_000), wits(MINIMUM_STAKE_WIT), + ).unwrap(); + assert_eq!(debit_nanowits(&next, validator, withdrawer), 0); + assert!(next.reward_weights.iter().all(|(reward_key, _)| reward_key != &key)); + assert!(state.participating_stakes_checked(&stakes).unwrap() + .query_stakes_checked(QueryStakesKey::Key(key.clone())).is_err()); + + let before_new_reward = balance_nanowits(&stakes, validator, withdrawer); + state.distribute_reward(&mut stakes, validator, wits(100), 3).unwrap(); + assert_eq!(balance_nanowits(&stakes, validator, withdrawer), before_new_reward); + let refunded = state.refund_collateral(&mut stakes, reservation_key, 4).unwrap(); + state.distribute_committed_reward( + &mut stakes, validator, wits(100), 4, &refunded.reward_weights, + ).unwrap(); + // The old equal-weight entitlement remains 50 WIT minus 15% commission even though + // the owner received a newer reward and this delegator is already draining. + assert_eq!(balance_nanowits(&stakes, validator, withdrawer), + wits(20_000).nanowits() + 42_500_000_000); + state.ensure_entry_can_be_removed(&key).unwrap(); + let final_balance = Wit::from_nanowits(balance_nanowits(&stakes, validator, withdrawer)); + stakes.remove_stake(key.clone(), final_balance, 5, true, wits(MINIMUM_STAKE_WIT)).unwrap(); + state.complete_exit_if_removed(&stakes, &key).unwrap(); + assert!(!state.exit_intents.contains(&key)); + assert_eq!(state.validate_authoritative_owner_policies(&stakes), Ok(())); + } + + #[test] + fn preactivation_exit_intent_sidecars_are_inert() { + let validator = pkh(41); + let owner = pkh(42); + let key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + state.exit_intents.insert(key.clone()); + + // Exit-intent bytes may have been written by an upgrade-history version before the + // canonical activation marker. They cannot suppress the authoritative legacy projection + // or reject a native boundary deposit before activation reconstructs state and clears the + // sidecar. + assert_eq!(state.ensure_entry_accepts_deposit(&key), Ok(())); + assert!(state + .participating_stakes_checked(&stakes) + .unwrap() + .query_stakes_checked(QueryStakesKey::Key(key)) + .is_ok()); + + // The same inert marker must not remove the legacy owner from reward allocation or + // native collateral capacity. Both paths are reached by cross-fork/activation + // projections and must remain independent of an optional pre-marker cache. + let before_reward = balance_nanowits(&stakes, validator, owner); + state.distribute_reward(&mut stakes, validator, wits(100), 1).unwrap(); + assert_eq!( + balance_nanowits(&stakes, validator, owner), + before_reward + wits(100).nanowits() + ); + let reservation = state + .plan_collateral_reservation( + &stakes, + validator, + wits(1_000), + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + assert_eq!( + debit_nanowits(&reservation, validator, owner), + wits(1_000).nanowits() + ); + } + + #[test] + fn zero_debit_reward_entitlement_still_blocks_exit_until_settlement() { + let validator = pkh(1); + let owner = pkh(2); + let withdrawer = pkh(3); + let key = stake_key(validator, withdrawer); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, withdrawer, MINIMUM_STAKE_WIT); + let mut state = DelegationState { activation_initialized: true, ..Default::default() }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation_key = CollateralReservationKey { data_request: Hash::with_first_u32(92), validator }; + let reservation = state.reserve_collateral( + &mut stakes, reservation_key, wits(1_000), 2, wits(MINIMUM_STAKE_WIT), + ).unwrap(); + assert_eq!(debit_nanowits(&reservation, validator, withdrawer), 0); + state.request_exit(&stakes, key.clone(), 1).unwrap(); + assert!(matches!(state.ensure_entry_can_be_removed(&key), Err(StakesError::StakeEntryLocked { .. }))); + state + .slash_reserved_collateral_checked(&stakes, reservation_key) + .unwrap(); + assert_eq!(state.ensure_entry_can_be_removed(&key), Ok(())); + } + + #[test] + fn committed_reward_distribution_requires_activation_marker_atomically() { + let validator = pkh(49); + let owner = pkh(50); + let owner_key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.distribute_committed_reward( + &mut stakes, + validator, + wits(1), + 2, + &[(owner_key, wits(20_000))], + ), + Err(StakesError::InvalidDelegationState { + reason: "native committed reward distribution requires the activation marker", + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn committed_reward_rejects_missing_weight_key_after_reservation_consumption() { + let validator = pkh(51); + let owner = pkh(52); + let delegator = pkh(53); + let delegator_key = stake_key(validator, delegator); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(94), + validator, + }; + let reservation = state + .reserve_collateral(&mut stakes, reservation_key, wits(1_000), 2, wits(MINIMUM_STAKE_WIT)) + .unwrap(); + state + .slash_reserved_collateral_checked(&stakes, reservation_key) + .unwrap(); + let delegator_balance = Wit::from_nanowits(balance_nanowits(&stakes, validator, delegator)); + stakes + .remove_stake( + delegator_key.clone(), + delegator_balance, + 3, + true, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + + assert_eq!( + state.distribute_committed_reward( + &mut stakes, + validator, + wits(1), + 3, + &reservation.reward_weights, + ), + Err(StakesError::EntryNotFound { key: delegator_key }) + ); + } + + #[test] + fn reservation_refund_rejects_missing_committed_weight_key_before_consumption() { + let validator = pkh(54); + let owner = pkh(55); + let delegator = pkh(56); + let delegator_key = stake_key(validator, delegator); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(95), + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + wits(1_000), + 2, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + let delegator_balance = Wit::from_nanowits(balance_nanowits(&stakes, validator, delegator)); + stakes + .remove_stake( + delegator_key.clone(), + delegator_balance, + 3, + true, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.refund_collateral(&mut stakes, reservation_key, 4), + Err(StakesError::EntryNotFound { key: delegator_key }) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state.collateral_reservations[&reservation_key], reservation); + } + + #[test] + fn reservation_slash_rejects_missing_committed_weight_key_before_consumption() { + let validator = pkh(57); + let owner = pkh(58); + let delegator = pkh(59); + let delegator_key = stake_key(validator, delegator); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(96), + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + wits(1_000), + 2, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + let delegator_balance = Wit::from_nanowits(balance_nanowits(&stakes, validator, delegator)); + stakes + .remove_stake( + delegator_key.clone(), + delegator_balance, + 3, + true, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.slash_reserved_collateral_checked(&stakes, reservation_key), + Err(StakesError::EntryNotFound { key: delegator_key }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + assert_eq!(state.collateral_reservations[&reservation_key], reservation); + } + + #[test] + fn owner_exit_requires_no_delegators_and_keeps_earned_owner_reward() { + let validator = pkh(1); + let owner = pkh(2); + let withdrawer = pkh(3); + let key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, withdrawer, MINIMUM_STAKE_WIT); + let mut state = DelegationState { activation_initialized: true, ..Default::default() }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + assert_eq!(state.request_exit(&stakes, key.clone(), 1), + Err(StakesError::OwnerExitWithDelegators { validator })); + // Admission is not sufficient: a forged persisted intent must fail the shared + // authoritative proof before eligibility, deposits, or new reservations consume it. + state.exit_intents.insert(key.clone()); + assert_eq!(state.validate_authoritative_owner_policies(&stakes), + Err(StakesError::OwnerExitWithDelegators { validator })); + state.exit_intents.remove(&key); + stakes.remove_stake(stake_key(validator, withdrawer), wits(MINIMUM_STAKE_WIT), 2, true, wits(MINIMUM_STAKE_WIT)).unwrap(); + let reservation_key = CollateralReservationKey { data_request: Hash::with_first_u32(93), validator }; + state.reserve_collateral(&mut stakes, reservation_key, wits(1_000), 2, wits(MINIMUM_STAKE_WIT)).unwrap(); + state.request_exit(&stakes, key.clone(), 1).unwrap(); + assert_eq!(state.active_validator_count_checked(&stakes, wits(MINIMUM_STAKE_WIT)), Ok(0)); + assert!(matches!(state.plan_collateral_reservation(&stakes, validator, wits(1), wits(MINIMUM_STAKE_WIT)), + Err(StakesError::StakeEntryExiting { .. }))); + let reservation = state.refund_collateral(&mut stakes, reservation_key, 3).unwrap(); + state.distribute_committed_reward(&mut stakes, validator, wits(100), 3, &reservation.reward_weights).unwrap(); + assert_eq!(balance_nanowits(&stakes, validator, owner), wits(20_100).nanowits()); + } + + #[test] + fn owner_shortfall_rejects_even_when_delegators_can_cover_all_collateral() { + let validator = pkh(1); + let owner = pkh(2); + let delegator = pkh(3); + let collateral = wits(10_000); + let minimum = wits(MINIMUM_STAKE_WIT); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(8), + validator, + }; + for owner_stake in [10_000, 12_000, 16_999] { + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, owner_stake); + add_entry(&mut stakes, validator, delegator, 110_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + assert_eq!( + state.reserve_collateral( + &mut stakes, + reservation_key, + collateral, + 2, + minimum, + ), + Err(StakesError::InsufficientOwnerCollateralCapacity { + validator, + required: wits(7_000), + available: wits(owner_stake - MINIMUM_STAKE_WIT), + }) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + } + + #[test] + fn owner_only_validator_can_cover_the_complete_collateral() { + let validator = pkh(1); + let owner = pkh(2); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let collateral = wits(10_000); + let reservation = state + .plan_collateral_reservation( + &stakes, + validator, + collateral, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + assert_eq!(reservation.total, collateral); + assert_eq!(reservation.debits.len(), 1); + assert_eq!(debit_nanowits(&reservation, validator, owner), collateral.nanowits()); + } + + #[test] + fn collateral_reservation_transfers_stake_without_inflating_admission_total() { + let validator = pkh(61); + let owner = pkh(62); + let delegator = pkh(63); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 9_989_000); + add_entry(&mut stakes, validator, delegator, MINIMUM_STAKE_WIT); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(61), + validator, + }; + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + wits(2_000), + 2, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + assert_eq!(reservation.total, wits(2_000)); + assert_eq!( + stakes.validator_total_stake(validator).unwrap().nanowits(), + wits(9_997_000).nanowits() + ); + assert_eq!( + state + .collateral_reservations + .get(&reservation_key) + .unwrap() + .total, + wits(2_000) + ); + } + + #[test] + fn reserved_slash_conserves_the_exact_penalty_without_a_second_debit() { + let validator = pkh(1); + let owner = pkh(2); + let delegator = pkh(3); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(7), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let collateral = wits(10_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let before = stakes.validator_total_stake(validator).unwrap(); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + collateral, + 2, + minimum, + ) + .unwrap(); + let after_reservation = stakes.validator_total_stake(validator).unwrap(); + assert_eq!(before.nanowits() - after_reservation.nanowits(), collateral.nanowits()); + assert_eq!(debit_nanowits(&reservation, validator, owner), wits(7_000).nanowits()); + assert_eq!( + debit_nanowits(&reservation, validator, delegator), + wits(3_000).nanowits() + ); + + let slashed = state + .slash_reserved_collateral_checked(&stakes, reservation_key) + .unwrap(); + assert_eq!(slashed, reservation); + assert_eq!(stakes.validator_total_stake(validator).unwrap(), after_reservation); + assert!(!state.collateral_reservations.contains_key(&reservation_key)); + } + + #[test] + fn native_slash_uses_excess_capacity_not_total_delegator_balance() { + let validator = pkh(71); + let owner = pkh(72); + let first_delegator = pkh(73); + let second_delegator = pkh(74); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(71), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, first_delegator, 20_000); + add_entry(&mut stakes, validator, second_delegator, 30_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + wits(10_000), + 2, + minimum, + ) + .unwrap(); + + // The 3,000-WIT delegator share is weighted by 10,000 and 20,000 WIT + // of excess capacity, not by the 20,000 and 30,000 WIT balances. + assert_eq!( + debit_nanowits(&reservation, validator, owner), + wits(7_000).nanowits() + ); + assert_eq!( + debit_nanowits(&reservation, validator, first_delegator), + wits(1_000).nanowits() + ); + assert_eq!( + debit_nanowits(&reservation, validator, second_delegator), + wits(2_000).nanowits() + ); + + // Slash consumes the committed vector exactly; it must not replan against + // post-commit balances or debit the live stake a second time. + let live_total_after_commit = stakes.validator_total_stake(validator).unwrap(); + let slashed = state + .slash_reserved_collateral_checked(&stakes, reservation_key) + .unwrap(); + assert_eq!(slashed, reservation); + assert_eq!( + stakes.validator_total_stake(validator).unwrap(), + live_total_after_commit + ); + } + + #[test] + fn malformed_native_reservation_cannot_be_refunded_or_slashed() { + let validator = pkh(25); + let owner = pkh(26); + let delegator = pkh(27); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(17), + validator, + }; + let collateral = wits(1_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + // The canonical keys and nonzero amounts look superficially plausible, but the + // debit vector refunds one WIT less than the recorded slash total. + debits: vec![ + CollateralDebit { + key: stake_key(validator, owner), + amount: wits(700), + }, + CollateralDebit { + key: stake_key(validator, delegator), + amount: wits(299), + }, + ], + }, + ); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.validate_collateral_reservation_total(reservation_key, collateral), + Err(StakesError::InvalidDelegationState { + reason: "collateral debit sum does not match reservation total", + }) + ); + assert_eq!( + state.refund_collateral(&mut stakes, reservation_key, 2), + Err(StakesError::InvalidDelegationState { + reason: "collateral debit sum does not match reservation total", + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + assert_eq!( + state.slash_reserved_collateral(reservation_key), + Err(StakesError::InvalidDelegationState { + reason: "collateral debit sum does not match reservation total", + }) + ); + assert_eq!(state, state_before); + + // Once the debit vector itself is exact, an empty-weight activated reservation is still + // classified as a legacy sidecar and must remain owner-only before any request-total + // comparison can consume it. + state + .collateral_reservations + .get_mut(&reservation_key) + .unwrap() + .debits[1] + .amount = wits(300); + assert_eq!( + state.validate_collateral_reservation_total(reservation_key, wits(1_001)), + Err(StakesError::InvalidDelegationState { + reason: "active legacy collateral reservation is not owner-only", + }) + ); + } + + #[test] + fn native_reservation_requires_the_owner_collateral_floor() { + let validator = pkh(29); + let owner = pkh(30); + let delegator = pkh(31); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(18), + validator, + }; + let collateral = wits(1_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + total: collateral, + reward_weights: vec![ + (stake_key(validator, owner), wits(20_000)), + (stake_key(validator, delegator), wits(20_000)), + ], + debits: vec![ + CollateralDebit { + key: stake_key(validator, owner), + amount: wits(699), + }, + CollateralDebit { + key: stake_key(validator, delegator), + amount: wits(301), + }, + ], + }, + ); + + let expected = Err(StakesError::InvalidDelegationState { + reason: "native reservation owner debit is below the mandatory collateral share", + }); + assert_eq!( + state.validate_authoritative_owner_policies(&stakes), + expected + ); + assert!(matches!( + state.validate_collateral_reservation_total(reservation_key, collateral), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert!(matches!( + state.refund_collateral(&mut stakes, reservation_key, 2), + Err(StakesError::InvalidDelegationState { .. }) + )); + let before_unchecked_slash = state.clone(); + assert_eq!( + state.slash_reserved_collateral(reservation_key), + Err(StakesError::InvalidDelegationState { + reason: "native collateral slash requires live reward-key validation", + }) + ); + assert_eq!(state, before_unchecked_slash); + assert!(matches!( + state.slash_reserved_collateral_checked(&stakes, reservation_key), + Err(StakesError::InvalidDelegationState { .. }) + )); + } + + #[test] + fn native_refund_replays_the_exact_debit_vector_once() { + let validator = pkh(28); + let owner = pkh(29); + let delegator = pkh(30); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(18), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let collateral = wits(10_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let owner_before = balance_nanowits(&stakes, validator, owner); + let delegator_before = balance_nanowits(&stakes, validator, delegator); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + + let reserved = state + .reserve_collateral( + &mut stakes, + reservation_key, + collateral, + 2, + minimum, + ) + .unwrap(); + let refunded = state + .refund_collateral(&mut stakes, reservation_key, 3) + .unwrap(); + + assert_eq!(refunded, reserved); + assert_eq!(balance_nanowits(&stakes, validator, owner), owner_before); + assert_eq!( + balance_nanowits(&stakes, validator, delegator), + delegator_before + ); + assert!(!state.collateral_reservations.contains_key(&reservation_key)); + assert_eq!( + state.refund_collateral(&mut stakes, reservation_key, 3), + Err(StakesError::CollateralReservationNotFound { validator }) + ); + } + + #[test] + fn native_refund_allows_an_entry_left_at_the_hard_minimum() { + let validator = pkh(34); + let owner = pkh(35); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(19), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let collateral = wits(10_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + + let reservation = state + .reserve_collateral( + &mut stakes, + reservation_key, + collateral, + 2, + minimum, + ) + .unwrap(); + assert_eq!(reservation.debits.len(), 1); + assert_eq!(balance_nanowits(&stakes, validator, owner), minimum.nanowits()); + + state + .refund_collateral(&mut stakes, reservation_key, 3) + .unwrap(); + assert_eq!(balance_nanowits(&stakes, validator, owner), collateral.nanowits() * 2); + } + + #[test] + fn native_zero_collateral_reservation_is_rejected_atomically() { + let validator = pkh(5); + let owner = pkh(6); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(8), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.reserve_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(0), + 2, + minimum, + ), + Err(StakesError::InvalidDelegationState { + reason: "native collateral reservation cannot be zero", + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_zero_collateral_requires_a_live_owner_after_activation() { + let validator = pkh(7); + let owner = pkh(8); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(10), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + // A legacy zero-collateral request is a protocol no-op, but after activation it still + // must resolve the persisted owner entry. Otherwise an absent owner could bypass the + // state shape required by later cross-fork settlement. + assert_eq!( + state.reserve_legacy_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(0), + 2, + minimum, + ), + Err(StakesError::EntryNotFound { + key: stake_key(validator, owner), + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_zero_collateral_remains_a_noop_for_a_valid_owner() { + let validator = pkh(11); + let owner = pkh(12); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(12), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + let reservation = state + .reserve_legacy_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(0), + 2, + minimum, + ) + .unwrap(); + assert_eq!(reservation.total, Wit::from_nanowits(0)); + assert!(reservation.debits.is_empty()); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_collateral_rejects_a_below_minimum_owner_atomically() { + let validator = pkh(9); + let owner = pkh(10); + let owner_key = stake_key(validator, owner); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(11), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, MINIMUM_STAKE_WIT); + // Runtime transitions cannot create this shape. Corrupt the fixture directly to ensure + // the cross-fork helper itself, rather than only activation validation, fails closed. + stakes + .by_key + .get(&owner_key) + .unwrap() + .value + .write() + .unwrap() + .coins = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1); + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.reserve_legacy_collateral( + &mut stakes, + reservation_key, + wits(1), + 2, + minimum, + ), + Err(StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + minimum, + }) + ); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn reward_commission_is_charged_only_to_delegators_and_conserves_reward() { + let validator = pkh(1); + let owner = pkh(2); + let delegator = pkh(3); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 80_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 15) + .unwrap(); + + let gross_reward = wits(100); + let distribution = state + .distribute_reward(&mut stakes, validator, gross_reward, 2) + .unwrap(); + + // Gross shares are 20/80 WIT. Only 15% of the delegator's 80 WIT share is commission. + assert_eq!(distribution.owner_commission, wits(12)); + assert_eq!( + distribution + .credits + .iter() + .map(|credit| credit.amount.nanowits()) + .sum::(), + gross_reward.nanowits() + ); + assert_eq!(balance_nanowits(&stakes, validator, owner), wits(20_032).nanowits()); + assert_eq!( + balance_nanowits(&stakes, validator, delegator), + wits(80_068).nanowits() + ); + } + + #[test] + fn reward_rejects_a_malformed_future_pending_commission() { + let validator = pkh(46); + let owner = pkh(47); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state + .validators + .get_mut(&validator) + .expect("validator policy was just registered") + .pending_commission = Some(PendingCommission { + commission_percent: MAX_COMMISSION_PERCENT + 1, + effective_epoch: 100, + nonce: 1, + }); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + assert_eq!( + state.distribute_reward(&mut stakes, validator, wits(1), 0), + Err(StakesError::CommissionAboveMaximum { + commission: MAX_COMMISSION_PERCENT + 1, + maximum: MAX_COMMISSION_PERCENT, + }) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + + #[test] + fn post_activation_reward_rejects_a_mature_pending_commission() { + let validator = pkh(48); + let owner = pkh(49); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state + .validators + .get_mut(&validator) + .expect("validator policy was just registered") + .pending_commission = Some(PendingCommission { + commission_percent: 5, + effective_epoch: 2, + nonce: 1, + }); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + assert_eq!( + state.distribute_reward(&mut stakes, validator, wits(1), 2), + Err(StakesError::InvalidDelegationState { + reason: "pending commission effective epoch has already arrived", + }) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + + #[test] + fn reward_rejects_validator_wide_live_plus_reserved_overflow_atomically() { + let validator = pkh(40); + let owner = pkh(41); + let delegator = pkh(42); + let owner_key = stake_key(validator, owner); + let delegator_key = stake_key(validator, delegator); + let minimum = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS); + let half = u64::MAX / 2; + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(half), + 1, + true, + minimum, + ) + .unwrap(); + stakes + .add_stake( + delegator_key, + Wit::from_nanowits(half - 100), + 1, + true, + minimum, + ) + .unwrap(); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(40), + validator, + }, + CollateralReservation { + reward_weights: Vec::new(), + total: Wit::from_nanowits(100), + debits: vec![CollateralDebit { + key: owner_key, + amount: Wit::from_nanowits(100), + }], + }, + ); + assert_eq!( + state.validate_persisted_invariants( + &stakes, + 1, + minimum, + wits(10_000_000), + ), + Ok(()) + ); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + // Live totals are u64::MAX - 101 and escrow restores another 100. A two-nanowit + // reward fits every individual entry but cannot fit the validator-wide reconstruction. + assert_eq!( + state.distribute_reward(&mut stakes, validator, Wit::from_nanowits(2), 2), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + + #[test] + fn legacy_reserved_reward_rejects_validator_wide_overflow_atomically() { + let validator = pkh(43); + let owner = pkh(44); + let delegator = pkh(45); + let owner_key = stake_key(validator, owner); + let minimum = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS); + let half = u64::MAX / 2; + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(half), + 1, + true, + minimum, + ) + .unwrap(); + stakes + .add_stake( + stake_key(validator, delegator), + Wit::from_nanowits(half - 100), + 1, + true, + minimum, + ) + .unwrap(); + + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(43), + validator, + }; + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: Wit::from_nanowits(100), + debits: vec![CollateralDebit { + key: owner_key, + amount: Wit::from_nanowits(100), + }], + }, + ); + assert_eq!( + state.validate_persisted_invariants(&stakes, 1, minimum, wits(10_000_000)), + Ok(()) + ); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + assert_eq!( + state.settle_legacy_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(2), + 2, + ), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + + #[test] + fn legacy_reward_without_reservation_rejects_validator_wide_overflow_atomically() { + let validator = pkh(46); + let owner = pkh(47); + let delegator = pkh(48); + let minimum = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS); + let half = u64::MAX / 2; + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + stake_key(validator, owner), + Wit::from_nanowits(half), + 1, + true, + minimum, + ) + .unwrap(); + stakes + .add_stake( + stake_key(validator, delegator), + Wit::from_nanowits(half), + 1, + true, + minimum, + ) + .unwrap(); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + assert_eq!( + state.validate_persisted_invariants(&stakes, 1, minimum, wits(10_000_000)), + Ok(()) + ); + let stakes_before = stakes.clone(); + let state_before = state.clone(); + + assert_eq!( + state.settle_legacy_tally_without_reservation( + &mut stakes, + validator, + Wit::from_nanowits(0), + Wit::from_nanowits(2), + 2, + ), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!(stakes, stakes_before); + assert_eq!(state, state_before); + } + + #[test] + fn legacy_no_sidecar_tally_requires_activation_marker_atomically() { + let validator = pkh(49); + let owner = pkh(50); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let state = DelegationState::default(); + let stakes_before = stakes.clone(); + + assert_eq!( + state.settle_legacy_tally_without_reservation( + &mut stakes, + validator, + Wit::from_nanowits(0), + wits(1), + 2, + ), + Err(StakesError::InvalidDelegationState { + reason: "legacy cross-fork tally settlement requires the activation marker", + }) + ); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_no_sidecar_tally_reward_targets_the_owner_after_activation() { + let validator = pkh(9); + let owner = pkh(3); + let delegator = pkh(1); + let mut stakes = StakesTracker::default(); + // The delegator sorts before the owner, which catches accidental use of the legacy + // first-entry reward wrapper after the validator has acquired multiple entries. + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + + state + .settle_legacy_tally_without_reservation( + &mut stakes, + validator, + Wit::from_nanowits(0), + wits(100), + 2, + ) + .unwrap(); + + assert_eq!(balance_nanowits(&stakes, validator, owner), wits(20_100).nanowits()); + assert_eq!(balance_nanowits(&stakes, validator, delegator), wits(20_000).nanowits()); + } + + #[test] + fn activation_obligation_defers_unproven_collateral_and_reward() { + let validator = pkh(51); + let owner = pkh(52); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(51), + validator, + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + + state + .record_legacy_activation_obligation( + &stakes, + reservation_key, + wits(100), + wits(2), + ) + .unwrap(); + let before = stakes.clone(); + state + .settle_legacy_activation_obligation( + &mut stakes, + reservation_key, + false, + false, + 2, + ) + .unwrap(); + + assert_eq!(stakes, before); + assert_eq!( + state.legacy_activation_obligations[&reservation_key].settlement, + LegacyActivationSettlement::Deferred { + collateral: wits(100), + witness_reward: wits(2), + } + ); + assert!(state.collateral_reservations.is_empty()); + } + + #[test] + fn activation_obligation_with_missing_owner_never_invents_a_beneficiary() { + let validator = pkh(53); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(53), + validator, + }; + let mut stakes = StakesTracker::default(); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + + state + .record_legacy_activation_obligation( + &stakes, + reservation_key, + Wit::from_nanowits(0), + wits(2), + ) + .unwrap(); + state + .settle_legacy_activation_obligation( + &mut stakes, + reservation_key, + false, + false, + 2, + ) + .unwrap(); + + assert!(stakes.query_stakes_checked(QueryStakesKey::All).unwrap().is_empty()); + assert_eq!( + state.legacy_activation_obligations[&reservation_key].settlement, + LegacyActivationSettlement::Deferred { + collateral: Wit::from_nanowits(0), + witness_reward: wits(2), + } + ); + } + + #[test] + fn legacy_inclusion_reward_targets_the_owner_after_activation() { + let validator = pkh(10); + let owner = pkh(4); + let delegator = pkh(2); + let mut stakes = StakesTracker::default(); + // The delegator sorts before the owner, so the compatibility `add_reward` wrapper would + // pay the wrong entry once this validator has acquired delegated stake. + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + + state + .distribute_legacy_owner_reward(&mut stakes, validator, wits(100), 2) + .unwrap(); + + assert_eq!(balance_nanowits(&stakes, validator, owner), wits(20_100).nanowits()); + assert_eq!(balance_nanowits(&stakes, validator, delegator), wits(20_000).nanowits()); + } + + #[test] + fn legacy_inclusion_reward_skips_an_owner_already_draining() { + let validator = pkh(11); + let owner = pkh(5); + let owner_key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.request_exit(&stakes, owner_key, 1).unwrap(); + + let before_owner = balance_nanowits(&stakes, validator, owner); + state + .distribute_legacy_owner_reward(&mut stakes, validator, wits(100), 2) + .unwrap(); + state + .distribute_reward(&mut stakes, validator, wits(100), 2) + .unwrap(); + + assert_eq!(balance_nanowits(&stakes, validator, owner), before_owner); + } + + #[test] + fn activation_preflight_and_initialization_use_only_the_live_owner() { + let validator = pkh(1); + let historical_withdrawer = pkh(2); + let live_owner = pkh(3); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(9), + validator, + }; + let collateral = wits(1_000); + let beacon = CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }; + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, live_owner, 20_000); + + let mut state = DelegationState::default(); + state.policy = DelegationPolicy { + maximum_commission_percent: 99, + owner_collateral_percent: 0, + }; + state.validators.insert( + validator, + ValidatorPolicy { + owner_withdrawer: historical_withdrawer, + commission_percent: 15, + commission_nonce: 7, + pending_commission: Some(PendingCommission { + commission_percent: 1, + effective_epoch: 100, + nonce: 8, + }), + }, + ); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, historical_withdrawer), + amount: collateral, + }], + }, + ); + let outstanding = BTreeMap::from([(reservation_key, collateral)]); + + // Pre-activation policy records are upgrade-history-dependent caches. They must not + // override the owner deterministically derived from the live Wit/2.0 ledger. + assert_eq!( + state.owner_withdrawer_or_legacy(&stakes, validator), + Ok(live_owner) + ); + assert_eq!(state.active_validator_count(&stakes, wits(10_000)), 1); + + let report = state.activation_preflight( + &stakes, + &outstanding, + beacon, + wits(MINIMUM_STAKE_WIT), + ); + assert!(report.safe); + assert_eq!(report.inspected_beacon, beacon); + assert_eq!(report.minimum_stake_nanowits, MINIMUM_STAKE_NANOWITS); + assert_eq!(report.live_stake_entry_count, Some(1)); + assert!(report.ambiguous_live_validators.is_empty()); + assert!(report.below_minimum_live_stakes.is_empty()); + assert!(!report.activation_accounting_error); + assert_eq!(report.cached_legacy_sidecar_count, 1); + assert_eq!(report.missing_legacy_sidecar_count, 0); + assert_eq!(report.commitments.len(), 1); + assert_eq!(report.commitments[0].canonical_live_owner, Some(live_owner)); + assert_eq!(report.commitments[0].live_stake_entry_count, 1); + assert_eq!( + report.commitments[0] + .cached_sidecar + .as_ref() + .unwrap() + .historical_withdrawer, + Some(historical_withdrawer) + ); + assert!(report.commitments[0].errors.is_empty()); + + state + .initialize_legacy_validators_for_activation( + &stakes, + wits(MINIMUM_STAKE_WIT), + ) + .unwrap(); + assert!(state.activation_initialized); + assert_eq!(state.policy, DelegationPolicy::default()); + assert_eq!( + state.validator_policy(validator).unwrap(), + ValidatorPolicy::new(live_owner) + ); + // The preactivation sidecar is discarded. Reconstructing the authoritative outstanding + // commitment is a separate transition keyed by the request pool. + assert!(state.collateral_reservations.is_empty()); + state + .track_legacy_collateral(&stakes, reservation_key.data_request, validator, collateral) + .unwrap(); + assert_eq!( + state.collateral_reservations[&reservation_key].debits, + vec![CollateralDebit { + key: stake_key(validator, live_owner), + amount: collateral, + }] + ); + } + + #[test] + fn activation_rejects_ambiguous_legacy_owner_instead_of_guessing() { + let validator = pkh(5); + let first_withdrawer = pkh(6); + let second_withdrawer = pkh(7); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, first_withdrawer, 20_000); + add_entry(&mut stakes, validator, second_withdrawer, 20_000); + let mut state = DelegationState::default(); + let before = state.clone(); + let report = state.activation_preflight( + &stakes, + &BTreeMap::new(), + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + wits(MINIMUM_STAKE_WIT), + ); + + assert!(!report.safe); + assert_eq!(report.ambiguous_live_validators, vec![validator]); + assert_eq!( + report.errors, + vec![ActivationPreflightErrorCode::AmbiguousLiveOwner] + ); + assert_eq!( + state.owner_withdrawer_or_legacy(&stakes, validator), + Err(StakesError::InvalidDelegationState { + reason: "legacy validator has ambiguous live withdrawer ownership", + }) + ); + assert_eq!( + state.initialize_legacy_validators_for_activation(&stakes, wits(MINIMUM_STAKE_WIT)), + Err(StakesError::InvalidDelegationState { + reason: "activation encountered ambiguous legacy owner ownership", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn activation_rejects_an_empty_eligible_validator_set() { + let stakes = StakesTracker::default(); + let mut state = DelegationState::default(); + let before = state.clone(); + let report = state.activation_preflight( + &stakes, + &BTreeMap::new(), + CheckpointBeacon { + checkpoint: 43, + hash_prev_block: Hash::with_first_u32(42), + }, + wits(MINIMUM_STAKE_WIT), + ); + + assert!(!report.safe); + assert_eq!( + report.errors, + vec![ActivationPreflightErrorCode::NoEligibleValidator] + ); + assert_eq!(report.live_stake_entry_count, Some(0)); + assert_eq!( + state.initialize_legacy_validators_for_activation(&stakes, wits(MINIMUM_STAKE_WIT)), + Err(StakesError::InvalidDelegationState { + reason: "activation has no eligible validator", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn activation_rejects_when_all_legacy_entries_are_below_the_new_minimum() { + let validator = pkh(21); + let owner = pkh(22); + let first_key = CollateralReservationKey { + data_request: Hash::with_first_u32(21), + validator, + }; + let second_key = CollateralReservationKey { + data_request: Hash::with_first_u32(22), + validator, + }; + let collateral = wits(10_000); + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + // Wit/2.0 validated same-block commitments independently and its consolidation wrapper + // retained the zero-balance index entry after cumulative collateral consumed the balance. + stakes + .reserve_collateral(validator, collateral, minimum) + .unwrap(); + stakes + .reserve_collateral(validator, collateral, minimum) + .unwrap(); + assert_eq!(balance_nanowits(&stakes, validator, owner), 0); + + let debit = || CollateralDebit { + key: stake_key(validator, owner), + amount: collateral, + }; + let mut state = DelegationState::default(); + state.collateral_reservations.insert( + first_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![debit()], + }, + ); + state.collateral_reservations.insert( + second_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![debit()], + }, + ); + let before = state.clone(); + let outstanding = BTreeMap::from([ + (first_key, collateral), + (second_key, collateral), + ]); + let report = state.activation_preflight( + &stakes, + &outstanding, + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + minimum, + ); + + assert!(!report.safe); + assert_eq!(report.live_stake_entry_count, Some(1)); + assert_eq!( + report.below_minimum_live_stakes, + vec![ActivationPreflightLiveStake { + key: stake_key(validator, owner), + stake_nanowits: 0, + }] + ); + assert_eq!( + report.errors, + vec![ActivationPreflightErrorCode::NoEligibleValidator] + ); + assert!(report.commitments.iter().all(|commitment| commitment.safe)); + + assert_eq!( + state.initialize_legacy_validators_for_activation(&stakes, minimum), + Err(StakesError::InvalidDelegationState { + reason: "activation has no eligible validator", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn activation_grandfathers_a_below_minimum_legacy_owner_as_settlement_only() { + let eligible_validator = pkh(41); + let eligible_owner = pkh(42); + let legacy_validator = pkh(43); + let legacy_owner = pkh(44); + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, eligible_validator, eligible_owner, 20_000); + add_entry(&mut stakes, legacy_validator, legacy_owner, 20_000); + stakes + .reserve_collateral(legacy_validator, wits(10_000), minimum) + .unwrap(); + stakes + .reserve_collateral(legacy_validator, wits(10_000), minimum) + .unwrap(); + + let mut state = DelegationState::default(); + let report = state.activation_preflight( + &stakes, + &BTreeMap::new(), + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + minimum, + ); + assert!(report.safe); + assert_eq!(report.below_minimum_live_stakes.len(), 1); + assert!(report.errors.is_empty()); + + state + .initialize_legacy_validators_for_activation(&stakes, minimum) + .unwrap(); + assert!(state.activation_initialized); + assert_eq!(state.validators.len(), 2); + assert_eq!(state.validator_policy(legacy_validator).unwrap().owner_withdrawer, legacy_owner); + assert!(!state.is_validator_active(&stakes, legacy_validator, minimum)); + assert!(state + .validate_stake_and_reservation_accounting(&stakes, minimum) + .is_ok()); + } + + #[test] + fn activation_preflight_rejects_checked_live_plus_reserved_overflow() { + let validator = pkh(31); + let owner = pkh(32); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(31), + validator, + }; + let minimum = wits(MINIMUM_STAKE_WIT); + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + stake_key(validator, owner), + Wit::from_nanowits(u64::MAX), + 1, + true, + minimum, + ) + .unwrap(); + let state = DelegationState::default(); + let outstanding = BTreeMap::from([(reservation_key, Wit::from_nanowits(1))]); + + let report = state.activation_preflight( + &stakes, + &outstanding, + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + minimum, + ); + + assert!(!report.safe); + assert!(report.activation_accounting_error); + assert_eq!( + report.errors, + vec![ActivationPreflightErrorCode::ActivationAccountingInvalid] + ); + assert!(report.below_minimum_live_stakes.is_empty()); + assert!(report.commitments.iter().all(|commitment| commitment.safe)); + } + + #[test] + fn post_activation_missing_policy_fails_closed_without_owner_recovery() { + let validator = pkh(1); + let live_withdrawer = pkh(2); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, live_withdrawer, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + let before = state.clone(); + + // A current v6 state cannot legitimately contain live stake without the immutable policy + // created at activation or first registration. Never reinterpret a delegator as the owner + // or reset missing commission/nonce history to zero when such state is encountered. + assert_eq!( + state.owner_withdrawer_or_legacy(&stakes, validator), + Err(StakesError::ValidatorPolicyNotFound { validator }) + ); + assert_eq!( + state.initialize_legacy_validators(&stakes), + Err(StakesError::ValidatorPolicyNotFound { validator }) + ); + assert_eq!(state, before); + } + + #[test] + fn preactivation_policy_hydration_is_atomic_on_later_ambiguous_owner() { + let first_validator = pkh(5); + let first_owner = pkh(6); + let ambiguous_validator = pkh(7); + let ambiguous_first = pkh(8); + let ambiguous_second = pkh(9); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, first_validator, first_owner, 20_000); + add_entry(&mut stakes, ambiguous_validator, ambiguous_first, 20_000); + add_entry(&mut stakes, ambiguous_validator, ambiguous_second, 20_000); + + let mut state = DelegationState::default(); + let before = state.clone(); + + assert_eq!( + state.initialize_legacy_validators(&stakes), + Err(StakesError::InvalidDelegationState { + reason: "legacy validator has ambiguous live withdrawer ownership", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn preactivation_policy_hydration_discards_cached_policy_metadata_and_ignores_reservation_population() { + let validator = pkh(10); + let live_owner = pkh(11); + let cached_owner = pkh(12); + let orphan_validator = pkh(13); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, live_owner, 20_000); + + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(13), + validator: orphan_validator, + }; + let cached_reservation = CollateralReservation { + reward_weights: Vec::new(), + total: wits(1), + debits: Vec::new(), + }; + let mut state = DelegationState::default(); + state.validators.insert( + validator, + ValidatorPolicy::with_commission(cached_owner, 9), + ); + state.validators.insert(orphan_validator, ValidatorPolicy::new(pkh(14))); + state + .collateral_reservations + .insert(reservation_key, cached_reservation.clone()); + + state.initialize_legacy_validators(&stakes).unwrap(); + + assert_eq!( + state.validator_policy(validator).unwrap(), + ValidatorPolicy::new(live_owner) + ); + assert_eq!(state.validators.len(), 1); + assert_eq!( + state.collateral_reservations.get(&reservation_key), + Some(&cached_reservation) + ); + } + + #[test] + fn post_activation_reserved_validator_without_policy_fails_closed() { + let validator = pkh(3); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(31), + validator, + }; + let collateral = wits(1_000); + let stakes = StakesTracker::default(); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, pkh(4)), + amount: collateral, + }], + }, + ); + let before = state.clone(); + + assert_eq!( + state.initialize_legacy_validators(&stakes), + Err(StakesError::ValidatorPolicyNotFound { validator }) + ); + assert_eq!(state, before); + } + + #[test] + fn persisted_post_activation_state_rejects_policy_and_reservation_corruption() { + let validator = pkh(1); + let owner = pkh(2); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let empty_activated = DelegationState { + activation_initialized: true, + ..Default::default() + }; + assert!(matches!( + empty_activated.validate_persisted_invariants( + &StakesTracker::default(), + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { reason }) + if reason == "post-activation state has no authenticated validator policy" + )); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, MAX_COMMISSION_PERCENT) + .unwrap(); + state.activation_initialized = true; + assert!(state + .validate_persisted_invariants(&stakes, 10, wits(10_000), wits(10_000_000)) + .is_ok()); + + let mut subminimum_stakes = StakesTracker::default(); + subminimum_stakes + .add_stake( + stake_key(validator, owner), + wits(9_999), + 1, + true, + Wit::from_nanowits(1), + ) + .unwrap(); + assert!(state + .validate_persisted_invariants( + &subminimum_stakes, + 10, + wits(10_000), + wits(10_000_000), + ) + .is_ok()); + + let mut over_cap_stakes = StakesTracker::default(); + add_entry(&mut over_cap_stakes, validator, owner, 10_000_001); + // Rewards may legitimately move a validator above the deposit cap. Loading that state + // must succeed; subsequent deposits remain responsible for enforcing the cap. + assert!(state + .validate_persisted_invariants( + &over_cap_stakes, + 10, + wits(10_000), + wits(10_000_000), + ) + .is_ok()); + + let mut excessive_active = state.clone(); + excessive_active + .validators + .get_mut(&validator) + .unwrap() + .commission_percent = MAX_COMMISSION_PERCENT + 1; + assert!(matches!( + excessive_active.validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::CommissionAboveMaximum { commission: 16, .. }) + )); + + let mut stale_pending = state.clone(); + stale_pending + .validators + .get_mut(&validator) + .unwrap() + .pending_commission = Some(PendingCommission { + commission_percent: 1, + effective_epoch: 10, + nonce: 1, + }); + assert!(matches!( + stale_pending.validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { .. }) + )); + + let mut replayed_pending = state.clone(); + replayed_pending + .validators + .get_mut(&validator) + .unwrap() + .pending_commission = Some(PendingCommission { + commission_percent: 1, + effective_epoch: 11, + nonce: 0, + }); + assert!(matches!( + replayed_pending.validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { .. }) + )); + + // A later nonce can be valid when it replaced an earlier pending update. Persisted state + // does not retain the superseded value, so validation must accept a pending nonce above the + // active value rather than requiring exactly active + 1. + let mut replaced_pending = state.clone(); + replaced_pending + .validators + .get_mut(&validator) + .unwrap() + .pending_commission = Some(PendingCommission { + commission_percent: 1, + effective_epoch: 11, + nonce: 2, + }); + assert!(replaced_pending + .validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ) + .is_ok()); + + let mut malformed_reservation = state.clone(); + malformed_reservation.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(99), + validator, + }, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(100), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: wits(99), + }], + }, + ); + assert!(matches!( + malformed_reservation.validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { .. }) + )); + + // A structurally valid native reservation is still invalid when its immutable owner + // contributes less than the mandatory 70% collateral floor. Persistence must reject this + // before a later refund/slash path can publish the malformed economics. + let native_validator = pkh(5); + let native_owner = pkh(6); + let native_delegator = pkh(7); + let mut native_stakes = StakesTracker::default(); + add_entry(&mut native_stakes, native_validator, native_owner, 20_000); + add_entry(&mut native_stakes, native_validator, native_delegator, 20_000); + let mut native_state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + native_state + .register_validator_if_absent(native_validator, native_owner, 0) + .unwrap(); + native_state.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(103), + validator: native_validator, + }, + CollateralReservation { + total: wits(1_000), + // Weights are canonical and large enough to cover their corresponding debits; + // only the owner-floor economics are malformed here. + reward_weights: vec![ + (stake_key(native_validator, native_owner), wits(20_000)), + (stake_key(native_validator, native_delegator), wits(20_000)), + ], + debits: vec![ + CollateralDebit { + key: stake_key(native_validator, native_owner), + amount: wits(600), + }, + CollateralDebit { + key: stake_key(native_validator, native_delegator), + amount: wits(400), + }, + ], + }, + ); + assert!(matches!( + native_state.validate_persisted_invariants( + &native_stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { .. }) + )); + + // Multiple reservations may share one entry. Persisted stakes already carry the aggregate + // debit, so validation must accept the post-debit balance while proving that restoring the + // aggregate escrow remains representable. Runtime reservation planning enforces the hard + // minimum before each debit; reload validation cannot subtract the escrow again because + // that would count the already-applied debit twice. + let mut aggregate_reservations = state.clone(); + for data_request in [Hash::with_first_u32(100), Hash::with_first_u32(101)] { + aggregate_reservations.collateral_reservations.insert( + CollateralReservationKey { + data_request, + validator, + }, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(6_000), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: wits(6_000), + }], + }, + ); + } + let mut post_debit_stakes = StakesTracker::default(); + add_entry(&mut post_debit_stakes, validator, owner, 22_000); + assert!(aggregate_reservations + .validate_persisted_invariants( + &post_debit_stakes, + 10, + wits(10_000), + wits(10_000_000), + ) + .is_ok()); + + let mut overflow_reservation = state.clone(); + overflow_reservation.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(102), + validator, + }, + CollateralReservation { + reward_weights: Vec::new(), + total: Wit::from_nanowits(1), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: Wit::from_nanowits(1), + }], + }, + ); + let mut overflow_stakes = StakesTracker::default(); + let minimum = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS); + overflow_stakes + .add_stake( + stake_key(validator, owner), + Wit::from_nanowits(u64::MAX), + 1, + true, + minimum, + ) + .unwrap(); + // Restoring the escrow would overflow the u64 stake field, which reload validation must + // reject independently of the admission cap. + assert!(matches!( + overflow_reservation.validate_persisted_invariants( + &overflow_stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::ArithmeticOverflow) + )); + + let mut altered_constants = state; + altered_constants.policy.maximum_commission_percent = 99; + assert!(matches!( + altered_constants.validate_persisted_invariants( + &stakes, + 10, + wits(10_000), + wits(10_000_000), + ), + Err(StakesError::InvalidDelegationState { .. }) + )); + } + + #[test] + fn historical_sidecar_cannot_replace_a_missing_live_activation_owner() { + let validator = pkh(1); + let historical_withdrawer = pkh(2); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(10), + validator, + }; + let collateral = wits(1_000); + let stakes = StakesTracker::default(); + let mut state = DelegationState::default(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, historical_withdrawer), + amount: collateral, + }], + }, + ); + let before = state.clone(); + let outstanding = BTreeMap::from([(reservation_key, collateral)]); + + let report = state.activation_preflight( + &stakes, + &outstanding, + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + wits(MINIMUM_STAKE_WIT), + ); + assert!(!report.safe); + assert_eq!(report.commitments[0].canonical_live_owner, None); + assert_eq!(report.commitments[0].live_stake_entry_count, 0); + assert_eq!( + report.commitments[0].errors, + vec![ActivationPreflightErrorCode::MissingLiveOwner] + ); + + assert_eq!( + state.initialize_legacy_validators_for_activation( + &stakes, + wits(MINIMUM_STAKE_WIT), + ), + Err(StakesError::InvalidDelegationState { + reason: "activation has no eligible validator", + }) + ); + assert_eq!(state, before); + } + + #[test] + fn activation_preflight_reports_malformed_and_orphaned_sidecars_without_blocking() { + let validator = pkh(1); + let owner = pkh(2); + let unexpected_validator = pkh(3); + let commitment_key = CollateralReservationKey { + data_request: Hash::with_first_u32(11), + validator, + }; + let orphan_key = CollateralReservationKey { + data_request: Hash::with_first_u32(12), + validator, + }; + let expected_collateral = wits(1_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + let mut state = DelegationState::default(); + state.collateral_reservations.insert( + commitment_key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(900), + debits: vec![CollateralDebit { + key: stake_key(unexpected_validator, owner), + amount: wits(800), + }], + }, + ); + state.collateral_reservations.insert( + orphan_key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(500), + debits: vec![CollateralDebit { + key: stake_key(validator, owner), + amount: wits(500), + }], + }, + ); + let before = state.clone(); + let outstanding = BTreeMap::from([(commitment_key, expected_collateral)]); + + let report = state.activation_preflight( + &stakes, + &outstanding, + CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::with_first_u32(41), + }, + wits(MINIMUM_STAKE_WIT), + ); + assert!(report.safe); + assert_eq!(report.cached_legacy_sidecar_count, 1); + assert_eq!(report.missing_legacy_sidecar_count, 0); + assert_eq!( + report.commitments[0].errors, + vec![ + ActivationPreflightErrorCode::CachedTotalMismatch, + ActivationPreflightErrorCode::CachedDebitValidatorMismatch, + ActivationPreflightErrorCode::CachedDebitAmountMismatch, + ] + ); + assert_eq!(report.orphaned_cached_sidecars.len(), 1); + assert_eq!(report.orphaned_cached_sidecars[0].key, orphan_key); + assert_eq!( + report.orphaned_cached_sidecars[0].errors, + vec![ActivationPreflightErrorCode::OrphanedCachedReservation] + ); + + state + .initialize_legacy_validators_for_activation(&stakes, wits(MINIMUM_STAKE_WIT)) + .unwrap(); + assert!(state.activation_initialized); + assert!(state.collateral_reservations.is_empty()); + state + .track_legacy_collateral( + &stakes, + commitment_key.data_request, + validator, + expected_collateral, + ) + .unwrap(); + assert_eq!( + state.collateral_reservations[&commitment_key].debits, + vec![CollateralDebit { + key: stake_key(validator, owner), + amount: expected_collateral, + }] + ); + assert_ne!(state, before); + + // A node with no upgrade-history cache must enter the identical consensus image. + let mut cache_free = DelegationState::default(); + cache_free + .initialize_legacy_validators_for_activation(&stakes, wits(MINIMUM_STAKE_WIT)) + .unwrap(); + cache_free + .track_legacy_collateral( + &stakes, + commitment_key.data_request, + validator, + expected_collateral, + ) + .unwrap(); + assert_eq!(state, cache_free); + } + + #[test] + fn active_legacy_sidecar_must_remain_keyed_to_owner() { + let validator = pkh(13); + let owner = pkh(14); + let delegator = pkh(15); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(13), + validator, + }; + let collateral = wits(1_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, delegator), + amount: collateral, + }], + }, + ); + + // A native Wit/2.1 reservation may debit a delegator, but a legacy sidecar is settled to + // the immutable owner. Do not allow a malformed sidecar to lock one account and refund + // another after activation. + assert!(matches!( + state.validate_active_legacy_collateral_owner(reservation_key), + Err(StakesError::InvalidDelegationState { .. }) + )); + state + .collateral_reservations + .get_mut(&reservation_key) + .unwrap() + .debits[0] + .key = stake_key(validator, owner); + assert!(state + .validate_active_legacy_collateral_owner(reservation_key) + .is_ok()); + } + + #[test] + fn active_legacy_sidecar_rejects_noncanonical_debit_shape() { + let validator = pkh(19); + let owner = pkh(20); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(20), + validator, + }; + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: wits(1_000), + debits: Vec::new(), + }, + ); + + assert_eq!( + state.validate_active_legacy_collateral_owner(reservation_key), + Err(StakesError::InvalidDelegationState { + reason: "collateral reservation is empty", + }) + ); + } + + #[test] + fn legacy_settlement_rejects_a_misowned_sidecar_atomically() { + let validator = pkh(16); + let owner = pkh(17); + let delegator = pkh(18); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(14), + validator, + }; + let collateral = wits(1_000); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, delegator, 20_000); + + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, delegator), + amount: collateral, + }], + }, + ); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert!(matches!( + state.settle_legacy_collateral(&mut stakes, reservation_key, wits(100), 2), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + } + + #[test] + fn legacy_slashing_rejects_a_misowned_sidecar_atomically() { + let validator = pkh(19); + let owner = pkh(20); + let delegator = pkh(21); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(15), + validator, + }; + let collateral = wits(1_000); + let mut state = DelegationState::default(); + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.activation_initialized = true; + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + debits: vec![CollateralDebit { + key: stake_key(validator, delegator), + amount: collateral, + }], + }, + ); + let state_before = state.clone(); + + assert!(matches!( + state.slash_legacy_collateral(reservation_key), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!(state, state_before); + } + + #[test] + fn activated_legacy_settlement_rejects_native_reward_weights_atomically() { + let validator = pkh(25); + let owner = pkh(26); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(17), + validator, + }; + let collateral = wits(1_000); + let owner_key = stake_key(validator, owner); + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + total: collateral, + debits: vec![CollateralDebit { + key: owner_key.clone(), + amount: collateral, + }], + reward_weights: vec![(owner_key, wits(20_000))], + }, + ); + let state_before = state.clone(); + let stakes_before = stakes.clone(); + + assert!(matches!( + state.settle_legacy_collateral(&mut stakes, reservation_key, wits(100), 2), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!(state, state_before); + assert_eq!(stakes, stakes_before); + assert!(matches!( + state.slash_legacy_collateral(reservation_key), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!(state, state_before); + } + + #[test] + fn preactivation_legacy_slashing_ignores_malformed_sidecar_shape() { + let validator = pkh(22); + let owner = pkh(23); + let delegator = pkh(24); + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(16), + validator, + }; + let collateral = wits(1_000); + let mut state = DelegationState::default(); + state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + reward_weights: Vec::new(), + total: collateral, + // This cache is advisory while Wit/2.0 is active. Its malformed shape must not + // make the historical slash transition depend on upgrade timing. + debits: vec![ + CollateralDebit { + key: stake_key(validator, owner), + amount: wits(700), + }, + CollateralDebit { + key: stake_key(validator, delegator), + amount: wits(300), + }, + ], + }, + ); + + let slashed = state.slash_legacy_collateral(reservation_key).unwrap(); + assert_eq!(slashed.total, collateral); + assert!(!state.collateral_reservations.contains_key(&reservation_key)); + } + + #[test] + fn proportional_allocator_rejects_an_amount_above_total_weight() { + let entries = vec![ + (stake_key(pkh(30), pkh(31)), 1_u64), + (stake_key(pkh(30), pkh(32)), 1_u64), + ]; + + assert_eq!( + allocate_by_weight(&entries, 3), + Err(StakesError::ArithmeticOverflow), + ); + } + + #[test] + fn delegated_multi_withdrawer_lifecycle_survives_settlement_exit_and_restart() { + let fixture = lifecycle_contract_fixture(); + let validator = pkh(70); + let owner = pkh(71); + let first_delegator = pkh(72); + let second_delegator = pkh(73); + let minimum = wits(MINIMUM_STAKE_WIT); + let owner_key = stake_key(validator, owner); + let first_delegator_key = stake_key(validator, first_delegator); + let second_delegator_key = stake_key(validator, second_delegator); + + assert_eq!(fixture.protocol, "Wit/2.1"); + assert_eq!( + fixture.minimum_stake_nanowits.parse::().unwrap(), + MINIMUM_STAKE_NANOWITS + ); + assert_eq!(fixture.validator, repeated_hex(70)); + assert_eq!(fixture.owner, repeated_hex(71)); + assert_eq!(fixture.first_delegator, repeated_hex(72)); + assert_eq!(fixture.second_delegator, repeated_hex(73)); + + let mut stakes = StakesTracker::default(); + add_entry(&mut stakes, validator, owner, 20_000); + add_entry(&mut stakes, validator, first_delegator, 30_000); + add_entry(&mut stakes, validator, second_delegator, 40_000); + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner, 10) + .unwrap(); + + // Exercise the ordinary multi-withdrawer reward path first. The 10% commission is taken + // only from the delegator shares, and the exact gross reward is conserved. + let distribution = state + .distribute_reward(&mut stakes, validator, wits(90), 2) + .unwrap(); + assert_eq!( + distribution.owner_commission.nanowits().to_string(), + fixture.reward.owner_commission_nanowits + ); + assert_eq!( + distribution + .credits + .iter() + .map(|credit| credit.amount.nanowits()) + .sum::(), + fixture.reward.gross_nanowits.parse::().unwrap() + ); + + // A 10,000-WIT commitment needs 7,000 WIT from the owner. The remaining 3,000 WIT is + // split by capacity above the 10,000-WIT floor: 1,200/1,800 between the two delegators. + let slash_key = CollateralReservationKey { + data_request: Hash::with_first_u32(70), + validator, + }; + let slash_reservation = state + .reserve_collateral( + &mut stakes, + slash_key, + Wit::from_nanowits(fixture.slash.collateral_nanowits.parse().unwrap()), + 3, + minimum, + ) + .unwrap(); + assert_eq!( + debit_nanowits(&slash_reservation, validator, owner), + fixture.slash.owner_debit_nanowits.parse::().unwrap() + ); + assert_eq!( + debit_nanowits(&slash_reservation, validator, first_delegator), + fixture.slash.first_delegator_debit_nanowits.parse::().unwrap() + ); + assert_eq!( + debit_nanowits(&slash_reservation, validator, second_delegator), + fixture.slash.second_delegator_debit_nanowits.parse::().unwrap() + ); + state + .slash_reserved_collateral_checked(&stakes, slash_key) + .unwrap(); + + // A separate committed reward is refunded before the first delegator exits. Its immutable + // pre-debit weights remain payable after the exit request, while future ordinary rewards + // would exclude that draining entry. + let reward_key = CollateralReservationKey { + data_request: Hash::with_first_u32(71), + validator, + }; + let reward_reservation = state + .reserve_collateral(&mut stakes, reward_key, wits(1_000), 4, minimum) + .unwrap(); + let committed_weights = reward_reservation.reward_weights.clone(); + state + .refund_collateral(&mut stakes, reward_key, 5) + .unwrap(); + + let exit_nonce = stakes.query_nonce(first_delegator_key.clone()).unwrap(); + state + .request_exit(&stakes, first_delegator_key.clone(), exit_nonce) + .unwrap(); + let exiting_before_reward = balance_nanowits(&stakes, validator, first_delegator); + state + .distribute_committed_reward( + &mut stakes, + validator, + Wit::from_nanowits(fixture.exit.committed_reward_nanowits.parse().unwrap()), + 6, + &committed_weights, + ) + .unwrap(); + assert!(balance_nanowits(&stakes, validator, first_delegator) > exiting_before_reward); + + let ordinary_reward_after_exit = state + .distribute_reward(&mut stakes, validator, wits(100), 7) + .unwrap(); + assert!(!ordinary_reward_after_exit + .credits + .iter() + .any(|credit| credit.key == first_delegator_key)); + assert!(fixture.exit.ordinary_reward_excludes_exiting); + assert_eq!(fixture.exit.exiting_delegator, repeated_hex(72)); + let exiting_balance = Wit::from_nanowits( + balance_nanowits(&stakes, validator, first_delegator), + ); + stakes + .remove_stake(first_delegator_key.clone(), exiting_balance, 8, true, minimum) + .unwrap(); + state + .complete_exit_if_removed(&stakes, &first_delegator_key) + .unwrap(); + assert!(!state.exit_intents.contains(&first_delegator_key)); + assert!(stakes + .query_stakes_checked(QueryStakesKey::Key(first_delegator_key)) + .is_err()); + assert!(stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key)) + .is_ok()); + assert!(stakes + .query_stakes_checked(QueryStakesKey::Key(second_delegator_key)) + .is_ok()); + + // Persist and reload the complete post-settlement image, including policy, reservations, + // and exact stake indexes. This is the state-machine counterpart to the node's storage + // restart checks and catches any lifecycle-only field that a byte round-trip would drop. + let encoded_state = bincode::serialize(&state).unwrap(); + let encoded_stakes = bincode::serialize(&stakes).unwrap(); + let restored_state: DelegationState = bincode::deserialize(&encoded_state).unwrap(); + let restored_stakes: StakesTracker = bincode::deserialize(&encoded_stakes).unwrap(); + assert_eq!(restored_state, state); + assert_eq!(restored_stakes, stakes); + assert!(restored_state + .validate_authoritative_owner_policies(&restored_stakes) + .is_ok()); + } +} diff --git a/data_structures/src/staking/delegation/activation.rs b/data_structures/src/staking/delegation/activation.rs new file mode 100644 index 0000000000..e51b8874bd --- /dev/null +++ b/data_structures/src/staking/delegation/activation.rs @@ -0,0 +1,354 @@ +//! Read-only classification of canonical legacy state before delegated-staking activation. +//! +//! This is not a migration, readiness gate, historical debit proof, or ownership-claim rule. +//! Optional preactivation policies and collateral caches are deliberately not inputs. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize, Serializer}; + +use super::{ + CheckpointBeacon, DELEGATED_STAKING_MINIMUM_NANOWITS, DelegationResult, DelegationState, + Epoch, Hash, PublicKeyHash, QueryStakesKey, StakesError, StakesTracker, + number_from_string, serialize_u64_as_string, +}; +use crate::{ + chain::DataRequestStage, + data_request::DataRequestPool, + proto::versioning::{ProtocolInfo, ProtocolVersion}, +}; + +/// What the canonical live ledger says about a validator's current legacy recipient. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActivationOwnerStatus { + /// Exactly one live key, including a retained zero-balance key; not historical debit proof. + SoleLiveKey, + /// An outstanding commitment has no corresponding live stake key. + MissingLiveKey, + /// More than one live key exists; no lexicographic ownership guess is made. + AmbiguousLiveKeys, +} + +/// Deterministic treatment of the canonical live owner at the activation boundary. +/// +/// This is a migration disposition, not an activation approval. In particular, a +/// `NativeEligible` row may still carry `historical_debit_proof_required` when an outstanding +/// Wit/2.0 commitment has positive nominal collateral. That flag prevents callers from treating +/// a request amount as proof that the old wrapper actually debited the exact live entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActivationMigrationDisposition { + /// Exactly one live entry meets the hard native balance floor. + NativeEligible, + /// Exactly one live entry remains below the floor and is preserved for settlement only. + SettlementOnlyLegacyOwner, + /// An outstanding legacy obligation has no live entry; no owner is fabricated. + MissingOwnerRecovery, + /// More than one live entry exists; no owner is selected by key ordering. + AmbiguousOwnerRecovery, +} + +/// An unchanged live entry observed by the diagnostic classifier. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationBoundaryEntry { + /// Cold withdrawer in the canonical key; the enclosing row supplies the validator. + pub withdrawer: PublicKeyHash, + /// Visible current balance, not balance before any historical collateral debit. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub stake_nanowits: u64, + /// Unmodified mining capability epoch, including a possible future penalty epoch. + pub mining_epoch: Epoch, + /// Unmodified witnessing capability epoch, including a possible future penalty epoch. + pub witnessing_epoch: Epoch, + /// Unmodified exact-entry Unstake nonce. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub nonce: u64, +} + +/// Canonical facts for a validator with live stake or an unresolved legacy commitment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationBoundaryValidator { + /// Operational validator identity. + pub validator: PublicKeyHash, + /// Every live primary-ledger entry, in withdrawer key order. + pub live_entries: Vec, + /// Sole, missing, or ambiguous current-entry ownership. + pub owner_status: ActivationOwnerStatus, + /// Explicit boundary treatment derived only from `live_entries` and the hard floor. + pub migration_disposition: ActivationMigrationDisposition, + /// Present only for a sole live key; never taken from an advisory cache. + pub current_entry_withdrawer: Option, + /// Whether the sole live key meets the native balance floor, not a power/liveness assertion. + pub sole_owner_meets_minimum: bool, + /// Wide sum of visible live balances; no spendable wide account is created. + #[serde(serialize_with = "serialize_u128_as_string", deserialize_with = "number_from_string")] + pub live_nanowits: u128, + /// Sum of raw request collateral for unresolved Wit/2.0 commitments, not proven escrow. + #[serde(serialize_with = "serialize_u128_as_string", deserialize_with = "number_from_string")] + pub nominal_legacy_collateral_nanowits: u128, + /// Positive request collateral still needs historical evidence of an actual legacy debit. + pub historical_debit_proof_required: bool, + /// Diagnostic live-plus-nominal sum, without re-debiting or crediting either amount. + #[serde(serialize_with = "serialize_u128_as_string", deserialize_with = "number_from_string")] + pub live_plus_nominal_nanowits: u128, + /// Whether that diagnostic sum cannot fit the native single-validator u64 accounting domain. + pub live_plus_nominal_exceeds_u64: bool, +} + +/// One unresolved legacy commitment, including a raw-zero collateral reward obligation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationBoundaryCommitment { + /// Enclosing canonical request pointer, structurally bound to the Commit body. + pub data_request: Hash, + /// Commit-map identity, structurally bound to its signer and VRF proof identity. + pub validator: PublicKeyHash, + /// Request epoch used to select its pinned request-era protocol. + pub request_epoch: Epoch, + /// Raw serialized collateral; zero is preserved without minimum substitution. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub raw_collateral_nanowits: u64, + /// Raw witness reward term; this does not assert a future honest outcome or payable credit. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub witness_reward_nanowits: u64, + /// Sole current live withdrawer, if known; not the proven historical collateral payer. + pub current_entry_withdrawer: Option, +} + +/// Versioned canonical diagnostics, intentionally without a `safe` or activation-ready flag. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActivationBoundaryReport { + /// Diagnostic wire schema version, currently 2; not a database version. + pub schema_version: u32, + /// Always true; this report never authorizes migration, spending, or activation. + pub diagnostic_only: bool, + /// Exact parent tip supplied with the actor-owned ledger and request pool. + pub inspected_beacon: CheckpointBeacon, + /// Hard native floor, without treating legacy below-floor entries as corrupt balances. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub minimum_stake_nanowits: u64, + /// Count of sole live owners meeting only the balance floor, not eligible block proposers. + pub sole_owner_meets_minimum_count: usize, + /// Number of live entries strictly below the floor, including retained zero entries. + pub below_minimum_entry_count: usize, + /// True if any positive nominal collateral requires historical evidence of actual debit. + pub nominal_collateral_requires_history: bool, + /// Canonical union of live validators and unresolved Wit/2.0 commitment identities. + pub validators: Vec, + /// Every unresolved Wit/2.0 commitment, ordered by `(data_request, validator)`. + pub commitments: Vec, +} + +/// Serialize a wide activation diagnostic sum as a decimal string for lossless RPC output. It is used by ActivationBoundaryValidator and consumed by the SDK activation-classification normalizer. +fn serialize_u128_as_string(value: &u128, serializer: S) -> Result { + if serializer.is_human_readable() { + serializer.serialize_str(&value.to_string()) + } else { + serializer.serialize_u128(*value) + } +} + +impl DelegationState { + /// Classify canonical preactivation state without consulting or rewriting optional caches. + /// + /// The caller supplies one accepted tip, its post-block stake/request state (not an in-flight + /// transaction projection) and one validated pinned schedule. Structural Commit identity is + /// checked here; cryptographic validity and historic + /// chain authenticity remain the caller's provenance obligations. A sole current entry is + /// not proof that nominal collateral was actually debited from that entry. This method is + /// never a replacement for activation preflight, migration, or consensus validation. + pub fn activation_boundary_classification( + &self, + stakes: &StakesTracker, + dr_pool: &DataRequestPool, + inspected_beacon: CheckpointBeacon, + protocol_info: &ProtocolInfo, + ) -> DelegationResult { + if self.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification requires an uninitialized legacy state", + }); + } + protocol_info.validate().map_err(|_| StakesError::InvalidDelegationState { + reason: "activation classification requires a valid pinned protocol schedule", + })?; + if protocol_info.all_versions.version_for_epoch(inspected_beacon.checkpoint) >= ProtocolVersion::V2_1 { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification requires a pre-V2.1 canonical tip", + }); + } + stakes.validate_validator_index_checked()?; + dr_pool.validate_stage_round_configuration().map_err(|_| StakesError::InvalidDelegationState { + reason: "activation classification encountered invalid request round configuration", + })?; + let maximum_round = dr_pool.extra_rounds.checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + + let mut validators = BTreeMap::::new(); + for entry in stakes.query_stakes_primary_checked(QueryStakesKey::All)? { + let row = validators.entry(entry.key.validator) + .or_insert_with(|| empty_validator(entry.key.validator)); + row.live_nanowits = row.live_nanowits.checked_add(u128::from(entry.value.coins.nanowits())) + .ok_or(StakesError::ArithmeticOverflow)?; + row.live_entries.push(ActivationBoundaryEntry { + withdrawer: entry.key.withdrawer, + stake_nanowits: entry.value.coins.nanowits(), + mining_epoch: entry.value.epochs.mining, + witnessing_epoch: entry.value.epochs.witnessing, + nonce: entry.value.nonce, + }); + } + + let mut commitments = Vec::new(); + // Sort the HashMap inputs before any error is selected, not merely the final report. + let ordered_requests = dr_pool.data_request_pool.iter().collect::>(); + for (data_request, request) in ordered_requests { + let has_commits = !request.info.commits.is_empty(); + // Historical and current progression both enter REVEAL with round one after the + // first accepted Commit. They never clear accepted Commits or move back to COMMIT. + // Do not infer a minimum Commit round or a terminal TALLY cause: round-zero new or + // time-locked requests and early no-Commit TALLY transitions remain representable. + let malformed_stage_shape = match request.stage { + DataRequestStage::COMMIT => { + has_commits + || !request.info.reveals.is_empty() + || request.info.current_reveal_round != 0 + } + DataRequestStage::REVEAL => { + !has_commits + || request.info.current_commit_round == 0 + || request.info.current_reveal_round == 0 + } + DataRequestStage::TALLY => { + has_commits != (request.info.current_reveal_round != 0) + || (has_commits && request.info.current_commit_round == 0) + } + }; + if request.info.tally.is_some() || request.info.block_hash_tally_tx.is_some() + || request.info.current_stage != Some(request.stage) + || request.info.current_commit_round > maximum_round + || request.info.current_reveal_round > maximum_round + || malformed_stage_shape + || request.info.reveals.keys().any(|validator| !request.info.commits.contains_key(validator)) + { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification encountered malformed active request state", + }); + } + // Uncommitted time-locked requests may legitimately unlock after the inspected tip. + // A commitment cannot already exist for such an unavailable request. + if !request.info.commits.is_empty() && request.epoch > inspected_beacon.checkpoint { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification encountered a future-epoch commitment", + }); + } + if protocol_info.all_versions.version_for_epoch(request.epoch) != ProtocolVersion::V2_0 { + continue; + } + let ordered_commits = request.info.commits.iter().collect::>(); + for (validator, commit) in ordered_commits { + let [signature] = commit.signatures.as_slice() else { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification requires exactly one Commit signature", + }); + }; + if commit.body.dr_pointer != *data_request + || commit.body.proof.proof.pkh() != *validator + || signature.public_key.pkh() != *validator + { + return Err(StakesError::InvalidDelegationState { + reason: "activation classification encountered inconsistent Commit identity", + }); + } + let row = validators.entry(*validator).or_insert_with(|| empty_validator(*validator)); + row.nominal_legacy_collateral_nanowits = row.nominal_legacy_collateral_nanowits + .checked_add(u128::from(request.data_request.collateral)) + .ok_or(StakesError::ArithmeticOverflow)?; + commitments.push(ActivationBoundaryCommitment { + data_request: *data_request, + validator: *validator, + request_epoch: request.epoch, + raw_collateral_nanowits: request.data_request.collateral, + witness_reward_nanowits: request.data_request.witness_reward, + current_entry_withdrawer: None, + }); + } + } + + let mut sole_owner_meets_minimum_count = 0usize; + let mut below_minimum_entry_count = 0usize; + for row in validators.values_mut() { + row.live_entries.sort_by_key(|entry| entry.withdrawer); + row.owner_status = match row.live_entries.as_slice() { + [] => ActivationOwnerStatus::MissingLiveKey, + [entry] => { + row.current_entry_withdrawer = Some(entry.withdrawer); + row.sole_owner_meets_minimum = entry.stake_nanowits >= DELEGATED_STAKING_MINIMUM_NANOWITS; + ActivationOwnerStatus::SoleLiveKey + } + _ => ActivationOwnerStatus::AmbiguousLiveKeys, + }; + row.migration_disposition = match row.owner_status { + ActivationOwnerStatus::SoleLiveKey if row.sole_owner_meets_minimum => { + ActivationMigrationDisposition::NativeEligible + } + ActivationOwnerStatus::SoleLiveKey => { + ActivationMigrationDisposition::SettlementOnlyLegacyOwner + } + ActivationOwnerStatus::MissingLiveKey => { + ActivationMigrationDisposition::MissingOwnerRecovery + } + ActivationOwnerStatus::AmbiguousLiveKeys => { + ActivationMigrationDisposition::AmbiguousOwnerRecovery + } + }; + if row.sole_owner_meets_minimum { + sole_owner_meets_minimum_count = sole_owner_meets_minimum_count.checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + } + below_minimum_entry_count = below_minimum_entry_count.checked_add( + row.live_entries.iter().filter(|entry| entry.stake_nanowits < DELEGATED_STAKING_MINIMUM_NANOWITS).count(), + ).ok_or(StakesError::ArithmeticOverflow)?; + row.live_plus_nominal_nanowits = row.live_nanowits.checked_add(row.nominal_legacy_collateral_nanowits) + .ok_or(StakesError::ArithmeticOverflow)?; + row.historical_debit_proof_required = row.nominal_legacy_collateral_nanowits > 0; + row.live_plus_nominal_exceeds_u64 = row.live_plus_nominal_nanowits > u128::from(u64::MAX); + } + for commitment in &mut commitments { + commitment.current_entry_withdrawer = validators.get(&commitment.validator) + .and_then(|row| row.current_entry_withdrawer); + } + + Ok(ActivationBoundaryReport { + schema_version: 2, + diagnostic_only: true, + inspected_beacon, + minimum_stake_nanowits: DELEGATED_STAKING_MINIMUM_NANOWITS, + sole_owner_meets_minimum_count, + below_minimum_entry_count, + nominal_collateral_requires_history: commitments.iter().any(|entry| entry.raw_collateral_nanowits > 0), + validators: validators.into_values().collect(), + commitments, + }) + } +} + +/// Create the empty diagnostic row for a validator with no live stake entries. It is used by activation_boundary_classification when unresolved commitments still identify the validator. +fn empty_validator(validator: PublicKeyHash) -> ActivationBoundaryValidator { + ActivationBoundaryValidator { + validator, + live_entries: Vec::new(), + owner_status: ActivationOwnerStatus::MissingLiveKey, + migration_disposition: ActivationMigrationDisposition::MissingOwnerRecovery, + current_entry_withdrawer: None, + sole_owner_meets_minimum: false, + live_nanowits: 0, + nominal_legacy_collateral_nanowits: 0, + historical_debit_proof_required: false, + live_plus_nominal_nanowits: 0, + live_plus_nominal_exceeds_u64: false, + } +} + +#[cfg(test)] +mod tests; diff --git a/data_structures/src/staking/delegation/activation/tests.rs b/data_structures/src/staking/delegation/activation/tests.rs new file mode 100644 index 0000000000..14d9e8a373 --- /dev/null +++ b/data_structures/src/staking/delegation/activation/tests.rs @@ -0,0 +1,552 @@ +use super::*; +use crate::{ + capabilities::CapabilityMap, + chain::{DataRequestOutput, DataRequestStage, DataRequestState, KeyedSignature, PublicKey}, + staking::{ + delegation::{CollateralReservation, CollateralReservationKey, ValidatorPolicy}, + helpers::{StakeEntry, StakeKey}, + stake::Stake, + }, + transaction::{CommitTransaction, RevealTransaction, TallyTransaction}, + vrf::VrfProof, + wit::Wit, +}; + +fn pkh(byte: u8) -> PublicKeyHash { + PublicKeyHash::from_bytes(&[byte; 20]).unwrap() +} + +fn beacon(epoch: Epoch) -> CheckpointBeacon { + CheckpointBeacon { + checkpoint: epoch, + hash_prev_block: Hash::with_first_u32(epoch), + } +} + +fn schedule() -> ProtocolInfo { + // Pure value construction only: these fixtures never install a runtime schedule or open + // the release gate. `current_version` is deliberately unrelated to the inspected tip. + let mut info = ProtocolInfo::default(); + info.register(1, ProtocolVersion::V1_8, 45); + info.register(10, ProtocolVersion::V2_0, 45); + info.register(100, ProtocolVersion::V2_1, 45); + info.current_version = ProtocolVersion::V2_1; + info +} + +fn ledger( + rows: &[(PublicKeyHash, PublicKeyHash, u64, Epoch, Epoch, u64)], +) -> StakesTracker { + let mut entries = BTreeMap::new(); + for &(validator, withdrawer, amount, mining, witnessing, nonce) in rows { + let key = StakeKey { validator, withdrawer }; + let entry = StakeEntry { + key: key.clone(), + value: Stake { + coins: Wit::from_nanowits(amount), + epochs: CapabilityMap { mining, witnessing }, + nonce, + ..Default::default() + }, + }; + assert!(entries.insert(key, entry.into()).is_none()); + } + StakesTracker::with_entries(entries) +} + +fn structural_commit(pointer: Hash, identity: u8) -> (PublicKeyHash, CommitTransaction) { + // Empty VRF and DER proofs are intentionally NOT cryptographically valid. This fixture + // proves only the classifier's structural identity contract, never accepted-chain + // authenticity, historical collateral debit, or the rightful historical reward recipient. + let public_key = PublicKey { compressed: 2, bytes: [identity; 32] }; + let proof: VrfProof = serde_json::from_value(serde_json::json!({ + "proof": [], + "public_key": public_key.clone(), + })).unwrap(); + let validator = public_key.pkh(); + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = pointer; + commit.body.proof.proof = proof; + commit.signatures.push(KeyedSignature { public_key, ..Default::default() }); + (validator, commit) +} + +fn request( + epoch: Epoch, + collateral: u64, + reward: u64, + commits: Vec<(PublicKeyHash, CommitTransaction)>, +) -> DataRequestState { + let mut state = DataRequestState::new( + DataRequestOutput { collateral, witness_reward: reward, ..Default::default() }, + pkh(250), + epoch, + None, + ); + state.info.commits.extend(commits); + if !state.info.commits.is_empty() { + state.set_stage(DataRequestStage::REVEAL); + state.info.current_commit_round = 1; + state.info.current_reveal_round = 1; + } + state +} + +fn pool(requests: Vec<(Hash, DataRequestState)>) -> DataRequestPool { + DataRequestPool { + extra_rounds: 3, + data_request_pool: requests.into_iter().collect(), + ..Default::default() + } +} + +fn classify(stakes: &StakesTracker, pool: &DataRequestPool) -> ActivationBoundaryReport { + DelegationState::default() + .activation_boundary_classification(stakes, pool, beacon(20), &schedule()) + .unwrap() +} + +fn assert_invalid( + result: DelegationResult, + expected_reason: &'static str, +) { + match result { + Err(StakesError::InvalidDelegationState { reason }) => assert_eq!(reason, expected_reason), + other => panic!("expected invalid diagnostic input, got {other:?}"), + } +} + +#[test] +fn retains_every_entry_and_ignores_all_optional_preactivation_caches() { + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let rows = [ + (pkh(1), pkh(11), 0, Epoch::MAX, 90, u64::MAX), + (pkh(2), pkh(12), minimum - 1, 0, Epoch::MAX, 1), + (pkh(3), pkh(13), minimum, 35, 40, 2), + (pkh(4), pkh(15), minimum, 6, 7, 3), + (pkh(4), pkh(14), 1, 8, 9, 4), + ]; + let stakes = ledger(&rows); + let requests = pool(Vec::new()); + let entries_before = stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(); + let requests_before = requests.clone(); + let expected = classify(&stakes, &requests); + + let mut advisory = DelegationState::default(); + advisory.policy.maximum_commission_percent = u8::MAX; + advisory.policy.owner_collateral_percent = 0; + advisory.validators.insert(pkh(1), ValidatorPolicy::new(pkh(99))); + advisory.validators.insert(pkh(4), ValidatorPolicy::with_commission(pkh(15), u8::MAX)); + advisory.validators.insert(pkh(99), ValidatorPolicy::new(pkh(98))); + advisory.collateral_reservations.insert( + CollateralReservationKey { data_request: Hash::with_first_u32(99), validator: pkh(99) }, + CollateralReservation { total: Wit::from_nanowits(u64::MAX), ..Default::default() }, + ); + advisory.exit_intents.insert(StakeKey { validator: pkh(1), withdrawer: pkh(11) }); + let advisory_before = advisory.clone(); + let actual = advisory.activation_boundary_classification(&stakes, &requests, beacon(20), &schedule()).unwrap(); + + assert_eq!(actual, expected); + assert_eq!(advisory, advisory_before); + assert_eq!(requests, requests_before); + assert_eq!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap(), entries_before); + assert_eq!(actual.validators.len(), 4); + assert_eq!(actual.below_minimum_entry_count, 3); + assert_eq!(actual.sole_owner_meets_minimum_count, 1); + assert_eq!(actual.validators[0].owner_status, ActivationOwnerStatus::SoleLiveKey); + assert_eq!( + actual.validators[0].migration_disposition, + ActivationMigrationDisposition::SettlementOnlyLegacyOwner + ); + assert_eq!(actual.validators[0].current_entry_withdrawer, Some(pkh(11))); + assert!(!actual.validators[0].sole_owner_meets_minimum); + assert!(!actual.validators[0].historical_debit_proof_required); + assert_eq!(actual.validators[0].live_entries[0].mining_epoch, Epoch::MAX); + assert_eq!(actual.validators[1].live_entries[0].witnessing_epoch, Epoch::MAX); + assert_eq!(actual.validators[0].live_entries[0].nonce, u64::MAX); + assert_eq!(actual.validators[3].owner_status, ActivationOwnerStatus::AmbiguousLiveKeys); + assert_eq!( + actual.validators[3].migration_disposition, + ActivationMigrationDisposition::AmbiguousOwnerRecovery + ); + assert_eq!(actual.validators[3].current_entry_withdrawer, None); + assert!(!actual.validators[3].sole_owner_meets_minimum); + + for (validator, withdrawer, balance, mining, witnessing, nonce) in rows { + let row = actual.validators.iter().find(|row| row.validator == validator).unwrap(); + let entry = row.live_entries.iter().find(|entry| entry.withdrawer == withdrawer).unwrap(); + assert_eq!(*entry, ActivationBoundaryEntry { + withdrawer, stake_nanowits: balance, mining_epoch: mining, witnessing_epoch: witnessing, nonce, + }); + } +} + +#[test] +fn retains_raw_zero_obligations_without_guessing_missing_or_ambiguous_owners() { + let pointer = Hash::with_first_u32(1); + let missing = structural_commit(pointer, 1); + let ambiguous = structural_commit(pointer, 2); + let stakes = ledger(&[ + (ambiguous.0, pkh(12), DELEGATED_STAKING_MINIMUM_NANOWITS, 1, 1, 0), + (ambiguous.0, pkh(11), DELEGATED_STAKING_MINIMUM_NANOWITS, 1, 1, 0), + ]); + let requests = pool(vec![(pointer, request(10, 0, 77, vec![missing.clone(), ambiguous.clone()]))]); + let report = classify(&stakes, &requests); + assert_eq!(report.commitments.len(), 2); + assert!(!report.nominal_collateral_requires_history); + let missing_row = report.validators.iter().find(|row| row.validator == missing.0).unwrap(); + assert_eq!(missing_row.owner_status, ActivationOwnerStatus::MissingLiveKey); + assert_eq!( + missing_row.migration_disposition, + ActivationMigrationDisposition::MissingOwnerRecovery + ); + assert!(missing_row.live_entries.is_empty()); + let ambiguous_row = report.validators.iter().find(|row| row.validator == ambiguous.0).unwrap(); + assert_eq!(ambiguous_row.owner_status, ActivationOwnerStatus::AmbiguousLiveKeys); + assert_eq!( + ambiguous_row.migration_disposition, + ActivationMigrationDisposition::AmbiguousOwnerRecovery + ); + assert_eq!(ambiguous_row.current_entry_withdrawer, None); + for commitment in &report.commitments { + assert_eq!(commitment.raw_collateral_nanowits, 0); + assert_eq!(commitment.witness_reward_nanowits, 77); + assert_eq!(commitment.current_entry_withdrawer, None); + } + // Acceptance of these unproven cryptographic fixtures is not an activation approval. + assert!(report.diagnostic_only); + assert!(serde_json::to_value(&report).unwrap().get("safe").is_none()); +} + +#[test] +fn preserves_wide_diagnostic_sums_and_exact_decimal_string_serialization() { + let pointer_a = Hash::with_first_u32(1); + let pointer_b = Hash::with_first_u32(2); + let commit_a = structural_commit(pointer_a, 1); + let commit_b = structural_commit(pointer_b, 1); + let stakes = ledger(&[ + (commit_a.0, pkh(11), u64::MAX, Epoch::MAX, 40, u64::MAX), + (commit_a.0, pkh(12), u64::MAX, 0, 0, 0), + ]); + let requests = pool(vec![ + (pointer_b, request(11, u64::MAX, u64::MAX, vec![commit_b])), + (pointer_a, request(10, u64::MAX, u64::MAX, vec![commit_a])), + ]); + let report = classify(&stakes, &requests); + let row = &report.validators[0]; + assert_eq!(row.live_nanowits, u128::from(u64::MAX) * 2); + assert_eq!(row.nominal_legacy_collateral_nanowits, u128::from(u64::MAX) * 2); + assert_eq!(row.live_plus_nominal_nanowits, u128::from(u64::MAX) * 4); + assert!(row.live_plus_nominal_exceeds_u64); + assert!(row.historical_debit_proof_required); + assert!(report.nominal_collateral_requires_history); + + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["minimum_stake_nanowits"].as_str(), Some("10000000000000")); + assert_eq!(json["validators"][0]["owner_status"].as_str(), Some("ambiguous_live_keys")); + assert_eq!(json["validators"][0]["live_nanowits"].as_str(), Some("36893488147419103230")); + assert_eq!(json["validators"][0]["nominal_legacy_collateral_nanowits"].as_str(), Some("36893488147419103230")); + assert_eq!(json["validators"][0]["live_plus_nominal_nanowits"].as_str(), Some("73786976294838206460")); + assert_eq!(json["validators"][0]["live_entries"][0]["stake_nanowits"].as_str(), Some("18446744073709551615")); + assert_eq!(json["validators"][0]["live_entries"][0]["nonce"].as_str(), Some("18446744073709551615")); + assert_eq!(json["validators"][0]["live_entries"][0]["mining_epoch"].as_u64(), Some(u64::from(Epoch::MAX))); + assert_eq!(json["commitments"][0]["raw_collateral_nanowits"].as_str(), Some("18446744073709551615")); + assert_eq!(json["commitments"][0]["witness_reward_nanowits"].as_str(), Some("18446744073709551615")); + assert_eq!(serde_json::from_value::(json).unwrap(), report); + let binary = bincode::serialize(&report).unwrap(); + assert_eq!(bincode::deserialize::(&binary).unwrap(), report); +} + +#[test] +fn selects_request_era_from_pinned_schedule_not_current_version() { + let old_pointer = Hash::with_first_u32(1); + let legacy_pointer = Hash::with_first_u32(2); + let requests = pool(vec![ + (old_pointer, request(5, 99, 3, vec![structural_commit(old_pointer, 1)])), + (legacy_pointer, request(10, 0, 7, vec![structural_commit(legacy_pointer, 2)])), + ]); + let stakes = ledger(&[]); + let mut pinned = schedule(); + let state = DelegationState::default(); + let report = state.activation_boundary_classification(&stakes, &requests, beacon(20), &pinned).unwrap(); + assert_eq!(report.commitments.len(), 1); + assert_eq!(report.commitments[0].data_request, legacy_pointer); + assert_eq!(report.commitments[0].request_epoch, 10); + pinned.current_version = ProtocolVersion::V1_7; + assert_eq!(state.activation_boundary_classification(&stakes, &requests, beacon(20), &pinned).unwrap(), report); +} + +#[test] +fn permits_future_uncommitted_timelocks_but_rejects_future_commitments() { + let pointer = Hash::with_first_u32(1); + let mut requests = pool(vec![(pointer, request(150, 0, 7, Vec::new()))]); + let stakes = ledger(&[]); + assert!(classify(&stakes, &requests).commitments.is_empty()); + let (validator, commit) = structural_commit(pointer, 1); + let state = requests.data_request_pool.get_mut(&pointer).unwrap(); + state.info.commits.insert(validator, commit); + state.set_stage(DataRequestStage::REVEAL); + state.info.current_commit_round = 1; + state.info.current_reveal_round = 1; + assert_invalid( + DelegationState::default().activation_boundary_classification(&stakes, &requests, beacon(20), &schedule()), + "activation classification encountered a future-epoch commitment", + ); +} + +#[test] +fn rejects_malformed_commit_structure_before_reporting_obligations() { + let pointer = Hash::with_first_u32(1); + let (validator, valid) = structural_commit(pointer, 1); + let (other_validator, other) = structural_commit(pointer, 2); + let signature_reason = "activation classification requires exactly one Commit signature"; + let identity_reason = "activation classification encountered inconsistent Commit identity"; + let mut cases = Vec::new(); + let mut missing_signature = valid.clone(); + missing_signature.signatures.clear(); + cases.push((validator, missing_signature, signature_reason)); + let mut duplicate_signature = valid.clone(); + duplicate_signature.signatures.push(valid.signatures[0].clone()); + cases.push((validator, duplicate_signature, signature_reason)); + let mut wrong_pointer = valid.clone(); + wrong_pointer.body.dr_pointer = Hash::with_first_u32(2); + cases.push((validator, wrong_pointer, identity_reason)); + let mut wrong_signer = valid.clone(); + wrong_signer.signatures[0] = other.signatures[0].clone(); + cases.push((validator, wrong_signer, identity_reason)); + let mut wrong_proof = valid.clone(); + wrong_proof.body.proof = other.body.proof; + cases.push((validator, wrong_proof, identity_reason)); + cases.push((other_validator, valid, identity_reason)); + + for (map_validator, commit, reason) in cases { + let requests = pool(vec![(pointer, request(10, 0, 0, vec![(map_validator, commit)]))]); + assert_invalid( + DelegationState::default().activation_boundary_classification(&ledger(&[]), &requests, beacon(20), &schedule()), + reason, + ); + } +} + +#[test] +fn rejects_finished_or_inconsistent_active_request_state_and_invalid_round_configuration() { + let pointer = Hash::with_first_u32(1); + let base = request(10, 0, 0, vec![structural_commit(pointer, 1)]); + let mut cases = Vec::new(); + let mut finished = base.clone(); + finished.info.tally = Some(TallyTransaction::default()); + cases.push(finished); + let mut tally_block = base.clone(); + tally_block.info.block_hash_tally_tx = Some(Hash::with_first_u32(20)); + cases.push(tally_block); + let mut mismatched_stage = base.clone(); + mismatched_stage.info.current_stage = Some(DataRequestStage::TALLY); + cases.push(mismatched_stage); + let mut missing_stage = base.clone(); + missing_stage.info.current_stage = None; + cases.push(missing_stage); + let mut commit_round = base.clone(); + commit_round.info.current_commit_round = 5; + cases.push(commit_round); + let mut reveal_round = base; + reveal_round.info.current_reveal_round = 5; + cases.push(reveal_round); + for request in cases { + assert_invalid( + DelegationState::default().activation_boundary_classification(&ledger(&[]), &pool(vec![(pointer, request)]), beacon(20), &schedule()), + "activation classification encountered malformed active request state", + ); + } + let mut requests = pool(Vec::new()); + requests.extra_rounds = u16::MAX; + assert_invalid( + DelegationState::default().activation_boundary_classification(&ledger(&[]), &requests, beacon(20), &schedule()), + "activation classification encountered invalid request round configuration", + ); +} + +#[test] +fn rejects_impossible_post_block_stage_round_and_reveal_membership_shapes() { + let pointer = Hash::with_first_u32(1); + let (validator, commit) = structural_commit(pointer, 1); + let base = request(10, 0, 0, vec![(validator, commit)]); + let mut cases = Vec::new(); + + let mut reveal_round_zero = base.clone(); + reveal_round_zero.info.current_reveal_round = 0; + cases.push(reveal_round_zero); + + let mut reveal_without_commit_round = base.clone(); + reveal_without_commit_round.info.current_commit_round = 0; + cases.push(reveal_without_commit_round); + + let mut reveal_without_commits = base.clone(); + reveal_without_commits.info.commits.clear(); + cases.push(reveal_without_commits); + + // Commits may temporarily exist in COMMIT while applying a block, but that state is not + // the completed canonical tip accepted by this diagnostic API. + let mut unadvanced_commit = base.clone(); + unadvanced_commit.set_stage(DataRequestStage::COMMIT); + unadvanced_commit.info.current_reveal_round = 0; + cases.push(unadvanced_commit); + + let mut commit_with_reveal_round = request(10, 0, 0, Vec::new()); + commit_with_reveal_round.info.current_reveal_round = 1; + cases.push(commit_with_reveal_round); + + let mut commit_with_reveal = request(10, 0, 0, Vec::new()); + commit_with_reveal.info.reveals.insert(validator, RevealTransaction::default()); + cases.push(commit_with_reveal); + + let mut committed_tally_without_reveal_round = base.clone(); + committed_tally_without_reveal_round.set_stage(DataRequestStage::TALLY); + committed_tally_without_reveal_round.info.current_reveal_round = 0; + cases.push(committed_tally_without_reveal_round); + + let mut committed_tally_without_commit_round = base.clone(); + committed_tally_without_commit_round.set_stage(DataRequestStage::TALLY); + committed_tally_without_commit_round.info.current_commit_round = 0; + cases.push(committed_tally_without_commit_round); + + let mut uncommitted_tally_with_reveal_round = request(10, 0, 0, Vec::new()); + uncommitted_tally_with_reveal_round.set_stage(DataRequestStage::TALLY); + uncommitted_tally_with_reveal_round.info.current_reveal_round = 1; + cases.push(uncommitted_tally_with_reveal_round); + + let mut orphan_reveal = base; + let (other_validator, _) = structural_commit(pointer, 2); + orphan_reveal.info.reveals.insert(other_validator, RevealTransaction::default()); + cases.push(orphan_reveal); + + for state in cases { + assert_invalid( + DelegationState::default().activation_boundary_classification( + &ledger(&[]), + &pool(vec![(pointer, state)]), + beacon(20), + &schedule(), + ), + "activation classification encountered malformed active request state", + ); + } +} + +#[test] +fn preserves_zero_and_terminal_rounds_and_both_unresolved_tally_paths() { + let pointer = Hash::with_first_u32(1); + let stakes = ledger(&[]); + let terminal_round = 4; + + // New/future time-locked COMMIT requests may still have round zero. Do not turn the + // structural diagnostic into a temporal-causality proof or reject terminal COMMIT rounds. + for epoch in [10, 150] { + for commit_round in [0, terminal_round] { + let mut state = request(epoch, 0, 7, Vec::new()); + state.info.current_commit_round = commit_round; + assert!(classify(&stakes, &pool(vec![(pointer, state)])).commitments.is_empty()); + } + } + + // A no-Commit TALLY may be an immediate population-threshold transition at round zero or + // an ordinary terminal timeout. Neither has ever entered a Reveal round. + for commit_round in [0, terminal_round] { + let mut state = request(10, 0, 7, Vec::new()); + state.set_stage(DataRequestStage::TALLY); + state.info.current_commit_round = commit_round; + assert!(classify(&stakes, &pool(vec![(pointer, state)])).commitments.is_empty()); + } + + let (validator, commit) = structural_commit(pointer, 1); + let mut reveal = RevealTransaction::default(); + reveal.body.dr_pointer = pointer; + reveal.body.pkh = validator; + reveal.signatures = commit.signatures.clone(); + for stage in [DataRequestStage::REVEAL, DataRequestStage::TALLY] { + for reveal_round in [1, terminal_round] { + for has_reveal in [false, true] { + let mut state = request(10, 0, 7, vec![(validator, commit.clone())]); + state.set_stage(stage); + state.info.current_reveal_round = reveal_round; + if has_reveal { + state.info.reveals.insert(validator, reveal.clone()); + } + // Only stage shape and accepted-map membership are proven, not signatures, + // witness completeness, a TALLY cause, or any future payable reward. + let report = classify(&stakes, &pool(vec![(pointer, state)])); + assert_eq!(report.commitments.len(), 1); + assert_eq!(report.commitments[0].raw_collateral_nanowits, 0); + assert_eq!(report.commitments[0].witness_reward_nanowits, 7); + assert!(!report.nominal_collateral_requires_history); + } + } + } +} + +#[test] +fn fully_orders_validators_entries_and_commitments_independently_of_insertion_order() { + let pointer_a = Hash::with_first_u32(1); + let pointer_b = Hash::with_first_u32(2); + let a1 = structural_commit(pointer_a, 1); + let a2 = structural_commit(pointer_a, 2); + let ambiguous_validator = a1.0; + let sole_validator = a2.0; + let b1 = structural_commit(pointer_b, 1); + let b2 = structural_commit(pointer_b, 2); + let rows = [ + (a2.0, pkh(12), 2, 2, 2, 2), + (a1.0, pkh(12), 1, 1, 1, 1), + (a1.0, pkh(11), 0, 0, 0, 0), + ]; + let forward = pool(vec![ + (pointer_a, request(10, 4, 5, vec![a1.clone(), a2.clone()])), + (pointer_b, request(11, 6, 7, vec![b1.clone(), b2.clone()])), + ]); + let reverse = pool(vec![ + (pointer_b, request(11, 6, 7, vec![b2, b1])), + (pointer_a, request(10, 4, 5, vec![a2, a1])), + ]); + let mut reversed_rows = rows; + reversed_rows.reverse(); + let expected = classify(&ledger(&rows), &forward); + let actual = classify(&ledger(&reversed_rows), &reverse); + assert_eq!(actual, expected); + assert_eq!(actual.validators.iter().map(|row| row.live_entries.len()).sum::(), 3); + assert_eq!(actual.commitments.len(), 4); + assert!(actual.validators.windows(2).all(|pair| pair[0].validator < pair[1].validator)); + for row in &actual.validators { + assert!(row.live_entries.windows(2).all(|pair| pair[0].withdrawer < pair[1].withdrawer)); + } + assert!(actual.commitments.windows(2).all(|pair| { + (pair[0].data_request, pair[0].validator) < (pair[1].data_request, pair[1].validator) + })); + for commitment in &actual.commitments { + if commitment.validator == sole_validator { + assert_eq!(commitment.current_entry_withdrawer, Some(pkh(12))); + } else { + assert_eq!(commitment.validator, ambiguous_validator); + assert_eq!(commitment.current_entry_withdrawer, None); + } + } +} + +#[test] +fn rejects_activated_state_native_tip_and_malformed_pinned_schedule() { + let stakes = ledger(&[]); + let requests = pool(Vec::new()); + let initialized = DelegationState { activation_initialized: true, ..Default::default() }; + assert_invalid( + initialized.activation_boundary_classification(&stakes, &requests, beacon(20), &schedule()), + "activation classification requires an uninitialized legacy state", + ); + assert_invalid( + DelegationState::default().activation_boundary_classification(&stakes, &requests, beacon(100), &schedule()), + "activation classification requires a pre-V2.1 canonical tip", + ); + let mut invalid_schedule = schedule(); + invalid_schedule.all_checkpoints_periods.insert(ProtocolVersion::V2_0, 0); + assert_invalid( + DelegationState::default().activation_boundary_classification(&stakes, &requests, beacon(20), &invalid_schedule), + "activation classification requires a valid pinned protocol schedule", + ); +} diff --git a/data_structures/src/staking/delegation/allocation.rs b/data_structures/src/staking/delegation/allocation.rs new file mode 100644 index 0000000000..2c3237c7a1 --- /dev/null +++ b/data_structures/src/staking/delegation/allocation.rs @@ -0,0 +1,251 @@ +//! Deterministic integer allocation for delegated staking. +//! +//! Kept separate from state publication so proportional rounding, owner dust, and bounded +//! capacity filling can be reviewed independently. These helpers do not mutate the ledger. + +use super::{DelegationResult, PublicKeyHash, StakeKey, StakesError}; + +#[derive(Clone, Debug)] +pub(super) struct WeightedCapacity { + pub(super) key: StakeKey, + pub(super) weight: u64, + pub(super) capacity: u64, +} +/// Sum allocation values with checked u64 arithmetic. It is shared by reward, collateral, and capacity allocators in delegation.rs. +pub(super) fn checked_sum(mut values: impl Iterator) -> DelegationResult { + values.try_fold(0u64, |total, value| { + total + .checked_add(value) + .ok_or(StakesError::ArithmeticOverflow) + }) +} + +/// Allocate an amount proportionally by integer weights and deterministic remainder order. It is used for validator rewards, commissions, and delegator collateral in DelegationState. +pub(super) fn allocate_by_weight( + entries: &[(StakeKey, u64)], + amount: u64, +) -> DelegationResult, u64)>> { + if amount == 0 { + return Ok(entries.iter().map(|(key, _)| (key.clone(), 0)).collect()); + } + + let total_weight = entries + .iter() + .try_fold(0u128, |total, (_, weight)| { + total + .checked_add(u128::from(*weight)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + if total_weight == 0 { + return Err(StakesError::ArithmeticOverflow); + } + if u128::from(amount) > total_weight { + return Err(StakesError::ArithmeticOverflow); + } + + let mut allocations = Vec::with_capacity(entries.len()); + let mut allocated = 0u64; + for (key, weight) in entries { + let numerator = u128::from(amount) * u128::from(*weight); + let share = u64::try_from(numerator / total_weight) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let remainder = numerator % total_weight; + allocated = allocated + .checked_add(share) + .ok_or(StakesError::ArithmeticOverflow)?; + allocations.push((key.clone(), share, remainder)); + } + + let mut remaining = amount + .checked_sub(allocated) + .ok_or(StakesError::ArithmeticOverflow)?; + let mut remainder_order = (0..allocations.len()).collect::>(); + remainder_order.sort_by(|left, right| { + allocations[*right] + .2 + .cmp(&allocations[*left].2) + .then_with(|| allocations[*left].0.cmp(&allocations[*right].0)) + }); + for index in remainder_order { + if remaining == 0 { + break; + } + allocations[index].1 = allocations[index] + .1 + .checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + remaining = remaining + .checked_sub(1) + .ok_or(StakesError::ArithmeticOverflow)?; + } + + // Keep the conservation invariant at this boundary even if the allocation algorithm changes: + // the precondition above rejects over-weight requests, while this residual check prevents a + // future rounding change from returning a short vector that a later aggregate check might miss. + if remaining != 0 { + return Err(StakesError::ArithmeticOverflow); + } + + Ok(allocations + .into_iter() + .map(|(key, share, _)| (key, share)) + .collect()) +} + +/// Allocate a reward proportionally and assign rounding dust to the immutable owner. It is called by DelegationState::distribute_reward_inner for native and committed reward paths. +pub(super) fn allocate_reward_by_weight( + entries: &[(StakeKey, u64)], + amount: u64, + owner_key: &StakeKey, +) -> DelegationResult, u64)>> { + let total_weight = entries + .iter() + .try_fold(0u128, |total, (_, weight)| { + total + .checked_add(u128::from(*weight)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + if total_weight == 0 { + return if amount == 0 { + Ok(entries.iter().map(|(key, _)| (key.clone(), 0)).collect()) + } else { + Err(StakesError::ArithmeticOverflow) + }; + } + + let mut owner_index = None; + let mut allocated = 0u64; + let mut allocations = Vec::with_capacity(entries.len()); + for (index, (key, weight)) in entries.iter().enumerate() { + if key == owner_key { + owner_index = Some(index); + } + let share = u64::try_from(u128::from(amount) * u128::from(*weight) / total_weight) + .map_err(|_| StakesError::ArithmeticOverflow)?; + allocated = allocated + .checked_add(share) + .ok_or(StakesError::ArithmeticOverflow)?; + allocations.push((key.clone(), share)); + } + + let owner_index = owner_index.ok_or_else(|| StakesError::OwnerStakeNotFound { + validator: owner_key.validator, + owner: owner_key.withdrawer, + })?; + let dust = amount + .checked_sub(allocated) + .ok_or(StakesError::ArithmeticOverflow)?; + allocations[owner_index].1 = allocations[owner_index] + .1 + .checked_add(dust) + .ok_or(StakesError::ArithmeticOverflow)?; + + Ok(allocations) +} + +/// Allocate collateral across weighted entries without exceeding each entry's excess capacity. It is called by DelegationState::plan_collateral_reservation for deterministic bounded reservations. +pub(super) fn allocate_with_capacities( + entries: &[WeightedCapacity], + amount: u64, +) -> DelegationResult, u64)>> { + let total_capacity = checked_sum(entries.iter().map(|entry| entry.capacity))?; + if amount > total_capacity { + return Err(StakesError::ArithmeticOverflow); + } + + let mut allocations = vec![0u64; entries.len()]; + let mut remaining = amount; + + // Water-fill the capped proportional allocation. Every round either exhausts the remaining + // amount (including its largest-remainder dust) or fills at least one entry to capacity, so + // the number of rounds is bounded by `entries.len()`. The previous implementation repeated a + // round whenever dust remained; with large stake weights and a large collateral amount that + // could devolve into one iteration per nanowit and make commitment admission attacker-sized. + while remaining > 0 { + let active = entries + .iter() + .enumerate() + .filter(|(index, entry)| allocations[*index] < entry.capacity) + .map(|(index, _)| index) + .collect::>(); + if active.is_empty() { + return Err(StakesError::ArithmeticOverflow); + } + + let total_weight = active.iter().try_fold(0u128, |total, index| { + total + .checked_add(u128::from(entries[*index].weight)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + if total_weight == 0 { + return Err(StakesError::ArithmeticOverflow); + } + + let round_amount = remaining; + let mut remainders = Vec::with_capacity(active.len()); + let mut saturated = false; + for index in active.iter().copied() { + let numerator = u128::from(round_amount) + .checked_mul(u128::from(entries[index].weight)) + .ok_or(StakesError::ArithmeticOverflow)?; + let proportional = u64::try_from(numerator / total_weight) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let available = entries[index] + .capacity + .checked_sub(allocations[index]) + .ok_or(StakesError::ArithmeticOverflow)?; + let granted = proportional.min(available); + if granted == available { + saturated = true; + } + if granted > 0 { + allocations[index] = allocations[index] + .checked_add(granted) + .ok_or(StakesError::ArithmeticOverflow)?; + remaining = remaining + .checked_sub(granted) + .ok_or(StakesError::ArithmeticOverflow)?; + } + remainders.push((index, numerator % total_weight)); + } + + // If no entry was capped, the floor shares leave fewer than one unit per active entry; + // largest-remainder allocation therefore completes the round in this single pass. If an + // entry was capped, distribute any available dust now and let the next bounded round + // recompute proportions over the entries that still have capacity. + remainders.sort_by(|(left_index, left_remainder), (right_index, right_remainder)| { + right_remainder + .cmp(left_remainder) + .then_with(|| entries[*left_index].key.cmp(&entries[*right_index].key)) + }); + for (index, _) in remainders { + if remaining == 0 { + break; + } + if allocations[index] < entries[index].capacity { + allocations[index] = allocations[index] + .checked_add(1) + .ok_or(StakesError::ArithmeticOverflow)?; + remaining = remaining + .checked_sub(1) + .ok_or(StakesError::ArithmeticOverflow)?; + } + } + + if remaining > 0 && !saturated { + // The floor identity guarantees this cannot happen unless the input capacities or + // arithmetic are inconsistent. Keep the failure explicit rather than falling into an + // unbounded retry loop. + return Err(StakesError::ArithmeticOverflow); + } + } + + let mut result = entries + .iter() + .enumerate() + .map(|(index, entry)| (entry.key.clone(), allocations[index])) + .collect::>(); + result.sort_by(|a, b| a.0.cmp(&b.0)); + + Ok(result) +} diff --git a/data_structures/src/staking/errors.rs b/data_structures/src/staking/errors.rs index 3001897e6a..a6fa347ddf 100644 --- a/data_structures/src/staking/errors.rs +++ b/data_structures/src/staking/errors.rs @@ -25,6 +25,19 @@ where /// The minimum stakeable amount. minimum: Coins, }, + /// A live stake entry cannot cross the delegated-staking activation boundary because its + /// balance is below the protocol minimum required of every post-activation entry. + #[error( + "Live stake entry {key} has {amount}, below the delegated-staking activation minimum {minimum}" + )] + LiveStakeEntryBelowMinimum { + /// Exact validator and withdrawer pair whose live balance is invalid at activation. + key: StakeKey
, + /// Live balance found in the legacy stake ledger. + amount: Coins, + /// Minimum balance required after activation. + minimum: Coins, + }, /// Tried to query `Stakes` for information that belongs to the past. #[error( "Tried to query `Stakes` for information that belongs to the past. Query Epoch: {epoch} Latest Epoch: {latest}" @@ -51,10 +64,8 @@ where /// A validator and withdrawer address pair. key: StakeKey
, }, - /// Tried to obtain a lock on a write-locked piece of data that is already locked. - #[error( - "The authentication signature contained within a stake transaction is not valid for the given validator and withdrawer addresses" - )] + /// A stake-entry lock was poisoned by a previous panic while holding write access. + #[error("A stake entry lock is poisoned")] PoisonedLock, /// The authentication signature contained within a stake transaction is not valid for the given validator and /// withdrawer addresses. @@ -84,6 +95,138 @@ where /// A validator address. validator: Address, }, + /// Tried to use delegated staking rules for a validator without persistent policy metadata. + #[error("Validator {validator} has no delegated staking policy")] + ValidatorPolicyNotFound { + /// Validator whose policy could not be found. + validator: Address, + }, + /// The persistent owner stake entry could not be found. + #[error("Validator {validator} has no stake entry for its owner {owner}")] + OwnerStakeNotFound { + /// Validator whose owner stake is missing. + validator: Address, + /// Persistent owner withdrawer. + owner: Address, + }, + /// An operation tried to replace a validator's persistent cold owner. + #[error( + "Validator {validator} is owned by {recorded_owner}, not requested withdrawer {requested_owner}" + )] + ValidatorOwnerMismatch { + /// Validator whose owner is immutable. + validator: Address, + /// Owner already recorded or derived from the canonical live legacy stake ledger. + recorded_owner: Address, + /// Conflicting owner requested by the operation. + requested_owner: Address, + }, + /// A requested commission is above the protocol maximum. + #[error("Commission {commission}% is above the maximum of {maximum}%")] + CommissionAboveMaximum { + /// Requested whole-number percentage. + commission: u8, + /// Maximum whole-number percentage. + maximum: u8, + }, + /// A commission authorization used a stale or skipped sequence number. + #[error( + "Validator {validator} used commission nonce {used}, but the next expected nonce is {expected}" + )] + InvalidCommissionNonce { + /// Validator updating its commission. + validator: Address, + /// Nonce carried by the authorization. + used: u64, + /// Next valid nonce. + expected: u64, + }, + /// Persisted delegated-staking state violates an invariant that every state transition + /// constructs canonically. + #[error("Invalid persisted delegated-staking state: {reason}")] + InvalidDelegationState { + /// Stable diagnostic for the rejected invariant. + reason: &'static str, + }, + /// The stake ledger cannot reserve the requested collateral while preserving minimum entries. + #[error( + "Validator {validator} has only {available} slashable stake for collateral requirement {required}" + )] + InsufficientCollateralCapacity { + /// Validator attempting to reserve collateral. + validator: Address, + /// Required collateral amount. + required: Coins, + /// Amount that can be debited without creating a sub-minimum entry. + available: Coins, + }, + /// The owner cannot fund its mandatory collateral share without crossing the live minimum. + #[error( + "Validator {validator} owner has only {available} slashable stake for its mandatory collateral share {required}" + )] + InsufficientOwnerCollateralCapacity { + /// Validator whose owner must fund the mandatory share. + validator: Address, + /// Required owner share, rounded down to whole nanowits. + required: Coins, + /// Owner balance available above the minimum, excluding outstanding reservations. + available: Coins, + }, + /// New participation cannot be assigned after an irrevocable exit request. + #[error("Stake entry {key} is draining for exit")] + StakeEntryExiting { + /// Exact exiting stake entry. + key: StakeKey
, + }, + /// An exit signature must bind the live entry's current nonce. + #[error("Exit request for {key} uses nonce {used}, expected {expected}")] + InvalidExitNonce { + /// Exact stake entry. + key: StakeKey
, + /// Nonce covered by the withdrawer signature. + used: u64, + /// Current live entry nonce. + expected: u64, + }, + /// The owner must keep its self-bond active until all third-party stake has left. + #[error("Validator {validator} owner cannot request exit while delegators remain")] + OwnerExitWithDelegators { + /// Validator whose self-bond remains required. + validator: Address, + }, + /// Normal delegation cannot implicitly choose the validator's immutable owner. + #[error("Validator {validator} requires explicit owner registration before delegation")] + ValidatorRegistrationRequired { + /// Validator without a registered owner policy. + validator: Address, + }, + /// A persistent owner policy cannot be replaced by another registration. + #[error("Validator {validator} already has an immutable owner registration")] + ValidatorAlreadyRegistered { + /// Previously registered validator. + validator: Address, + }, + /// A reservation already exists for the validator and data request pair. + #[error("Validator {validator} already reserved collateral for this data request")] + CollateralAlreadyReserved { + /// Validator owning the duplicate reservation. + validator: Address, + }, + /// No reservation exists for the validator and data request pair. + #[error("Validator {validator} has no collateral reservation for this data request")] + CollateralReservationNotFound { + /// Validator whose reservation was requested. + validator: Address, + }, + /// An entry cannot be removed while it backs an unresolved oracle commitment. + #[error("Stake entry {key} is locked by outstanding collateral")] + StakeEntryLocked { + /// Locked validator and withdrawer pair. + key: StakeKey
, + }, + /// Consensus arithmetic exceeded the supported numeric range. + #[error("Delegated staking arithmetic overflow")] + ArithmeticOverflow, /// Tried to query for a stake entry without providing a validator or a withdrawer address. #[error("Tried to query a stake entry without providing a validator or a withdrawer address")] EmptyQuery, diff --git a/data_structures/src/staking/helpers.rs b/data_structures/src/staking/helpers.rs index 673290aa9e..e4710f6efc 100644 --- a/data_structures/src/staking/helpers.rs +++ b/data_structures/src/staking/helpers.rs @@ -23,6 +23,13 @@ use crate::{ /// Just a type alias for consistency of using the same data type to represent power. pub type Power = u64; +/// Maximum withdrawers accepted by one filtered tip-bound stake-ledger query. +/// +/// At 4,096 public-key hashes the decoded filter is only 80 KiB, while still covering far more +/// derived addresses than an ordinary wallet account. Wallets above this defensive RPC bound use +/// the compatibility-preserving unfiltered query instead. +pub const MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS: usize = 4_096; + /// The resulting type for all the fallible functions in this module. pub type StakesResult = Result>; /// Pairs a stake key and the stake data it refers. @@ -164,8 +171,8 @@ where let other_stake = other.value.read().unwrap(); self_stake.coins.eq(&other_stake.coins) - && other_stake.epochs.eq(&other_stake.epochs) - && other_stake.nonce.eq(&other_stake.nonce) + && self_stake.epochs.eq(&other_stake.epochs) + && self_stake.nonce.eq(&other_stake.nonce) } } @@ -384,10 +391,12 @@ where + Default + DeserializeOwned + Div + + From + + num_traits::CheckedAdd + Ord + Serialize + Sum, - u64: From + From, + u64: From + From + From, { fn deserialize(deserializer: D) -> Result where @@ -469,10 +478,12 @@ where + Default + Deserialize<'de> + Div + + From + + num_traits::CheckedAdd + Ord + Serialize + Sum, - u64: From + From, + u64: From + From + From, { type Value = Stakes; @@ -489,8 +500,29 @@ where SyncStakeEntry, >>::new(); - while let Some((key, value)) = map.next_entry()? { - entries.insert(key, value); + while let Some((key, value)) = map.next_entry::< + StakeKey
, + SyncStakeEntry, + >()? { + // The wire format duplicates each stake identity in the map key and the entry. Indexes + // are rebuilt from the former while checked queries read the latter, so accepting a + // mismatch would publish a ledger with two contradictory canonical identities. + let stored_key = value.key.read().map_err(|_| { + ::custom( + "cannot deserialize a poisoned stake-entry key lock", + ) + })?; + if &*stored_key != &key { + return Err(::custom( + "stake entry key does not match its canonical map key", + )); + } + drop(stored_key); + if entries.insert(key, value).is_some() { + return Err(::custom( + "duplicate canonical stake map key", + )); + } } let stakes = Stakes::with_entries(entries); @@ -518,6 +550,22 @@ pub enum NoncePolicy { #[cfg(test)] mod test { use super::*; + use serde::ser::SerializeMap; + + struct DuplicateCanonicalStakeMap<'a>(&'a StakesTester); + + impl Serialize for DuplicateCanonicalStakeMap<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let (key, value) = self.0.by_key.iter().next().unwrap(); + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry(key, value)?; + map.serialize_entry(key, value)?; + map.end() + } + } #[test] fn test_cloning_assumptions() { @@ -534,4 +582,30 @@ mod test { assert_eq!(a.value.read().unwrap().coins, 456); } + + #[test] + fn current_stakes_decode_rejects_malformed_canonical_keys() { + let canonical_key = StakeKey::from(("Validator", "Owner")); + let mut stakes = StakesTester::default(); + stakes + .add_stake(canonical_key.clone(), 1, 0, true, 1) + .unwrap(); + + let duplicate_canonical_key = + bincode::serialize(&DuplicateCanonicalStakeMap(&stakes)).unwrap(); + assert!(bincode::deserialize::(&duplicate_canonical_key).is_err()); + + // `Stakes` serializes both the canonical map key and the locked entry key. Simulate a + // malformed current-format snapshot in which those duplicated identities disagree. + *stakes + .by_key + .get(&canonical_key) + .unwrap() + .key + .write() + .unwrap() = StakeKey::from(("DifferentValidator", "Owner")); + let malformed_current_state = bincode::serialize(&stakes).unwrap(); + + assert!(bincode::deserialize::(&malformed_current_state).is_err()); + } } diff --git a/data_structures/src/staking/mod.rs b/data_structures/src/staking/mod.rs index 351a4e4114..75c5e97a7c 100644 --- a/data_structures/src/staking/mod.rs +++ b/data_structures/src/staking/mod.rs @@ -2,6 +2,8 @@ /// Errors related to the staking functionality. pub mod errors; +/// Consensus state and accounting primitives for delegated staking. +pub mod delegation; /// Auxiliary convenience types and data structures. pub mod helpers; /// The data structure and related logic for stake entries. @@ -12,6 +14,7 @@ pub mod stakes; /// Module re-exporting virtually every submodule on a single level to ease importing of everything /// staking-related. pub mod prelude { + pub use super::delegation::*; pub use super::errors::*; pub use super::helpers::*; pub use super::stake::*; diff --git a/data_structures/src/staking/stake.rs b/data_structures/src/staking/stake.rs index ab5b96a83f..a9c16373f0 100644 --- a/data_structures/src/staking/stake.rs +++ b/data_structures/src/staking/stake.rs @@ -73,8 +73,12 @@ where + Display + Sync + Send, - Power: Add + Clone + Div, - u64: From + From, + Power: Add + + Clone + + Div + + From + + num_traits::CheckedAdd, + u64: From + From + From, { /// Increase the amount of coins staked by a certain staker. /// @@ -101,23 +105,40 @@ where } let coins_before = self.coins; - let coins_after = coins_before + coins; - self.coins = coins_after; + let coins_after = Coins::from( + u64::from(coins_before) + .checked_add(u64::from(coins)) + .ok_or(StakesError::ArithmeticOverflow)?, + ); + let mut epochs = self.epochs; // When stake is added, all capabilities get their associated epochs moved to the past for capability in ALL_CAPABILITIES { let epoch_before = self.epochs.get(capability); - let product_before = coins_before.lose_precision(UNIT) * epoch_before; - let product_added = coins.lose_precision(UNIT) * epoch; + let product_before = u64::from(coins_before.lose_precision(UNIT)) + .checked_mul(u64::from(epoch_before)) + .ok_or(StakesError::ArithmeticOverflow)?; + let product_added = u64::from(coins.lose_precision(UNIT)) + .checked_mul(u64::from(epoch)) + .ok_or(StakesError::ArithmeticOverflow)?; - #[allow(clippy::cast_possible_truncation)] + let product = product_before + .checked_add(product_added) + .ok_or(StakesError::ArithmeticOverflow)?; + let denominator = u64::from(coins_after.lose_precision(UNIT)); + if denominator == 0 { + return Err(StakesError::ArithmeticOverflow); + } let epoch_after = Epoch::from( - (u64::from(product_before + product_added) - / u64::from(coins_after.lose_precision(UNIT))) as u32, + u32::try_from(product / denominator) + .map_err(|_| StakesError::ArithmeticOverflow)?, ); - self.epochs.update(capability, epoch_after); + epochs.update(capability, epoch_after); } + self.coins = coins_after; + self.epochs = epochs; + // Nonces are updated following the "keep the latest epoch where this stake was updated // manually" logic, where "manually" means by means of staking or unstaking, but not through // rewards nor slashing. @@ -144,8 +165,26 @@ where /// Derives the power of an identity in the network on a certain epoch from an entry. Most /// normally, the epoch is the current epoch. pub fn power(&self, capability: Capability, current_epoch: Epoch) -> Power { - self.coins.lose_precision(UNIT) - * (current_epoch.saturating_sub(self.epochs.get(capability))) + Power::from( + u64::from(self.coins.lose_precision(UNIT)).wrapping_mul(u64::from( + current_epoch.saturating_sub(self.epochs.get(capability)), + )), + ) + } + + /// Derive stake power while rejecting overflow in the coin-age product. + pub fn checked_power( + &self, + capability: Capability, + current_epoch: Epoch, + ) -> StakesResult { + let coins = u64::from(self.coins.lose_precision(UNIT)); + let age = u64::from(current_epoch.saturating_sub(self.epochs.get(capability))); + let power = coins + .checked_mul(age) + .ok_or(StakesError::ArithmeticOverflow)?; + + Ok(Power::from(power)) } /// Remove a certain amount of staked coins. @@ -155,7 +194,11 @@ where nonce_policy: NoncePolicy, minimum_stakeable: Coins, ) -> StakesResult { - let coins_after = self.coins.sub(coins); + let coins_after = Coins::from( + u64::from(self.coins) + .checked_sub(u64::from(coins)) + .ok_or(StakesError::ArithmeticOverflow)?, + ); if coins_after > Coins::zero() && coins_after < minimum_stakeable { Err(StakesError::AmountIsBelowMinimum { @@ -173,6 +216,86 @@ where Ok(self.coins) } + /// Reproduce the successful release-mode arithmetic used by the Wit/2.0 stake ledger. + /// + /// Historical block validation checked commitments independently, so cumulative collateral + /// debits could underflow during consolidation. Rust's release profile wrapped that subtraction + /// and the outer compatibility wrapper ignored only the subsequent minimum-balance error. Keep + /// this behavior isolated from the checked Wit/2.1 accounting primitives. + pub(super) fn remove_stake_legacy_release( + &mut self, + coins: Coins, + nonce_policy: NoncePolicy, + minimum_stakeable: Coins, + ) -> StakesResult { + let coins_after = Coins::from(u64::from(self.coins).wrapping_sub(u64::from(coins))); + + if coins_after > Coins::zero() && coins_after < minimum_stakeable { + return Err(StakesError::AmountIsBelowMinimum { + amount: coins_after, + minimum: minimum_stakeable, + }); + } + + self.coins = coins_after; + + if let NoncePolicy::SetFromEpoch(epoch) = nonce_policy { + self.nonce = Nonce::from(epoch); + } + + Ok(self.coins) + } + + /// Reproduce successful release-mode Wit/2.0 reward addition and age averaging. + pub(super) fn add_stake_legacy_release( + &mut self, + coins: Coins, + epoch: Epoch, + nonce_policy: NoncePolicy, + minimum_stakeable: Coins, + ) -> StakesResult { + if coins < minimum_stakeable { + return Err(StakesError::AmountIsBelowMinimum { + amount: coins, + minimum: minimum_stakeable, + }); + } + + let coins_before = self.coins; + let coins_after = Coins::from(u64::from(coins_before).wrapping_add(u64::from(coins))); + + // Preserve the historical release-mode wrapping products and sum, but stage the complete + // value before publishing it. The legacy reward wrapper can surface the represented + // zero-denominator trap, so publishing the balance first would leave a partially updated + // entry if the trap is encountered while calculating one of the capability ages. + let mut epochs = self.epochs; + for capability in ALL_CAPABILITIES { + let epoch_before = self.epochs.get(capability); + let product_before = u64::from(coins_before.lose_precision(UNIT)) + .wrapping_mul(u64::from(epoch_before)); + let product_added = u64::from(coins.lose_precision(UNIT)) + .wrapping_mul(u64::from(epoch)); + let denominator = u64::from(coins_after.lose_precision(UNIT)); + if denominator == 0 { + return Err(StakesError::ArithmeticOverflow); + } + #[allow(clippy::cast_possible_truncation)] + let epoch_after = Epoch::from( + (product_before.wrapping_add(product_added) / denominator) as u32, + ); + epochs.update(capability, epoch_after); + } + + self.coins = coins_after; + self.epochs = epochs; + + if let NoncePolicy::SetFromEpoch(epoch) = nonce_policy { + self.nonce = Nonce::from(epoch); + } + + Ok(coins_after) + } + /// Set the epoch for a certain capability. Most normally, the epoch is the current epoch. pub fn reset_age(&mut self, capability: Capability, reset_epoch: Epoch) { self.epochs.update(capability, reset_epoch); @@ -185,14 +308,25 @@ pub fn totalize_stakes StakesResult where Address: Clone + Debug + Default + Display + Send + Sync, - Coins: Clone + Debug + Display + num_traits::Zero + Send + Sync, + Coins: Clone + + Debug + + Display + + From + + num_traits::Zero + + Send + + Sync, Epoch: Clone + Debug + Default + Display + Send + Sync, Nonce: Clone + Debug + Default + Display + Send + Sync, I: IntoIterator, Power: Clone, S: Into>, + u64: From, { - Ok(stakes - .into_iter() - .fold(Coins::zero(), |a: Coins, b| a + b.into().coins)) + let total = stakes.into_iter().try_fold(0u64, |total, entry| { + total + .checked_add(u64::from(entry.into().coins)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + + Ok(Coins::from(total)) } diff --git a/data_structures/src/staking/stakes.rs b/data_structures/src/staking/stakes.rs index cf7a301faa..512c551e9a 100644 --- a/data_structures/src/staking/stakes.rs +++ b/data_structures/src/staking/stakes.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, btree_map::Entry}, + collections::{BTreeMap, BTreeSet, btree_map::Entry}, fmt::{Debug, Display}, iter::Sum, ops::{Add, AddAssign, Div, Mul, Rem, Sub}, @@ -8,12 +8,14 @@ use std::{ }; use itertools::Itertools; -use num_traits::Saturating; +use num_traits::{CheckedAdd, CheckedSub, Saturating}; +use num_traits::ops::wrapping::{WrappingAdd, WrappingSub}; use serde::{Deserialize, Serialize}; use crate::{ chain::{Epoch, PublicKeyHash}, get_environment, + proto::versioning::ProtocolVersion, transaction::{StakeTransaction, UnstakeTransaction}, wit::{PrecisionLoss, WIT_DECIMAL_PLACES, Wit}, }; @@ -108,8 +110,8 @@ where + Serialize + Sum + Div - + Rem - + PrecisionLoss, + + Rem + + PrecisionLoss, Epoch: Copy + Default + Saturating @@ -136,13 +138,15 @@ where + Sync, Power: Copy + Default + + num_traits::CheckedAdd + Ord + Add + Div + + From + Serialize + Sum + Display, - u64: From + From, + u64: From + From + From, { fn clone(&self) -> Self { let mut stakes = Stakes::default(); @@ -194,8 +198,8 @@ where + Send + Serialize + Sync - + Add - + Div, + + Add + + Div, Nonce: AddAssign + Copy + Debug @@ -207,8 +211,16 @@ where + Send + Serialize + Sync, - Power: Copy + Default + Ord + Add + Div + Serialize + Sum, - u64: From + From, + Power: Copy + + Default + + num_traits::CheckedAdd + + Ord + + Add + + Div + + From + + Serialize + + Sum, + u64: From + From + From, { /// Register a certain amount of additional stake for a certain address, capability and epoch. pub fn add_stake( @@ -224,46 +236,151 @@ where { let key = key.into(); - // Find or create a matching stake entry - let stake_found = self.by_key.contains_key(&key); - let stake = self - .by_key - .entry(key.clone()) - .or_insert(SyncStakeEntry::from(StakeEntry { - key: key.clone(), - value: Stake { - nonce: Nonce::from(epoch), - ..Default::default() - }, - })); + if let Some(stake) = self.by_key.get(&key) { + let mut updated = stake.value.read()?.clone(); + let nonce_policy = if set_nonce { + NoncePolicy::SetFromEpoch(epoch) + } else { + NoncePolicy::KeepAsIs + }; + updated.add_stake(coins, epoch, nonce_policy, minimum_stakeable)?; + *stake.value.write()? = updated.clone(); + return Ok(updated); + } + + // Prepare a new entry completely before making it visible in any index. A rejected first + // stake must not leave a zero-valued, unindexed phantom in `by_key`. + let mut updated = Stake { + nonce: Nonce::from(epoch), + ..Default::default() + }; + let nonce_policy = if set_nonce { + NoncePolicy::SetFromEpoch(epoch) + } else { + NoncePolicy::KeepAsIs + }; + updated.add_stake(coins, epoch, nonce_policy, minimum_stakeable)?; + + let stake = SyncStakeEntry::from(StakeEntry { + key: key.clone(), + value: updated.clone(), + }); + self.by_key.insert(key.clone(), stake.clone()); + index_addresses( + &mut self.by_validator, + &mut self.by_withdrawer, + key, + stake, + ); + + Ok(updated) + } + + /// Register stake with the release-wrapping arithmetic used by Wit/2.0. + /// + /// This must only be called from protocol-gated legacy consolidation. Native Wit/2.1 paths use + /// [`Self::add_stake`] and its checked, atomic accounting. + pub(super) fn add_stake_legacy( + &mut self, + key: ISK, + coins: Coins, + epoch: Epoch, + set_nonce: bool, + minimum_stakeable: Coins, + ) -> StakesResult, Address, Coins, Epoch> + where + ISK: Into>, + { + let key = key.into(); + let nonce_policy = if set_nonce { + NoncePolicy::SetFromEpoch(epoch) + } else { + NoncePolicy::KeepAsIs + }; + + if let Some(stake) = self.by_key.get(&key) { + // Stage release-compatible arithmetic on a private value. The helper deliberately + // surfaces the newly represented arithmetic trap; do not publish a partially updated + // balance or age map when that happens. + let mut updated = stake.value.read()?.clone(); + updated.add_stake_legacy_release(coins, epoch, nonce_policy, minimum_stakeable)?; + *stake.value.write()? = updated.clone(); + return Ok(updated); + } + + // Prepare a new entry completely before making it visible in any index. This mirrors the + // checked Wit/2.1 path and prevents a rejected first legacy stake from leaving an unindexed + // phantom in `by_key`. + let mut updated = Stake { + nonce: Nonce::from(epoch), + ..Default::default() + }; + updated.add_stake_legacy_release(coins, epoch, nonce_policy, minimum_stakeable)?; + + let stake = SyncStakeEntry::from(StakeEntry { + key: key.clone(), + value: updated.clone(), + }); + self.by_key.insert(key.clone(), stake.clone()); + index_addresses( + &mut self.by_validator, + &mut self.by_withdrawer, + key, + stake, + ); + + Ok(updated) + } - if !stake_found { - stake.key.write()?.validator = key.validator.clone(); - stake.key.write()?.withdrawer = key.withdrawer.clone(); + /// Add release-compatible stake to an existing entry without creating a new owner or + /// withdrawer pair. This is used when a legacy tally is settled after Wit/2.1 activation: + /// legacy arithmetic must be preserved, but the historical reward must remain owner-only. + pub fn add_stake_to_existing_legacy( + &mut self, + key: ISK, + coins: Coins, + epoch: Epoch, + set_nonce: bool, + minimum_stakeable: Coins, + ) -> StakesResult, Address, Coins, Epoch> + where + ISK: Into>, + { + let key = key.into(); + if !self.by_key.contains_key(&key) { + return Err(StakesError::EntryNotFound { key }); } + self.add_stake_legacy(key, coins, epoch, set_nonce, minimum_stakeable) + } + /// Add stake to an existing entry without implicitly creating a new validator/withdrawer pair. + pub fn add_stake_to_existing( + &mut self, + key: ISK, + coins: Coins, + epoch: Epoch, + set_nonce: bool, + minimum_stakeable: Coins, + ) -> StakesResult, Address, Coins, Epoch> + where + ISK: Into>, + { + let key = key.into(); + let stake = self + .by_key + .get_mut(&key) + .ok_or(StakesError::EntryNotFound { key })?; let nonce_policy = if set_nonce { NoncePolicy::SetFromEpoch(epoch) } else { NoncePolicy::KeepAsIs }; - // Actually increase the number of coins stake .value .write()? .add_stake(coins, epoch, nonce_policy, minimum_stakeable)?; - // Update all indexes if needed (only when the stake entry didn't exist before) - if !stake_found { - index_addresses( - &mut self.by_validator, - &mut self.by_withdrawer, - key, - stake.clone(), - ); - } - Ok(stake.value.read()?.clone()) } @@ -272,6 +389,163 @@ where self.by_key.len() } + /// Clone the tracker while propagating a poisoned stake-entry lock. + /// + /// The [`Clone`] implementation is retained for legacy compatibility and therefore still + /// panics when an entry lock is poisoned. Wit/2.1 consensus projections must use this fallible + /// boundary so corrupted in-memory state rejects the transition with [`StakesError::PoisonedLock`] + /// before any projected mutation can become visible. + pub fn try_clone_checked(&self) -> StakesResult { + let mut stakes = Stakes::default(); + for (stake_key, stake) in &self.by_key { + let locked_key = stake.key.read()?.clone(); + if &locked_key != stake_key { + return Err(StakesError::InvalidDelegationState { + reason: "stake entry key does not match its canonical index", + }); + } + let value = stake.value.read()?.clone(); + stakes.by_key.insert( + stake_key.clone(), + SyncStakeEntry { + key: Rc::new(RwLock::new(locked_key)), + value: Rc::new(RwLock::new(value)), + }, + ); + } + stakes.reindex(); + + Ok(stakes) + } + + /// Clone the tracker while retaining only entries selected by the caller. + /// + /// This preserves each retained entry's independently aged balance and nonce and rebuilds the + /// validator/withdrawer indexes. It is used for protocol views that must not let newly added + /// entries change the eligibility of an in-flight legacy request. + pub fn retain_entries(&self, mut keep: F) -> Self + where + F: FnMut(&StakeKey
) -> bool, + Epoch: PartialOrd, + Power: Display, + { + let mut retained = self.clone(); + retained.by_key.retain(|key, _| keep(key)); + retained.reindex(); + retained + } + + /// Fallibly clone the tracker while retaining only entries selected by the caller. + /// + /// This is the checked counterpart of [`Self::retain_entries`] for Wit/2.1 consensus views. + pub fn retain_entries_checked( + &self, + mut keep: F, + ) -> StakesResult + where + F: FnMut(&StakeKey
) -> bool, + { + let mut retained = self.try_clone_checked()?; + retained.by_key.retain(|key, _| keep(key)); + retained.reindex(); + + Ok(retained) + } + + /// Return the total stake controlled by one validator across all of its withdrawers. + pub fn validator_total_stake( + &self, + validator: IA, + ) -> StakesResult + where + IA: Into
, + { + // This aggregate is a consensus-visible validator projection. Prove that the maintained + // validator index still aliases the canonical primary ledger before trusting its rows; + // otherwise a detached, omitted, or extra secondary entry could report a total that is + // not backed by the authoritative stake state. + self.validate_validator_index_checked()?; + let validator = validator.into(); + let entries = self + .by_validator + .get(&validator) + .ok_or(StakesError::ValidatorNotFound { validator })?; + + let total = entries.iter().try_fold(0u64, |total, entry| { + total + .checked_add(u64::from(entry.value.read()?.coins)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + + Ok(Coins::from(total)) + } + + /// Return whether a validator's persistent owner entry meets the required self-stake. + pub fn validator_is_active( + &self, + validator: IA, + owner: IA, + minimum_stake: Coins, + ) -> bool + where + IA: Into
, + { + let key = StakeKey { + validator: validator.into(), + withdrawer: owner.into(), + }; + + self.by_key + .get(&key) + .is_some_and(|entry| entry.read_value().coins >= minimum_stake) + } + + /// Return whether a validator's persistent owner entry meets the required self-stake, + /// propagating a poisoned value lock instead of treating it as ordinary inactivity. + pub fn validator_is_active_checked( + &self, + validator: IA, + owner: IA, + minimum_stake: Coins, + ) -> StakesResult + where + IA: Into
, + { + // Owner activity is a consensus-visible admission/eligibility predicate. Even though + // this lookup targets one primary key, callers may use a false result to authorize a + // first delegation or exclude a validator from participation. Prove the maintained + // validator/withdrawer projections before treating a missing or undersized owner row as + // ordinary inactivity; the compatibility `validator_is_active` wrapper remains + // intentionally infallible and unchanged. + let validator = validator.into(); + let owner = owner.into(); + self.validate_validator_index_checked()?; + self.validator_is_active_after_index_validation(validator, owner, minimum_stake) + } + + /// Return owner activity after the caller has proved the maintained stake indexes. + /// + /// Native bulk projections validate the complete index image once at their operation + /// boundary, then evaluate this predicate for many validators. Keeping this narrow helper + /// separate avoids turning that one proof into an O(validators * ledger) rescan while making + /// the public checked wrapper fail closed for direct callers. + pub fn validator_is_active_after_index_validation( + &self, + validator: Address, + owner: Address, + minimum_stake: Coins, + ) -> StakesResult { + let key = StakeKey { + validator, + withdrawer: owner, + }; + + match self.by_key.get(&key) { + Some(entry) => Ok(entry.value.read()?.coins >= minimum_stake), + None => Ok(false), + } + } + /// Obtain a list of stakers, conveniently ordered by one of several strategies. /// /// ## Strategies @@ -291,19 +565,38 @@ where match strategy { CensusStrategy::All => Box::new(iterator), - CensusStrategy::StepBy(step) => Box::new(iterator.step_by(step)), + CensusStrategy::StepBy(step) => { + // Avoid the iterator contract's panic for a caller-supplied zero stride. A + // zero-step census has no meaningful deterministic interpretation, so expose it + // as an empty result rather than unwinding a node handling a query. + if step == 0 { + return Box::new(std::iter::empty()); + } + Box::new(iterator.step_by(step)) + } CensusStrategy::Take(head) => Box::new(iterator.take(head)), CensusStrategy::Evenly(count) => { let collected = iterator.collect::>(); - let step = collected.len() / count; + // `step_by(0)` panics. Census is also exposed to callers that may request an + // empty or oversized sample, so keep those cases deterministic: an empty sample + // yields no identities, while a request larger than the available ledger walks + // the available entries once (step one) and naturally returns them all. + if count == 0 || collected.is_empty() { + return Box::new(std::iter::empty()); + } + let step = (collected.len() / count).max(1); Box::new(collected.into_iter().step_by(step).take(count)) } } } - /// Tells what is the power of an identity in the network on a certain epoch. - pub fn query_power( + /// Tells what is the legacy Wit/2.0 power of an identity in the network on a certain epoch. + /// + /// Wit/2.0 permitted only one stake entry per validator. Preserve the historical maximum-entry + /// query and release-wrapping coin-age arithmetic here so pre-activation consensus behavior is + /// unchanged. Wit/2.1 aggregate-power paths must use [`Self::query_power`]. + pub fn query_power_legacy( &self, validator: ISK, capability: Capability, @@ -322,12 +615,115 @@ where Ok(validator .iter() .map(|stake| stake.read_value().power(capability, epoch)) - .collect::>() - .into_iter() .max() .unwrap_or_default()) } + /// Return aggregate validator power across every withdrawer, rejecting arithmetic overflow. + pub fn query_power( + &self, + validator: ISK, + capability: Capability, + epoch: Epoch, + ) -> StakesResult + where + ISK: Into
, + { + // Aggregate power is a consensus-visible validator projection. The calculation below + // reads the maintained validator index, so prove that index still aliases the canonical + // primary ledger before trusting its membership or sum. Higher-level delegated callers + // already perform this proof, but this public checked helper must remain safe when called + // directly by replay, RPC, or downstream integrations. + self.validate_validator_index_checked()?; + let validator = validator.into(); + + let entries = self + .by_validator + .get(&validator) + .ok_or(StakesError::ValidatorNotFound { validator })?; + + let total = entries.iter().try_fold(0u64, |total, stake| { + total + .checked_add(u64::from( + stake.value.read()?.checked_power(capability, epoch)?, + )) + .ok_or(StakesError::ArithmeticOverflow) + })?; + + Ok(Power::from(total)) + } + + /// Return validators ordered by their aggregate power across all withdrawers. + /// + /// This compatibility wrapper returns an empty ranking if aggregate power overflows. New + /// consensus paths should use `by_validator_rank_checked` so the arithmetic error is + /// propagated instead of silently producing a ranking. + pub fn by_validator_rank( + &self, + capability: Capability, + current_epoch: Epoch, + ) -> std::vec::IntoIter<(Address, Power)> + where + Coins: 'static, + Epoch: 'static, + { + self.by_validator_rank_checked(capability, current_epoch) + .unwrap_or_default() + } + + /// Return validators ordered by aggregate power, rejecting arithmetic overflow. + pub fn by_validator_rank_checked( + &self, + capability: Capability, + current_epoch: Epoch, + ) -> StakesResult, Address, Coins, Epoch> { + self.by_validator_rank_checked_filtered(capability, current_epoch, |_| true) + } + + /// Return the selected validators ordered by aggregate power, rejecting arithmetic overflow. + /// + /// Filtering before aggregation is important for protocol rules that define a validator + /// population (such as Wit/2.1 owner-active validators): excluded validators must not affect + /// either aggregate-power errors or rank thresholds. + pub fn by_validator_rank_checked_filtered( + &self, + capability: Capability, + current_epoch: Epoch, + mut include_validator: F, + ) -> StakesResult, Address, Coins, Epoch> + where + F: FnMut(&Address) -> bool, + { + // Ranking consumes the maintained validator index and determines thresholds and + // replication positions. Reject a detached, omitted, or extra secondary row before the + // filter can hide it or the aggregate pass can assign it a rank. + self.validate_validator_index_checked()?; + let mut ranking = self + .by_validator + .iter() + .filter(|(validator, _)| include_validator(validator)) + .map(|(validator, entries)| { + let power = entries.iter().try_fold(0u64, |total, entry| { + total + .checked_add(u64::from( + entry.value.read()?.checked_power(capability, current_epoch)?, + )) + .ok_or(StakesError::ArithmeticOverflow) + })?; + + Ok((validator.clone(), Power::from(power))) + }) + .collect::, Address, Coins, Epoch>>()?; + + ranking.sort_by(|(validator_1, power_1), (validator_2, power_2)| { + power_2 + .cmp(power_1) + // Preserve the legacy reverse-ranking tie break for validators with one entry. + .then_with(|| validator_2.cmp(validator_1)) + }); + Ok(ranking.into_iter()) + } + /// For a given capability, obtain the full list of positive stake entries reversely ordered by their power. /// TODO: we may memoize the rank by keeping the last one in a non-serializable field in `Self` that keeps a boxed /// iterator, so that this method doesn't have to sort multiple times if we are calling the `rank` method @@ -359,6 +755,77 @@ where .rev() } + /// For a given capability, obtain every stake entry ordered by checked power. + /// + /// This is the Wit/2.1 counterpart to [`Self::by_rank`]. It preserves the historical + /// descending-power and key tie-break ordering while surfacing coin-age multiplication + /// overflow instead of allowing release-mode wrapping into an RPC result or consensus + /// ranking. + pub fn by_rank_checked( + &self, + capability: Capability, + current_epoch: Epoch, + ) -> StakesResult, Power)>, Address, Coins, Epoch> { + self.by_rank_checked_filtered(capability, current_epoch, |_| true) + } + + /// Return selected stake entries ordered by checked power, rejecting arithmetic overflow. + /// + /// Filtering before calculating power is required when the caller's protocol defines a + /// restricted validator population. In particular, Wit/2.1 query and eligibility views must + /// not let an inactive validator's malformed coin-age product fail a result that excludes that + /// validator. The ordering and tie-break remain identical to [`Self::by_rank_checked`]. + pub fn by_rank_checked_filtered( + &self, + capability: Capability, + current_epoch: Epoch, + mut include_entry: F, + ) -> StakesResult, Power)>, Address, Coins, Epoch> + where + F: FnMut(&StakeKey
) -> bool, + { + // Checked ranking is consumed by the delegated RPC projection and can influence + // validator-visible power ordering. Prove that both maintained secondary indexes still + // represent the canonical primary ledger before a filtered primary walk is returned; + // otherwise a direct caller could observe a plausible ranking from a partially corrupted + // stake image while other checked projections fail closed. + self.validate_validator_index_checked()?; + let mut ranking = Vec::with_capacity(self.by_key.len()); + for (key, entry) in &self.by_key { + if !include_entry(key) { + continue; + } + let power = entry.value.read()?.checked_power(capability, current_epoch)?; + ranking.push((key.clone(), power)); + } + + ranking.sort_by(|(key_1, power_1), (key_2, power_2)| { + if power_1 == power_2 { + if key_1.validator == key_2.validator { + key_1.withdrawer.cmp(&key_2.withdrawer) + } else { + key_1.validator.cmp(&key_2.validator) + } + } else { + power_1.cmp(power_2) + } + }); + + ranking.sort_by(|(key_1, power_1), (key_2, power_2)| { + if power_1 == power_2 { + if key_1.validator == key_2.validator { + key_2.withdrawer.cmp(&key_1.withdrawer) + } else { + key_2.validator.cmp(&key_1.validator) + } + } else { + power_2.cmp(power_1) + } + }); + + Ok(ranking.into_iter()) + } + /// Query the current nonce from a stake entry. pub fn query_nonce(&self, key: ISK) -> StakesResult where @@ -370,7 +837,8 @@ where .by_key .get(&key) .ok_or(StakesError::EntryNotFound { key })? - .read_value(); + .value + .read()?; Ok(stake.nonce) } @@ -389,6 +857,48 @@ where { let key = key.into(); + // Resolve the exact index positions before mutating the entry. A malformed snapshot may + // contain multiple zero-balance entries, so removing the first zero from an index could + // delete a different delegated `(validator, withdrawer)` pair. Read index keys through + // the fallible lock boundary so a poisoned snapshot rejects the transition instead of + // panicking the node. + let validator_index = if self.by_key.contains_key(&key) { + let entries = self + .by_validator + .get(&key.validator) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + let mut exact_index = None; + for (index, entry) in entries.iter().enumerate() { + if entry.key.read()?.eq(&key) { + exact_index = Some(index); + break; + } + } + Some( + exact_index.ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?, + ) + } else { + None + }; + let withdrawer_index = if self.by_key.contains_key(&key) { + let entries = self + .by_withdrawer + .get(&key.withdrawer) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + let mut exact_index = None; + for (index, entry) in entries.iter().enumerate() { + if entry.key.read()?.eq(&key) { + exact_index = Some(index); + break; + } + } + Some( + exact_index.ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?, + ) + } else { + None + }; + if let Entry::Occupied(mut by_address_entry) = self.by_key.entry(key.clone()) { // Reduce the amount of stake let final_coins = { @@ -406,23 +916,17 @@ where if final_coins.is_zero() { by_address_entry.remove(); if let Some(ref mut stakes) = self.by_validator.get_mut(&key.validator) { - stakes.remove( - stakes - .iter() - .position(|stake| stake.read_value().coins == 0.into()) - .unwrap(), - ); + if let Some(index) = validator_index { + stakes.remove(index); + } if stakes.is_empty() { self.by_validator.remove(&key.validator); } } if let Some(ref mut stakes) = self.by_withdrawer.get_mut(&key.withdrawer) { - stakes.remove( - stakes - .iter() - .position(|stake| stake.read_value().coins == 0.into()) - .unwrap(), - ); + if let Some(index) = withdrawer_index { + stakes.remove(index); + } if stakes.is_empty() { self.by_withdrawer.remove(&key.withdrawer); } @@ -435,23 +939,100 @@ where } } - /// First, order stake entries by mining power rank, as to find first occurance for given validator. - /// Once found, update the entry's mining epoch on that stake entry and all others with a better mining rank. - /// The better the rank, the more in the future will the entry's next mining epoch be set to. - pub fn reset_mining_age( + /// Remove stake with the release-wrapping arithmetic used by Wit/2.0. + /// + /// Historical validation evaluated commit collateral and unstake transactions against the same + /// pre-block ledger. Consolidation applied collateral first, so an otherwise valid later + /// unstake could underflow and wrap in release builds. Keep that exact successful transition + /// isolated from the checked Wit/2.1 path. + fn remove_stake_legacy( &mut self, - validator: ISK, - current_epoch: Epoch, - ) -> StakesResult<(), Address, Coins, Epoch> + key: ISK, + coins: Coins, + epoch: Epoch, + set_nonce: bool, + minimum_stakeable: Coins, + ) -> StakesResult where - ISK: Into
, + ISK: Into>, { - let validator = validator.into(); + let key = key.into(); - // order mining stake entries by rank, as for given current_epoch: - let by_rank = self.by_rank(Capability::Mining, current_epoch); + let validator_index = if self.by_key.contains_key(&key) { + Some( + self.by_validator + .get(&key.validator) + .and_then(|entries| entries.iter().position(|entry| entry.read_key() == key)) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?, + ) + } else { + None + }; + let withdrawer_index = if self.by_key.contains_key(&key) { + Some( + self.by_withdrawer + .get(&key.withdrawer) + .and_then(|entries| entries.iter().position(|entry| entry.read_key() == key)) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?, + ) + } else { + None + }; + + if let Entry::Occupied(mut by_address_entry) = self.by_key.entry(key.clone()) { + let final_coins = { + let mut stake = by_address_entry.get_mut().value.write()?; + let nonce_policy = if set_nonce { + NoncePolicy::SetFromEpoch(epoch) + } else { + NoncePolicy::KeepAsIs + }; + stake.remove_stake_legacy_release(coins, nonce_policy, minimum_stakeable)? + }; + + if final_coins.is_zero() { + by_address_entry.remove(); + if let Some(ref mut entries) = self.by_validator.get_mut(&key.validator) { + if let Some(index) = validator_index { + entries.remove(index); + } + if entries.is_empty() { + self.by_validator.remove(&key.validator); + } + } + if let Some(ref mut entries) = self.by_withdrawer.get_mut(&key.withdrawer) { + if let Some(index) = withdrawer_index { + entries.remove(index); + } + if entries.is_empty() { + self.by_withdrawer.remove(&key.withdrawer); + } + } + } + + Ok(final_coins) + } else { + Err(StakesError::EntryNotFound { key }) + } + } + + /// First, order stake entries by mining power rank, as to find first occurance for given validator. + /// Once found, update the entry's mining epoch on that stake entry and all others with a better mining rank. + /// The better the rank, the more in the future will the entry's next mining epoch be set to. + pub fn reset_mining_age( + &mut self, + validator: ISK, + current_epoch: Epoch, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into
, + Epoch: WrappingAdd, + { + let validator = validator.into(); - // locate first entry whose validator matches the one searched for: + // Preserve the Wit/2.0 entry ranking and release-wrapping power calculation. Wit/2.1 + // callers use `reset_mining_age_filtered`, whose validator aggregate is checked. + let by_rank = self.by_rank(Capability::Mining, current_epoch); let winner_rank = by_rank .clone() .position(|(key, _)| key.validator == validator); @@ -459,26 +1040,201 @@ where if let Some(winner_rank) = winner_rank { let stakers: Vec> = by_rank.take(winner_rank + 1).map(|(key, _)| key).collect(); - // proportionally reset coin age on located entry and all those with a better mining rank: for (index, key) in stakers.iter().enumerate() { - let stake_entry = self.by_key.get_mut(key); - if let Some(stake_entry) = stake_entry { + if let Some(stake_entry) = self.by_key.get_mut(key) { + let penalty = winner_rank + .checked_sub(index) + .and_then(|rank| rank.checked_add(1)) + .ok_or(StakesError::ArithmeticOverflow)?; let penalty_epochs = Epoch::from( - u32::try_from(1 + winner_rank - index) - .expect("Validator ranks should not overflow u32"), + u32::try_from(penalty).map_err(|_| StakesError::ArithmeticOverflow)?, ); + let reset_epoch = current_epoch.wrapping_add(&penalty_epochs); log::debug!( "Resetting mining power of {} (ranked as #{}) during +{} epochs until {}", key, index + 1, penalty_epochs, - current_epoch + penalty_epochs + reset_epoch ); stake_entry .value - .write() - .unwrap() - .reset_age(Capability::Mining, current_epoch + penalty_epochs); + .write()? + .reset_age(Capability::Mining, reset_epoch); + } + } + } + + Ok(()) + } + + /// Reset mining age using a validator population that has already been filtered by the + /// caller's protocol rules. Wit/2.1 uses this to exclude validators with inactive owners + /// before rank positions and penalties are computed. + pub fn reset_mining_age_filtered( + &mut self, + validator: ISK, + current_epoch: Epoch, + include_validator: F, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into
, + F: FnMut(&Address) -> bool, + Epoch: CheckedAdd, + { + let validator = validator.into(); + + // Order validators by their aggregate mining power for the given epoch. + let by_rank = self + .by_validator_rank_checked_filtered( + Capability::Mining, + current_epoch, + include_validator, + )? + .collect::>(); + + self.reset_mining_age_ranked(validator, current_epoch, &by_rank) + } + + /// Apply mining age penalties using the caller's checked native participation ranking. + /// This permits draining stake to be excluded from power without deleting its locked balance. + pub fn reset_mining_age_ranked( + &mut self, + validator: Address, + current_epoch: Epoch, + by_rank: &[(Address, Power)], + ) -> StakesResult<(), Address, Coins, Epoch> + where + Epoch: CheckedAdd, + { + self.reset_mining_age_ranked_filtered(validator, current_epoch, by_rank, |_| true) + } + + /// Apply mining age penalties to the ranked validator population while excluding selected + /// stake entries from the mutation. Native delegated-staking callers use this to keep + /// withdrawer entries with an already-persisted exit intent out of future participation and + /// age resets; the historical unfiltered method above remains unchanged for Wit/2.0. + pub fn reset_mining_age_ranked_filtered( + &mut self, + validator: Address, + current_epoch: Epoch, + by_rank: &[(Address, Power)], + mut include_entry: F, + ) -> StakesResult<(), Address, Coins, Epoch> + where + Epoch: CheckedAdd, + F: FnMut(&StakeKey
) -> bool, + { + // Native age mutation writes through the maintained validator index. Prove that index is + // still the exact canonical projection before calculating any reset so a detached or + // swapped secondary row cannot mutate a different stake identity. + self.validate_validator_index_checked()?; + + // The ranking is supplied by the caller because protocol-specific eligibility filters + // are applied before this helper is reached. Even with that filtered population, the + // rows that are supplied must still be a canonical, unique projection of known + // validators; otherwise a duplicated, unknown, or reordered prefix could alter which + // validators receive an age penalty. Do not require the ranking to contain every + // validator or recompute powers here: filtered Wit/2.1 views intentionally omit inactive + // validators and draining entries before rank positions are assigned. + let mut seen_validators = BTreeSet::new(); + let mut previous = None; + for (ranked_validator, power) in by_rank { + if !seen_validators.insert(ranked_validator.clone()) { + return Err(StakesError::InvalidDelegationState { + reason: "ranked stake reset contains a duplicate validator", + }); + } + if !self.by_validator.contains_key(ranked_validator) { + return Err(StakesError::InvalidDelegationState { + reason: "ranked stake reset references an unknown validator", + }); + } + if let Some((previous_validator, previous_power)) = previous { + let out_of_order = power > previous_power + || (power == previous_power && ranked_validator >= previous_validator); + if out_of_order { + return Err(StakesError::InvalidDelegationState { + reason: "ranked stake reset is not in canonical order", + }); + } + } + previous = Some((ranked_validator, power)); + } + + let winner_rank = by_rank + .iter() + .position(|(ranked_validator, _)| ranked_validator == &validator); + + if let Some(winner_rank) = winner_rank { + // Calculate every target before mutating any entry so arithmetic failure cannot leave + // a partially reset validator population. + let resets = by_rank + .iter() + .take(winner_rank + 1) + .enumerate() + .map(|(index, (ranked_validator, _))| { + let penalty = winner_rank + .checked_sub(index) + .and_then(|rank| rank.checked_add(1)) + .ok_or(StakesError::ArithmeticOverflow)?; + let penalty_epochs = Epoch::from( + u32::try_from(penalty) + .map_err(|_| StakesError::ArithmeticOverflow)?, + ); + let reset_epoch = current_epoch + .checked_add(&penalty_epochs) + .ok_or(StakesError::ArithmeticOverflow)?; + // Preflight every key lock before changing the first mining age. Ranking only + // reads value locks, so deferring these key reads to the mutation loop could + // otherwise reset better-ranked validators before encountering a poisoned key. + let entry_keys = self + .by_validator + .get(ranked_validator) + .ok_or_else(|| StakesError::ValidatorNotFound { + validator: ranked_validator.clone(), + })? + .iter() + .map(|entry| { + Ok(entry.key.read()?.clone()) + }) + .collect::, Address, Coins, Epoch>>()? + .into_iter() + .filter(|key| include_entry(key)) + .collect::>(); + + Ok(( + ranked_validator.clone(), + penalty_epochs, + reset_epoch, + entry_keys, + )) + }) + .collect::, Address, Coins, Epoch>>()?; + + // Proportionally reset every entry of the winner and all validators with a better + // aggregate rank. All entries of one validator receive the same reset epoch. + for (index, (ranked_validator, penalty_epochs, reset_epoch, entry_keys)) in + resets.into_iter().enumerate() + { + if let Some(stake_entries) = self.by_validator.get_mut(&ranked_validator) { + for stake_entry in stake_entries.iter_mut() { + let key = stake_entry.key.read()?.clone(); + if !entry_keys.contains(&key) { + continue; + } + log::debug!( + "Resetting mining power of {} (ranked as #{}) during +{} epochs until {}", + key, + index + 1, + penalty_epochs, + reset_epoch + ); + stake_entry.value.write()?.reset_age( + Capability::Mining, + reset_epoch, + ); + } } } } @@ -486,8 +1242,55 @@ where Ok(()) } - /// Set the epoch for a certain address and capability. Most normally, the epoch is the current - /// epoch. + /// Set one stake entry's epoch while preserving future penalty ages and rejecting division by + /// zero or target-age overflow. + /// Native Wit/2.1 cross-fork handling uses this to target the persistent owner entry only. + pub fn reset_age_for_key( + &mut self, + key: ISK, + capability: Capability, + current_epoch: Epoch, + reset_factor: u32, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into>, + Epoch: CheckedAdd + CheckedSub, + { + if reset_factor == 0 { + return Err(StakesError::ArithmeticOverflow); + } + // Exact-key resets are used by the post-activation cross-fork path. They write through + // the primary map, but a detached or omitted secondary row would still leave the + // validator projection inconsistent after a seemingly successful mutation. Prove the + // complete index image before reading or updating the target entry. + self.validate_validator_index_checked()?; + let key = key.into(); + let stake = self + .by_key + .get_mut(&key) + .ok_or(StakesError::EntryNotFound { key })?; + let old_epoch = stake.value.read()?.epochs.get(capability); + // A prior liar penalty can intentionally leave the witnessing age in the future. A + // second tally in the same block (or a request crossing activation) must not reject the + // block merely because that penalty has not elapsed yet; preserve the future age until + // the current epoch catches up. The checked addition below still rejects a target-age + // overflow. + let elapsed = current_epoch.checked_sub(&old_epoch).unwrap_or_default(); + let update_epoch = elapsed / Epoch::from(reset_factor); + let reset_epoch = old_epoch + .checked_add(&update_epoch) + .ok_or(StakesError::ArithmeticOverflow)?; + stake + .value + .write()? + .epochs + .update(capability, reset_epoch); + + Ok(()) + } + + /// Set the epoch for every stake entry belonging to a validator using the release-wrapping + /// arithmetic historically used by Wit/2.0. pub fn reset_age( &mut self, validator: ISK, @@ -497,56 +1300,171 @@ where ) -> StakesResult<(), Address, Coins, Epoch> where ISK: Into
, + Epoch: WrappingAdd + WrappingSub, { + if reset_factor == 0 { + return Err(StakesError::ArithmeticOverflow); + } let validator = validator.into(); let stakes = self .by_validator .get_mut(&validator) .ok_or(StakesError::ValidatorNotFound { validator })?; - stakes.iter_mut().for_each(|stake| { - let old_epoch = stake.value.read().unwrap().epochs.get(capability); - let update_epoch = (current_epoch - old_epoch) / Epoch::from(reset_factor); - stake - .value - .write() - .unwrap() - .epochs - .update(capability, old_epoch + update_epoch); - }); + for stake in stakes.iter_mut() { + let old_epoch = stake.value.read()?.epochs.get(capability); + let update_epoch = current_epoch + .wrapping_sub(&old_epoch) + / Epoch::from(reset_factor); + stake.value.write()?.epochs.update( + capability, + old_epoch.wrapping_add(&update_epoch), + ); + } Ok(()) } - /// Add a reward to the validator's balance - pub fn add_reward( + /// Set the epoch for every stake entry belonging to a validator, preserving future penalty + /// ages and rejecting division by zero or arithmetic overflow. Native Wit/2.1 consensus paths + /// use this method. + pub fn reset_age_checked( &mut self, validator: ISK, - coins: Coins, + capability: Capability, current_epoch: Epoch, + reset_factor: u32, ) -> StakesResult<(), Address, Coins, Epoch> where ISK: Into
, + Epoch: CheckedAdd + CheckedSub, { - let validator = validator.into(); + self.reset_age_checked_filtered( + validator, + capability, + current_epoch, + reset_factor, + |_| true, + ) + } + /// Set the epoch for selected entries of a validator using checked native arithmetic. Entries + /// excluded by `include_entry` are left byte-for-byte unchanged. This is used by native + /// delegated-staking age resets so an exact-key exit intent cannot receive new witnessing or + /// participation age while it drains; the unfiltered wrapper preserves the existing API. + pub fn reset_age_checked_filtered( + &mut self, + validator: ISK, + capability: Capability, + current_epoch: Epoch, + reset_factor: u32, + mut include_entry: F, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into
, + Epoch: CheckedAdd + CheckedSub, + F: FnMut(&StakeKey
) -> bool, + { + if reset_factor == 0 { + return Err(StakesError::ArithmeticOverflow); + } + // Native witnessing-age mutation also writes through the maintained validator index. Do + // not update any entry until both secondary indexes are proved against the canonical + // primary map; malformed replay state must fail closed rather than mutate a detached row. + self.validate_validator_index_checked()?; + let validator = validator.into(); let stakes = self .by_validator .get_mut(&validator) .ok_or(StakesError::ValidatorNotFound { validator })?; - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let _ = stakes[0].value.write().unwrap().add_stake( - coins, - current_epoch, - NoncePolicy::KeepAsIs, - 0.into(), - ); + // Compute the complete transition first so arithmetic rejection is atomic. + let reset_epochs = stakes + .iter() + .map(|stake| { + let key = stake.key.read()?.clone(); + if !include_entry(&key) { + return Ok(None); + } + let old_epoch = stake.value.read()?.epochs.get(capability); + // Preserve a future age produced by a prior penalty. This makes repeated + // witnessing resets idempotent while retaining checked target-age arithmetic. + let elapsed = current_epoch.checked_sub(&old_epoch).unwrap_or_default(); + let update_epoch = elapsed / Epoch::from(reset_factor); + old_epoch + .checked_add(&update_epoch) + .map(Some) + .ok_or(StakesError::ArithmeticOverflow) + }) + .collect::, Address, Coins, Epoch>>()?; + + for (stake, reset_epoch) in stakes.iter_mut().zip(reset_epochs) { + if let Some(reset_epoch) = reset_epoch { + stake + .value + .write()? + .epochs + .update(capability, reset_epoch); + } + } + + Ok(()) + } + + /// Add a reward to the canonical legacy stake entry of a Wit/2.0 validator. + /// + /// Wit/2.1 callers must use [`DelegationState::distribute_reward`] so rewards and commission + /// are allocated across every withdrawer. A valid Wit/2.0 validator has exactly one entry; + /// this compatibility wrapper retains its historical minimum-key behavior for malformed + /// preactivation state, while the separate Wit/2.1 activation boundary rejects ambiguity. + pub fn add_reward( + &mut self, + validator: ISK, + coins: Coins, + current_epoch: Epoch, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into
, + { + let validator = validator.into(); + + let key = self + .by_key + .keys() + .filter(|key| key.validator.eq(&validator)) + .min() + .cloned() + .ok_or_else(|| StakesError::ValidatorNotFound { + validator: validator.clone(), + })?; + // Wit/2.0 compatibility: the historical sole-entry wrapper deliberately discarded an + // inner accounting error. Native Wit/2.1 rewards use `DelegationState::distribute_reward` + // and therefore do not pass through this legacy behavior. + let stake = self + .by_key + .get(&key) + .ok_or(StakesError::EntryNotFound { key })?; + let result = stake + .value + .write()? + .add_stake_legacy_release(coins, current_epoch, NoncePolicy::KeepAsIs, 0.into()); + // The historical wrapper ignored ordinary inner errors, but integer division by zero + // panicked before an error could be discarded. Surface only that newly represented trap so + // the staged consolidation rejects the block instead of accepting a partially mutated + // balance that no legacy node could persist. + if matches!(result, Err(StakesError::ArithmeticOverflow)) { + return Err(StakesError::ArithmeticOverflow); + } Ok(()) } - /// Add a reward to the validator's balance + /// Reserve collateral from the canonical legacy stake entry of a Wit/2.0 validator. + /// + /// Wit/2.1 callers must use [`DelegationState::reserve_collateral`] so the exact 70/30 debit + /// plan is persisted for refund or slashing. A valid Wit/2.0 validator has exactly one entry; + /// this legacy wrapper retains its historical minimum-key selection, whereas activation uses + /// an explicit ambiguity error instead of inventing an immutable owner. pub fn reserve_collateral( &mut self, validator: ISK, @@ -558,17 +1476,30 @@ where { let validator = validator.into(); - let stakes = self - .by_validator - .get_mut(&validator) - .ok_or(StakesError::ValidatorNotFound { validator })?; - - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let _ = stakes[0].value.write().unwrap().remove_stake( - coins, - NoncePolicy::KeepAsIs, - minimum_stakeable, - ); + let key = self + .by_key + .keys() + .filter(|key| key.validator.eq(&validator)) + .min() + .cloned() + .ok_or_else(|| StakesError::ValidatorNotFound { + validator: validator.clone(), + })?; + // Wit/2.0 compatibility: validation checked each commitment independently and this + // wrapper historically ignored a cumulative below-minimum debit failure. Keep that exact + // balance outcome before activation; strict Wit/2.1 reservations use `DelegationState`. + // Keep the historical wrapper's zero-entry behavior: the direct legacy helper changed + // the balance in place and did not remove the index entry when collateral consumed it. + // Only the selected key changes; activation can therefore still derive that owner from + // the live legacy ledger until the request settles. + let stake = self + .by_key + .get(&key) + .ok_or(StakesError::EntryNotFound { key })?; + let _ = stake + .value + .write()? + .remove_stake_legacy_release(coins, NoncePolicy::KeepAsIs, minimum_stakeable); Ok(()) } @@ -607,6 +1538,122 @@ where } } + /// Prove that both maintained secondary indexes are exact shared-entry projections of `by_key`. + /// + /// Validator-wide Wit/2.1 eligibility, collateral, and reward accounting read the validator + /// index after a shared invariant check, while filtered wallet/owner views read the withdrawer + /// index. Reject omissions, extras, duplicate rows, detached entry copies, and + /// canonical/stored identity mismatches before either can change consensus or a safety-sensitive + /// projection. + pub fn validate_validator_index_checked( + &self, + ) -> StakesResult<(), Address, Coins, Epoch> { + let mut expected = BTreeMap::>>::new(); + let mut expected_by_withdrawer = BTreeMap::>>::new(); + for (canonical_key, canonical_entry) in &self.by_key { + let stored_key = canonical_entry.key.read()?; + if &*stored_key != canonical_key { + return Err(StakesError::InvalidDelegationState { + reason: "stake entry key does not match its canonical index", + }); + } + expected + .entry(canonical_key.validator.clone()) + .or_default() + .push(canonical_key.clone()); + expected_by_withdrawer + .entry(canonical_key.withdrawer.clone()) + .or_default() + .push(canonical_key.clone()); + } + + if self.by_validator.len() != expected.len() { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index does not match the canonical validator set", + }); + } + + for (validator, indexed_entries) in &self.by_validator { + let expected_keys = expected.get(validator).ok_or( + StakesError::InvalidDelegationState { + reason: "stake validator index contains a validator absent from the primary index", + }, + )?; + let mut indexed_keys = Vec::with_capacity(indexed_entries.len()); + for indexed_entry in indexed_entries { + let indexed_key = indexed_entry.key.read()?; + if &indexed_key.validator != validator { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index contains an entry for a different validator", + }); + } + let canonical_entry = self.by_key.get(&*indexed_key).ok_or( + StakesError::InvalidDelegationState { + reason: "stake validator index contains a key absent from the primary index", + }, + )?; + if !Rc::ptr_eq(&indexed_entry.key, &canonical_entry.key) + || !Rc::ptr_eq(&indexed_entry.value, &canonical_entry.value) + { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }); + } + indexed_keys.push(indexed_key.clone()); + } + indexed_keys.sort(); + if &indexed_keys != expected_keys { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index is not the complete primary-key projection", + }); + } + } + + if self.by_withdrawer.len() != expected_by_withdrawer.len() { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index does not match the canonical withdrawer set", + }); + } + + for (withdrawer, indexed_entries) in &self.by_withdrawer { + let expected_keys = expected_by_withdrawer.get(withdrawer).ok_or( + StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains a withdrawer absent from the primary index", + }, + )?; + let mut indexed_keys = Vec::with_capacity(indexed_entries.len()); + for indexed_entry in indexed_entries { + let indexed_key = indexed_entry.key.read()?; + if &indexed_key.withdrawer != withdrawer { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains an entry for a different withdrawer", + }); + } + let canonical_entry = self.by_key.get(&*indexed_key).ok_or( + StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains a key absent from the primary index", + }, + )?; + if !Rc::ptr_eq(&indexed_entry.key, &canonical_entry.key) + || !Rc::ptr_eq(&indexed_entry.value, &canonical_entry.value) + { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index entry is detached from its primary record", + }); + } + indexed_keys.push(indexed_key.clone()); + } + indexed_keys.sort(); + if &indexed_keys != expected_keys { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index is not the complete primary-key projection", + }); + } + } + + Ok(()) + } + /// Query stakes based on different keys. pub fn query_stakes( &self, @@ -624,44 +1671,335 @@ where } } - /// Query the total amount of stake based on different keys. - pub fn query_total_stake( - &mut self, + /// Query stakes while propagating poisoned key/value locks. + /// + /// The historical [`Self::query_stakes`] method remains infallible at the individual-entry + /// read boundary for compatibility. Consensus delegated-staking projections must use this + /// checked form so a corrupted snapshot cannot panic or be interpreted as an absent entry. + pub fn query_stakes_checked( + &self, query: TIQSK, - ) -> StakesResult + ) -> StakeEntryVecResult where TIQSK: TryInto>, { - totalize_stakes( - self.query_stakes(query)? - .into_iter() - .map(|entry| entry.value), - ) - } - - /// Return the total number of validators. - pub fn validator_count(&self) -> usize { - self.by_validator.len() - } - - /// Return the total number staked. - pub fn total_staked(&self) -> Coins { - self.by_key - .values() - .map(|entry| entry.value.read().unwrap().coins) - .collect::>() - .into_iter() - .sum() + match query.try_into() { + Ok(QueryStakesKey::Key(key)) => { + let entry = self + .by_key + .get(&key) + .ok_or_else(|| StakesError::EntryNotFound { key: key.clone() })?; + let stored_key = entry.key.read()?.clone(); + if stored_key != key { + return Err(StakesError::InvalidDelegationState { + reason: "stake entry key does not match its canonical index", + }); + } + Ok(vec![StakeEntry { + key: stored_key, + value: entry.value.read()?.clone(), + }]) + } + Ok(QueryStakesKey::Validator(validator)) => { + // A selected-row check proves returned entries are canonical but cannot detect an + // omitted or extra row in the maintained projection. Checked consensus queries + // must reject that malformed state before materializing a validator view. + self.validate_validator_index_checked()?; + let entries = self + .by_validator + .get(&validator) + .ok_or_else(|| StakesError::ValidatorNotFound { + validator: validator.clone(), + })?; + entries + .iter() + .map(|entry| { + let stored_key = entry.key.read()?.clone(); + if stored_key.validator != validator { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index contains an entry for a different validator", + }); + } + let canonical_entry = self.by_key.get(&stored_key).ok_or( + StakesError::InvalidDelegationState { + reason: "stake validator index contains a key absent from the primary index", + }, + )?; + if !Rc::ptr_eq(&entry.key, &canonical_entry.key) + || !Rc::ptr_eq(&entry.value, &canonical_entry.value) + { + return Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }); + } + Ok(StakeEntry { + key: stored_key, + value: entry.value.read()?.clone(), + }) + }) + .collect() + } + Ok(QueryStakesKey::Withdrawer(withdrawer)) => { + // As above, prove the complete withdrawer projection so an omitted delegated + // entry cannot be interpreted as authoritative absence by a checked caller. + self.validate_validator_index_checked()?; + let entries = self + .by_withdrawer + .get(&withdrawer) + .ok_or_else(|| StakesError::WithdrawerNotFound { + withdrawer: withdrawer.clone(), + })?; + entries + .iter() + .map(|entry| { + let stored_key = entry.key.read()?.clone(); + if stored_key.withdrawer != withdrawer { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains an entry for a different withdrawer", + }); + } + let canonical_entry = self.by_key.get(&stored_key).ok_or( + StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains a key absent from the primary index", + }, + )?; + if !Rc::ptr_eq(&entry.key, &canonical_entry.key) + || !Rc::ptr_eq(&entry.value, &canonical_entry.value) + { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index entry is detached from its primary record", + }); + } + Ok(StakeEntry { + key: stored_key, + value: entry.value.read()?.clone(), + }) + }) + .collect() + } + Ok(QueryStakesKey::All) => self + .by_key + .iter() + .map(|(canonical_key, entry)| { + let stored_key = entry.key.read()?.clone(); + if &stored_key != canonical_key { + return Err(StakesError::InvalidDelegationState { + reason: "stake entry key does not match its canonical index", + }); + } + Ok(StakeEntry { + key: stored_key, + value: entry.value.read()?.clone(), + }) + }) + .collect(), + Err(_) => Err(StakesError::EmptyQuery), + } } - /// Query stakes to check for an existing validator / withdrawer pair. - pub fn check_validator_withdrawer( + /// Query stake entries directly from the canonical primary-key ledger. + /// + /// Maintained validator and withdrawer indexes are useful for consensus projections that + /// validate their complete shape first, but an externally trusted read must not turn an + /// omitted secondary-index row into authoritative absence. This method scans `by_key`, checks + /// every selected entry's stored identity, and reports absence only after the primary ledger + /// itself has no match. + pub fn query_stakes_primary_checked( &self, - validator: ISK, - withdrawer: ISK, - ) -> StakesResult<(), Address, Coins, Epoch> + query: TIQSK, + ) -> StakeEntryVecResult where - ISK: Into
, + TIQSK: TryInto>, + { + let query = query + .try_into() + .map_err(|_| StakesError::EmptyQuery)?; + let mut entries = Vec::new(); + for (canonical_key, entry) in &self.by_key { + let selected = match &query { + QueryStakesKey::Key(key) => canonical_key == key, + QueryStakesKey::Validator(validator) => { + &canonical_key.validator == validator + } + QueryStakesKey::Withdrawer(withdrawer) => { + &canonical_key.withdrawer == withdrawer + } + QueryStakesKey::All => true, + }; + if !selected { + continue; + } + + let stored_key = entry.key.read()?.clone(); + if &stored_key != canonical_key { + return Err(StakesError::InvalidDelegationState { + reason: "stake entry key does not match its canonical index", + }); + } + entries.push(StakeEntry { + key: stored_key, + value: entry.value.read()?.clone(), + }); + } + + if !entries.is_empty() || matches!(&query, QueryStakesKey::All) { + return Ok(entries); + } + + Err(match query { + QueryStakesKey::Key(key) => StakesError::EntryNotFound { key }, + QueryStakesKey::Validator(validator) => StakesError::ValidatorNotFound { validator }, + QueryStakesKey::Withdrawer(withdrawer) => { + StakesError::WithdrawerNotFound { withdrawer } + } + QueryStakesKey::All => StakesError::InvalidDelegationState { + // The branch is unreachable with the guard above, but keep this checked query + // fail-closed if that control flow is ever changed or a malformed caller reaches + // it through a future refactor instead of unwinding a consensus worker. + reason: "empty all-stakes query reached the absence boundary", + }, + }) + } + + /// Query one validator directly from the canonical primary-key ledger. + /// + /// This intentionally scans `by_key` instead of trusting the maintained validator index. It + /// is suitable for externally trusted projections where an omitted secondary-index row could + /// otherwise hide a live delegation. Only matching entries are materialized. + pub fn query_stakes_by_validator_primary_checked( + &self, + validator: Address, + ) -> StakeEntryVecResult { + self.query_stakes_primary_checked(QueryStakesKey::Validator(validator)) + } + + /// Query several withdrawer indexes and prove that their union is the exact primary-key view. + /// + /// Reading each secondary-index row back through `by_key` proves that returned values are + /// authoritative, but it cannot detect an omitted row. Wallet reconciliation treats absence as + /// a real financial-state transition, so this stronger boundary also scans the canonical keys + /// and requires exact set equality before returning the filtered projection. + pub fn query_stakes_by_withdrawers_checked( + &self, + withdrawers: I, + ) -> StakeEntryVecResult + where + I: IntoIterator, + Epoch: PartialEq, + Nonce: PartialEq, + { + let withdrawers = withdrawers.into_iter().collect::>(); + let expected_keys = self + .by_key + .keys() + .filter(|key| withdrawers.contains(&key.withdrawer)) + .cloned() + .collect::>(); + let mut indexed_entries = Vec::with_capacity(expected_keys.len()); + + for withdrawer in &withdrawers { + let Some(entries) = self.by_withdrawer.get(withdrawer) else { + continue; + }; + for indexed in entries { + let indexed_entry = StakeEntry { + key: indexed.key.read()?.clone(), + value: indexed.value.read()?.clone(), + }; + if &indexed_entry.key.withdrawer != withdrawer { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains a differently owned entry", + }); + } + + let canonical = self.by_key.get(&indexed_entry.key).ok_or( + StakesError::InvalidDelegationState { + reason: "stake withdrawer index contains a key absent from the primary index", + }, + )?; + let canonical_entry = StakeEntry { + key: canonical.key.read()?.clone(), + value: canonical.value.read()?.clone(), + }; + if canonical_entry != indexed_entry { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index entry differs from its primary record", + }); + } + indexed_entries.push(indexed_entry); + } + } + + indexed_entries.sort_by(|left, right| left.key.cmp(&right.key)); + let indexed_keys = indexed_entries + .iter() + .map(|entry| entry.key.clone()) + .collect::>(); + if indexed_keys != expected_keys { + return Err(StakesError::InvalidDelegationState { + reason: "stake withdrawer index is not the complete primary-key projection", + }); + } + + Ok(indexed_entries) + } + + /// Query the total amount of stake based on different keys. + pub fn query_total_stake( + &self, + query: TIQSK, + ) -> StakesResult + where + TIQSK: TryInto>, + { + totalize_stakes( + self.query_stakes(query)? + .into_iter() + .map(|entry| entry.value), + ) + } + + /// Return the total number of validators. + pub fn validator_count(&self) -> usize { + self.by_validator.len() + } + + /// Iterate validator identities in canonical address order. + pub fn validator_addresses(&self) -> impl Iterator { + self.by_validator.keys() + } + + /// Return the total number staked. + pub fn total_staked_checked(&self) -> StakesResult { + let total = self.by_key.values().try_fold(0u64, |total, entry| { + total + .checked_add(u64::from(entry.value.read()?.coins)) + .ok_or(StakesError::ArithmeticOverflow) + })?; + + Ok(Coins::from(total)) + } + + /// Return the total number staked. + /// + /// This compatibility wrapper preserves the infallible API for non-consensus callers. New + /// consensus code should use `total_staked_checked` and propagate overflow. + pub fn total_staked(&self) -> Coins + where + Coins: 'static, + Epoch: 'static, + { + self.total_staked_checked().unwrap_or_default() + } + + /// Query stakes to check for an existing validator / withdrawer pair. + pub fn check_validator_withdrawer( + &self, + validator: ISK, + withdrawer: ISK, + ) -> StakesResult<(), Address, Coins, Epoch> + where + ISK: Into
, { let validator = validator.into(); let withdrawer = withdrawer.into(); @@ -770,16 +2108,77 @@ pub fn index_addresses( } } -/// Adds stake, based on the data from a stake transaction. +/// Adds stake, based on the data from a stake transaction, using the historical Wit/2.0 +/// accounting behavior. +/// +/// This compatibility entry point intentionally retains its original four-argument signature. +/// Consensus code that must select legacy release-wrapping or native Wit/2.1 accounting should +/// call [`process_stake_transaction_for_protocol`] instead. +#[allow(clippy::type_complexity)] +pub fn process_stake_transaction( + stakes: &mut Stakes, + transaction: &StakeTransaction, + epoch: Epoch, + minimum_stakeable: u64, +) -> StakesResult<(), PublicKeyHash, Wit, Epoch> +where + Epoch: Copy + + Default + + Sub + + Saturating + + From + + Debug + + Display + + Send + + Serialize + + Sync + + Add + + Div + + PartialOrd, + Nonce: AddAssign + + Copy + + Debug + + Default + + Display + + From + + From + + Saturating + + Send + + Serialize + + Sync, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, + Wit: Mul, + u64: From + From + From, +{ + process_stake_transaction_for_protocol( + stakes, + transaction, + epoch, + minimum_stakeable, + ProtocolVersion::V2_0, + ) +} + +/// Adds stake, based on the data from a stake transaction, with explicit protocol arithmetic. /// /// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and /// `Coins`. #[allow(clippy::type_complexity)] -pub fn process_stake_transaction( +pub fn process_stake_transaction_for_protocol( stakes: &mut Stakes, transaction: &StakeTransaction, epoch: Epoch, minimum_stakeable: u64, + protocol_version: ProtocolVersion, ) -> StakesResult<(), PublicKeyHash, Wit, Epoch> where Epoch: Copy @@ -806,11 +2205,129 @@ where + Send + Serialize + Sync, - Power: - Add + Copy + Debug + Default + Div + Ord + Serialize + Sum, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, Wit: Mul, - u64: From + From, + u64: From + From + From, { + // Native delegated staking treats both maintained indexes as consensus state. Do not let + // `try_clone_checked` rebuild an omitted, duplicated, or detached secondary row and silently + // canonicalize malformed input before this direct mutation helper observes it. The legacy + // branch intentionally retains its historical compatibility behavior below. + if protocol_version >= ProtocolVersion::V2_1 { + stakes.validate_validator_index_checked()?; + } + + // Native Wit/2.1 accounting has one protocol-owned minimum. Keep the public compatibility + // parameter for legacy callers, but do not let a mutable/runtime value alter the minimum + // stake threshold when this helper is used on the native path. + let minimum_stakeable = if protocol_version >= ProtocolVersion::V2_1 { + DELEGATED_STAKING_MINIMUM_NANOWITS + } else { + minimum_stakeable + }; + + // Wit/2.1 policy-only actions are applied by the delegated-staking transition, not by the + // generic stake-ledger deposit primitive. Keep this helper fail-closed for direct replay and + // consolidation callers as well: otherwise a commission update or exit intent with a zero + // output could be interpreted as a deposit and mutate the entry nonce before policy state is + // updated. Ordinary delegation/registration actions continue through the existing checked + // ledger path, while legacy protocol versions retain their historical terms-ignored behavior. + if protocol_version >= ProtocolVersion::V2_1 { + if transaction.body.inputs.is_empty() { + return Err(StakesError::InvalidDelegationState { + reason: "native stake action must consume at least one input", + }); + } + // A single native deposit can never fit in the validator-wide admission cap when it is + // already larger than that cap. Higher-level validation also checks the aggregate + // live-plus-reserved total, but this lower mutation boundary has no delegation-state + // context and must still reject an individually impossible deposit instead of relying on + // every replay/consolidation caller to have run the envelope validator first. + if transaction.body.output.value > DELEGATED_STAKING_MAXIMUM_NANOWITS { + return Err(StakesError::InvalidDelegationState { + reason: "native stake deposit exceeds the fixed validator cap", + }); + } + let terms = transaction + .body + .output + .authorization_terms + .as_ref() + .ok_or(StakesError::InvalidDelegationState { + reason: "native stake transaction is missing authorization terms", + })?; + // The higher-level envelope validator and snapshot replay both enforce the complete + // action/nonce/commission shape. Repeat the state-independent portion here because this + // lower mutation primitive is also reachable from direct consolidation/replay callers; + // otherwise a malformed delegation nonce or out-of-range commission could still mutate + // the canonical stake ledger after the key and signer checks below. + terms + .validate_structure() + .map_err(|_| StakesError::InvalidDelegationState { + reason: "native stake authorization terms have an invalid structure", + })?; + if terms.validator != transaction.body.output.key.validator { + return Err(StakesError::InvalidDelegationState { + reason: "native stake authorization validator does not match its output key", + }); + } + if terms.withdrawer != transaction.body.output.key.withdrawer { + return Err(StakesError::InvalidDelegationState { + reason: "native stake authorization withdrawer does not match its output key", + }); + } + if transaction.body.output.authorization.public_key.pkh() != terms.authorizer() { + return Err(StakesError::InvalidDelegationState { + reason: "native stake signer does not match the authorization authorizer", + }); + } + match terms.action { + crate::chain::STAKE_AUTHORIZATION_ACTION_DELEGATE => {} + crate::chain::STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + // Owner registration is the only native action that may create a validator + // identity. This generic ledger primitive has no DelegationState, so it cannot + // prove the explicit registration/commission policy that the node transition + // requires (including retained policy after a full owner exit). Never accept + // action 2 here: the policy-aware delegated transition performs those checks + // first and then applies the plain checked ledger mutation itself. + return Err(StakesError::InvalidDelegationState { + reason: "native owner registration must be applied by delegated-staking transition", + }); + } + crate::chain::STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE + | crate::chain::STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + return Err(StakesError::InvalidDelegationState { + reason: "native policy-only stake action must be applied by delegated-staking transition", + }); + } + _ => { + return Err(StakesError::InvalidDelegationState { + reason: "native stake ledger received an unsupported authorization action", + }); + } + } + // The native envelope validator applies the same minimum to every ordinary deposit. + // Repeat it at this lower mutation boundary so a direct replay or consolidation caller + // cannot materialize a zero or sub-minimum live entry after passing only the structural, + // signer, and action checks above. Owner registration is intentionally rejected above and + // is admitted only by the policy-aware delegated transition. + if transaction.body.output.value < DELEGATED_STAKING_MINIMUM_NANOWITS { + return Err(StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(transaction.body.output.value), + minimum: Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + }); + } + } + // This line would check that the authorization message is valid for the provided validator and withdrawer // address. But it is commented out here because stake transactions should be validated upfront (when // considering block candidates). The line is reproduced here for later reference when implementing those @@ -829,17 +2346,23 @@ where ); // When adding stake, epochs get averaged but nonces get overwritten - stakes.add_stake(key, coins, epoch, true, minimum_stakeable.into())?; + if protocol_version >= ProtocolVersion::V2_1 { + stakes.add_stake(key, coins, epoch, true, minimum_stakeable.into())?; + } else { + stakes.add_stake_legacy(key, coins, epoch, true, minimum_stakeable.into())?; + } log::debug!("Current state of the stakes tracker: {stakes:#?}"); Ok(()) } -/// Removes stake, based on the data from a unstake transaction. +/// Removes stake, based on the data from an unstake transaction, using the historical Wit/2.0 +/// accounting behavior. /// -/// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and -/// `Coins`. +/// This compatibility entry point intentionally retains its original four-argument signature. +/// Consensus code that must select legacy release-wrapping or native Wit/2.1 accounting should +/// call [`process_unstake_transaction_for_protocol`] instead. pub fn process_unstake_transaction( stakes: &mut Stakes, transaction: &UnstakeTransaction, @@ -871,43 +2394,31 @@ where + Send + Serialize + Sync, - Power: - Add + Copy + Debug + Default + Div + Ord + Serialize + Sum, + Power: Add + + Copy + + Debug + + Default + + Div + + Ord + + Serialize + + Sum + + CheckedAdd + + From, Wit: Mul, - u64: From + From, + u64: From + From + From, { - let key: StakeKey = StakeKey { - validator: transaction.body.operator, - withdrawer: transaction.body.withdrawal.pkh, - }; - - let coins = Wit::from_nanowits(transaction.body.withdrawal.value + transaction.body.fee); - - let environment = get_environment(); - log::debug!( - "{} removed {} Wit stake", - key.validator.bech32(environment), - coins.wits_and_nanowits().0, - ); - - // When removing stake, epochs get averaged but nonces get overwritten - stakes.remove_stake(key, coins, epoch, true, minimum_stakeable.into())?; - - log::debug!("Current state of the stakes tracker: {stakes:#?}"); - - Ok(()) + process_unstake_transaction_legacy(stakes, transaction, epoch, minimum_stakeable) } -/// Adds stakes, based on the data from multiple stake transactions. -/// -/// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and -/// `Coins`. -pub fn process_stake_transactions<'a, const UNIT: u8, Epoch, Nonce, Power>( +/// Apply the historical Wit/2.0 Unstake mutation without imposing native-only nonce bounds on +/// the compatibility API. Wit/2.0 intentionally keeps its release-wrapping arithmetic and does +/// not bind the transaction nonce at this lower ledger boundary. +fn process_unstake_transaction_legacy( stakes: &mut Stakes, - transactions: impl Iterator, + transaction: &UnstakeTransaction, epoch: Epoch, minimum_stakeable: u64, -) -> Result<(), StakesError> +) -> StakesResult<(), PublicKeyHash, Wit, Epoch> where Epoch: Copy + Default @@ -915,10 +2426,10 @@ where + Saturating + From + Debug + + Display + Send + Serialize + Sync - + Display + Add + Div + PartialOrd, @@ -933,27 +2444,54 @@ where + Send + Serialize + Sync, - Power: - Add + Copy + Debug + Default + Div + Ord + Serialize + Sum, + Power: Add + + Copy + + Debug + + Default + + Div + + Ord + + Serialize + + Sum + + CheckedAdd + + From, Wit: Mul, - u64: From + From, + u64: From + From + From, { - for transaction in transactions { - process_stake_transaction(stakes, transaction, epoch, minimum_stakeable)?; - } + let key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.body.withdrawal.pkh, + }; + let coins = Wit::from_nanowits( + transaction + .body + .withdrawal + .value + .wrapping_add(transaction.body.fee), + ); + let environment = get_environment(); + log::debug!( + "{} removed {} Wit stake", + key.validator.bech32(environment), + coins.wits_and_nanowits().0, + ); + stakes.remove_stake_legacy(key, coins, epoch, true, minimum_stakeable.into())?; + log::debug!("Current state of the stakes tracker: {stakes:#?}"); Ok(()) } -/// Removes stakes, based on the data from multiple unstake transactions. + +/// Removes stake, based on the data from an unstake transaction, with explicit protocol +/// arithmetic. /// /// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and /// `Coins`. -pub fn process_unstake_transactions<'a, const UNIT: u8, Epoch, Nonce, Power>( +pub fn process_unstake_transaction_for_protocol( stakes: &mut Stakes, - transactions: impl Iterator, + transaction: &UnstakeTransaction, epoch: Epoch, minimum_stakeable: u64, -) -> Result<(), StakesError> + protocol_version: ProtocolVersion, +) -> StakesResult<(), PublicKeyHash, Wit, Epoch> where Epoch: Copy + Default @@ -961,10 +2499,10 @@ where + Saturating + From + Debug + + Display + Send + Serialize + Sync - + Display + Add + Div + PartialOrd, @@ -975,41 +2513,1263 @@ where + Display + From + From + + PartialEq + Saturating + Send + Serialize + Sync, - Power: - Add + Copy + Debug + Default + Div + Ord + Serialize + Sum, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, Wit: Mul, - u64: From + From, + u64: From + From + From, { - for transaction in transactions { - process_unstake_transaction(stakes, transaction, epoch, minimum_stakeable)?; + // Keep direct native unstake callers fail-closed on malformed maintained indexes. Without + // this proof, the checked clone used by the batch wrapper could silently repair an omitted or + // detached secondary row before the exact-entry removal is applied. + if protocol_version >= ProtocolVersion::V2_1 { + stakes.validate_validator_index_checked()?; + if transaction.signature.public_key.pkh() != transaction.body.withdrawal.pkh { + return Err(StakesError::InvalidDelegationState { + reason: "native unstake signer does not match its withdrawal output", + }); + } } - Ok(()) -} + // Native Wit/2.1 accounting has one protocol-owned minimum. Keep the public compatibility + // parameter for legacy callers, but do not let a mutable/runtime value alter the remainder + // threshold when this helper is used on the native path. + let minimum_stakeable = if protocol_version >= ProtocolVersion::V2_1 { + DELEGATED_STAKING_MINIMUM_NANOWITS + } else { + minimum_stakeable + }; -#[cfg(test)] -mod tests { - use super::*; + let key: StakeKey = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.body.withdrawal.pkh, + }; - const MIN_STAKE_NANOWITS: u64 = 1; + // The block validator binds the signed nonce to the exact live stake entry. Repeat that + // identity check at the native mutation boundary so direct replay/consolidation callers + // cannot remove a valid amount from the right key with a stale or replayed Unstake nonce. + // Legacy release-compatible callers intentionally retain their historical nonce behavior. + if protocol_version >= ProtocolVersion::V2_1 { + let current_nonce = stakes.query_nonce(key.clone())?; + if current_nonce != transaction.body.nonce { + return Err(StakesError::InvalidDelegationState { + reason: "native unstake nonce does not match the live stake entry", + }); + } + } - #[test] - fn test_stakes_initialization() { - let stakes = StakesTester::default(); - let ranking = stakes.by_rank(Capability::Mining, 0).collect::>(); - assert_eq!(ranking, Vec::default()); + let requested = if protocol_version >= ProtocolVersion::V2_1 { + transaction + .body + .withdrawal + .value + .checked_add(transaction.body.fee) + .ok_or(StakesError::ArithmeticOverflow)? + } else { + transaction + .body + .withdrawal + .value + .wrapping_add(transaction.body.fee) + }; + // Native validation rejects a zero-value withdrawal output before mutation. Keep the + // state-transition helper fail-closed as well so a direct replay/consolidation caller cannot + // turn a no-op withdrawal into a nonce update or other ledger mutation. + if protocol_version >= ProtocolVersion::V2_1 && transaction.body.withdrawal.value == 0 { + return Err(StakesError::InvalidDelegationState { + reason: "native unstake output must be positive", + }); } + let coins = Wit::from_nanowits(requested); - #[test] - fn test_add_stake() { - let mut stakes = StakesTester::default(); - let alice = "Alice"; - let bob = "Bob"; - let charlie = "Charlie"; + let environment = get_environment(); + log::debug!( + "{} removed {} Wit stake", + key.validator.bech32(environment), + coins.wits_and_nanowits().0, + ); + + // When removing stake, epochs get averaged but nonces get overwritten. Wit/2.0 must retain its + // release-wrapping consolidation semantics; native Wit/2.1 removal is checked. + if protocol_version >= ProtocolVersion::V2_1 { + stakes.remove_stake(key, coins, epoch, true, minimum_stakeable.into())?; + } else { + stakes.remove_stake_legacy(key, coins, epoch, true, minimum_stakeable.into())?; + } + + log::debug!("Current state of the stakes tracker: {stakes:#?}"); + + Ok(()) +} + +/// Adds stakes, based on the data from multiple stake transactions, using the historical Wit/2.0 +/// accounting behavior. +/// +/// This compatibility entry point intentionally retains its original four-argument signature. +/// Consensus code that must select legacy release-wrapping or native Wit/2.1 accounting should +/// call [`process_stake_transactions_for_protocol`] instead. +pub fn process_stake_transactions<'a, const UNIT: u8, Epoch, Nonce, Power>( + stakes: &mut Stakes, + transactions: impl Iterator, + epoch: Epoch, + minimum_stakeable: u64, +) -> Result<(), StakesError> +where + Epoch: Copy + + Default + + Sub + + Saturating + + From + + Debug + + Send + + Serialize + + Sync + + Display + + Add + + Div + + PartialOrd, + Nonce: AddAssign + + Copy + + Debug + + Default + + Display + + From + + From + + Saturating + + Send + + Serialize + + Sync, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, + Wit: Mul, + u64: From + From + From, +{ + process_stake_transactions_for_protocol( + stakes, + transactions, + epoch, + minimum_stakeable, + ProtocolVersion::V2_0, + ) +} + +/// Adds stakes, based on the data from multiple stake transactions, with explicit protocol +/// arithmetic. +/// +/// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and +/// `Coins`. +pub fn process_stake_transactions_for_protocol<'a, const UNIT: u8, Epoch, Nonce, Power>( + stakes: &mut Stakes, + transactions: impl Iterator, + epoch: Epoch, + minimum_stakeable: u64, + protocol_version: ProtocolVersion, +) -> Result<(), StakesError> +where + Epoch: Copy + + Default + + Sub + + Saturating + + From + + Debug + + Send + + Serialize + + Sync + + Display + + Add + + Div + + PartialOrd, + Nonce: AddAssign + + Copy + + Debug + + Default + + Display + + From + + From + + Saturating + + Send + + Serialize + + Sync, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, + Wit: Mul, + u64: From + From + From, +{ + if protocol_version >= ProtocolVersion::V2_1 { + // Validate the source image before cloning: the checked clone verifies canonical primary + // keys but intentionally rebuilds secondary indexes, so it cannot by itself detect a + // malformed source index. + stakes.validate_validator_index_checked()?; + + // Native delegated-staking batches are one consensus transition. Stage the complete + // sequence on a checked clone so a later malformed transaction cannot publish a successful + // prefix through this public helper. Higher-level block paths already clone their full + // ChainState, but keeping atomicity here protects direct admission/replay callers too. + let mut projected = stakes.try_clone_checked()?; + let mut seen_deposit_keys = BTreeSet::new(); + for transaction in transactions { + let key = transaction.body.output.key.clone(); + // A live identity slot is consumed only by a deposit or explicit owner + // registration. Commission updates are sequenced by the validator policy nonce, + // and exit intents by their exact entry key/nonce; counting either policy-only + // action here would reject a valid same-block top-up plus policy transition before + // the delegated-staking dispatcher can apply its dedicated checks. Missing or + // unsupported terms are intentionally left to the lower mutation helper, which + // rejects them before publishing the projected batch. + let consumes_deposit_identity = protocol_version < ProtocolVersion::V2_1 + || transaction + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| { + matches!( + terms.action, + crate::chain::STAKE_AUTHORIZATION_ACTION_DELEGATE + | crate::chain::STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + ) + }); + if consumes_deposit_identity && !seen_deposit_keys.insert(key) { + return Err(StakesError::InvalidDelegationState { + reason: "native stake batch contains a duplicate deposit key", + }); + } + process_stake_transaction_for_protocol( + &mut projected, + transaction, + epoch, + minimum_stakeable, + protocol_version, + )?; + } + *stakes = projected; + return Ok(()); + } + + for transaction in transactions { + process_stake_transaction_for_protocol( + stakes, + transaction, + epoch, + minimum_stakeable, + protocol_version, + )?; + } + + Ok(()) +} +/// Removes stakes, based on the data from multiple unstake transactions, using the historical +/// Wit/2.0 accounting behavior. +/// +/// This compatibility entry point intentionally retains its original four-argument signature. +/// Consensus code that must select legacy release-wrapping or native Wit/2.1 accounting should +/// call [`process_unstake_transactions_for_protocol`] instead. +pub fn process_unstake_transactions<'a, const UNIT: u8, Epoch, Nonce, Power>( + stakes: &mut Stakes, + transactions: impl Iterator, + epoch: Epoch, + minimum_stakeable: u64, +) -> Result<(), StakesError> +where + Epoch: Copy + + Default + + Sub + + Saturating + + From + + Debug + + Send + + Serialize + + Sync + + Display + + Add + + Div + + PartialOrd, + Nonce: AddAssign + + Copy + + Debug + + Default + + Display + + From + + From + + Saturating + + Send + + Serialize + + Sync, + Power: Add + + Copy + + Debug + + Default + + Div + + Ord + + Serialize + + Sum + + CheckedAdd + + From, + Wit: Mul, + u64: From + From + From, +{ + for transaction in transactions { + process_unstake_transaction_legacy(stakes, transaction, epoch, minimum_stakeable)?; + } + Ok(()) +} + +/// Removes stakes, based on the data from multiple unstake transactions, with explicit protocol +/// arithmetic. +/// +/// This function was made static instead of adding it to `impl Stakes` because it is not generic over `Address` and +/// `Coins`. +pub fn process_unstake_transactions_for_protocol<'a, const UNIT: u8, Epoch, Nonce, Power>( + stakes: &mut Stakes, + transactions: impl Iterator, + epoch: Epoch, + minimum_stakeable: u64, + protocol_version: ProtocolVersion, +) -> Result<(), StakesError> +where + Epoch: Copy + + Default + + Sub + + Saturating + + From + + Debug + + Send + + Serialize + + Sync + + Display + + Add + + Div + + PartialOrd, + Nonce: AddAssign + + Copy + + Debug + + Default + + Display + + From + + From + + PartialEq + + Saturating + + Send + + Serialize + + Sync, + Power: Add + + Copy + + Debug + + Default + + Div + + From + + num_traits::CheckedAdd + + Ord + + Serialize + + Sum, + Wit: Mul, + u64: From + From + From, +{ + if protocol_version >= ProtocolVersion::V2_1 { + // Validate the source image before cloning for the same reason as the native stake batch: + // `try_clone_checked` rebuilds secondary indexes and would otherwise hide their corruption. + stakes.validate_validator_index_checked()?; + + // Keep native unstake batches failure-atomic for the same reason as native stake batches: + // an exact-entry removal or checked age transition later in the sequence must not leave + // an earlier removal visible when the helper returns an error. Legacy batches intentionally + // retain their historical successful-prefix semantics below. + let mut projected = stakes.try_clone_checked()?; + let mut seen_keys = BTreeSet::new(); + for transaction in transactions { + let key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.body.withdrawal.pkh, + }; + if !seen_keys.insert(key) { + return Err(StakesError::InvalidDelegationState { + reason: "native unstake batch contains a duplicate stake key", + }); + } + process_unstake_transaction_for_protocol( + &mut projected, + transaction, + epoch, + minimum_stakeable, + protocol_version, + )?; + } + *stakes = projected; + return Ok(()); + } + + for transaction in transactions { + process_unstake_transaction_for_protocol( + stakes, + transaction, + epoch, + minimum_stakeable, + protocol_version, + )?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MIN_STAKE_NANOWITS: u64 = 1; + + #[test] + fn checked_withdrawer_projection_requires_an_exact_secondary_index() { + let mut stakes = StakesTester::default(); + let first = StakeKey::from(("FirstValidator", "Owner")); + let second = StakeKey::from(("SecondValidator", "Owner")); + let foreign = StakeKey::from(("ForeignValidator", "ForeignOwner")); + stakes + .add_stake(first.clone(), 11, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake(second.clone(), 22, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake(foreign, 33, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + assert_eq!( + stakes + .query_stakes_by_withdrawers_checked(["Owner".to_string()]) + .unwrap() + .into_iter() + .map(|entry| (entry.key, entry.value.coins)) + .collect::>(), + vec![(first, 11), (second, 22)] + ); + + stakes.by_withdrawer.get_mut("Owner").unwrap().pop(); + assert!(matches!( + stakes.query_stakes_by_withdrawers_checked(["Owner".to_string()]), + Err(StakesError::InvalidDelegationState { .. }) + )); + + stakes.reindex(); + let duplicate = stakes.by_withdrawer["Owner"][0].clone(); + stakes + .by_withdrawer + .get_mut("Owner") + .unwrap() + .push(duplicate); + assert!(matches!( + stakes.query_stakes_by_withdrawers_checked(["Owner".to_string()]), + Err(StakesError::InvalidDelegationState { .. }) + )); + } + + #[test] + fn primary_validator_projection_does_not_trust_the_secondary_index() { + let mut stakes = StakesTester::default(); + let owner = StakeKey::from(("Validator", "Owner")); + let delegator = StakeKey::from(("Validator", "Delegator")); + stakes + .add_stake(owner.clone(), 11, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake(delegator.clone(), 22, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake( + ("ForeignValidator", "ForeignOwner"), + 33, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + + // Simulate one live delegation being omitted only from the maintained validator index. + stakes.by_validator.get_mut("Validator").unwrap().pop(); + assert!(matches!( + stakes.query_stakes_checked(QueryStakesKey::Validator("Validator".to_string())), + Err(StakesError::InvalidDelegationState { .. }) + )); + + assert_eq!( + stakes + .query_stakes_primary_checked(QueryStakesKey::Validator( + "Validator".to_string(), + )) + .unwrap() + .into_iter() + .map(|entry| (entry.key, entry.value.coins)) + .collect::>(), + vec![(delegator.clone(), 22), (owner.clone(), 11)] + ); + + // Restore the validator projection before isolating a malformed withdrawer index. + stakes.reindex(); + stakes.by_withdrawer.remove("Delegator"); + assert!(matches!( + stakes.query_stakes_checked(QueryStakesKey::Withdrawer("Delegator".to_string())), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!( + stakes + .query_stakes_primary_checked(QueryStakesKey::Withdrawer( + "Delegator".to_string(), + )) + .unwrap() + .into_iter() + .map(|entry| (entry.key, entry.value.coins)) + .collect::>(), + vec![(delegator, 22)] + ); + + // A primary map slot whose locked key was corrupted is not a canonical stake entry. + *stakes.by_key.get(&owner).unwrap().key.write().unwrap() = + StakeKey::from(("DifferentValidator", "Owner")); + assert!(matches!( + stakes.query_stakes_by_validator_primary_checked("Validator".to_string()), + Err(StakesError::InvalidDelegationState { .. }) + )); + } + + #[test] + fn validator_index_invariant_rejects_power_changing_omissions_and_extras() { + let mut stakes = StakesTester::default(); + stakes + .add_stake( + ("Validator", "Owner"), + 11, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + stakes + .add_stake( + ("Validator", "Delegator"), + 22, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + stakes + .add_stake( + ("ForeignValidator", "ForeignOwner"), + 20, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + + stakes.validate_validator_index_checked().unwrap(); + assert_eq!( + stakes + .by_validator_rank_checked(Capability::Mining, 1) + .unwrap() + .collect::>(), + vec![("Validator".to_string(), 33), ("ForeignValidator".to_string(), 20)] + ); + + // Omitting the delegator from only the secondary index must fail closed rather than + // allowing a lower-power ranking to be consumed as canonical state. + stakes.by_validator.get_mut("Validator").unwrap().pop(); + assert!(matches!( + stakes.by_validator_rank_checked(Capability::Mining, 1), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert!(matches!( + stakes.validate_validator_index_checked(), + Err(StakesError::InvalidDelegationState { .. }) + )); + + stakes.reindex(); + let duplicate = stakes.by_validator["Validator"][0].clone(); + stakes + .by_validator + .get_mut("Validator") + .unwrap() + .push(duplicate); + assert!(matches!( + stakes.validate_validator_index_checked(), + Err(StakesError::InvalidDelegationState { .. }) + )); + } + + #[test] + fn checked_native_slash_rejects_a_detached_validator_index_before_consumption() { + let validator = PublicKeyHash::from_bytes(&[0x51; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x52; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[0x53; 20]).unwrap(); + let owner_key = StakeKey { validator, withdrawer: owner }; + let delegator_key = StakeKey { validator, withdrawer: delegator }; + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let twenty_thousand_wit = Wit::from_nanowits(20_000_000_000_000); + let collateral = Wit::from_nanowits(1_000_000_000_000); + let reservation_key = CollateralReservationKey { + data_request: crate::chain::Hash::with_first_u32(0x51), + validator, + }; + + let mut stakes = StakesTracker::default(); + stakes + .add_stake(owner_key, twenty_thousand_wit, 0, true, minimum) + .unwrap(); + stakes + .add_stake(delegator_key, twenty_thousand_wit, 0, true, minimum) + .unwrap(); + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + let reservation = state + .reserve_collateral(&mut stakes, reservation_key, collateral, 2, minimum) + .unwrap(); + + // The primary map still contains both entries, but the maintained validator index no + // longer does. A checked native slash must reject this malformed source image before it + // can consume the reservation sidecar. + stakes.by_validator.get_mut(&validator).unwrap().pop(); + let state_before = state.clone(); + let primary_before = stakes.by_key.clone(); + let validator_index_before = stakes.by_validator.clone(); + let withdrawer_index_before = stakes.by_withdrawer.clone(); + assert!(matches!( + state.slash_reserved_collateral_checked(&stakes, reservation_key), + Err(StakesError::InvalidDelegationState { .. }) + )); + assert_eq!(state, state_before); + assert_eq!(stakes.by_key, primary_before); + assert_eq!(stakes.by_validator, validator_index_before); + assert_eq!(stakes.by_withdrawer, withdrawer_index_before); + assert_eq!(state.collateral_reservations[&reservation_key], reservation); + } + + #[test] + fn test_stakes_initialization() { + let stakes = StakesTester::default(); + let ranking = stakes.by_rank(Capability::Mining, 0).collect::>(); + assert_eq!(ranking, Vec::default()); + } + + #[test] + fn legacy_power_wraps_while_delegated_aggregate_power_rejects_overflow() { + let mut stakes = StakesTester::default(); + stakes + .add_stake(("Validator", "Owner"), u64::MAX, 0, true, 1) + .unwrap(); + + assert_eq!( + stakes.query_power_legacy("Validator", Capability::Mining, 2), + Ok(u64::MAX - 1) + ); + assert_eq!( + stakes.query_power("Validator", Capability::Mining, 2), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!( + stakes.by_rank(Capability::Mining, 2).collect::>(), + vec![(StakeKey::from(("Validator", "Owner")), u64::MAX - 1)] + ); + } + + #[test] + fn legacy_reward_zero_denominator_is_atomic() { + let mut stakes = StakesTester::default(); + stakes + .add_stake(("Validator", "Owner"), u64::MAX, 0, true, 1) + .unwrap(); + let before = stakes + .query_stakes(QueryStakesKey::Key(StakeKey::from(("Validator", "Owner")))) + .unwrap()[0] + .value + .clone(); + + assert_eq!( + stakes.add_reward("Validator", 1, 1), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!( + stakes + .query_stakes(QueryStakesKey::Key(StakeKey::from(("Validator", "Owner")))) + .unwrap()[0] + .value, + before + ); + } + + #[test] + fn reset_age_for_key_does_not_reset_other_validator_entries() { + let mut stakes = StakesTester::default(); + let owner = StakeKey::from(("Validator", "Owner")); + let delegator = StakeKey::from(("Validator", "Delegator")); + stakes + .add_stake(owner.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake(delegator.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + stakes + .reset_age_for_key(owner.clone(), Capability::Witnessing, 10, 1) + .unwrap(); + + let owner_epoch = stakes + .query_stakes(QueryStakesKey::Key(owner)) + .unwrap()[0] + .value + .epochs + .get(Capability::Witnessing); + let delegator_epoch = stakes + .query_stakes(QueryStakesKey::Key(delegator)) + .unwrap()[0] + .value + .epochs + .get(Capability::Witnessing); + assert_eq!(owner_epoch, 10); + assert_eq!(delegator_epoch, 0); + } + + #[test] + fn legacy_age_reset_wraps_but_checked_reset_preserves_future_age_atomically() { + let owner = StakeKey::from(("Validator", "Owner")); + let delegator = StakeKey::from(("Validator", "Delegator")); + + let mut legacy_stakes = StakesTester::default(); + legacy_stakes + .add_stake(owner.clone(), 10, 1, true, MIN_STAKE_NANOWITS) + .unwrap(); + legacy_stakes + .reset_age("Validator", Capability::Witnessing, 0, 1) + .unwrap(); + assert_eq!( + legacy_stakes + .query_stakes(QueryStakesKey::Key(owner.clone())) + .unwrap()[0] + .value + .epochs + .get(Capability::Witnessing), + 0 + ); + + let mut checked_stakes = StakesTester::default(); + checked_stakes + .add_stake(owner.clone(), 10, 1, true, MIN_STAKE_NANOWITS) + .unwrap(); + checked_stakes + .add_stake(delegator.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + assert_eq!( + checked_stakes.reset_age_checked( + "Validator", + Capability::Witnessing, + 0, + 1, + ), + Ok(()) + ); + assert_eq!( + checked_stakes + .query_stakes(QueryStakesKey::Key(owner)) + .unwrap()[0] + .value + .epochs + .get(Capability::Witnessing), + 1 + ); + assert_eq!( + checked_stakes + .query_stakes(QueryStakesKey::Key(delegator)) + .unwrap()[0] + .value + .epochs + .get(Capability::Witnessing), + 0 + ); + } + + #[test] + fn legacy_age_reset_rejects_zero_factor_without_mutation() { + let mut stakes = StakesTester::default(); + let key = StakeKey::from(("Validator", "Owner")); + stakes + .add_stake(key.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + let before = stakes + .query_stakes(QueryStakesKey::Key(key.clone())) + .unwrap()[0] + .value + .clone(); + assert_eq!( + stakes.reset_age("Validator", Capability::Witnessing, 10, 0), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key)).unwrap()[0].value, + before + ); + } + + #[test] + fn checked_age_resets_preserve_future_and_reject_target_overflow() { + let mut legacy_stakes = StakesTester::default(); + legacy_stakes + .add_stake(("Validator", "Owner"), 1, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + legacy_stakes + .reset_mining_age("Validator", u64::MAX) + .unwrap(); + assert_eq!( + legacy_stakes + .query_stakes(QueryStakesKey::Key(StakeKey::from(( + "Validator", + "Owner", + )))) + .unwrap()[0] + .value + .epochs + .get(Capability::Mining), + 0 + ); + + let mut stakes = StakesTester::default(); + let key = StakeKey::from(("Validator", "Owner")); + stakes + .add_stake(key.clone(), 1, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + assert_eq!( + stakes.reset_age_for_key( + key.clone(), + Capability::Witnessing, + u64::MAX, + 1, + ), + Ok(()) + ); + assert_eq!( + stakes.reset_age_for_key( + key, + Capability::Witnessing, + 0, + 1, + ), + Ok(()) + ); + assert_eq!( + stakes.reset_mining_age_filtered("Validator", u64::MAX, |_| true), + Err(StakesError::ArithmeticOverflow) + ); + } + + #[test] + fn checked_remove_stake_rejects_a_poisoned_validator_index_key_without_mutation() { + let mut stakes = StakesTester::default(); + let key = StakeKey::from(("Validator", "Owner")); + stakes + .add_stake(key.clone(), 20, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + let entry = stakes.by_key.get(&key).unwrap().clone(); + let poisoned_key = entry.key.clone(); + let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = poisoned_key.write().unwrap(); + panic!("poison stake-key lock fixture"); + })); + assert!(poison_result.is_err()); + + assert_eq!( + stakes.remove_stake(key.clone(), 10, 1, true, MIN_STAKE_NANOWITS), + Err(StakesError::PoisonedLock) + ); + assert!(stakes.by_key.contains_key(&key)); + assert_eq!(entry.value.read().unwrap().coins, 20); + } + + #[test] + fn checked_remove_stake_rejects_a_poisoned_withdrawer_index_key_without_mutation() { + let mut stakes = StakesTester::default(); + let key = StakeKey::from(("Validator", "Owner")); + stakes + .add_stake(key.clone(), 20, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + // Secondary indexes normally share the canonical entry's key lock. Give only the + // withdrawer index an equivalent independent lock so this fixture reaches the second scan + // after the validator scan succeeds. + let entry = stakes.by_key.get(&key).unwrap().clone(); + let withdrawer_entry = stakes + .by_withdrawer + .get_mut("Owner") + .unwrap() + .first_mut() + .unwrap(); + withdrawer_entry.key = Rc::new(RwLock::new(key.clone())); + let poisoned_key = withdrawer_entry.key.clone(); + let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = poisoned_key.write().unwrap(); + panic!("poison withdrawer-index stake-key lock fixture"); + })); + assert!(poison_result.is_err()); + + assert_eq!( + stakes.remove_stake(key.clone(), 10, 1, true, MIN_STAKE_NANOWITS), + Err(StakesError::PoisonedLock) + ); + assert!(stakes.by_key.contains_key(&key)); + assert_eq!(entry.value.read().unwrap().coins, 20); + } + + #[test] + fn checked_age_resets_reject_a_detached_validator_index_before_mutation() { + let mut stakes = StakesTester::default(); + let key = StakeKey::from(("Validator", "Owner")); + stakes + .add_stake(key.clone(), 20, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + // Keep the same logical key but replace only the validator-index value lock. This is a + // detached secondary row: mutating it would leave the canonical primary entry unchanged + // while a validator-wide age projection appeared to succeed. + let canonical_entry = stakes.by_key.get(&key).unwrap().clone(); + let canonical_value = canonical_entry.value.read().unwrap().clone(); + stakes + .by_validator + .get_mut("Validator") + .unwrap() + .first_mut() + .unwrap() + .value = Rc::new(RwLock::new(canonical_value)); + + let expected = Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }); + assert_eq!( + stakes.query_power("Validator", Capability::Mining, 10), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + ); + assert!(matches!( + stakes.by_validator_rank_checked(Capability::Mining, 10), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + )); + assert_eq!( + stakes.validator_total_stake("Validator"), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + ); + assert!(matches!( + stakes.by_rank_checked_filtered(Capability::Mining, 10, |_| true), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + )); + assert_eq!( + stakes.validator_is_active_checked( + "Validator", + "Owner", + 10_u64, + ), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + ); + assert_eq!( + stakes.query_stakes_checked(QueryStakesKey::Validator("Validator".to_string())), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index entry is detached from its primary record", + }) + ); + let ranking = vec![("Validator".to_owned(), 20_u64)]; + assert_eq!( + stakes.reset_mining_age_ranked_filtered( + "Validator".to_owned(), + 10, + &ranking, + |_| true, + ), + expected + ); + assert_eq!( + stakes.reset_age_checked_filtered( + "Validator".to_owned(), + Capability::Witnessing, + 10, + 1, + |_| true, + ), + expected + ); + assert_eq!( + stakes.reset_age_for_key( + key.clone(), + Capability::Witnessing, + 10, + 1, + ), + expected + ); + assert_eq!( + canonical_entry + .value + .read() + .unwrap() + .epochs + .get(Capability::Mining), + 0 + ); + assert_eq!( + canonical_entry + .value + .read() + .unwrap() + .epochs + .get(Capability::Witnessing), + 0 + ); + } + + #[test] + fn activated_exit_completion_rejects_a_stale_secondary_row_atomically() { + let validator = PublicKeyHash::from_bytes(&[31; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[32; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let mut stakes = StakesTracker::default(); + stakes + .add_stake(key.clone(), minimum, 0, true, minimum) + .unwrap(); + + let mut state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + state.register_validator_if_absent(validator, owner, 0).unwrap(); + state.request_exit(&stakes, key.clone(), 0).unwrap(); + + // Simulate a malformed activated image whose primary row was removed while its + // validator/withdrawer index rows were left behind. Exit completion must not clear the + // authoritative intent or otherwise publish a partially repaired state. + stakes.by_key.remove(&key); + let before = state.clone(); + assert_eq!( + state.complete_exit_if_removed(&stakes, &key), + Err(StakesError::InvalidDelegationState { + reason: "stake validator index does not match the canonical validator set", + }) + ); + assert_eq!(state, before); + assert!(state.exit_intents.contains(&key)); + } + + #[test] + fn checked_ranked_age_reset_rejects_noncanonical_validator_rows_atomically() { + let mut stakes = StakesTester::default(); + stakes + .add_stake( + ("BetterRanked", "BetterOwner"), + 20, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + stakes + .add_stake( + ("Winner", "WinnerOwner"), + 10, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + + let malformed_rankings = vec![ + ( + vec![ + ("BetterRanked".to_owned(), 20_u64), + ("BetterRanked".to_owned(), 10_u64), + ], + "ranked stake reset contains a duplicate validator", + ), + ( + vec![ + ("BetterRanked".to_owned(), 20_u64), + ("Missing".to_owned(), 10_u64), + ], + "ranked stake reset references an unknown validator", + ), + ( + vec![ + ("Winner".to_owned(), 10_u64), + ("BetterRanked".to_owned(), 20_u64), + ], + "ranked stake reset is not in canonical order", + ), + ( + vec![ + ("BetterRanked".to_owned(), 10_u64), + ("Winner".to_owned(), 10_u64), + ], + "ranked stake reset is not in canonical order", + ), + ]; + + for (ranking, reason) in malformed_rankings { + let before = stakes.clone(); + assert_eq!( + stakes.reset_mining_age_ranked_filtered( + "Winner".to_owned(), + 0, + &ranking, + |_| true, + ), + Err(StakesError::InvalidDelegationState { reason }) + ); + assert_eq!(stakes, before); + } + } + + #[test] + fn checked_mining_reset_preflights_key_locks_before_mutation() { + let mut stakes = StakesTester::default(); + let better_ranked = StakeKey::from(("BetterRanked", "BetterOwner")); + let winner = StakeKey::from(("Winner", "WinnerOwner")); + stakes + .add_stake( + better_ranked.clone(), + 20, + 0, + true, + MIN_STAKE_NANOWITS, + ) + .unwrap(); + stakes + .add_stake(winner.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + let better_entry = stakes.by_key.get(&better_ranked).unwrap().clone(); + let winner_entry = stakes.by_key.get(&winner).unwrap().clone(); + let poisoned_key = winner_entry.key.clone(); + let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = poisoned_key.write().unwrap(); + panic!("poison stake-key lock fixture"); + })); + assert!(poison_result.is_err()); + + assert_eq!( + stakes.reset_mining_age_filtered("Winner", 10, |_| true), + Err(StakesError::PoisonedLock) + ); + assert_eq!( + better_entry + .value + .read() + .unwrap() + .epochs + .get(Capability::Mining), + 0 + ); + assert_eq!( + winner_entry + .value + .read() + .unwrap() + .epochs + .get(Capability::Mining), + 0 + ); + } + + #[test] + fn filtered_native_age_resets_leave_excluded_entries_unchanged() { + let mut stakes = StakesTester::default(); + let owner = StakeKey::from(("Validator", "Owner")); + let delegator = StakeKey::from(("Validator", "Delegator")); + stakes + .add_stake(owner.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + stakes + .add_stake(delegator.clone(), 10, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + let ranking = vec![("Validator".to_owned(), 20_u64)]; + assert_eq!( + stakes.reset_mining_age_ranked_filtered( + "Validator".to_owned(), + 10, + &ranking, + |key| key != &delegator, + ), + Ok(()) + ); + assert_eq!( + stakes.reset_age_checked_filtered( + "Validator".to_owned(), + Capability::Witnessing, + 10, + 1, + |key| key != &delegator, + ), + Ok(()) + ); + + let owner_entry = stakes + .query_stakes(QueryStakesKey::Key(owner)) + .unwrap() + .remove(0); + let delegator_entry = stakes + .query_stakes(QueryStakesKey::Key(delegator)) + .unwrap() + .remove(0); + assert_eq!(owner_entry.value.epochs.get(Capability::Mining), 11); + assert_eq!(owner_entry.value.epochs.get(Capability::Witnessing), 10); + assert_eq!(delegator_entry.value.epochs.get(Capability::Mining), 0); + assert_eq!(delegator_entry.value.epochs.get(Capability::Witnessing), 0); + } + + #[test] + fn test_add_stake() { + let mut stakes = StakesTester::default(); + let alice = "Alice"; + let bob = "Bob"; + let charlie = "Charlie"; let david = "David"; let alice_charlie = (alice, charlie); @@ -1131,6 +3891,295 @@ mod tests { ); } + #[test] + fn mandatory_ten_thousand_wit_minimum_rejects_deposits_without_mutation() { + const NANOWITS_PER_WIT: u64 = 1_000_000_000; + const MINIMUM_STAKE_NANOWITS: u64 = 10_000 * NANOWITS_PER_WIT; + + let validator = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let minimum = Wit::from_nanowits(MINIMUM_STAKE_NANOWITS); + let mut stakes = StakesTracker::default(); + + assert_eq!( + stakes + .add_stake( + key.clone(), + Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + 1, + true, + minimum, + ) + .unwrap_err(), + StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + minimum, + } + ); + assert_eq!(stakes.stakes_count(), 0); + assert_eq!(stakes.validator_count(), 0); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key.clone())), + Err(StakesError::EntryNotFound { key: key.clone() }) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Validator(validator)), + Err(StakesError::ValidatorNotFound { validator }) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Withdrawer(owner)), + Err(StakesError::WithdrawerNotFound { withdrawer: owner }) + ); + + stakes + .add_stake(key.clone(), minimum, 1, true, minimum) + .unwrap(); + let before_top_up = stakes.query_stakes(QueryStakesKey::Key(key.clone())).unwrap(); + assert_eq!( + stakes + .add_stake( + key.clone(), + Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + 2, + true, + minimum, + ) + .unwrap_err(), + StakesError::AmountIsBelowMinimum { + amount: Wit::from_nanowits(MINIMUM_STAKE_NANOWITS - 1), + minimum, + } + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key.clone())), + Ok(before_top_up) + ); + stakes + .add_stake(key.clone(), minimum, 2, true, minimum) + .unwrap(); + assert_eq!( + stakes + .query_stakes(QueryStakesKey::Key(key)) + .unwrap()[0] + .value + .coins, + Wit::from_nanowits(MINIMUM_STAKE_NANOWITS * 2) + ); + } + + #[test] + fn failed_existing_stake_addition_is_atomic() { + let key: StakeKey = ("Validator", "Owner").into(); + let mut stakes = StakesTester::default(); + stakes + .add_stake(key.clone(), u64::MAX, 0, true, 1) + .unwrap(); + let before = stakes + .query_stakes(QueryStakesKey::Key(key.clone())) + .unwrap(); + let before_validator = stakes + .query_stakes(QueryStakesKey::Validator(key.validator.clone())) + .unwrap(); + let before_withdrawer = stakes + .query_stakes(QueryStakesKey::Withdrawer(key.withdrawer.clone())) + .unwrap(); + + assert_eq!( + stakes.add_stake(key.clone(), 1, 1, true, 1), + Err(StakesError::ArithmeticOverflow) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key)), + Ok(before) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Validator("Validator".to_string())), + Ok(before_validator) + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Withdrawer("Owner".to_string())), + Ok(before_withdrawer) + ); + assert_eq!(stakes.stakes_count(), 1); + assert_eq!(stakes.validator_count(), 1); + } + + #[test] + fn native_stake_batch_failure_does_not_publish_a_successful_prefix() { + // Use a structurally valid native delegation so the duplicate-deposit guard, rather + // than the missing-terms guard, is the boundary exercised by this atomicity fixture. + let validator = crate::chain::PublicKey::default().pkh(); + let withdrawer = validator; + let key = StakeKey { validator, withdrawer }; + let mut first = StakeTransaction::default(); + first.body.inputs.push(crate::chain::Input::default()); + first.body.output.key = key.clone(); + first.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + first.body.output.authorization_terms = Some(crate::chain::StakeAuthorization::delegation( + 0, + validator, + withdrawer, + 100, + 0, + )); + let mut overflowing = StakeTransaction::default(); + overflowing.body.inputs.push(crate::chain::Input::default()); + overflowing.body.output.key = key.clone(); + overflowing.body.output.value = u64::MAX; + overflowing.body.output.authorization_terms = + first.body.output.authorization_terms.clone(); + let mut stakes = StakesTracker::default(); + + assert_eq!( + process_stake_transactions_for_protocol( + &mut stakes, + [&first, &overflowing].into_iter(), + 1, + 0, + ProtocolVersion::V2_1, + ), + Err(StakesError::InvalidDelegationState { + reason: "native stake batch contains a duplicate deposit key", + }), + ); + assert_eq!(stakes.stakes_count(), 0); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key.clone())), + Err(StakesError::EntryNotFound { key }), + ); + } + + #[test] + fn native_owner_registration_requires_the_policy_transition() { + use crate::{ + chain::{Input, PublicKey}, + transaction::StakeTransactionBody, + }; + + let validator = PublicKey::default().pkh(); + let owner = PublicKeyHash::from_bytes(&[9; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let mut stakes = StakesTracker::default(); + stakes + .add_stake(key.clone(), minimum, 1, true, minimum) + .unwrap(); + let before = stakes.query_stakes(QueryStakesKey::Key(key.clone())).unwrap(); + + let transaction = StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::default()], + crate::chain::StakeOutput { + authorization: crate::chain::KeyedSignature { + public_key: PublicKey::default(), + ..Default::default() + }, + key: key.clone(), + value: DELEGATED_STAKING_MINIMUM_NANOWITS, + authorization_terms: Some(crate::chain::StakeAuthorization::register_owner( + 0, + validator, + owner, + u32::MAX, + 15, + )), + ..Default::default() + }, + None, + ), + vec![], + ); + assert_eq!( + process_stake_transaction_for_protocol( + &mut stakes, + &transaction, + 1, + DELEGATED_STAKING_MINIMUM_NANOWITS, + ProtocolVersion::V2_1, + ), + Err(StakesError::InvalidDelegationState { + reason: "native owner registration must be applied by delegated-staking transition", + }), + ); + assert_eq!( + stakes.query_stakes(QueryStakesKey::Key(key)).unwrap(), + before + ); + + let mut empty_stakes = StakesTracker::default(); + assert_eq!( + process_stake_transaction_for_protocol( + &mut empty_stakes, + &transaction, + 1, + DELEGATED_STAKING_MINIMUM_NANOWITS, + ProtocolVersion::V2_1, + ), + Err(StakesError::InvalidDelegationState { + reason: "native owner registration must be applied by delegated-staking transition", + }), + ); + assert_eq!(empty_stakes.stakes_count(), 0); + } + + #[test] + fn native_unstake_batch_failure_does_not_publish_a_successful_prefix() { + let validator = PublicKeyHash::from_bytes(&[3; 20]).unwrap(); + let withdrawer = crate::chain::PublicKey::default().pkh(); + let key = StakeKey { validator, withdrawer }; + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + key.clone(), + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS * 2), + 1, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .unwrap(); + + let mut first = UnstakeTransaction::default(); + first.body.operator = validator; + first.body.withdrawal.pkh = withdrawer; + first.body.withdrawal.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + first.body.nonce = 1; + first.signature.public_key = crate::chain::PublicKey::default(); + let mut insufficient = UnstakeTransaction::default(); + insufficient.body.operator = validator; + insufficient.body.withdrawal.pkh = withdrawer; + insufficient.body.withdrawal.value = u64::MAX; + insufficient.body.nonce = 1; + insufficient.signature.public_key = crate::chain::PublicKey::default(); + + assert_eq!( + process_unstake_transactions_for_protocol( + &mut stakes, + [&first, &insufficient].into_iter(), + 2, + 0, + ProtocolVersion::V2_1, + ), + Err(StakesError::InvalidDelegationState { + reason: "native unstake batch contains a duplicate stake key", + }), + ); + assert_eq!( + stakes + .query_stakes(QueryStakesKey::Key(key)) + .unwrap()[0] + .value + .coins, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS * 2) + ); + } + #[test] fn test_coin_age_resets() { // First, lets create a setup with a few stakers diff --git a/data_structures/src/superblock.rs b/data_structures/src/superblock.rs index 59cb0bf424..087c350b5f 100644 --- a/data_structures/src/superblock.rs +++ b/data_structures/src/superblock.rs @@ -451,6 +451,35 @@ impl SuperBlockState { block_epoch: Epoch, ) -> SuperBlock { let protocol_version = ProtocolVersion::from_epoch(block_epoch); + self.build_superblock_for_protocol( + block_headers, + ars_identities, + signing_committee_size, + superblock_index, + last_block_in_previous_superblock, + alt_keys, + sync_superblock, + block_epoch, + protocol_version, + ) + } + + /// Produce a superblock using an explicitly selected protocol version for all block-header + /// identities included in it. Consensus callers should pass the version from their operation's + /// schedule snapshot; [`Self::build_superblock`] remains the epoch-derived compatibility API. + #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] + pub fn build_superblock_for_protocol( + &mut self, + block_headers: &[BlockHeader], + ars_identities: ARSIdentities, + signing_committee_size: u32, + superblock_index: u32, + last_block_in_previous_superblock: Hash, + alt_keys: &AltKeys, + sync_superblock: Option, + block_epoch: Epoch, + protocol_version: ProtocolVersion, + ) -> SuperBlock { let key_leaves = hash_key_leaves(&ars_identities.get_rep_ordered_bn256_list(alt_keys)); self.update_ars_identities(ars_identities); diff --git a/data_structures/src/transaction.rs b/data_structures/src/transaction.rs index 1a4709c4b8..6195c317f0 100644 --- a/data_structures/src/transaction.rs +++ b/data_structures/src/transaction.rs @@ -29,6 +29,8 @@ use crate::{ pub const INPUT_SIZE: u32 = 133; pub const OUTPUT_SIZE: u32 = 36; pub const STAKE_OUTPUT_WEIGHT: u32 = 105; +/// Additional fixed-width terms carried by a Wit/2.1 stake authorization. +pub const STAKE_AUTHORIZATION_TERMS_WEIGHT: u32 = 68; pub const UNSTAKE_TRANSACTION_WEIGHT: u32 = 153; pub const COMMIT_WEIGHT: u32 = 400; pub const REVEAL_WEIGHT: u32 = 200; @@ -293,8 +295,7 @@ impl VTTransactionBody { self.outputs .iter() .map(ValueTransferOutput::value) - .reduce(|acc, value| acc + value) - .unwrap_or_default() + .fold(0u64, u64::wrapping_add) } /// Calculates the added value of the transaction outputs that point to the same recipient as @@ -306,8 +307,7 @@ impl VTTransactionBody { .iter() .filter(|output| output.pkh == recipient.pkh) .map(ValueTransferOutput::value) - .reduce(|acc, value| acc + value) - .unwrap_or_default() + .fold(0u64, u64::wrapping_add) } else { 0 } @@ -476,10 +476,9 @@ impl DRTransactionBody { .outputs .iter() .map(ValueTransferOutput::value) - .reduce(|acc, value| acc + value) - .unwrap_or_default(); + .fold(0u64, u64::wrapping_add); - Ok(dr_value + change_value) + Ok(dr_value.wrapping_add(change_value)) } /// Data Request Transaction weight. It is calculated as: @@ -807,6 +806,31 @@ impl StakeTransaction { pub fn weight(&self) -> u32 { self.body.weight() } + + /// Return the consensus weight at a particular protocol version. + pub fn weight_for_protocol(&self, protocol_version: ProtocolVersion) -> u32 { + self.body.weight_for_protocol(protocol_version) + } + + /// Return the canonical transaction representation admitted by a protocol version. + /// + /// Before Wit/2.1, old protobuf schemas discard delegated-staking authorization terms. Clear + /// that appended field and rebuild the body so all memoized hashes, mempool keys, relay bytes, + /// and unconfirmed-transaction tracking use the same legacy identity. + pub fn normalized_for_protocol(self, protocol_version: ProtocolVersion) -> Self { + if protocol_version >= ProtocolVersion::V2_1 + || self.body.output.authorization_terms.is_none() + { + return self; + } + + let mut output = self.body.output; + output.authorization_terms = None; + Self::new( + StakeTransactionBody::new(self.body.inputs, output, self.body.change), + self.signatures, + ) + } } #[derive(Debug, Default, Eq, PartialEq, Clone, Serialize, Deserialize, ProtobufConvert, Hash)] @@ -822,9 +846,15 @@ pub struct StakeTransactionBody { } impl StakeTransactionBody { + /// Validate a legacy authorization that signs only the withdrawer address. pub fn authorization_is_valid(&self) -> Result<(), anyhow::Error> { let msg = Secp256k1Message::from_digest(self.output.key.withdrawer.as_secp256k1_msg()); - let public_key = PublicKey::from_slice(&self.output.authorization.public_key.bytes)?; + let public_key = PublicKey::from_slice(&self.output.authorization.public_key.to_bytes())?; + + anyhow::ensure!( + self.output.authorization.public_key.pkh() == self.output.key.validator, + "stake authorization was not signed by the declared validator" + ); self.output .authorization @@ -832,6 +862,51 @@ impl StakeTransactionBody { .verify(&msg, &public_key) } + /// Validate either a legacy or Wit/2.1 authorization for the protocol at + /// the transaction's prospective inclusion epoch. + pub fn authorization_is_valid_at( + &self, + protocol_version: ProtocolVersion, + network: u16, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + // Before activation, old schemas discard the appended field as unknown. Validate and hash + // exactly the legacy authorization so an upgraded node cannot diverge before the fork. + if protocol_version < ProtocolVersion::V2_1 { + return self.authorization_is_valid(); + } + + let terms = self + .output + .authorization_terms + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 stake transactions require versioned authorization terms" + ) + })?; + terms.validate_at(network, epoch)?; + anyhow::ensure!( + terms.validator == self.output.key.validator, + "authorized validator does not match stake output" + ); + anyhow::ensure!( + terms.withdrawer == self.output.key.withdrawer, + "authorized withdrawer does not match stake output" + ); + anyhow::ensure!( + self.output.authorization.public_key.pkh() == terms.authorizer(), + "stake authorization was not signed by the action authorizer" + ); + + let msg = Secp256k1Message::from_digest(terms.signing_digest()?); + let public_key = PublicKey::from_slice(&self.output.authorization.public_key.to_bytes())?; + self.output + .authorization + .signature + .verify(&msg, &public_key) + } + /// Construct a `StakeTransactionBody` from a list of inputs and one `StakeOutput`. pub fn new( inputs: Vec, @@ -854,7 +929,27 @@ impl StakeTransactionBody { .map(ValueTransferOutput::value) .unwrap_or_default(); - stake_value + change_value + stake_value.wrapping_add(*change_value) + } + + /// Return the total value committed by the stake output and optional change output. + /// + /// `value()` is retained for compatibility with callers that historically relied on the + /// wrapping arithmetic used by the other transaction bodies. Transaction construction must + /// not use that behavior, however: a malformed stake amount plus change could wrap to a small + /// value and cause insufficient UTXO selection. The checked variant lets builders fail closed + /// with the same error used for other output-sum overflows. + pub fn checked_value(&self) -> Result { + let change_value = self + .change + .as_ref() + .map(ValueTransferOutput::value) + .unwrap_or_default(); + + self.output + .value + .checked_add(change_value) + .ok_or(TransactionError::OutputValueOverflow) } /// Stake transaction weight. It is calculated as: @@ -864,6 +959,11 @@ impl StakeTransactionBody { /// /// ``` pub fn weight(&self) -> u32 { + self.weight_for_protocol(ProtocolVersion::V2_1) + } + + /// Stake transaction weight under the selected protocol rules. + pub fn weight_for_protocol(&self, protocol_version: ProtocolVersion) -> u32 { let inputs_len = u32::try_from(self.inputs.len()).unwrap_or(u32::MAX); let inputs_weight = inputs_len.saturating_mul(INPUT_SIZE); let change_weight = if self.change.is_some() { @@ -874,7 +974,7 @@ impl StakeTransactionBody { inputs_weight .saturating_add(change_weight) - .saturating_add(STAKE_OUTPUT_WEIGHT) + .saturating_add(self.output.weight_for_protocol(protocol_version)) } } @@ -990,6 +1090,12 @@ impl MemoizedHashable for StakeTransactionBody { } fn memoized_hash(&self) -> &MemoHash { + // Stake bodies expose mutable consensus fields, including the optional Wit/2.1 + // authorization terms. A caller may therefore change the body after a previous hash + // lookup (for example while assembling or normalizing a delegated transaction). Do not + // reuse an identity derived from the old terms/output; this mirrors the unstake body and + // keeps transaction IDs, signatures, and pool keys tied to the current wire image. + self.hash.set(None); &self.hash } } @@ -1129,6 +1235,16 @@ mod tests { assert_eq!(mint_tx.hash.get(), Some(hash)); } + #[test] + fn stake_hash_tracks_mutated_consensus_fields() { + let mut body = StakeTransactionBody::default(); + let initial_hash = body.hash(); + + body.output.authorization_terms = Some(crate::chain::StakeAuthorization::default()); + + assert_ne!(initial_hash, body.hash()); + } + #[test] fn memohash_eq() { let tx1 = VTTransaction::default(); diff --git a/data_structures/src/transaction_factory.rs b/data_structures/src/transaction_factory.rs index c8442126d0..abb9634195 100644 --- a/data_structures/src/transaction_factory.rs +++ b/data_structures/src/transaction_factory.rs @@ -7,12 +7,18 @@ use std::{ use serde::{Deserialize, Serialize}; use crate::{ + get_protocol_version, chain::{ DataRequestOutput, Epoch, EpochConstants, Input, OutputPointer, PublicKeyHash, StakeOutput, - ValueTransferOutput, + ValueTransferOutput, STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, }, error::TransactionError, fee::{AbsoluteFee, Fee}, + proto::versioning::ProtocolVersion, + staking::delegation::{ + DELEGATED_STAKING_MAXIMUM_NANOWITS, DELEGATED_STAKING_MINIMUM_NANOWITS, + }, transaction::{ DRTransactionBody, INPUT_SIZE, StakeTransactionBody, UnstakeTransactionBody, VTTransactionBody, @@ -40,6 +46,29 @@ pub struct TransactionInfo { pub outputs: Vec, } +/// Exact local UTXO timeout changes made while preparing a stake transaction. +/// +/// Signing and mempool admission are asynchronous at the node boundary. If either fails, restore +/// an entry only while this reservation's opaque generation still owns it. The timeout alone is +/// not an ownership proof because a later attempt may install the same value, especially when the +/// configured pending timeout is zero. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct StakeUtxoReservation { + entries: Vec<(OutputPointer, u64)>, + generation: Option, +} + +impl StakeUtxoReservation { + pub fn rollback(self, own_utxos: &mut OwnUnspentOutputsPool) { + let Some(generation) = self.generation else { + return; + }; + for (pointer, previous_timeout) in self.entries { + own_utxos.rollback_reservation(pointer, generation, previous_timeout); + } + } +} + // Structure that includes the confirmed and pending balance of a node #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] @@ -159,6 +188,31 @@ impl From for Vec { } } +/// Return the protocol-specific transaction error for a body that cannot fit in the +/// configured block weight budget. Keeping this mapping next to the generic factory +/// prevents malformed limits from reaching its bounded-iteration fallback. +fn weight_limit_error( + outputs: &TransactionOutputs, + weight: u32, + max_weight: u32, +) -> TransactionError { + match outputs { + TransactionOutputs::DataRequest((dr_output, _)) => { + TransactionError::DataRequestWeightLimitExceeded { + weight, + max_weight, + dr_output: Box::new(dr_output.clone()), + } + } + TransactionOutputs::Stake(_) => { + TransactionError::StakeWeightLimitExceeded { weight, max_weight } + } + TransactionOutputs::ValueTransfer(_) => { + TransactionError::ValueTransferWeightLimitExceeded { weight, max_weight } + } + } +} + /// Abstraction that facilitates the creation of new transactions from a set of unspent outputs. /// Transaction factories are expected to operate on this trait so that their business logic /// can be applied on many heterogeneous data structures that may implement it. @@ -188,7 +242,7 @@ pub trait OutputsCollection { return Err(TransactionError::ZeroAmount); } - let mut total_value = 0; + let mut total_value: u64 = 0; let mut total: u64 = 0; let mut outputs = vec![]; let mut pointers = vec![]; @@ -196,7 +250,13 @@ pub trait OutputsCollection { let utxo_iter = self.sort_by(utxo_strategy); for pointer in utxo_iter.iter() { - let output: ValueTransferOutput = self.get(pointer).unwrap(); + // A local own-UTXO index can briefly get ahead of the durable pool during startup, + // restoration, or a failed backend read. Treat that pointer as unavailable instead + // of turning transaction construction into a process panic; the resulting NoMoney + // error leaves the caller's UTXO reservation untouched. + let Some(output) = self.get(pointer) else { + continue; + }; total = total .checked_add(output.value) .ok_or(TransactionError::OutputValueOverflow)?; @@ -220,7 +280,9 @@ pub trait OutputsCollection { } } - total_value += output.value; + total_value = total_value + .checked_add(output.value) + .ok_or(TransactionError::OutputValueOverflow)?; pointers.push(*pointer); outputs.push(output); @@ -254,6 +316,7 @@ pub trait OutputsCollection { block_number_limit: Option, utxo_strategy: &UtxoSelectionStrategy, max_weight: u32, + protocol_version: ProtocolVersion, ) -> Result { let output_value; let mut current_weight; @@ -268,8 +331,8 @@ pub trait OutputsCollection { } TransactionOutputs::Stake((stake_output, change)) => { let body = StakeTransactionBody::new(inputs, stake_output, change); - output_value = body.value(); - current_weight = body.weight(); + output_value = body.checked_value()?; + current_weight = body.weight_for_protocol(protocol_version); } TransactionOutputs::ValueTransfer(outputs) => { let body = VTTransactionBody::new(inputs, outputs); @@ -295,10 +358,25 @@ pub trait OutputsCollection { }) } Fee::Relative(priority) => { - let absolute_fee = priority.into_absolute(current_weight); + // The initial estimate already represents the smallest possible body + // (one input and the requested outputs). Report an ordinary protocol + // error immediately when even that body exceeds the configured limit; + // otherwise a fee-driven extra input could make the bounded loop fall + // through to its old `unreachable!()` panic. + if current_weight > max_weight { + return Err(weight_limit_error(&outputs, current_weight, max_weight)); + } - let max_iterations = 1 + ((max_weight - current_weight) / INPUT_SIZE); + // A malformed or temporarily undersized stake weight limit must be handled by + // the final protocol-specific weight check, not by an underflow while estimating + // relative-fee iterations. + let max_iterations = 1 + (max_weight.saturating_sub(current_weight) / INPUT_SIZE); for _i in 0..max_iterations { + // Recompute the absolute fee from the latest body weight. Selecting + // additional UTXOs can increase the weight, so carrying the initial fee + // forward would underpay a relative-priority transaction that converges at + // a larger input count. + let absolute_fee = priority.into_absolute(current_weight); let amount = output_value .checked_add(absolute_fee.as_nanowits()) .ok_or(TransactionError::FeeOverflow)?; @@ -329,7 +407,7 @@ pub trait OutputsCollection { TransactionOutputs::Stake((stake_output, change)) => { let body = StakeTransactionBody::new(inputs, stake_output, change); - body.weight() + body.weight_for_protocol(protocol_version) } TransactionOutputs::ValueTransfer(outputs) => { let body = VTTransactionBody::new(inputs, outputs); @@ -350,7 +428,10 @@ pub trait OutputsCollection { } } - unreachable!("Unexpected exit in build_inputs_outputs method"); + // A fee-driven input can push the body over the cap on the last + // iteration. Treat that as a normal protocol error instead of + // panicking when the bounded estimator cannot converge. + Err(weight_limit_error(&outputs, current_weight, max_weight)) } } } @@ -477,68 +558,123 @@ pub fn calculate_weight( } /// Get utxos balance -pub fn get_utxos_balance( +pub fn try_get_utxos_balance( all_utxos: &UnspentOutputsPool, pkh: PublicKeyHash, now: u64, -) -> NodeBalance2 { +) -> Result { let mut locked = 0u64; let mut unlocked = 0u64; - all_utxos.visit_with_pkh( + let mut overflow = None; + all_utxos.try_visit_with_pkh( pkh, |_| {}, |x| { + if overflow.is_some() { + return; + } let vto = &x.1.0; if vto.time_lock <= now { - unlocked += vto.value; + match unlocked.checked_add(vto.value) { + Some(value) => unlocked = value, + None => overflow = Some("unlocked"), + } } else { - locked += vto.value; + match locked.checked_add(vto.value) { + Some(value) => locked = value, + None => overflow = Some("locked"), + } } }, - ); + )?; + if let Some(bucket) = overflow { + return Err(anyhow::anyhow!("{bucket} UTXO balance overflow")); + } - NodeBalance2::One { + Ok(NodeBalance2::One { locked, unlocked, staked: 0u64, - } + }) +} + +/// Get utxos balance, aborting if persistent UTXO iteration fails. +pub fn get_utxos_balance( + all_utxos: &UnspentOutputsPool, + pkh: PublicKeyHash, + now: u64, +) -> NodeBalance2 { + try_get_utxos_balance(all_utxos, pkh, now) + .expect("failed to iterate persistent UTXO storage while calculating balance") } /// Get total balance -pub fn get_total_balance( +pub fn try_get_total_balance( all_utxos: &UnspentOutputsPool, pkh: PublicKeyHash, simple: bool, -) -> NodeBalance { +) -> Result { // FIXME: this does not scale, we need to be able to get UTXOs by PKH // Get the balance of the current utxo set - let mut confirmed = 0; - let mut total = 0; - all_utxos.visit_with_pkh( + // `try_visit_with_pkh` accepts both callbacks at once, so use interior cells rather than + // borrowing the three accumulators mutably through two closures simultaneously. + let confirmed = std::cell::Cell::new(0u64); + let total = std::cell::Cell::new(0u64); + let overflow = std::cell::Cell::new(None::<&'static str>); + all_utxos.try_visit_with_pkh( pkh, |x| { + if overflow.get().is_some() { + return; + } let vto = &x.1.0; - confirmed += vto.value; + match confirmed.get().checked_add(vto.value) { + Some(value) => confirmed.set(value), + None => overflow.set(Some("confirmed")), + } }, |x| { + if overflow.get().is_some() { + return; + } let vto = &x.1.0; - total += vto.value; + match total.get().checked_add(vto.value) { + Some(value) => total.set(value), + None => overflow.set(Some("total")), + } }, - ); + )?; + + if let Some(bucket) = overflow.get() { + return Err(anyhow::anyhow!("{bucket} UTXO balance overflow")); + } + + let confirmed = confirmed.get(); + let total = total.get(); if simple { - NodeBalance::One { + Ok(NodeBalance::One { confirmed: None, total, - } + }) } else { - NodeBalance::One { + Ok(NodeBalance::One { confirmed: Some(confirmed), total, - } + }) } } +/// Get total balance, aborting if persistent UTXO iteration fails. +pub fn get_total_balance( + all_utxos: &UnspentOutputsPool, + pkh: PublicKeyHash, + simple: bool, +) -> NodeBalance { + try_get_total_balance(all_utxos, pkh, simple) + .expect("failed to iterate persistent UTXO storage while calculating total balance") +} + /// If the change_amount is greater than 0, insert a change output using the supplied `pkh`. pub fn insert_change_output( outputs: &mut Vec, @@ -586,12 +722,15 @@ pub fn build_vtt( )?; let used_pointers = tx_info.inputs.pointers.iter().cloned().map(Input::new); + let pending_until = timestamp + .checked_add(tx_pending_timeout) + .ok_or(TransactionError::PendingTimeoutOverflow)?; // Mark UTXOs as used so we don't double spend // Save the timestamp after which the transaction will be considered timed out // and the output will become available for spending it again if !dry_run { - utxos.set_used_output_pointer(used_pointers.clone(), timestamp + tx_pending_timeout); + utxos.set_used_output_pointer(used_pointers.clone(), pending_until); } let mut outputs = tx_info.outputs; @@ -637,12 +776,15 @@ pub fn build_drt( )?; let used_pointers = tx_info.inputs.pointers.iter().cloned().map(Input::new); + let pending_until = timestamp + .checked_add(tx_pending_timeout) + .ok_or(TransactionError::PendingTimeoutOverflow)?; // Mark UTXOs as used so we don't double spend // Save the timestamp after which the transaction will be considered timed out // and the output will become available for spending it again if !dry_run { - utxos.set_used_output_pointer(used_pointers.clone(), timestamp + tx_pending_timeout); + utxos.set_used_output_pointer(used_pointers.clone(), pending_until); } let mut outputs = tx_info.outputs; @@ -732,11 +874,14 @@ pub fn build_commit_collateral( )?; let used_pointers = tx_info.inputs.pointers.iter().cloned().map(Input::new); + let pending_until = timestamp + .checked_add(tx_pending_timeout) + .ok_or(TransactionError::PendingTimeoutOverflow)?; // Mark UTXOs as used so we don't double spend // Save the timestamp after which the transaction will be considered timed out // and the output will become available for spending it again - utxos.set_used_output_pointer(used_pointers.clone(), timestamp + tx_pending_timeout); + utxos.set_used_output_pointer(used_pointers.clone(), pending_until); let mut outputs = tx_info.outputs; insert_change_output( @@ -762,7 +907,7 @@ pub fn transaction_inputs_sum( let mut seen_output_pointers = HashSet::with_capacity(inputs.len()); for input in inputs { - let vt_output = utxo_diff.get(input.output_pointer()).ok_or_else(|| { + let vt_output = utxo_diff.try_get(input.output_pointer())?.ok_or_else(|| { TransactionError::OutputNotFound { output: *input.output_pointer(), } @@ -820,37 +965,230 @@ pub fn build_st( max_weight: u32, dry_run: bool, ) -> Result { + // Keep the historical public return shape for downstream Rust callers. The reservation-aware + // result is intentionally exposed by `build_st_for_protocol`, which is the explicit API used by + // ChainManager when it must roll a local UTXO reservation back after asynchronous signing or + // mempool admission fails. Dropping that handle here preserves the old fire-and-forget + // reservation semantics: a non-dry build leaves the selected entries pending until their + // timeout or an authoritative pool update supersedes them. + let protocol_version = get_protocol_version(None); + // Match ChainManager's construction-only preview behavior: an explicit authorization sidecar + // in a dry-run is a future Wit/2.1 body, even when the process is still pre-activation. This + // keeps its fee/weight estimate and downstream body identity consistent with the retained + // terms instead of silently estimating a legacy projection. + let build_protocol_version = if dry_run + && protocol_version < ProtocolVersion::V2_1 + && output.authorization_terms.is_some() + { + ProtocolVersion::V2_1 + } else { + protocol_version + }; + build_st_for_protocol( + output, + fee, + own_utxos, + own_pkh, + all_utxos, + timestamp, + tx_pending_timeout, + utxo_strategy, + max_weight, + dry_run, + build_protocol_version, + ) + .map(|(body, _reservation)| body) +} + +/// Build a stake transaction using the containing protocol's authorization and weight rules. +/// +/// Before Wit/2.1, an appended authorization-terms field is unknown to the historical protobuf +/// schema and must not affect legacy fee estimation, commission-action checks, or transaction +/// weight when this function is explicitly called with the active legacy version. The explicit +/// path therefore uses a terms-free projection for those calculations while retaining the signed +/// terms in a construction-only preview. ChainManager and the compatibility [`build_st`] wrapper +/// promote an explicit term-bearing dry-run to Wit/2.1 construction so that preview is internally +/// future-consistent; normal broadcast ingress remains pinned to the active protocol until +/// Wit/2.1 is active. +#[allow(clippy::too_many_arguments)] +pub fn build_st_for_protocol( + output: StakeOutput, + fee: Fee, + own_utxos: &mut OwnUnspentOutputsPool, + own_pkh: PublicKeyHash, + all_utxos: &UnspentOutputsPool, + timestamp: u64, + tx_pending_timeout: u64, + utxo_strategy: &UtxoSelectionStrategy, + max_weight: u32, + dry_run: bool, + protocol_version: ProtocolVersion, +) -> Result<(StakeTransactionBody, StakeUtxoReservation), TransactionError> { + if protocol_version < ProtocolVersion::V2_1 + && !dry_run + && output.authorization_terms.is_some() + { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "Wit/2.1 stake authorizations cannot be broadcast before protocol activation" + .to_owned(), + }); + } + + if protocol_version >= ProtocolVersion::V2_1 && output.authorization_terms.is_none() { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "Wit/2.1 stake transactions require versioned authorization terms".to_owned(), + }); + } + + // Validate the versioned terms before selecting or reserving any local UTXOs. The consensus + // admission boundary performs the same checks with network, inclusion-epoch, output-key, and + // signature context. The factory has the output-key context but not the network, epoch, or + // signature context yet, so it rejects every available structural/key-binding failure here. + // This also applies to dry-run V2 previews prepared before activation and keeps malformed terms + // from surviving as an apparently buildable transaction until a later submission attempt. + let is_commission_update = output + .authorization_terms + .as_ref() + .is_some_and(|terms| terms.action == STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE); + let is_exit_request = output.authorization_terms.as_ref() + .is_some_and(|terms| terms.action == STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT); + let is_policy_only = is_commission_update || is_exit_request; + if let Some(authorization_terms) = output.authorization_terms.as_ref() { + authorization_terms + .validate_structure() + .map_err(|error| TransactionError::InvalidStakeAuthorization { + reason: error.to_string(), + })?; + if authorization_terms.validator != output.key.validator { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "stake authorization validator does not match the stake output key" + .to_owned(), + }); + } + if authorization_terms.withdrawer != output.key.withdrawer { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "stake authorization withdrawer does not match the stake output key" + .to_owned(), + }); + } + } + + // Wit/1.8 through Wit/2.1 use the fixed validator stake bounds. A V2 commission + // update or signed exit intent has an intentionally zero-valued stake output. + if protocol_version >= ProtocolVersion::V1_8 && !is_policy_only { + if output.value < DELEGATED_STAKING_MINIMUM_NANOWITS { + return Err(TransactionError::StakeBelowMinimum { + min_stake: DELEGATED_STAKING_MINIMUM_NANOWITS, + stake: output.value, + }); + } + if output.value > DELEGATED_STAKING_MAXIMUM_NANOWITS { + return Err(TransactionError::StakeAboveMaximum { + max_stake: DELEGATED_STAKING_MAXIMUM_NANOWITS, + stake: output.value, + }); + } + } + + // Treat appended V2.1 terms exactly as an old schema would for legacy calculations. Keep the + // original output for a dry-run preview: the signed terms are needed if the caller will submit + // that preview after activation, while pre-fork ingress strips them before identity-sensitive + // relay/admission handling. + let legacy_output = if protocol_version < ProtocolVersion::V2_1 { + let mut legacy_output = output.clone(); + legacy_output.authorization_terms = None; + legacy_output + } else { + output.clone() + }; let mut utxos = NodeUtxos { all_utxos, own_utxos, pkh: own_pkh, }; - let tx_info = utxos.generic_transaction_factory( - TransactionOutputs::Stake((output.clone(), None)), + if is_commission_update && output.value != 0 { + return Err(TransactionError::CommissionUpdateWithStake { + validator: output.key.validator, + value: output.value, + }); + } + if is_exit_request && output.value != 0 { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "exit intent requires a zero stake value".to_owned(), + }); + } + + let tx_info = match utxos.generic_transaction_factory( + // Estimate the stake body with a possible change output. The concrete change amount is + // only known after UTXO selection, but its serialized slot still contributes to weight; + // omitting it here would let a multi-input relative-fee stake transaction underpay once + // `build_st` materializes change below. A zero-valued placeholder preserves output-value + // arithmetic while matching the existing VTT/DRT builders' change-inclusive estimate. + TransactionOutputs::Stake(( + legacy_output.clone(), + Some(ValueTransferOutput { + pkh: own_pkh, + value: 0, + time_lock: 0, + }), + )), fee, timestamp, None, utxo_strategy, max_weight, - )?; - - let used_pointers = tx_info.inputs.pointers.iter().cloned().map(Input::new); + protocol_version, + ) { + Ok(tx_info) => tx_info, + Err(TransactionError::ZeroAmount) if is_commission_update => { + return Err(TransactionError::CommissionUpdateWithoutFee { + validator: output.key.validator, + }); + } + Err(TransactionError::ZeroAmount) if is_exit_request => { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "exit intent requires a positive UTXO-funded fee".to_owned(), + }); + } + Err(error) => return Err(error), + }; - // Mark UTXOs as used so we don't double spend - // Save the timestamp after which the transaction will be considered timed out - // and the output will become available for spending it again - if !dry_run { - utxos.set_used_output_pointer(used_pointers.clone(), timestamp + tx_pending_timeout); + // Commission updates are policy-only Wit/2.1 actions. They must carry a zero-valued + // stake output and still consume a positive transaction fee so that the resulting body has + // at least one input. Enforce the invariant in the shared builder as well as RPC/consensus + // validation: this covers direct `BuildStake` callers, dry-runs, and relative-fee estimates + // that round down to zero before any local UTXO reservation or signing occurs. + if is_commission_update && tx_info.fee.as_nanowits() == 0 { + return Err(TransactionError::CommissionUpdateWithoutFee { + validator: output.key.validator, + }); + } + if is_exit_request && tx_info.fee.as_nanowits() == 0 { + return Err(TransactionError::InvalidStakeAuthorization { + reason: "exit intent requires a positive UTXO-funded fee".to_owned(), + }); } + let used_pointers = tx_info + .inputs + .pointers + .iter() + .cloned() + .map(Input::new) + .collect::>(); + let pending_until = timestamp + .checked_add(tx_pending_timeout) + .ok_or(TransactionError::PendingTimeoutOverflow)?; + // Only use a change output if there is value inserted by inputs that is not consumed by outputs // or fees let change_value = tx_info .inputs .total_value - .wrapping_sub(tx_info.output_value) - .wrapping_sub(tx_info.fee.as_nanowits()); + .checked_sub(tx_info.output_value) + .and_then(|value| value.checked_sub(tx_info.fee.as_nanowits())) + .ok_or(TransactionError::NegativeFee)?; let change = if change_value > 0 { Some(ValueTransferOutput { pkh: own_pkh, @@ -861,10 +1199,9 @@ pub fn build_st( None }; - let inputs = used_pointers.collect::>(); - let body = StakeTransactionBody::new(inputs, output, change); + let body = StakeTransactionBody::new(used_pointers.clone(), output, change); - let txn_weight = body.weight(); + let txn_weight = body.weight_for_protocol(protocol_version); if txn_weight > max_weight { return Err(TransactionError::StakeWeightLimitExceeded { weight: txn_weight, @@ -872,7 +1209,40 @@ pub fn build_st( }); } - Ok(body) + // Mark UTXOs as used only after every fallible stake-body calculation succeeds. This keeps a + // failed change or weight computation from leaving a reservation that the caller cannot see. + let mut reservation = StakeUtxoReservation { + entries: Vec::new(), + generation: None, + }; + if !dry_run { + for input in &used_pointers { + let pointer = *input.output_pointer(); + let previous_timeout = utxos + .own_utxos + .get(&pointer) + .copied() + .ok_or(TransactionError::OutputNotFound { output: pointer })?; + reservation.entries.push((pointer, previous_timeout)); + } + let generation = utxos + .own_utxos + .next_reservation_generation() + .ok_or(TransactionError::UtxoReservationGenerationOverflow)?; + for input in &used_pointers { + let pointer = *input.output_pointer(); + // Every pointer was resolved immediately above while the actor held exclusive access + // to the pool. Keep the mutation non-fallible after the first reservation write. + utxos.own_utxos.reserve_existing_with_generation( + pointer, + pending_until, + generation, + ); + } + reservation.generation = Some(generation); + } + + Ok((body, reservation)) } /// Build unstake transaction. @@ -906,6 +1276,106 @@ mod tests { use super::*; + #[test] + fn exit_intent_factory_requires_zero_stake_and_positive_utxo_fee() { + use crate::chain::StakeAuthorization; + + let own_pkh = my_pkh(); + let (mut own_utxos, all_utxos) = build_utxo_set(vec![pay_me(100)], None, vec![]); + let output = StakeOutput { + key: (own_pkh, own_pkh).into(), + value: 0, + authorization_terms: Some(StakeAuthorization::request_exit( + 0, own_pkh, own_pkh, 10, 0, + )), + ..Default::default() + }; + let (body, _) = build_st_for_protocol( + output.clone(), Fee::absolute_from_nanowits(1), &mut own_utxos, own_pkh, + &all_utxos, 1, 10, &UtxoSelectionStrategy::default(), u32::MAX, true, + ProtocolVersion::V2_1, + ).unwrap(); + assert_eq!(body.output.value, 0); + assert_eq!(body.inputs.len(), 1); + assert_eq!(body.change.unwrap().value, 99); + + for (value, fee) in [(0, 0), (1, 1)] { + let result = build_st_for_protocol( + StakeOutput { value, ..output.clone() }, Fee::absolute_from_nanowits(fee), + &mut own_utxos, own_pkh, &all_utxos, 1, 10, + &UtxoSelectionStrategy::default(), u32::MAX, true, ProtocolVersion::V2_1, + ); + assert!(matches!(result, Err(TransactionError::InvalidStakeAuthorization { .. }))); + } + } + + #[test] + fn stake_utxo_reservation_rollback_is_generation_safe() { + let restored = OutputPointer::default(); + let superseded = OutputPointer { + output_index: 1, + ..OutputPointer::default() + }; + let mut own_utxos = OwnUnspentOutputsPool::default(); + own_utxos.insert(restored, 3); + own_utxos.insert(superseded, 5); + let first_generation = own_utxos.next_reservation_generation().unwrap(); + let reservation = StakeUtxoReservation { + entries: vec![(restored, 3), (superseded, 5)], + generation: Some(first_generation), + }; + own_utxos.reserve_existing_with_generation(restored, 10, first_generation); + own_utxos.reserve_existing_with_generation(superseded, 10, first_generation); + + // A later reservation may use the byte-identical timeout (the zero-timeout, same-second + // case). Only its generation, not its timestamp, distinguishes ownership. + let second_generation = own_utxos.next_reservation_generation().unwrap(); + own_utxos.reserve_existing_with_generation(superseded, 10, second_generation); + + reservation.rollback(&mut own_utxos); + + assert_eq!(own_utxos.get(&restored), Some(&3)); + assert_eq!(own_utxos.get(&superseded), Some(&10)); + assert!(own_utxos.rollback_reservation( + superseded, + second_generation, + 5, + )); + assert_eq!(own_utxos.get(&superseded), Some(&5)); + + // Replacing the entire local pool (snapshot/key/storage refresh) must not reset the owner + // namespace while an older actor future can still return. + let replaced = OutputPointer { + output_index: 2, + ..OutputPointer::default() + }; + let mut old_pool = OwnUnspentOutputsPool::default(); + old_pool.insert(replaced, 7); + let old_generation = old_pool.next_reservation_generation().unwrap(); + old_pool.reserve_existing_with_generation(replaced, 20, old_generation); + let old_reservation = StakeUtxoReservation { + entries: vec![(replaced, 7)], + generation: Some(old_generation), + }; + + let mut replacement_pool = OwnUnspentOutputsPool::default(); + replacement_pool.insert(replaced, 7); + let replacement_generation = replacement_pool.next_reservation_generation().unwrap(); + replacement_pool.reserve_existing_with_generation( + replaced, + 20, + replacement_generation, + ); + old_reservation.rollback(&mut replacement_pool); + + assert_eq!(replacement_pool.get(&replaced), Some(&20)); + assert!(replacement_pool.rollback_reservation( + replaced, + replacement_generation, + 7, + )); + } + const MAX_VT_WEIGHT: u32 = 20000; const MAX_DR_WEIGHT: u32 = 80000; diff --git a/data_structures/src/utxo_pool.rs b/data_structures/src/utxo_pool.rs index 7f30ff1c23..2cc22a3780 100644 --- a/data_structures/src/utxo_pool.rs +++ b/data_structures/src/utxo_pool.rs @@ -7,12 +7,22 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, fmt, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, }; use witnet_util::timestamp::get_timestamp; pub use crate::utxo_pool::utxo_db::{CacheUtxosByPkh, UtxoDb, UtxoDbWrapStorage, UtxoWriteBatch}; +/// Process-wide ownership source for asynchronous own-UTXO reservations. +/// +/// The allocator is intentionally outside `OwnUnspentOutputsPool`: snapshot import, storage +/// reinitialization, and key refresh can replace the complete pool while an older actor future is +/// still outstanding. A pool-local counter could then reuse the old callback's generation. +static NEXT_OWN_UTXO_RESERVATION_GENERATION: AtomicU64 = AtomicU64::new(0); + /// Traits that provide a generic UTXO database pub mod utxo_db; @@ -44,21 +54,38 @@ impl PartialEq for UnspentOutputsPool { } impl UnspentOutputsPool { + /// Fallibly get the value transfer output referred to by the provided `OutputPointer`. + pub fn try_get( + &self, + k: &OutputPointer, + ) -> Result, anyhow::Error> { + Ok(self.try_get_map(k)?.map(|(vt, _n)| vt)) + } + /// Get the value transfer output referred to by the provided `OutputPointer` pub fn get(&self, k: &OutputPointer) -> Option { - self.get_map(k).map(|(vt, _n)| vt) + self.try_get(k) + .expect("failed to read a persistent UTXO from storage") } - fn get_map(&self, k: &OutputPointer) -> Option<(ValueTransferOutput, u32)> { + fn try_get_map( + &self, + k: &OutputPointer, + ) -> Result, anyhow::Error> { if self.diff.utxos_to_remove.contains(k) { - return None; + return Ok(None); } if let Some(x) = self.diff.utxos_to_add.get(k) { - return Some(x.clone()); + return Ok(Some(x.clone())); } - self.db_get(k) + self.try_db_get(k) + } + + fn get_map(&self, k: &OutputPointer) -> Option<(ValueTransferOutput, u32)> { + self.try_get_map(k) + .expect("failed to read a persistent UTXO from storage") } /// Returns true if the `OutputPointer` exists inside the `UnspentOutputsPool` @@ -80,8 +107,14 @@ impl UnspentOutputsPool { assert!(did_exist, "tried to remove an already removed UTXO"); } - fn db_get(&self, k: &OutputPointer) -> Option<(ValueTransferOutput, u32)> { - self.db.as_ref()?.get_utxo(k).expect("db fail") + fn try_db_get( + &self, + k: &OutputPointer, + ) -> Result, anyhow::Error> { + match &self.db { + Some(db) => db.get_utxo(k), + None => Ok(None), + } } fn db_insert( @@ -118,86 +151,140 @@ impl UnspentOutputsPool { old_vto } - fn db_iter(&self) -> impl Iterator + '_ { - self.db - .as_ref() - .map(|db| db.utxo_iterator().unwrap()) - // Transform `Option` into `impl Iterator`, with 0 elements in - // None case - .into_iter() - .flatten() + fn db_iter(&self) -> utxo_db::UtxoStorageIterator<'_> { + match &self.db { + Some(db) => match db.utxo_iterator() { + Ok(iter) => iter, + Err(error) => Box::new(std::iter::once(Err(error))), + }, + None => Box::new(std::iter::empty()), + } } - /// Iterate over all the unspent outputs - pub fn iter(&self) -> impl Iterator + '_ { + fn db_iter_by_pkh(&self, pkh: PublicKeyHash) -> utxo_db::UtxoStorageIterator<'_> { + match &self.db { + Some(db) => match db.utxo_iterator_by_pkh(pkh) { + Ok(iter) => iter, + Err(error) => Box::new(std::iter::once(Err(error))), + }, + None => Box::new(std::iter::empty()), + } + } + + /// Fallibly iterate over all the unspent outputs. + pub fn try_iter( + &self, + ) -> impl Iterator< + Item = Result<(OutputPointer, (ValueTransferOutput, u32)), anyhow::Error>, + > + '_ { self.diff .utxos_to_add .iter() - .map(|(k, v)| (*k, v.clone())) + .map(|(k, v)| Ok((*k, v.clone()))) .chain(self.db_iter()) - .filter_map(move |(k, v)| { - if self.diff.utxos_to_remove.contains(&k) { - None - } else { - Some((k, v)) - } + .filter_map(move |entry| match entry { + Ok((k, _)) if self.diff.utxos_to_remove.contains(&k) => None, + other => Some(other), }) } + /// Iterate over all the unspent outputs. + /// + /// Callers that can return an error should use [`Self::try_iter`]. This compatibility view + /// deliberately stops the process on a backend error instead of returning an incomplete set. + pub fn iter(&self) -> impl Iterator + '_ { + self.try_iter() + .map(|entry| entry.expect("failed to iterate persistent UTXO storage")) + } + + /// Fallibly visit all the UTXOs using two functions: the first visits confirmed UTXOs, while + /// the second visits all UTXOs, confirmed and unconfirmed. + pub fn try_visit( + &self, + mut fn_confirmed: F1, + mut fn_all: F2, + ) -> Result<(), anyhow::Error> + where + F1: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), + F2: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), + { + for (k, v) in &self.diff.utxos_to_add { + let entry = (*k, v.clone()); + if !self.diff.utxos_to_remove.contains(k) { + fn_all(&entry); + } + } + + for entry in self.db_iter() { + let entry = entry?; + fn_confirmed(&entry); + if !self.diff.utxos_to_remove.contains(&entry.0) { + fn_all(&entry); + } + } + + Ok(()) + } + /// Visit all the UTXOs using two functions: the first one will visit the confirmed UTXOs, while /// the second one will visit all the UTXOs, confirmed and unconfirmed. - pub fn visit(&self, fn_confirmed: F1, mut fn_all: F2) + pub fn visit(&self, fn_confirmed: F1, fn_all: F2) where F1: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), F2: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), { - self.diff - .utxos_to_add - .iter() - .map(|(k, v)| (*k, v.clone())) - .chain(self.db_iter().inspect(fn_confirmed)) - .filter_map(move |(k, v)| { - if self.diff.utxos_to_remove.contains(&k) { - None - } else { - Some((k, v)) - } - }) - .for_each(|x| fn_all(&x)) + self.try_visit(fn_confirmed, fn_all) + .expect("failed to visit persistent UTXO storage"); + } + + /// Fallibly visit all the UTXOs controlled by `pkh`. + pub fn try_visit_with_pkh( + &self, + pkh: PublicKeyHash, + mut fn_confirmed: F1, + mut fn_all: F2, + ) -> Result<(), anyhow::Error> + where + F1: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), + F2: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), + { + for (k, v) in &self.diff.utxos_to_add { + let entry = (*k, v.clone()); + if v.0.pkh == pkh && !self.diff.utxos_to_remove.contains(k) { + fn_all(&entry); + } + } + + for entry in self.db_iter_by_pkh(pkh) { + let entry = entry?; + fn_confirmed(&entry); + if !self.diff.utxos_to_remove.contains(&entry.0) { + fn_all(&entry); + } + } + + Ok(()) } /// Visit all the UTXOs with this pkh using two functions: the first one will visit the confirmed UTXOs, while /// the second one will visit all the UTXOs, confirmed and unconfirmed. - pub fn visit_with_pkh(&self, pkh: PublicKeyHash, fn_confirmed: F1, mut fn_all: F2) + pub fn visit_with_pkh(&self, pkh: PublicKeyHash, fn_confirmed: F1, fn_all: F2) where F1: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), F2: FnMut(&(OutputPointer, (ValueTransferOutput, u32))), { - self.diff - .utxos_to_add - .iter() - .filter_map(|(k, v)| { - if v.0.pkh == pkh { - Some((*k, v.clone())) - } else { - None - } - }) - .chain( - self.db - .as_ref() - .map(|db| db.utxo_iterator_by_pkh(pkh).unwrap().inspect(fn_confirmed)) - .into_iter() - .flatten(), - ) - .filter_map(move |(k, v)| { - if self.diff.utxos_to_remove.contains(&k) { - None - } else { - Some((k, v)) - } - }) - .for_each(|x| fn_all(&x)) + self.try_visit_with_pkh(pkh, fn_confirmed, fn_all) + .expect("failed to visit persistent UTXO storage by address"); + } + + /// Returns the number of the block that included the transaction referenced + /// by this OutputPointer. The difference between that number and the + /// current number of consolidated blocks is the "collateral age". + pub fn try_included_in_block_number( + &self, + k: &OutputPointer, + ) -> Result, anyhow::Error> { + Ok(self.try_get_map(k)?.map(|(_vt, n)| n)) } /// Returns the number of the block that included the transaction referenced @@ -219,34 +306,84 @@ impl UnspentOutputsPool { .expect("write_batch fail"); } - pub fn persist_add_to_batch(&mut self, batch: &mut UtxoWriteBatch) { - let mut diff = std::mem::take(&mut self.diff); - for (k, (v, block_number)) in diff.utxos_to_add.drain() { - if diff.utxos_to_remove.remove(&k) { - // This UTXO would be inserted and then removed, so we can skip both operations. - // But check that the insertion would have been valid, to detect errors. - let old_vto = self.get_map(&k); - assert_eq!( - old_vto, None, - "Tried to consolidate an UTXO that was already consolidated" - ); - } else { - self.db_insert(batch, k, v, block_number); + /// Stage the pending UTXO diff after validating every backend read and transition. + /// + /// Unlike the historical infallible helper, this method leaves the live diff untouched when + /// a represented backend error or impossible insert/remove is found. Callers can therefore + /// return the error before submitting an atomic ChainState batch, and the original in-memory + /// state remains available for retry or fail-stop recovery. + pub fn try_persist_add_to_batch( + &mut self, + batch: &mut UtxoWriteBatch, + ) -> Result<(), anyhow::Error> { + // Validate against the durable database, not `get_map`, because `get_map` also sees the + // pending diff and would make an add+remove pair appear present. Such a pair is a valid + // no-op only when the key was absent from the durable set. + for key in self.diff.utxos_to_add.keys() { + let durable = self.try_db_get(key)?; + if durable.is_some() && !self.diff.utxos_to_remove.contains(key) { + return Err(anyhow::anyhow!( + "Tried to consolidate an UTXO that was already consolidated: {key}" + )); + } + if durable.is_some() && self.diff.utxos_to_remove.contains(key) { + return Err(anyhow::anyhow!( + "Tried to insert and remove an already consolidated UTXO: {key}" + )); + } + } + for key in &self.diff.utxos_to_remove { + if self.diff.utxos_to_add.contains_key(key) { + // The add-loop already checked that this is a valid no-op against an absent + // durable key. Do not emit either operation for the pair. + continue; + } + if self.try_db_get(key)?.is_none() { + return Err(anyhow::anyhow!( + "Tried to remove an UTXO that was already removed or is missing: {key}" + )); } } - for k in diff.utxos_to_remove.drain() { - self.db_remove(batch, k).expect("db_remove failed"); + // Move the validated diff into the backend batch instead of borrowing and cloning every + // output. Snapshot import and legacy migration can carry millions of outputs; retaining + // both the source map and cloned batch values needlessly doubles their peak memory. An + // add/remove pair was validated above as an absent durable no-op, so remove it from the + // moved additions map and emit neither operation. + let Diff { + mut utxos_to_add, + utxos_to_remove, + } = std::mem::take(&mut self.diff); + for key in utxos_to_remove { + if utxos_to_add.remove(&key).is_none() { + batch.delete(key); + } } + for (key, (value, block_number)) in utxos_to_add { + batch.put(key, (value, block_number)); + } + Ok(()) + } + + pub fn persist_add_to_batch(&mut self, batch: &mut UtxoWriteBatch) { + self.try_persist_add_to_batch(batch) + .expect("failed to stage UTXO persistence batch") } pub fn remove_persisted_from_memory(&mut self, persisted: &Diff) { - for k in persisted.utxos_to_add.keys() { - self.diff.utxos_to_add.remove(k).unwrap(); + // Persistence futures can acknowledge overlapping snapshots. Treat the captured value as + // the per-key generation token: remove it only when the live pending addition is still + // exactly the operation that was written. A later replacement must remain pending. + for (k, persisted_value) in &persisted.utxos_to_add { + if self.diff.utxos_to_add.get(k) == Some(persisted_value) { + self.diff.utxos_to_add.remove(k); + } } for k in &persisted.utxos_to_remove { - assert!(self.diff.utxos_to_remove.remove(k)); + // A broader overlapping snapshot may acknowledge the same removal after an earlier + // successful write. Missing entries are therefore an idempotent no-op. + self.diff.utxos_to_remove.remove(k); } } @@ -272,43 +409,92 @@ impl UnspentOutputsPool { /// Delete all the UTXOs stored in the database. Returns the number of removed UTXOs. pub fn delete_all_from_db(&mut self) -> usize { + self.try_delete_all_from_db() + .expect("failed to delete all persistent UTXOs") + } + + /// Fallibly delete all the UTXOs stored in the database. + pub fn try_delete_all_from_db(&mut self) -> Result { let mut batch = UtxoWriteBatch::default(); - let total = self.delete_all_from_db_batch(&mut batch); + let total = self.try_delete_all_from_db_batch(&mut batch)?; self.db .as_mut() - .expect("no db") - .write(batch) - .expect("write_batch fail"); + .ok_or_else(|| anyhow::anyhow!("cannot delete UTXOs without a database"))? + .write(batch)?; - total + Ok(total) } /// Delete all the UTXOs stored in the database. Returns the number of removed UTXOs. pub fn delete_all_from_db_batch(&mut self, batch: &mut UtxoWriteBatch) -> usize { - let mut total = 0; - for (k, _v) in self.db_iter() { - batch.delete(k); - total += 1; + self.try_delete_all_from_db_batch(batch) + .expect("failed to iterate persistent UTXO storage for deletion") + } + + /// Stage deletion of all UTXOs, propagating iterator failures before any storage write occurs. + pub fn try_delete_all_from_db_batch( + &mut self, + batch: &mut UtxoWriteBatch, + ) -> Result { + let mut total = 0usize; + for entry in self.db_iter() { + let (_k, _v) = entry?; + total = total + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("persistent UTXO count exceeds usize capacity"))?; } - total + // Keep the count/error-propagation pass above, but represent the deletion as one backend + // range operation instead of retaining one key per UTXO in the caller's atomic batch. + if self.db.is_some() { + batch.delete_all(); + } + + Ok(total) + } + + /// Collect this database-backed pool into the portable snapshot representation. + pub fn try_into_portable(self) -> Result { + let map = self.try_iter().collect::, _>>()?; + + Ok(OldUnspentOutputsPool { map }) } } /// List of unspent outputs that can be spent by this node -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct OwnUnspentOutputsPool { /// Map of output pointer to timestamp /// Those UTXOs have a timestamp value to avoid double spending map: HashMap, + /// Local-only ownership of asynchronous UTXO reservations. + /// + /// The timeout remains the selection policy, but it is not a safe compare-and-swap token: two + /// reservations can legitimately install the same timeout, in particular when the configured + /// pending timeout is zero. Generations let a late rollback distinguish its own write from a + /// newer reservation without changing the serialized `OutputPointer -> timestamp` contract. + #[serde(skip)] + reservation_generations: HashMap, } +// Reservation generations are transient ownership metadata, not part of the logical or persisted +// own-UTXO set. Preserve the type's historical equality contract by comparing only the map that is +// serialized. +impl PartialEq for OwnUnspentOutputsPool { + fn eq(&self, other: &Self) -> bool { + self.map == other.map + } +} + +impl Eq for OwnUnspentOutputsPool {} + impl OwnUnspentOutputsPool { pub fn new() -> Self { Self { map: HashMap::default(), + reservation_generations: HashMap::default(), } } pub fn get(&self, k: &Q) -> Option<&u64> @@ -322,6 +508,9 @@ impl OwnUnspentOutputsPool { where OutputPointer: std::borrow::Borrow, { + // Any unowned timestamp write supersedes an asynchronous reservation. Clear its generation + // before exposing the mutable value so a stale callback cannot roll the write back. + self.reservation_generations.remove(k); self.map.get_mut(k) } @@ -333,17 +522,75 @@ impl OwnUnspentOutputsPool { } pub fn insert(&mut self, k: OutputPointer, v: u64) -> Option { + // Ordinary insertions are authoritative local-state changes, not continuations of a + // transaction builder's asynchronous reservation. + self.reservation_generations.remove(&k); self.map.insert(k, v) } pub fn remove(&mut self, k: &OutputPointer) -> Option { + self.reservation_generations.remove(k); self.map.remove(k) } pub fn drain(&mut self) -> std::collections::hash_map::Drain<'_, OutputPointer, u64> { + self.reservation_generations.clear(); self.map.drain() } + /// Allocate an opaque owner for one asynchronous reservation attempt. + /// + /// Generation zero is never issued. Exhaustion fails closed rather than reusing an owner that + /// an arbitrarily late callback could still hold. + pub(crate) fn next_reservation_generation(&self) -> Option { + let mut current = NEXT_OWN_UTXO_RESERVATION_GENERATION.load(Ordering::Relaxed); + loop { + let generation = current.checked_add(1)?; + match NEXT_OWN_UTXO_RESERVATION_GENERATION.compare_exchange_weak( + current, + generation, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return Some(generation), + Err(observed) => current = observed, + } + } + } + + /// Install a usage timeout owned by `generation`, returning the value it replaced. + pub(crate) fn reserve_existing_with_generation( + &mut self, + pointer: OutputPointer, + pending_until: u64, + generation: u64, + ) -> u64 { + debug_assert_ne!(generation, 0); + let previous = self + .map + .get(&pointer) + .copied() + .expect("cannot reserve a missing own UTXO"); + self.map.insert(pointer, pending_until); + self.reservation_generations.insert(pointer, generation); + previous + } + + /// Restore one reservation only if the supplied generation still owns the pointer. + pub(crate) fn rollback_reservation( + &mut self, + pointer: OutputPointer, + generation: u64, + previous_timeout: u64, + ) -> bool { + if self.reservation_generations.get(&pointer).copied() != Some(generation) { + return false; + } + self.reservation_generations.remove(&pointer); + self.map.insert(pointer, previous_timeout); + true + } + pub fn iter(&self) -> std::collections::hash_map::Iter<'_, OutputPointer, u64> { self.map.iter() } @@ -364,7 +611,15 @@ impl OwnUnspentOutputsPool { pub fn sort(&self, all_utxos: &UnspentOutputsPool, bigger_first: bool) -> Vec { self.keys() .sorted_by_key(|o| { - let value = all_utxos.get(o).map(|vt| i128::from(vt.value)).unwrap_or(0); + // Sorting is a best-effort admission aid. A backend read failure must not panic + // while a transaction builder is preparing a candidate; the later selection + // pass will skip the same unavailable pointer and return a normal NoMoney error. + let value = all_utxos + .try_get(o) + .ok() + .flatten() + .map(|vt| i128::from(vt.value)) + .unwrap_or(0); if bigger_first { -value } else { value } }) @@ -373,15 +628,26 @@ impl OwnUnspentOutputsPool { } /// Get balance + pub fn try_get_balance( + &self, + all_utxos: &UnspentOutputsPool, + ) -> Result { + self.keys().try_fold(0u64, |total, output_pointer| { + let output = all_utxos.try_get(output_pointer)?.ok_or_else(|| { + anyhow::anyhow!( + "mismatch between OwnUnspentOutputsPool and UnspentOutputsPool for {output_pointer}" + ) + })?; + total + .checked_add(output.value) + .ok_or_else(|| anyhow::anyhow!("own UTXO balance overflow")) + }) + } + + /// Get balance, aborting if a persistent UTXO cannot be read. pub fn get_balance(&self, all_utxos: &UnspentOutputsPool) -> u64 { - self.keys() - .map(|o| { - all_utxos - .get(o) - .expect("mismatch between OwnUnspentOutputsPool and UnspentOutputsPool") - .value - }) - .sum() + self.try_get_balance(all_utxos) + .expect("failed to calculate own UTXO balance") } } @@ -416,7 +682,7 @@ impl OutputsCollection for NodeUtxosRef<'_> { } fn get(&self, outptr: &OutputPointer) -> Option { - self.all_utxos.get(outptr) + self.all_utxos.try_get(outptr).ok().flatten() } fn get_usage_timeout(&self, outptr: &OutputPointer) -> Option { @@ -437,7 +703,10 @@ impl OutputsCollection for NodeUtxosRef<'_> { } fn get_included_block_number(&self, outptr: &OutputPointer) -> Option { - self.all_utxos.included_in_block_number(outptr) + self.all_utxos + .try_included_in_block_number(outptr) + .ok() + .flatten() } fn set_used_output_pointer(&mut self, _inputs: impl Iterator, _ts: u64) { @@ -561,7 +830,7 @@ pub struct UtxoInfo { fn create_utxo_metadata( vto: &ValueTransferOutput, output_pointer: OutputPointer, - all_utxos: &UnspentOutputsPool, + included_block_number: u32, block_number_limit: u32, ) -> UtxoMetadata { let now = get_timestamp() as u64; @@ -570,8 +839,7 @@ fn create_utxo_metadata( } else { 0 }; - let utxo_mature: bool = - all_utxos.included_in_block_number(&output_pointer).unwrap() <= block_number_limit; + let utxo_mature = included_block_number <= block_number_limit; UtxoMetadata { output_pointer, @@ -583,43 +851,72 @@ fn create_utxo_metadata( /// Get Utxo Information #[allow(clippy::cast_sign_loss)] -pub fn get_utxo_info( +pub fn try_get_utxo_info( pkh: Option, own_utxos: &OwnUnspentOutputsPool, all_utxos: &UnspentOutputsPool, collateral_min: u64, block_number_limit: u32, -) -> UtxoInfo { +) -> Result { let utxos = if let Some(pkh) = pkh { let mut v = vec![]; - all_utxos.visit_with_pkh( + all_utxos.try_visit_with_pkh( pkh, |_| { // This closure handles confirmed utxos and the next one handles all utxos. // We do not need to do anything here because this method returns all utxos. }, - |(o, (vto, _block_number))| { - v.push(create_utxo_metadata(vto, *o, all_utxos, block_number_limit)); + |(o, (vto, block_number))| { + v.push(create_utxo_metadata( + vto, + *o, + *block_number, + block_number_limit, + )); }, - ); + )?; v } else { // Read your own UtxoInfo is cheaper than from other pkhs - own_utxos - .iter() - .filter_map(|(o, _)| { - all_utxos - .get(o) - .as_ref() - .map(|vto| create_utxo_metadata(vto, *o, all_utxos, block_number_limit)) - }) - .collect() + let mut v = vec![]; + for (output_pointer, _) in own_utxos.iter() { + if let Some((vto, included_block_number)) = + all_utxos.try_get_map(output_pointer)? + { + v.push(create_utxo_metadata( + &vto, + *output_pointer, + included_block_number, + block_number_limit, + )); + } + } + v }; - UtxoInfo { + Ok(UtxoInfo { utxos, collateral_min, - } + }) +} + +/// Get UTXO information, aborting if persistent UTXO iteration fails. +#[allow(clippy::cast_sign_loss)] +pub fn get_utxo_info( + pkh: Option, + own_utxos: &OwnUnspentOutputsPool, + all_utxos: &UnspentOutputsPool, + collateral_min: u64, + block_number_limit: u32, +) -> UtxoInfo { + try_get_utxo_info( + pkh, + own_utxos, + all_utxos, + collateral_min, + block_number_limit, + ) + .expect("failed to iterate persistent UTXO storage while collecting UTXO information") } /// Diffs to apply to an utxo set. This type does not contains a @@ -644,6 +941,118 @@ impl Diff { utxo_set.remove(output_pointer); } } + + /// Apply this diff while representing stale or duplicate additions/removals as an error. + /// + /// [`Self::apply`] preserves the historical assertion-based contract. Accepted-block + /// consolidation needs a rejectable boundary instead: a malformed or stale diff must not abort + /// the process after other state has been projected. Preflight the complete diff before making + /// any change, then perform the historical insertion/removal operations on the private pool. + pub fn try_apply( + mut self, + utxo_set: &mut UnspentOutputsPool, + ) -> Result<(), anyhow::Error> { + // Preflight against both the pending diff and the durable backend. `insert` and `remove` + // only assert on duplicate pending keys; they cannot see a database-backed UTXO. Without + // this check a stale block could be projected into the live in-memory state and only fail + // later when a superblock persistence batch finally revalidated the durable key. + for output_pointer in self.utxos_to_add.keys() { + if utxo_set.diff.utxos_to_add.contains_key(output_pointer) { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} already exists in the pending addition set" + )); + } + if utxo_set.diff.utxos_to_remove.contains(output_pointer) { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} already exists in the pending removal set" + )); + } + if utxo_set.try_db_get(output_pointer)?.is_some() { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} already exists in durable storage" + )); + } + } + + for output_pointer in &self.utxos_to_remove { + if utxo_set.diff.utxos_to_remove.contains(output_pointer) { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} already exists in the pending removal set" + )); + } + + // A removal of an output created by this diff or by an earlier pending diff is the + // historical add-then-remove no-op. Otherwise require the durable key to exist so a + // missing tombstone cannot be published and deferred to storage acknowledgement. + if !self.utxos_to_add.contains_key(output_pointer) + && !utxo_set.diff.utxos_to_add.contains_key(output_pointer) + && utxo_set.try_db_get(output_pointer)?.is_none() + { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} is missing from durable storage" + )); + } + } + + for (output_pointer, (output, block_number)) in self.utxos_to_add.drain() { + utxo_set.insert(output_pointer, output, block_number); + } + for output_pointer in self.utxos_to_remove.drain() { + utxo_set.remove(&output_pointer); + } + + Ok(()) + } + + /// Apply this diff to a database-free pool while retaining only its logical live UTXOs. + /// + /// Ordinary [`Self::apply`] deliberately leaves additions and removal tombstones in the + /// pool's pending diff until persistence acknowledges them. Historical snapshot verification + /// has no database to acknowledge and can replay millions of already-persisted blocks; using + /// that representation there would retain every output ever created. This fallible variant is + /// for such read-only projections: it requires an already compact, database-free pool, + /// preflights the full transition, and then removes spent entries outright. + pub fn try_apply_in_memory_compacting( + mut self, + utxo_set: &mut UnspentOutputsPool, + ) -> Result<(), anyhow::Error> { + if utxo_set.db.is_some() { + return Err(anyhow::anyhow!( + "cannot compact a UTXO diff into a database-backed pool" + )); + } + if !utxo_set.diff.utxos_to_remove.is_empty() { + return Err(anyhow::anyhow!( + "cannot compact into a pool that already contains removal tombstones" + )); + } + + for output_pointer in self.utxos_to_add.keys() { + if utxo_set.diff.utxos_to_add.contains_key(output_pointer) { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} already exists before compacting a replay diff" + )); + } + } + for output_pointer in &self.utxos_to_remove { + if !utxo_set.diff.utxos_to_add.contains_key(output_pointer) + && !self.utxos_to_add.contains_key(output_pointer) + { + return Err(anyhow::anyhow!( + "UTXO {output_pointer} is absent before compacting a replay diff" + )); + } + } + + for (output_pointer, output) in self.utxos_to_add.drain() { + utxo_set.diff.utxos_to_add.insert(output_pointer, output); + } + for output_pointer in self.utxos_to_remove.drain() { + utxo_set.diff.utxos_to_add.remove(&output_pointer); + } + Ok(()) + } + /// Iterate over all the utxos_to_add and utxos_to_remove while applying a function. /// /// Any shared mutable state used by `F1` and `F2` can be used as the first argument: @@ -678,6 +1087,7 @@ impl Diff { /// Contains a reference to an UnspentOutputsPool plus subsequent /// insertions and deletions to performed on that pool. /// Use `.take_diff()` to obtain an instance of the `Diff` type. +#[derive(Clone)] pub struct UtxoDiff<'a> { diff: Diff, utxo_set: &'a UnspentOutputsPool, @@ -717,22 +1127,30 @@ impl<'a> UtxoDiff<'a> { /// Get an utxo from the original utxo set or one that has been /// recorded as inserted later. If the same utxo has been recorded /// as removed, None will be returned. - pub fn get(&self, output_pointer: &OutputPointer) -> Option { - self.utxo_set - .get(output_pointer) - .or_else(|| { + pub fn try_get( + &self, + output_pointer: &OutputPointer, + ) -> Result, anyhow::Error> { + if self.diff.utxos_to_remove.contains(output_pointer) { + return Ok(None); + } + + self.utxo_set.try_get(output_pointer).map(|output| { + output.or_else(|| { self.diff .utxos_to_add .get(output_pointer) .map(|(vt, _block_number)| vt.clone()) }) - .and_then(|output| { - if self.diff.utxos_to_remove.contains(output_pointer) { - None - } else { - Some(output) - } - }) + }) + } + + /// Get an utxo from the original utxo set or one that has been + /// recorded as inserted later. If the same utxo has been recorded + /// as removed, None will be returned. + pub fn get(&self, output_pointer: &OutputPointer) -> Option { + self.try_get(output_pointer) + .expect("failed to read a persistent UTXO from storage") } /// Consumes the UtxoDiff and returns only the diffs, without the @@ -741,19 +1159,35 @@ impl<'a> UtxoDiff<'a> { self.diff } + /// Returns the number of the block that included the transaction referenced + /// by this OutputPointer. The difference between that number and the + /// current number of consolidated blocks is the "collateral age". + pub fn try_included_in_block_number( + &self, + output_pointer: &OutputPointer, + ) -> Result, anyhow::Error> { + if self.diff.utxos_to_remove.contains(output_pointer) { + return Ok(None); + } + + // Keep the maturity view in lockstep with `try_get`: an output created by an earlier + // transaction in this block-local diff is spendable in the value view and carries the + // candidate block number recorded at insertion time. Falling through to the durable pool + // would incorrectly report it as missing metadata and could reject an otherwise valid + // same-block collateral input. + if let Some((_, block_number)) = self.diff.utxos_to_add.get(output_pointer) { + return Ok(Some(*block_number)); + } + + self.utxo_set.try_included_in_block_number(output_pointer) + } + /// Returns the number of the block that included the transaction referenced /// by this OutputPointer. The difference between that number and the /// current number of consolidated blocks is the "collateral age". pub fn included_in_block_number(&self, output_pointer: &OutputPointer) -> Option { - self.utxo_set - .included_in_block_number(output_pointer) - .and_then(|output| { - if self.diff.utxos_to_remove.contains(output_pointer) { - None - } else { - Some(output) - } - }) + self.try_included_in_block_number(output_pointer) + .expect("failed to read a persistent UTXO inclusion height from storage") } pub fn get_utxo_set(&self) -> &'a UnspentOutputsPool { @@ -784,8 +1218,22 @@ impl From for OldUnspentOutputsPool { /// Enable backwards compatibility of `UnspentOutputsPool` (this eases the portability of /// `ChainState`). fn from(pool: UnspentOutputsPool) -> OldUnspentOutputsPool { - let map = pool.iter().collect(); + pool.try_into_portable() + .expect("failed to iterate persistent UTXO storage for portable conversion") + } +} - OldUnspentOutputsPool { map } +impl From for UnspentOutputsPool { + /// Stage a portable in-memory UTXO image as an ordinary diff. The resulting pool deliberately + /// has no database: callers can append every imported output to an existing atomic write batch + /// without consulting or mutating the live backend first. + fn from(pool: OldUnspentOutputsPool) -> UnspentOutputsPool { + UnspentOutputsPool { + diff: Diff { + utxos_to_add: pool.map, + utxos_to_remove: HashSet::default(), + }, + db: None, + } } } diff --git a/data_structures/src/utxo_pool/utxo_db.rs b/data_structures/src/utxo_pool/utxo_db.rs index 67c1e69ebb..9bbd46e0fe 100644 --- a/data_structures/src/utxo_pool/utxo_db.rs +++ b/data_structures/src/utxo_pool/utxo_db.rs @@ -3,7 +3,10 @@ use anyhow::Error; use std::{ collections::{HashMap, HashSet}, str::FromStr, - sync::RwLock, + sync::{ + RwLock, + atomic::{AtomicBool, Ordering}, + }, }; use witnet_storage::storage::{Storage, StorageIterator, WriteBatch, WriteBatchItem}; @@ -19,6 +22,10 @@ pub trait UtxoDb { pkh: PublicKeyHash, ) -> Result, anyhow::Error>; fn write(&self, batch: UtxoWriteBatch) -> Result<(), anyhow::Error>; + /// Atomically persist a UTXO batch using the backend's synchronous durability primitive. + fn write_sync(&self, batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + self.write(batch) + } } #[derive(Default)] @@ -29,20 +36,79 @@ pub struct UtxoWriteBatch { enum UtxoWriteBatchItem { Put(OutputPointer, (ValueTransferOutput, u32)), Delete(OutputPointer), + /// Atomically clear the complete UTXO key namespace. + DeleteAll, Raw(WriteBatchItem), } +const UTXO_KEY_PREFIX: &[u8] = b"UTXO-"; +const UTXO_KEY_RANGE_END: &[u8] = b"UTXO."; + +fn raw_item_touches_utxo_namespace(item: &WriteBatchItem) -> bool { + match item { + WriteBatchItem::Put(key, _) | WriteBatchItem::Delete(key) => { + key.starts_with(UTXO_KEY_PREFIX) + } + WriteBatchItem::DeleteRange(start, end) => { + // Treat malformed ranges as potentially overlapping. The backend remains responsible + // for rejecting them, but the cache must never assume that an untrusted raw range is + // harmless before the write has been reconciled. + if start.as_slice() >= end.as_slice() { + return true; + } + start.as_slice() < UTXO_KEY_RANGE_END && end.as_slice() > UTXO_KEY_PREFIX + } + } +} + impl UtxoWriteBatch { pub fn put(&mut self, k: OutputPointer, v: (ValueTransferOutput, u32)) { self.v.push(UtxoWriteBatchItem::Put(k, v)); } + /// Add a raw non-UTXO put operation in the same atomic batch. + /// + /// Raw operations are not interpreted as typed cache deltas. The cache wrapper detects a raw + /// operation that intersects the `UTXO-` namespace and performs a full reconciliation, but + /// callers should still use [`Self::put`] for UTXO values whenever possible. pub fn put_raw(&mut self, k: Vec, v: Vec) { self.v .push(UtxoWriteBatchItem::Raw(WriteBatchItem::Put(k, v))); } + /// Delete a raw non-UTXO key in the same atomic batch. + /// + /// Raw operations are not interpreted as typed cache deltas. The cache wrapper detects a raw + /// operation that intersects the `UTXO-` namespace and performs a full reconciliation, but + /// callers should still use [`Self::delete`] for UTXOs whenever possible. + pub fn delete_raw(&mut self, k: Vec) { + self.v + .push(UtxoWriteBatchItem::Raw(WriteBatchItem::Delete(k))); + } pub fn delete(&mut self, k: OutputPointer) { self.v.push(UtxoWriteBatchItem::Delete(k)); } + /// Atomically delete every persisted UTXO in the batch's backend namespace. + /// + /// This is intentionally a UTXO-specific operation rather than a raw range so cached + /// address indexes can be cleared only after the backend confirms the write. + pub fn delete_all(&mut self) { + self.v.push(UtxoWriteBatchItem::DeleteAll); + } + + /// Return the keys used by raw put/delete operations in this batch. + /// + /// Raw operations are intentionally exposed only as borrowed keys so storage boundaries can + /// enforce namespace ownership before the batch is consumed. Typed UTXO operations are always + /// encoded under the `UTXO-` namespace and therefore do not appear here. + pub fn raw_keys(&self) -> impl Iterator { + self.v.iter().filter_map(|item| match item { + UtxoWriteBatchItem::Raw(WriteBatchItem::Put(key, _)) + | UtxoWriteBatchItem::Raw(WriteBatchItem::Delete(key)) => Some(key.as_slice()), + UtxoWriteBatchItem::Raw(WriteBatchItem::DeleteRange(_, _)) + | UtxoWriteBatchItem::Put(_, _) + | UtxoWriteBatchItem::Delete(_) + | UtxoWriteBatchItem::DeleteAll => None, + }) + } } impl From for WriteBatch { @@ -62,6 +128,12 @@ impl From for WriteBatch { let key_string = format!("UTXO-{k}"); batch.delete(key_string.as_bytes().to_vec()); } + UtxoWriteBatchItem::DeleteAll => { + // Output pointers are encoded after the fixed `UTXO-` namespace prefix. The + // exclusive upper bound `UTXO.` is the immediate lexicographic successor of + // that prefix, so this range contains every UTXO key and no adjacent key. + batch.delete_range(UTXO_KEY_PREFIX.to_vec(), UTXO_KEY_RANGE_END.to_vec()); + } UtxoWriteBatchItem::Raw(x) => { batch.batch.push(x); } @@ -74,7 +146,7 @@ impl From for WriteBatch { /// Iterator over key-value pairs pub type UtxoStorageIterator<'a> = - Box + 'a>; + Box> + 'a>; /// Wrap a `Storage` implementation that allows to put and delete raw bytes in a way that allows /// storing `OutputPointer`s. UTXOs are prefixed as `"UTXO-"`, followed by the string representation @@ -109,6 +181,10 @@ impl Storage for UtxoDbWrapStorage { fn write(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { self.0.write(batch) } + + fn write_sync(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { + self.0.write_sync(batch) + } } // The UtxoDb implementation handles the conversion from UTXOs and ValueTransferOutputs into raw @@ -117,35 +193,57 @@ impl UtxoDb for UtxoDbWrapStorage { fn get_utxo(&self, k: &OutputPointer) -> Result, Error> { let key_string = format!("UTXO-{k}"); - Ok(self.0.get(key_string.as_bytes())?.map(|bytes| { - bincode::deserialize::<(ValueTransferOutput, u32)>(&bytes).expect("bincode fail") - })) + self.0 + .get(key_string.as_bytes())? + .map(|bytes| { + crate::bincode_compatibility::deserialize_exact::<( + ValueTransferOutput, + u32, + )>(&bytes) + .map_err(|error| anyhow::anyhow!("invalid value for stored UTXO {k}: {error}")) + }) + .transpose() } fn utxo_iterator(&self) -> Result, Error> { let iter = self.0.prefix_iterator(b"UTXO-")?; - Ok(Box::new(iter.map(|(k, v)| { - let key_string = String::from_utf8(k).unwrap(); - let output_pointer_str = key_string.strip_prefix("UTXO-").unwrap(); - let key = OutputPointer::from_str(output_pointer_str).unwrap(); - let value = bincode::deserialize(&v).unwrap(); - - (key, value) + Ok(Box::new(iter.map(|entry| { + let (k, v) = entry?; + let key_string = String::from_utf8(k) + .map_err(|error| anyhow::anyhow!("invalid UTF-8 UTXO storage key: {error}"))?; + let output_pointer_str = key_string.strip_prefix("UTXO-").ok_or_else(|| { + anyhow::anyhow!("UTXO storage key is missing the expected prefix: {key_string}") + })?; + let key = OutputPointer::from_str(output_pointer_str).map_err(|error| { + anyhow::anyhow!("invalid output pointer in UTXO storage key {key_string}: {error}") + })?; + let value: (ValueTransferOutput, u32) = + crate::bincode_compatibility::deserialize_exact(&v).map_err(|error| { + anyhow::anyhow!("invalid value for stored UTXO {key}: {error}") + })?; + + Ok((key, value)) }))) } fn utxo_iterator_by_pkh(&self, pkh: PublicKeyHash) -> Result, Error> { let iter = self.utxo_iterator()?; - Ok(Box::new(iter.filter_map(move |(k, v)| { - if v.0.pkh == pkh { Some((k, v)) } else { None } + Ok(Box::new(iter.filter_map(move |entry| match entry { + Ok((k, v)) if v.0.pkh == pkh => Some(Ok((k, v))), + Ok(_) => None, + Err(error) => Some(Err(error)), }))) } fn write(&self, batch: UtxoWriteBatch) -> Result<(), Error> { self.0.write(batch.into()) } + + fn write_sync(&self, batch: UtxoWriteBatch) -> Result<(), Error> { + self.0.write_sync(batch.into()) + } } /// Wrap a `UtxoDb` implementation and add a cache of `address` to `list of UTXOs`. @@ -157,6 +255,10 @@ pub struct CacheUtxosByPkh { // * write needs to be able to quickly insert and remove elements, so `HashSet` is better than `Vec`. // * utxo_iterator_by_pkh needs to be able to borrow a value from the hashmap, so `RwLock` is better than `Mutex`. cache: RwLock>>, + /// False while a raw storage mutation is being reconciled, or when an ambiguous backend error + /// was followed by a failed full-cache rebuild. Address-scoped reads must never expose the + /// potentially stale pre-write projection. + cache_valid: AtomicBool, } impl CacheUtxosByPkh { @@ -166,36 +268,235 @@ impl CacheUtxosByPkh { pub fn new_with_progress( db: S, - mut progress_cb: F, + progress_cb: F, ) -> Result { + let cache = Self::collect_cache(&db, progress_cb)?; + + Ok(Self { + db, + cache: RwLock::new(cache), + cache_valid: AtomicBool::new(true), + }) + } + + fn collect_cache( + db: &S, + mut progress_cb: F, + ) -> Result>, anyhow::Error> { let mut cache: HashMap> = HashMap::default(); - // Initialize cache - for (i, (k, v)) in db.utxo_iterator()?.enumerate() { + // Build a complete projection before publishing it. This path is shared by startup and by + // ambiguous-write recovery, where the backend may already contain the attempted batch. + for (i, entry) in db.utxo_iterator()?.enumerate() { + let (k, v) = entry?; cache.entry(v.0.pkh).or_default().insert(k); - // Allow caller to log progress because this iterator may take a few seconds on mainnet. progress_cb(i); } - Ok(Self { - db, - cache: RwLock::new(cache), - }) + Ok(cache) + } + + fn restore_cache_if_invalid(&self) -> Result<(), anyhow::Error> { + if self.cache_valid.load(Ordering::Acquire) { + return Ok(()); + } + + let mut cache = self + .cache + .write() + .map_err(|_| anyhow::anyhow!("UTXO address cache lock is poisoned"))?; + if self.cache_valid.load(Ordering::Acquire) { + return Ok(()); + } + *cache = Self::collect_cache(&self.db, |_| {})?; + self.cache_valid.store(true, Ordering::Release); + Ok(()) + } + + fn write_and_publish(&self, batch: UtxoWriteBatch, persist: F) -> Result<(), Error> + where + F: FnOnce(&S, UtxoWriteBatch) -> Result<(), Error>, + { + // Resolve every cache delta first, but do not expose it until the underlying atomic write + // succeeds. Holding the cache lock preserves write ordering. A backend error is ambiguous, + // so the error path below reconstructs whichever complete image is actually durable. + let mut cache = self + .cache + .write() + .map_err(|_| anyhow::anyhow!("UTXO address cache lock is poisoned"))?; + enum CacheDelta { + Clear, + Put(PublicKeyHash, OutputPointer), + Delete(PublicKeyHash, OutputPointer), + } + + let mut cache_deltas = Vec::new(); + // A typed put normally appends a never-before-seen output pointer. If it overwrites an + // existing pointer, however, the old address mapping cannot be removed from the cache + // from the new value alone. The same ambiguity occurs when one batch touches a pointer + // more than once (for example Put/Delete or two Puts). Mark those batches for a complete + // post-write reconciliation instead of publishing a stale address index. + let mut touched_typed_keys = HashSet::new(); + let mut rebuild_after_success = false; + let raw_utxo_mutation = batch.v.iter().any(|item| match item { + UtxoWriteBatchItem::Raw(raw) => raw_item_touches_utxo_namespace(raw), + UtxoWriteBatchItem::Put(_, _) + | UtxoWriteBatchItem::Delete(_) + | UtxoWriteBatchItem::DeleteAll => false, + }); + if !raw_utxo_mutation { + // Track the ordered result of typed operations while preparing cache deltas. A plain + // storage lookup only describes the pre-batch image, so it incorrectly rejects valid + // sequences such as Put(new_pointer) followed by Delete(new_pointer), or DeleteAll + // followed by Delete(pointer). `None` means that this pointer is known to be absent in + // the image produced by the operations seen so far; an untracked pointer still needs a + // one-time lookup in the authoritative pre-batch database. + let mut typed_state: HashMap> = HashMap::new(); + let mut delete_all_seen = false; + for item in &batch.v { + match item { + UtxoWriteBatchItem::Put(k, v) => { + let previous = if let Some(previous) = typed_state.get(k) { + previous.clone() + } else if delete_all_seen { + None + } else { + self.db.get_utxo(k)?.map(|(vto, _)| vto.pkh) + }; + if !touched_typed_keys.insert(*k) || previous.is_some() { + rebuild_after_success = true; + } + typed_state.insert(*k, Some(v.0.pkh)); + cache_deltas.push(CacheDelta::Put(v.0.pkh, *k)); + } + UtxoWriteBatchItem::Delete(k) => { + if !touched_typed_keys.insert(*k) { + rebuild_after_success = true; + } + let previous = if let Some(previous) = typed_state.get(k) { + previous.clone() + } else if delete_all_seen { + None + } else { + Some(self.db.get_utxo(k)?.map(|(vto, _)| vto.pkh).ok_or_else(|| { + anyhow::format_err!("cannot delete missing cached UTXO {k}") + })?) + }; + if let Some(pkh) = previous { + cache_deltas.push(CacheDelta::Delete(pkh, *k)); + } + typed_state.insert(*k, None); + } + UtxoWriteBatchItem::DeleteAll => { + delete_all_seen = true; + typed_state.clear(); + cache_deltas.push(CacheDelta::Clear); + } + UtxoWriteBatchItem::Raw(_) => {} + } + } + } + + if !self.cache_valid.load(Ordering::Acquire) { + *cache = Self::collect_cache(&self.db, |_| {})?; + self.cache_valid.store(true, Ordering::Release); + } + + if let Err(persist_error) = persist(&self.db, batch) { + // Backend errors do not prove that the atomic batch was rejected. Rebuild from the + // durable namespace while the cache write lock excludes readers: fail-before-apply + // recovers the old image, while apply-then-error recovers the new one. If the rebuild + // itself fails, poison address-scoped reads instead of exposing a stale projection. + // This deliberately pays one full UTXO scan on the rare error path. + match Self::collect_cache(&self.db, |_| {}) { + Ok(rebuilt) => { + *cache = rebuilt; + self.cache_valid.store(true, Ordering::Release); + return Err(persist_error); + } + Err(rebuild_error) => { + self.cache_valid.store(false, Ordering::Release); + return Err(anyhow::anyhow!( + "UTXO backend write failed ambiguously: {persist_error}; cache reconciliation also failed: {rebuild_error}" + )); + } + } + } + + if raw_utxo_mutation || rebuild_after_success { + // Raw UTXO operations have no typed cache delta. Rebuild the complete projection before + // releasing the write lock so address-scoped readers cannot observe a partial image. + // The same reconciliation is required for typed overwrite/repeated-key batches, + // whose final owner cannot be represented by independent append/remove deltas. + match Self::collect_cache(&self.db, |_| {}) { + Ok(rebuilt) => { + *cache = rebuilt; + self.cache_valid.store(true, Ordering::Release); + return Ok(()); + } + Err(rebuild_error) => { + self.cache_valid.store(false, Ordering::Release); + return Err(anyhow::anyhow!( + "UTXO batch committed but cache reconciliation failed: {rebuild_error}" + )); + } + } + } + + for delta in cache_deltas { + match delta { + CacheDelta::Clear => cache.clear(), + CacheDelta::Put(pkh, output_pointer) => { + cache.entry(pkh).or_default().insert(output_pointer); + } + CacheDelta::Delete(pkh, output_pointer) => { + if let Some(outputs) = cache.get_mut(&pkh) { + outputs.remove(&output_pointer); + if outputs.is_empty() { + cache.remove(&pkh); + } + } + } + } + } + + Ok(()) } } -// The Storage implementation simply forwards to the inner Storage. +impl CacheUtxosByPkh { + /// Run a mutation issued through the generic `Storage` view while preventing readers from + /// observing the address cache against a different backend image. Generic storage operations + /// may contain raw UTXO keys, so they cannot be translated into typed cache deltas safely. Mark + /// the cache invalid before the operation and keep the write lock until it returns; the next + /// address-scoped query will rebuild from authoritative storage, whether the backend mutation + /// succeeded or failed ambiguously. + fn mutate_storage( + &self, + operation: impl FnOnce(&S) -> witnet_storage::storage::Result, + ) -> witnet_storage::storage::Result { + let _cache_guard = self + .cache + .write() + .map_err(|_| anyhow::anyhow!("UTXO address cache lock is poisoned"))?; + self.cache_valid.store(false, Ordering::Release); + operation(&self.db) + } +} + +// The Storage implementation forwards reads to the inner Storage. Generic mutations invalidate the +// address cache because raw batches may touch UTXO keys without exposing typed cache deltas. impl Storage for CacheUtxosByPkh { fn get(&self, key: &[u8]) -> witnet_storage::storage::Result>> { self.db.get(key) } fn put(&self, key: Vec, value: Vec) -> witnet_storage::storage::Result<()> { - self.db.put(key, value) + self.mutate_storage(|db| db.put(key, value)) } fn delete(&self, key: &[u8]) -> witnet_storage::storage::Result<()> { - self.db.delete(key) + self.mutate_storage(|db| db.delete(key)) } fn prefix_iterator<'a, 'b: 'a>( @@ -206,13 +507,17 @@ impl Storage for CacheUtxosByPkh { } fn write(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { - self.db.write(batch) + self.mutate_storage(|db| db.write(batch)) + } + + fn write_sync(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { + self.mutate_storage(|db| db.write_sync(batch)) } } // The UtxoDb implementation forwards to the inner UtxoDb, except for the utxo_iterator_by_pkh -// method, which uses the cache, and the write method which updates the cache before forwarding the -// call. +// method, which uses the cache, and the write methods which publish staged cache changes only after +// success or reconstruct the cache from authoritative storage after an ambiguous error. impl UtxoDb for CacheUtxosByPkh { fn get_utxo(&self, k: &OutputPointer) -> Result, Error> { self.db.get_utxo(k) @@ -223,7 +528,15 @@ impl UtxoDb for CacheUtxosByPkh { } fn utxo_iterator_by_pkh(&self, pkh: PublicKeyHash) -> Result, Error> { - let cache = self.cache.read().unwrap(); + self.restore_cache_if_invalid()?; + let cache = self + .cache + .read() + .map_err(|_| anyhow::anyhow!("UTXO address cache lock is poisoned"))?; + anyhow::ensure!( + self.cache_valid.load(Ordering::Acquire), + "UTXO-by-address cache is unavailable after ambiguous persistence" + ); // We must clone the list of UTXOs to be able to return an iterator over it, because // otherwise the iterator would need to get ownership of the RwLockReadGuard somehow. let utxos_of_pkh: Vec = cache @@ -235,38 +548,355 @@ impl UtxoDb for CacheUtxosByPkh { // Move ownership of cache RwLockReadGuard into this closure to ensure that it is not // possible to acquire a write lock over the cache while this iterator is alive. let _cache = &cache; - // TODO: we could return an error instead of the first unwrap here, but that would force - // UtxoStorageIterator to return a Result - let vto = self.db.get_utxo(&out_ptr).unwrap().unwrap(); + let vto = self + .db + .get_utxo(&out_ptr)? + .ok_or_else(|| anyhow::anyhow!("cached UTXO {out_ptr} is missing from storage"))?; - (out_ptr, vto) + Ok((out_ptr, vto)) }); Ok(Box::new(iter)) } fn write(&self, batch: UtxoWriteBatch) -> Result<(), Error> { - // Update self.cache taking into account batch - let mut cache = self.cache.write().unwrap(); + self.write_and_publish(batch, |db, batch| db.write(batch)) + } - for item in &batch.v { - match item { - UtxoWriteBatchItem::Put(k, v) => { - cache.entry(v.0.pkh).or_default().insert(*k); - } - UtxoWriteBatchItem::Delete(k) => { - let vto = self.db.get_utxo(k)?.unwrap(); - let hs = cache.get_mut(&vto.0.pkh).unwrap(); - hs.remove(k); - // Remove entry from cache if hashset is empty - if hs.is_empty() { - cache.remove(&vto.0.pkh); - } - } - UtxoWriteBatchItem::Raw(_) => {} + fn write_sync(&self, batch: UtxoWriteBatch) -> Result<(), Error> { + self.write_and_publish(batch, |db, batch| db.write_sync(batch)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + struct EntryFailureStorage; + + impl Storage for EntryFailureStorage { + fn get(&self, _key: &[u8]) -> witnet_storage::storage::Result>> { + Ok(None) + } + + fn put( + &self, + _key: Vec, + _value: Vec, + ) -> witnet_storage::storage::Result<()> { + Ok(()) + } + + fn delete(&self, _key: &[u8]) -> witnet_storage::storage::Result<()> { + Ok(()) + } + + fn prefix_iterator<'a, 'b: 'a>( + &'a self, + _prefix: &'b [u8], + ) -> witnet_storage::storage::Result> { + Ok(Box::new(std::iter::once(Err(anyhow::anyhow!( + "injected iterator read failure" + ))))) + } + + fn write(&self, _batch: WriteBatch) -> witnet_storage::storage::Result<()> { + Ok(()) + } + } + + #[test] + fn utxo_iteration_preserves_storage_entry_errors() { + let db = UtxoDbWrapStorage(EntryFailureStorage); + let error = db + .utxo_iterator() + .unwrap() + .next() + .unwrap() + .unwrap_err(); + + assert_eq!(error.to_string(), "injected iterator read failure"); + assert!(CacheUtxosByPkh::new(UtxoDbWrapStorage(EntryFailureStorage)).is_err()); + } + + #[test] + fn utxo_reads_reject_trailing_serialized_bytes() { + let storage = witnet_storage::backends::hashmap::Backend::default(); + let output_pointer: OutputPointer = + "0222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let mut value = + bincode::serialize(&(ValueTransferOutput::default(), 0_u32)).unwrap(); + value.push(0xff); + storage + .put(format!("UTXO-{output_pointer}").into_bytes(), value) + .unwrap(); + + let db = UtxoDbWrapStorage(storage); + assert!(db.get_utxo(&output_pointer).is_err()); + assert!(db.utxo_iterator().unwrap().next().unwrap().is_err()); + } + + #[derive(Default)] + struct SyncTrackingStorage { + inner: witnet_storage::backends::hashmap::Backend, + sync_writes: AtomicUsize, + fail_sync: bool, + } + + impl Storage for SyncTrackingStorage { + fn get(&self, key: &[u8]) -> witnet_storage::storage::Result>> { + self.inner.get(key) + } + + fn put( + &self, + key: Vec, + value: Vec, + ) -> witnet_storage::storage::Result<()> { + self.inner.put(key, value) + } + + fn delete(&self, key: &[u8]) -> witnet_storage::storage::Result<()> { + self.inner.delete(key) + } + + fn prefix_iterator<'a, 'b: 'a>( + &'a self, + prefix: &'b [u8], + ) -> witnet_storage::storage::Result> { + self.inner.prefix_iterator(prefix) + } + + fn write(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { + Storage::write(&self.inner, batch) + } + + fn write_sync(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { + self.sync_writes.fetch_add(1, Ordering::SeqCst); + if self.fail_sync { + Err(anyhow::anyhow!("injected synchronous write failure")) + } else { + Storage::write(&self.inner, batch) + } + } + } + + #[derive(Default)] + struct ApplyThenErrorStorage { + inner: witnet_storage::backends::hashmap::Backend, + fail_next_write_after_apply: AtomicBool, + fail_iterations: AtomicBool, + } + + impl ApplyThenErrorStorage { + fn apply_then_maybe_fail(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { + Storage::write(&self.inner, batch)?; + if self + .fail_next_write_after_apply + .swap(false, Ordering::SeqCst) + { + Err(anyhow::anyhow!( + "injected lost acknowledgement after durable UTXO apply" + )) + } else { + Ok(()) + } + } + } + + impl Storage for ApplyThenErrorStorage { + fn get(&self, key: &[u8]) -> witnet_storage::storage::Result>> { + self.inner.get(key) + } + + fn put( + &self, + key: Vec, + value: Vec, + ) -> witnet_storage::storage::Result<()> { + self.inner.put(key, value) + } + + fn delete(&self, key: &[u8]) -> witnet_storage::storage::Result<()> { + self.inner.delete(key) + } + + fn prefix_iterator<'a, 'b: 'a>( + &'a self, + prefix: &'b [u8], + ) -> witnet_storage::storage::Result> { + if self.fail_iterations.load(Ordering::SeqCst) { + Err(anyhow::anyhow!( + "injected authoritative UTXO reconciliation failure" + )) + } else { + self.inner.prefix_iterator(prefix) } } - self.db.write(batch) + fn write(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { + self.apply_then_maybe_fail(batch) + } + + fn write_sync(&self, batch: WriteBatch) -> witnet_storage::storage::Result<()> { + self.apply_then_maybe_fail(batch) + } + } + + fn fixture_output_pointer() -> OutputPointer { + "0222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap() + } + + #[test] + fn synchronous_utxo_write_is_forwarded_and_cache_publication_is_success_driven() { + let output_pointer = fixture_output_pointer(); + let output = ValueTransferOutput::default(); + + let successful = CacheUtxosByPkh::new(UtxoDbWrapStorage( + SyncTrackingStorage::default(), + )) + .unwrap(); + let mut successful_batch = UtxoWriteBatch::default(); + successful_batch.put(output_pointer, (output.clone(), 0)); + UtxoDb::write_sync(&successful, successful_batch).unwrap(); + assert_eq!(successful.db.0.sync_writes.load(Ordering::SeqCst), 1); + assert!(successful + .cache + .read() + .unwrap() + .get(&output.pkh) + .is_some_and(|outputs| outputs.contains(&output_pointer))); + + let failing = CacheUtxosByPkh::new(UtxoDbWrapStorage(SyncTrackingStorage { + fail_sync: true, + ..Default::default() + })) + .unwrap(); + let mut failing_batch = UtxoWriteBatch::default(); + failing_batch.put(output_pointer, (output.clone(), 0)); + assert!(UtxoDb::write_sync(&failing, failing_batch).is_err()); + assert_eq!(failing.db.0.sync_writes.load(Ordering::SeqCst), 1); + assert!(failing.cache.read().unwrap().get(&output.pkh).is_none()); + } + + #[test] + fn lost_utxo_write_acknowledgements_rebuild_the_cache_from_durable_state() { + let output_pointer = fixture_output_pointer(); + let output = ValueTransferOutput::default(); + let cached = CacheUtxosByPkh::new(UtxoDbWrapStorage( + ApplyThenErrorStorage::default(), + )) + .unwrap(); + + cached + .db + .0 + .fail_next_write_after_apply + .store(true, Ordering::SeqCst); + let mut addition = UtxoWriteBatch::default(); + addition.put(output_pointer, (output.clone(), 7)); + let error = UtxoDb::write(&cached, addition) + .expect_err("the injected acknowledgement loss must remain visible to the caller"); + assert!(error.to_string().contains("lost acknowledgement")); + assert_eq!( + cached.db.get_utxo(&output_pointer).unwrap(), + Some((output.clone(), 7)) + ); + assert_eq!( + cached + .utxo_iterator_by_pkh(output.pkh) + .unwrap() + .collect::, _>>() + .unwrap(), + vec![(output_pointer, (output.clone(), 7))] + ); + + cached + .db + .0 + .fail_next_write_after_apply + .store(true, Ordering::SeqCst); + let mut removal = UtxoWriteBatch::default(); + removal.delete(output_pointer); + let error = UtxoDb::write_sync(&cached, removal) + .expect_err("the applied removal must still report its lost acknowledgement"); + assert!(error.to_string().contains("lost acknowledgement")); + assert!(cached.db.get_utxo(&output_pointer).unwrap().is_none()); + assert_eq!( + cached + .utxo_iterator_by_pkh(output.pkh) + .unwrap() + .count(), + 0 + ); + } + + #[test] + fn failed_lost_ack_reconciliation_poisons_address_scoped_cache_reads() { + let output_pointer = fixture_output_pointer(); + let output = ValueTransferOutput::default(); + let cached = CacheUtxosByPkh::new(UtxoDbWrapStorage( + ApplyThenErrorStorage::default(), + )) + .unwrap(); + cached + .db + .0 + .fail_next_write_after_apply + .store(true, Ordering::SeqCst); + cached + .db + .0 + .fail_iterations + .store(true, Ordering::SeqCst); + + let mut addition = UtxoWriteBatch::default(); + addition.put(output_pointer, (output.clone(), 7)); + let error = UtxoDb::write(&cached, addition) + .expect_err("write and authoritative reconciliation failures must both surface"); + assert!(error.to_string().contains("lost acknowledgement")); + assert!(error.to_string().contains("reconciliation also failed")); + assert_eq!( + cached.db.get_utxo(&output_pointer).unwrap(), + Some((output.clone(), 7)) + ); + + let read_error = match cached.utxo_iterator_by_pkh(output.pkh) { + Ok(_) => panic!("a potentially stale address cache must not be exposed"), + Err(error) => error, + }; + assert!(read_error.to_string().contains("reconciliation failure")); + assert!(!cached.cache_valid.load(Ordering::Acquire)); + } + + #[test] + fn raw_delete_is_preserved_when_converting_utxo_batches() { + let key = b"SNAPSHOT-IMPORT-JOURNAL".to_vec(); + let mut batch = UtxoWriteBatch::default(); + batch.delete_raw(key.clone()); + let batch = WriteBatch::from(batch); + + assert!(matches!( + batch.batch.as_slice(), + [WriteBatchItem::Delete(actual)] if actual == &key + )); + } + + #[test] + fn delete_all_is_converted_to_the_exact_utxo_namespace_range() { + let mut batch = UtxoWriteBatch::default(); + batch.delete_all(); + let batch = WriteBatch::from(batch); + + assert!(matches!( + batch.batch.as_slice(), + [WriteBatchItem::DeleteRange(start, end)] + if start == b"UTXO-" && end == b"UTXO." + )); } } diff --git a/data_structures/tests/proto.rs b/data_structures/tests/proto.rs index d8e7a15bb7..e41b8a3e9f 100644 --- a/data_structures/tests/proto.rs +++ b/data_structures/tests/proto.rs @@ -1,5 +1,12 @@ -use witnet_data_structures::chain::PublicKey; -use witnet_data_structures::{proto::ProtobufConvert, types, types::IpAddress}; +use protobuf::Message; +use witnet_data_structures::chain::{ + PublicKey, PublicKeyHash, StakeAuthorization, StakeOutput, +}; +use witnet_data_structures::{ + proto::{ProtobufConvert, schema}, + types, + types::IpAddress, +}; #[test] fn address_proto() { @@ -45,3 +52,108 @@ fn public_key_proto() { assert_eq!(test_public_key, deserialize_public_key); } + +#[test] +fn stake_authorization_v2_proto_matches_sdk_literal_vector() { + // Shared with `witnet-sdk/tests/delegated-staking/protobuf.test.ts`. This is the protobuf wire + // encoding, which is deliberately distinct from the fixed-width, big-endian 68-byte signing + // body: protobuf uses field tags, nested-message lengths, and little-endian fixed scalars. + const SDK_CANONICAL_V2_PROTO_HEX: &str = concat!( + "0d02000000", + "1534120000", + "1a160a14000102030405060708090a0b0c0d0e0f10111213", + "22160a141415161718191a1b1c1d1e1f2021222324252627", + "2d10a40000", + "350f000000", + "3d01000000", + "41ffffffffffffffff", + ); + let validator = PublicKeyHash::from_bytes(&[ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, + ]) + .unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[ + 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, + 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, + ]) + .unwrap(); + let terms = StakeAuthorization::commission_update( + 0x1234, + validator, + withdrawer, + 42_000, + 15, + u64::MAX, + ); + let expected = hex::decode(SDK_CANONICAL_V2_PROTO_HEX).unwrap(); + let encoded = terms.to_pb_bytes().unwrap(); + + assert_eq!(encoded, expected); + assert_eq!(StakeAuthorization::from_pb_bytes(&encoded).unwrap(), terms); + + // Exercise the exact generated setter expected from tag 4 and pin its length-delimited append. + let mut stake_output = schema::witnet::StakeOutput::new(); + stake_output.set_value(1); + stake_output.set_authorization_terms(terms.to_pb()); + let mut expected_output = hex::decode("08012252").unwrap(); + expected_output.extend_from_slice(&expected); + assert_eq!(stake_output.write_to_bytes().unwrap(), expected_output); +} + +#[test] +fn stake_output_proto_roundtrips_optional_authorization_terms() { + let validator = PublicKeyHash::from_bytes(&[0x11; 20]).unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[0x22; 20]).unwrap(); + let terms = StakeAuthorization::delegation(0x1234, validator, withdrawer, 42_000, 7); + + let legacy = StakeOutput { + authorization_terms: None, + ..StakeOutput::default() + }; + let delegated = StakeOutput { + authorization_terms: Some(terms), + ..StakeOutput::default() + }; + + let legacy_pb = legacy.to_pb(); + assert!(!legacy_pb.has_authorization_terms()); + // Reconstruct exactly what the pre-tag-4 derive emitted: it eagerly set each of the three + // baseline fields, including nested defaults. The presence-aware implementation must produce + // the same bytes rather than appending a synthesized tag-4 default message. + let mut expected_legacy_pb = schema::witnet::StakeOutput::new(); + expected_legacy_pb.set_authorization(legacy.authorization.to_pb()); + expected_legacy_pb.set_key(legacy.key.to_pb()); + expected_legacy_pb.set_value(legacy.value); + let legacy_bytes = legacy_pb.write_to_bytes().unwrap(); + assert_eq!(legacy_bytes, expected_legacy_pb.write_to_bytes().unwrap()); + + assert_eq!( + StakeOutput::from_pb_bytes(&legacy_bytes).unwrap(), + legacy + ); + assert_eq!( + StakeOutput::from_pb_bytes(&delegated.to_pb_bytes().unwrap()).unwrap(), + delegated + ); + + // Presence is consensus-relevant after activation. Even an explicitly present default message + // must survive conversion so validation can reject it instead of treating it as a legacy field. + let present_default = StakeOutput { + authorization_terms: Some(StakeAuthorization::default()), + ..StakeOutput::default() + }; + let present_default_pb = present_default.to_pb(); + assert!(present_default_pb.has_authorization_terms()); + expected_legacy_pb.set_authorization_terms(StakeAuthorization::default().to_pb()); + let expected_present_default = expected_legacy_pb.write_to_bytes().unwrap(); + assert_ne!(expected_present_default, legacy_bytes); + assert_eq!( + present_default_pb.write_to_bytes().unwrap(), + expected_present_default + ); + assert_eq!( + StakeOutput::from_pb_bytes(&expected_present_default).unwrap(), + present_default + ); +} diff --git a/data_structures/tests/utxo_pool.rs b/data_structures/tests/utxo_pool.rs index 375adf2d9a..5b8104b275 100644 --- a/data_structures/tests/utxo_pool.rs +++ b/data_structures/tests/utxo_pool.rs @@ -3,12 +3,98 @@ use witnet_data_structures::{ chain::{Hash, Hashable, Input, OutputPointer, PublicKeyHash, ValueTransferOutput}, transaction::{Transaction, VTTransaction, VTTransactionBody}, utxo_pool::{ - CacheUtxosByPkh, OwnUnspentOutputsPool, UnspentOutputsPool, UtxoDb, UtxoDbWrapStorage, - UtxoWriteBatch, + utxo_db::UtxoStorageIterator, CacheUtxosByPkh, OwnUnspentOutputsPool, + UnspentOutputsPool, UtxoDb, UtxoDbWrapStorage, UtxoDiff, UtxoWriteBatch, }, }; use witnet_storage::storage::Storage; +/// Minimal deterministic backend doubles for the fallible UTXO staging fixtures below. +/// These tests are intentionally source-only during the current owner-review pass. +struct FailingGetUtxoDb; + +impl UtxoDb for FailingGetUtxoDb { + fn get_utxo( + &self, + _out_ptr: &OutputPointer, + ) -> Result, anyhow::Error> { + Err(anyhow::anyhow!("injected UTXO read failure")) + } + + fn utxo_iterator(&self) -> Result, anyhow::Error> { + Err(anyhow::anyhow!("injected UTXO iterator failure")) + } + + fn utxo_iterator_by_pkh( + &self, + _pkh: PublicKeyHash, + ) -> Result, anyhow::Error> { + Err(anyhow::anyhow!("injected UTXO address iterator failure")) + } + + fn write(&self, _batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + Ok(()) + } +} + +struct EmptyUtxoDb; + +impl UtxoDb for EmptyUtxoDb { + fn get_utxo( + &self, + _out_ptr: &OutputPointer, + ) -> Result, anyhow::Error> { + Ok(None) + } + + fn utxo_iterator(&self) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn utxo_iterator_by_pkh( + &self, + _pkh: PublicKeyHash, + ) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn write(&self, _batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + Ok(()) + } +} + +struct ExistingUtxoDb; + +impl UtxoDb for ExistingUtxoDb { + fn get_utxo( + &self, + _out_ptr: &OutputPointer, + ) -> Result, anyhow::Error> { + Ok(Some((ValueTransferOutput::default(), 0))) + } + + fn utxo_iterator(&self) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn utxo_iterator_by_pkh( + &self, + _pkh: PublicKeyHash, + ) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn write(&self, _batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + Ok(()) + } +} + +fn staging_fixture_pointer() -> OutputPointer { + "0222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap() +} + fn update_utxo_inputs(utxo: &mut UnspentOutputsPool, inputs: &[Input]) { for input in inputs { // Obtain the OutputPointer of each input and remove it from the utxo_set @@ -150,6 +236,87 @@ fn utxo_set_insert_twice() { assert_eq!(p.included_in_block_number(&k0), None); } +#[test] +fn fallible_diff_application_rejects_the_complete_duplicate_removal_batch_atomically() { + let existing_removal: OutputPointer = + "0222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let otherwise_valid_addition: OutputPointer = + "1222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let mut pool = UnspentOutputsPool::default(); + pool.remove(&existing_removal); + let pool_before = pool.clone(); + + let mut candidate = UtxoDiff::new(&pool, 7); + candidate.insert_utxo( + otherwise_valid_addition, + ValueTransferOutput::default(), + None, + ); + candidate.remove_utxo(existing_removal); + let candidate = candidate.take_diff(); + let error = candidate.try_apply(&mut pool).unwrap_err(); + + assert!(error.to_string().contains("pending removal set")); + assert_eq!(pool, pool_before); + assert!(!pool.contains_key(&otherwise_valid_addition)); +} + +#[test] +fn fallible_diff_application_rejects_a_durable_duplicate_addition_atomically() { + let existing: OutputPointer = + "0222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let db = Arc::new(UtxoDbWrapStorage( + witnet_storage::backends::hashmap::Backend::default(), + )); + let mut pool = UnspentOutputsPool { + db: Some(db), + ..Default::default() + }; + pool.insert(existing, ValueTransferOutput::default(), 1); + pool.persist(); + let pool_before = pool.clone(); + + let mut candidate = UtxoDiff::new(&pool, 7); + candidate.insert_utxo(existing, ValueTransferOutput::default(), None); + let error = candidate.take_diff().try_apply(&mut pool).unwrap_err(); + + assert!(error.to_string().contains("durable storage")); + assert_eq!(pool, pool_before); +} + +#[test] +fn fallible_diff_application_rejects_a_missing_durable_removal_atomically() { + let missing: OutputPointer = + "1222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let otherwise_valid_addition: OutputPointer = + "3222222222222222222222222222222222222222222222222222222222222222:0" + .parse() + .unwrap(); + let mut pool = UnspentOutputsPool::default(); + let pool_before = pool.clone(); + + let mut candidate = UtxoDiff::new(&pool, 7); + candidate.insert_utxo( + otherwise_valid_addition, + ValueTransferOutput::default(), + None, + ); + candidate.remove_utxo(missing); + let error = candidate.take_diff().try_apply(&mut pool).unwrap_err(); + + assert!(error.to_string().contains("missing from durable storage")); + assert_eq!(pool, pool_before); + assert!(!pool.contains_key(&otherwise_valid_addition)); +} + #[test] fn utxo_set_insert_and_remove() { // Inserting and removing an UTXO in the same superblock @@ -257,7 +424,9 @@ fn utxo_set_insert_and_remove_on_next_superblock() { let db_count_entries = || { db.prefix_iterator(b"") .expect("prefix iterator error") - .count() + .collect::, _>>() + .expect("prefix iterator entry error") + .len() }; let v = ValueTransferOutput::default; let k0: OutputPointer = "0222222222222222222222222222222222222222222222222222222222222222:0" @@ -451,3 +620,124 @@ fn utxo_set_initialize_cache_utxos_by_pkh() { assert_eq!(count_confirmed, 2); assert_eq!(count_all, 2); } + +#[test] +fn try_persist_add_to_batch_preserves_diff_on_backend_read_failure() { + let db = Arc::new(FailingGetUtxoDb); + let mut pool = UnspentOutputsPool { + db: Some(db), + ..Default::default() + }; + let pointer = staging_fixture_pointer(); + pool.insert(pointer, ValueTransferOutput::default(), 7); + let expected_diff = pool.diff.clone(); + + let mut batch = UtxoWriteBatch::default(); + let error = pool + .try_persist_add_to_batch(&mut batch) + .expect_err("the injected backend read must fail staging"); + + assert!(error.to_string().contains("injected UTXO read failure")); + assert_eq!(pool.diff, expected_diff); +} + +#[test] +fn try_persist_add_to_batch_rejects_duplicate_durable_insert_without_draining_diff() { + let db = Arc::new(ExistingUtxoDb); + let mut pool = UnspentOutputsPool { + db: Some(db), + ..Default::default() + }; + let pointer = staging_fixture_pointer(); + pool.insert(pointer, ValueTransferOutput::default(), 7); + let expected_diff = pool.diff.clone(); + + let mut batch = UtxoWriteBatch::default(); + let error = pool + .try_persist_add_to_batch(&mut batch) + .expect_err("an already durable UTXO must not be inserted twice"); + + assert!(error + .to_string() + .contains("already consolidated")); + assert_eq!(pool.diff, expected_diff); +} + +#[test] +fn try_persist_add_to_batch_rejects_missing_remove_without_draining_diff() { + let db = Arc::new(EmptyUtxoDb); + let mut pool = UnspentOutputsPool { + db: Some(db), + ..Default::default() + }; + let pointer = staging_fixture_pointer(); + pool.remove(&pointer); + let expected_diff = pool.diff.clone(); + + let mut batch = UtxoWriteBatch::default(); + let error = pool + .try_persist_add_to_batch(&mut batch) + .expect_err("a missing durable UTXO must not be removed"); + + assert!(error.to_string().contains("missing")); + assert_eq!(pool.diff, expected_diff); +} + +#[test] +fn try_persist_add_to_batch_clears_an_absent_add_remove_noop() { + let db = Arc::new(EmptyUtxoDb); + let mut pool = UnspentOutputsPool { + db: Some(db), + ..Default::default() + }; + let pointer = staging_fixture_pointer(); + pool.insert(pointer, ValueTransferOutput::default(), 7); + pool.remove(&pointer); + + let mut batch = UtxoWriteBatch::default(); + pool.try_persist_add_to_batch(&mut batch) + .expect("an absent add/remove pair is a no-op"); + + assert_eq!(pool.iter().count(), 0); +} + +#[test] +fn remove_persisted_from_memory_removes_only_the_matching_addition_generation() { + let pointer = staging_fixture_pointer(); + let persisted_output = ValueTransferOutput::default(); + + let mut persisted_pool = UnspentOutputsPool::default(); + persisted_pool.insert(pointer, persisted_output.clone(), 7); + let persisted_diff = persisted_pool.diff.clone(); + + let mut current_pool = UnspentOutputsPool::default(); + current_pool.insert( + pointer, + ValueTransferOutput { + value: 8, + ..persisted_output + }, + 8, + ); + current_pool.remove_persisted_from_memory(&persisted_diff); + + assert_eq!(current_pool.get(&pointer).unwrap().value, 8); +} + +#[test] +fn remove_persisted_from_memory_acknowledges_matching_addition_and_overlapping_removal() { + let pointer = staging_fixture_pointer(); + let output = ValueTransferOutput::default(); + + let mut persisted_pool = UnspentOutputsPool::default(); + persisted_pool.insert(pointer, output.clone(), 7); + persisted_pool.remove(&pointer); + let persisted_diff = persisted_pool.diff.clone(); + + let mut current_pool = UnspentOutputsPool::default(); + current_pool.insert(pointer, output, 7); + current_pool.remove(&pointer); + current_pool.remove_persisted_from_memory(&persisted_diff); + + assert_eq!(current_pool.iter().count(), 0); +} diff --git a/docker/witnet-rust/testnet-1/witnet.toml b/docker/witnet-rust/testnet-1/witnet.toml index e09969e6af..43b5543b87 100644 --- a/docker/witnet-rust/testnet-1/witnet.toml +++ b/docker/witnet-rust/testnet-1/witnet.toml @@ -73,6 +73,9 @@ genesis_path = "/tmp/testnet-1/genesis_block.json" #mint_external_percentage = 50 # Set a minimum fee you require before your node includes a value transfer transaction into a block minimum_vtt_fee_nanowits = 1 +# Local delegated-staking mempool fee floors. Zero retains every consensus-valid fee. +minimum_st_fee_nanowits = 0 +minimum_ut_fee_nanowits = 0 [witnessing] # Tells how strict or lenient to be with inconsistent data sources. Paranoid level is defined as percentage of diff --git a/docker/witnet-rust/testnet-2/witnet.toml b/docker/witnet-rust/testnet-2/witnet.toml index ddc7837cb9..3ebe773908 100644 --- a/docker/witnet-rust/testnet-2/witnet.toml +++ b/docker/witnet-rust/testnet-2/witnet.toml @@ -73,6 +73,9 @@ genesis_path = "/tmp/testnet-2/genesis_block.json" #mint_external_percentage = 50 # Set a minimum fee you require before your node includes a value transfer transaction into a block minimum_vtt_fee_nanowits = 1 +# Local delegated-staking mempool fee floors. Zero retains every consensus-valid fee. +minimum_st_fee_nanowits = 0 +minimum_ut_fee_nanowits = 0 [witnessing] # Tells how strict or lenient to be with inconsistent data sources. Paranoid level is defined as percentage of diff --git a/node/src/actors/chain_manager/actor.rs b/node/src/actors/chain_manager/actor.rs index 6d259895fc..dbe9ffb820 100644 --- a/node/src/actors/chain_manager/actor.rs +++ b/node/src/actors/chain_manager/actor.rs @@ -1,4 +1,11 @@ -use std::{path::PathBuf, pin::Pin, str::FromStr, time::Duration}; +use std::{ + collections::BTreeMap, + path::PathBuf, + pin::Pin, + str::FromStr, + sync::Arc, + time::Duration, +}; use actix::prelude::*; use actix::{ActorTryFutureExt, ContextFutureSpawner, WrapFuture}; @@ -8,36 +15,55 @@ use itertools::Itertools; use witnet_config::defaults::PSEUDO_CONSENSUS_CONSTANTS_WIP0022_REWARD_COLLATERAL_RATIO; use witnet_data_structures::{ chain::{ - Block, ChainImport, ChainInfo, ChainState, CheckpointBeacon, CheckpointVRF, EpochConstants, - GenesisBlockInfo, PublicKeyHash, ReputationEngine, StateMachine, SuperBlock, + Block, Blockchain, ChainImport, ChainInfo, ChainState, CheckpointBeacon, CheckpointVRF, + Epoch, GenesisBlockInfo, Hash, Hashable, PublicKeyHash, ReputationEngine, StateMachine, + SuperBlock, }, data_request::DataRequestPool, - get_environment, get_protocol_version_activation_epoch, get_protocol_version_period, - proto::versioning::ProtocolVersion, + get_environment, get_protocol_runtime, refresh_protocol_version, + try_load_protocol_info_with_consensus_constants, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, staking::prelude::*, superblock::SuperBlockState, types::LastBeacon, - utxo_pool::{OldUnspentOutputsPool, OwnUnspentOutputsPool, UtxoWriteBatch}, + utxo_pool::{OwnUnspentOutputsPool, UnspentOutputsPool, UtxoWriteBatch}, vrf::VrfCtx, }; -use witnet_util::timestamp::pretty_print; +use witnet_util::timestamp::{get_timestamp, pretty_print}; use crate::{ actors::{ chain_manager::{ - ChainManager, ChainManagerError, ImportError, handlers::EveryEpochPayload, + ChainManager, ChainManagerError, ImportError, SNAPSHOT_BLOCK_BATCH_SIZE, + SNAPSHOT_MANIFEST_MAGIC, SNAPSHOT_MANIFEST_VERSION, SnapshotBlockBatch, + SnapshotManifest, handlers::EveryEpochPayload, snapshot_blocks_batch_path, + snapshot_chain_state_path, snapshot_reports_path, snapshot_superblocks_path, + }, + epoch_manager::{ + EpochManager, EpochManagerError, EpochManagerError::CheckpointZeroInTheFuture, }, - epoch_manager::{EpochManager, EpochManagerError::CheckpointZeroInTheFuture}, inventory_manager::InventoryManager, messages::{ - AddBlocks, GetEpoch, GetEpochConstants, SetEpochConstants, SetLastBeacon, - SnapshotExport, StoreInventoryItem, Subscribe, + AddBlocks, SetEpochConstants, SetLastBeacon, SnapshotExport, StoreInventoryItem, + Subscribe, SuperBlockNotify, }, sessions_manager::SessionsManager, storage_keys, }, config_mngr, signature_mngr, storage_mngr, - utils::{Force, file_name_compose, serialize_to_file}, + utils::{Force, serialize_to_file}, +}; + +mod snapshot; + +use snapshot::{ + SnapshotGenerationCleanup, SnapshotReportObservations, SnapshotReportPlan, + SnapshotTransactionVerifier, cleanup_configured_snapshot_generation_orphans, + cleanup_snapshot_generation_dirs, create_snapshot_generation, snapshot_component_from_file, + validate_snapshot_block_body, validate_snapshot_block_index_origin, + validate_snapshot_block_predecessor, validate_snapshot_common_finalized_prefix, + validate_snapshot_identity_and_pending_wit2_activation, + validate_snapshot_superblock_state_binding, }; /// Implement Actor trait for `ChainManager` @@ -49,21 +75,74 @@ impl Actor for ChainManager { fn started(&mut self, ctx: &mut Self::Context) { log::debug!("ChainManager actor has been started!"); - ctx.wait(Self::check_only_one_chain_state_in_storage().into_actor(self)); + // A startup snapshot import replaces the initially loaded ChainState before the actor + // can process its mailbox. Do not queue local genesis against the pre-import image; the + // post-import reload must perform the normal genesis preparation instead. + let startup_snapshot_import_pending = self.import.has_force(); + + ctx.wait( + Self::check_only_one_chain_state_in_storage() + .into_actor(self) + .map_err(|(), _act, ctx| { + log::error!( + "ChainState storage namespace check failed during startup; stopping the node" + ); + ctx.stop(); + System::current().stop_with_code(1); + }) + .map(|_res, _act, _ctx| ()), + ); ctx.wait( - self.initialize_from_storage_fut(false) + self.initialize_from_storage_fut_inner( + false, + true, + true, + startup_snapshot_import_pending, + ) + .map_err(|(), _act, ctx| { + log::error!( + "ChainManager initialization failed during startup; stopping the node" + ); + ctx.stop(); + System::current().stop_with_code(1); + }) .and_then(|_res, act, _ctx| { // If we are importing a chain snapshot, it is time to load and store all the relevant data, // before the actor starts to communicate with any other actors - act.snapshot_import().map_err(|_, _, _| ()) + act.snapshot_import().map_err(|error, _act, ctx| { + log::error!( + "Chain snapshot import failed during startup; stopping the node: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + }) }) - .then(|_res, act, _ctx| { - // Export chain snapshot if requested to do so + .and_then(|_res, act, _ctx| { + match cleanup_configured_snapshot_generation_orphans( + &act.export, + &act.chain_state, + ) { + Ok(reclaimed) if reclaimed > 0 => log::info!( + "Reclaimed {reclaimed} interrupted filesystem snapshot generations during startup" + ), + Ok(_) => {} + Err(error) => log::warn!( + "Could not recover interrupted filesystem snapshot generations during startup: {error}" + ), + } + + // Export only after initialization and any explicitly requested import have + // succeeded. Export failures remain non-fatal startup outcomes. if act.export.has_force() { - Either::Left(act.snapshot_export().map_err(|_, _, _| ())) + Either::Left(act.snapshot_export().then(|result, _act, _ctx| { + if let Err(error) = result { + log::error!("Chain snapshot export failed during startup: {error}"); + } + actix::fut::ok::<(), ()>(()) + })) } else { - Either::Right(actix::fut::err(())) + Either::Right(actix::fut::ok::<(), ()>(())) } }) .map(|_, _, _| ()), @@ -87,10 +166,22 @@ impl Actor for ChainManager { impl ChainManager { /// Get configuration from ConfigManager and try to initialize ChainManager state from Storage - /// (initialize to Default values if empty) + /// (initialize to Default values if empty). Mismatched or malformed durable state fails + /// closed through the actor future rather than unwinding the process. pub fn initialize_from_storage(&mut self, ctx: &mut Context) { let fut = self - .initialize_from_storage_fut(false) + .initialize_from_storage_fut(false, false) + .map_err(|(), _act, ctx| { + // This compatibility wrapper is used by ordinary rewind/recovery paths that do + // not receive the Result directly. A failed durable-namespace proof or any later + // reload step must not be converted into apparent completion while the mailbox + // resumes against stale in-memory state. + log::error!( + "ChainManager storage reinitialization failed; stopping for durable recovery" + ); + ctx.stop(); + System::current().stop_with_code(1); + }) .map(|_res, _act, _ctx| ()); ctx.wait(fut); } @@ -100,14 +191,76 @@ impl ChainManager { pub fn initialize_from_storage_fut( &mut self, resync: bool, + cleanup_inactive_inventory: bool, + ) -> ResponseActFuture> { + self.initialize_from_storage_fut_inner(resync, cleanup_inactive_inventory, true, false) + } + + /// Reload a snapshot that has just committed a schedule supplied by its manifest. The + /// schedule was already validated and published after the durable commit; rolling back the + /// actor-local provisional activation journal at the top of the ordinary reload path would + /// immediately undo that publication. The journal remains intact for finality/reorg handling. + fn initialize_from_snapshot_import_fut( + &mut self, + ) -> ResponseActFuture> { + self.initialize_from_storage_fut_inner(false, false, false, false) + } + + fn initialize_from_storage_fut_inner( + &mut self, + resync: bool, + cleanup_inactive_inventory: bool, + rollback_protocol_activation: bool, + skip_genesis: bool, ) -> ResponseActFuture> { - let fut = config_mngr::get() + // A schedule derived from a block that has not reached an acknowledged finalized + // ChainState must not leak across recovery. Inventory durability alone is insufficient; + // `persist_chain_state` clears the journal only after the exact trigger belongs to the + // finalized snapshot committed atomically with its UTXO diff. + if rollback_protocol_activation && self.rollback_unpersisted_protocol_activation().is_err() { + // A rollback failure means neither the old nor the restored protocol clock is safe to + // publish. In particular, a post-commit snapshot reload must propagate this error to + // its fail-stop wrapper instead of acknowledging the durable replacement. + return Box::pin(actix::fut::err(())); + } + // Snapshot inventory generations are only safe to reclaim during process startup, before + // RPC inventory reads can pin the old active view. Rewind and online snapshot reload may + // still have in-flight readers, so both defer reclamation until the next startup. + let cleanup = async move { + // Authenticate the active namespace before deleting any inactive/predecessor + // generation. If corruption appeared after StorageManager configuration, preserve all + // discoverable recovery material and fail closed before cleanup changes durable state. + storage_mngr::validate_active_inventory_namespace().await?; + + if cleanup_inactive_inventory { + let reclaimed = + storage_mngr::cleanup_abandoned_snapshot_inventory_imports().await?; + if reclaimed > 0 { + log::info!( + "Reclaimed {reclaimed} keys from abandoned snapshot inventory generations" + ); + } + } + + Ok::<(), anyhow::Error>(()) + }; + let fut = cleanup .into_actor(self) - .map_err(|err, _act, _ctx| { - log::error!("Couldn't get config: {err}"); + .map_err(|error, _act, _ctx| { + log::error!( + "Failed to reclaim or validate snapshot inventory generations: {error}" + ); }) - .and_then(|config, act, _ctx| { - let consensus_constants = config.consensus_constants.clone(); + .and_then(move |(), act, _ctx| { + config_mngr::get() + .into_actor(act) + .map_err(|err, _act, _ctx| { + log::error!("Couldn't get config: {err}"); + }) + .and_then(|config, act, ctx| { + let consensus_constants = config.consensus_constants.clone(); + act.snapshot_trust_public_keys = + config.storage.snapshot_trust_public_keys.clone(); act.consensus_constants_wit2.checkpoint_zero_timestamp = consensus_constants.checkpoint_zero_timestamp; act.consensus_constants_wit2.checkpoints_period = consensus_constants.checkpoints_period; @@ -145,10 +298,19 @@ impl ChainManager { // Set weight limit of transactions pool let vt_to_dr_factor = f64::from(config.consensus_constants.max_vt_weight) / f64::from(config.consensus_constants.max_dr_weight); let _removed_transactions = act.transactions_pool.set_total_weight_limit(config.mining.transactions_pool_total_weight_limit, vt_to_dr_factor); + act.transactions_pool.set_wit21_commit_candidates_per_dr_limit( + config.consensus_constants.max_dr_weight, + ); // Minimum fee required to include a VTT into a block act.transactions_pool.set_minimum_vtt_fee(config.mining.minimum_vtt_fee); + // Local fee floors for delegated staking actions. These are miner policy only: + // zero preserves the historical accept-all default, while explicit values keep + // low-fee Stake/Unstake traffic out of this node's bounded transaction pool. + act.transactions_pool.set_minimum_st_fee(config.mining.minimum_st_fee); + act.transactions_pool.set_minimum_ut_fee(config.mining.minimum_ut_fee); + // Collateral minimum sourced from the consensus constants act.transactions_pool.set_collateral_minimum(config.consensus_constants.collateral_minimum); @@ -159,51 +321,108 @@ impl ChainManager { // Store settings for Threshold Activation of Protocol Improvements act.tapi = config.tapi.clone(); - // Calculate and store initial supply - let genesis_block = - GenesisBlockInfo::from_path(&config.mining.genesis_path, consensus_constants.bootstrap_hash, consensus_constants.genesis_hash) - .map_err(|e| { - log::error!("Failed to load genesis block from config: {e}"); - System::current().stop_with_code(1); - }).ok(); + // Calculate and store initial supply. This file is consensus configuration, not an + // optional UI input: swallowing the error after requesting shutdown allowed the + // reload future to continue and potentially acknowledge snapshot publication. + let genesis_block = match GenesisBlockInfo::from_path( + &config.mining.genesis_path, + consensus_constants.bootstrap_hash, + consensus_constants.genesis_hash, + ) { + Ok(genesis_block) => genesis_block, + Err(error) => { + log::error!("Failed to load genesis block from config: {error}"); + ctx.stop(); + System::current().stop_with_code(1); + return Either::Left(actix::fut::err(())); + } + }; - if let Some(genesis_block) = genesis_block { - act.initial_supply = genesis_block.alloc.iter().fold(0u64, |acc, item| acc + item.iter().fold(0, |acc: u64, utxo| acc.saturating_add(utxo.value))); - log::debug!("Initial WIT supply: {}", act.initial_supply); - } + act.initial_supply = genesis_block.alloc.iter().fold(0u64, |acc, item| { + acc + item + .iter() + .fold(0, |acc: u64, utxo| acc.saturating_add(utxo.value)) + }); + log::debug!("Initial WIT supply: {}", act.initial_supply); // Set whether an in-memory index of data request transactions is required act.rad_hashes_index = config.storage.rad_hashes_index; - storage_mngr::get_chain_state(storage_keys::chain_state_key(magic)) + Either::Right(storage_mngr::get_chain_state(storage_keys::chain_state_key(magic)) .into_actor(act) .then(|chain_state_from_storage, _, _| { - let result = match chain_state_from_storage { - Ok(x) => (x, config), - Err(e) => { - panic!("Error while getting chain state from storage: {e}"); + match chain_state_from_storage { + Ok(x) => actix::fut::ok((x, config)), + Err(error) => { + // A backend read failure is not evidence of an empty or + // incompatible ChainState. Stop before any replacement state is + // constructed so restart can retry against the same durable data. + log::error!( + "Failed to load ChainState from storage during initialization: {error}" + ); + System::current().stop_with_code(1); + actix::fut::err(()) } - }; - - actix::fut::ok(result) - }) + } + })) }) - .map_ok(move |(chain_state_from_storage, config), _act, _ctx| { + .and_then(move |(chain_state_from_storage, config), _act, _ctx| { // Get environment and consensus_constants parameters from config let environment = config.environment; let consensus_constants = &config.consensus_constants; + // Data-request round counters start at one and must be able to advance once past + // `extra_rounds` before the request moves to tally. The maximum `u16` value has + // no representable terminal round and would otherwise make stage advancement (and + // the corresponding validation/mining work bound) overflow. Reject that network + // configuration before adopting or creating any in-memory ChainState. + if consensus_constants.extra_rounds.checked_add(1).is_none() { + log::error!( + "Invalid consensus constants: extra_rounds must be less than {}", + u16::MAX, + ); + System::current().stop_with_code(1); + return actix::fut::err(()); + } // chain_info_from_storage can be None if the storage does not contain that key + // A pristine default is the explicit reset marker written by + // `delete_chain_state_and_reinitialize`; it is the only incomplete state that is + // safe to replace. Any partially populated value must remain fail-closed below. + let chain_state_from_storage = match chain_state_from_storage { + Some(chain_state) => match storage_mngr::is_pristine_chain_state(&chain_state) { + Ok(true) => { + log::info!( + "Found the persisted empty ChainState reset marker; initializing a fresh chain" + ); + None + } + Ok(false) => Some(chain_state), + Err(error) => { + // A poisoned stake-entry lock is not evidence of the replaceable + // reset marker. Stop before constructing replacement state so a + // restart can retry against the same durable value. + log::error!( + "Failed to inspect ChainState reset marker during initialization: {error}" + ); + System::current().stop_with_code(1); + return actix::fut::err(()); + } + }, + None => None, + }; let chain_state = match chain_state_from_storage { - Some( - chain_state_from_storage @ ChainState { - chain_info: Some(..), - reputation_engine: Some(..), - .. - } - ) => { - let chain_info_from_storage = - chain_state_from_storage.chain_info.as_ref().unwrap(); + Some(chain_state_from_storage) + if chain_state_from_storage.chain_info.is_some() + && chain_state_from_storage.reputation_engine.is_some() => + { + // Detach the comparison data before returning the owned ChainState. This + // keeps the durable state move independent from the temporary borrow used + // to validate its environment and consensus constants. + let chain_info_from_storage = chain_state_from_storage + .chain_info + .as_ref() + .expect("chain_info presence was checked by the match guard") + .clone(); if environment == chain_info_from_storage.environment { if consensus_constants == &chain_info_from_storage.consensus_constants { @@ -211,37 +430,54 @@ impl ChainManager { chain_state_from_storage } else { - // Mismatching consensus constants between config and storage - panic!( + // Mismatching consensus constants between config and storage are + // durable-state corruption, not a reason to unwind the actor. + // Stop before any replacement state is published so the operator + // can inspect the original ChainState and restart deliberately. + log::error!( "Mismatching consensus constants: tried to run a node using \ different consensus constants than the ones that were used when \ the local chain was initialized.\nNode constants: {:#?}\nChain \ constants: {:#?}", consensus_constants, chain_info_from_storage.consensus_constants ); + System::current().stop_with_code(1); + return actix::fut::err(()); } } else { - // Mismatching environment names between config and storage - panic!( + // Mismatching environment names between config and storage are the + // same fail-closed durable-state boundary as mismatching constants. + log::error!( "Mismatching environments: tried to run a node on environment \ \"{:?}\" with a chain that was initialized with environment \ \"{:?}\".", environment, chain_info_from_storage.environment ); + System::current().stop_with_code(1); + return actix::fut::err(()); } } - x => { - if x.is_some() { - log::debug!( - "Uninitialized local chain the ChainInfo in storage is incomplete. Proceeding to \ - initialize and store a new chain." - ); - } else { - log::debug!( - "Uninitialized local chain (no ChainInfo in storage). Proceeding to \ - initialize and store a new chain." - ); - } + Some(incomplete_chain_state) => { + // A present but structurally incomplete ChainState is durable state + // corruption (or an interrupted write), not an empty database. Replacing + // it with a fresh chain would discard the persisted chain and any + // delegated-staking state. Stop before acquiring a backend or publishing + // replacement state so restart/operator recovery can inspect the original + // bytes. + log::error!( + "Refusing to replace an incomplete ChainState from storage: \ + chain_info_present={}, reputation_engine_present={}", + incomplete_chain_state.chain_info.is_some(), + incomplete_chain_state.reputation_engine.is_some(), + ); + System::current().stop_with_code(1); + return actix::fut::err(()); + } + None => { + log::debug!( + "Uninitialized local chain (no ChainState in storage). Proceeding to \ + initialize and store a new chain." + ); // Create a new ChainInfo let bootstrap_hash = consensus_constants.bootstrap_hash; let hash_prev_block = bootstrap_hash; @@ -271,8 +507,18 @@ impl ChainManager { .consensus_constants .bootstrapping_committee .iter() - .map(|add| add.parse().expect("Malformed bootstrapping committee")) - .collect(); + .map(|address| address.parse::()) + .collect::, _>>(); + let bootstrap_committee = match bootstrap_committee { + Ok(committee) => committee, + Err(error) => { + log::error!( + "Cannot initialize a fresh chain from a malformed bootstrapping committee: {error}" + ); + System::current().stop_with_code(1); + return actix::fut::err(()); + } + }; let superblock_state = SuperBlockState::new(bootstrap_hash, bootstrap_committee); ChainState { @@ -287,12 +533,12 @@ impl ChainManager { } }; - (chain_state, config) + actix::fut::ok((chain_state, config)) }) .and_then(move |(chain_state, config), act, _ctx| { // Get storage backend for unspent_outputs_pool. // Avoid call to storage_mngr if the backend is already in memory. - let fut: Pin>>> = if let Some(x) = act.chain_state.unspent_outputs_pool.db.take() { + let fut: Pin>>> = if let Some(x) = act.chain_state.unspent_outputs_pool.db.clone() { Box::pin(actix::fut::ok(x)) } else { Box::pin(storage_mngr::get_backend() @@ -310,117 +556,251 @@ impl ChainManager { .and_then(move |(mut chain_state, config, backend), act, _ctx| { chain_state.unspent_outputs_pool.db = Some(backend); - let fut: Pin>>> = if !chain_state.unspent_outputs_pool_old_migration_db.is_empty() { + let fut: Pin), ()>>>> = if !chain_state.unspent_outputs_pool_old_migration_db.is_empty() { log::info!("Detected some UTXOs stored in memory, performing migration to store all UTXOs in database"); - // In case a previous attempt to perform this migration was interrupted, remove - // all the existing UTXOs from database first. - let removed_utxos = chain_state.unspent_outputs_pool.delete_all_from_db(); - if removed_utxos > 0 { - log::warn!( - "Found {removed_utxos} UTXOs already in the database. Assuming that this is \ - because a previous migration was interrupted, the UTXOs have \ - been deleted and the migration will restart from scratch." - ); - } - chain_state + // Replace both the UTXO image and the serialized migration marker in one + // backend batch. Until that write succeeds the previously persisted + // ChainState still contains the portable UTXO image, so a restart can retry + // without observing an empty or only partly migrated database. + let mut batch = UtxoWriteBatch::default(); + let removed_utxos = chain_state .unspent_outputs_pool - .migrate_old_unspent_outputs_pool_to_db( - &mut chain_state.unspent_outputs_pool_old_migration_db, - |i, total| { - if i % 10000 == 0 { - log::info!( - "UTXO set migration v3: [{i}/{total}]", + .try_delete_all_from_db_batch(&mut batch); + match removed_utxos { + Err(error) => { + log::error!("Failed to enumerate existing UTXOs for migration: {error}"); + Box::pin(actix::fut::err::< + (ChainState, Arc), + (), + >( () )) + } + Ok(removed_utxos) => { + if removed_utxos > 0 { + log::warn!( + "Found {removed_utxos} UTXOs already in the database. Assuming that this is \ + because a previous migration was interrupted, the UTXOs have \ + been staged for replacement and the migration will restart from scratch." + ); + } + let portable_pool = std::mem::take( + &mut chain_state.unspent_outputs_pool_old_migration_db, + ); + let mut staged_pool = UnspentOutputsPool::from(portable_pool); + match staged_pool.try_persist_add_to_batch(&mut batch) { + Err(error) => { + log::error!( + "Failed to stage migrated UTXOs for persistence: {error}" ); + Box::pin(actix::fut::err::< + (ChainState, Arc), + (), + >( () )) + } + Ok(()) => { + log::info!("UTXO migration staged, saving updated ChainState atomically"); + let fut = storage_mngr::put_chain_state_in_batch( + &storage_keys::chain_state_key(act.get_magic()), + &chain_state, + batch, + ) + .into_actor(act) + .and_then(move |_, _, _| { + log::debug!("Successfully persisted migrated UTXOs and chain_state into storage"); + fut::ok((chain_state, config)) + }) + .map_err(|err, _, _| { + log::error!( + "Failed to persist migrated UTXOs and chain_state atomically: {err}" + ); + // Some callers intentionally use the compatibility + // `initialize_from_storage` wrapper, which maps the result to + // unit and cannot observe this error. A failed migration must + // stop the node regardless of which initialization entrypoint + // triggered it; otherwise the in-memory portable image and the + // durable UTXO database can diverge silently. + System::current().stop_with_code(1); + }); + + Box::pin(fut) } } - ); - log::info!("Migration completed successfully, saving updated ChainState"); - // Write the chain state again right after this migration, to ensure that the - // migration is only executed once - let fut = storage_mngr::put_chain_state_in_batch( - &storage_keys::chain_state_key(act.get_magic()), - &chain_state, - UtxoWriteBatch::default(), - ) - .into_actor(act) - .and_then(|_, _, _| { - log::debug!("Successfully persisted chain_state into storage"); - fut::ok(chain_state) - }) - .map_err(|err, _, _| { - log::error!( - "Failed to persist chain_state into storage: {err}" - ) - }); - - Box::pin(fut) + } + } } else { - Box::pin(actix::fut::ok(chain_state)) + Box::pin(actix::fut::ok((chain_state, config))) }; fut - .map_ok(move |chain_state, _act, _ctx| { - (chain_state, config) - }) }) - .map_ok(move |(chain_state, config), act, ctx| { + .and_then(move |(mut chain_state, config), act, ctx| -> ResponseActFuture> { + // Rebuild the node-local spendable-output index while the restored ChainState is + // still staged. Previously this happened after assigning `act.chain_state` and + // broadcasting `SetLastBeacon`, and its iterator error was only logged. During an + // online snapshot import that could acknowledge successful publication even + // though the replacement's own-UTXO view had not been proven readable. Keep all + // actor/session publication on the far side of this fallible boundary. + if let Some(own_pkh) = act.own_pkh { + match Self::try_build_own_utxos( + &chain_state.unspent_outputs_pool, + own_pkh, + ) { + Ok(refreshed) => chain_state.own_utxos = refreshed, + Err(error) => { + log::error!( + "Failed to stage own UTXO index before chain-state publication: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + } + } + let consensus_constants = &config.consensus_constants; - let chain_info = chain_state.chain_info.as_ref().unwrap(); + let chain_info = match chain_state.chain_info.as_ref() { + Some(chain_info) => chain_info, + None => { + log::error!( + "Refusing to publish a ChainState without chain information" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + }; log::info!( "Current ChainState CheckpointBeacon: epoch ({}), hash_block ({})", chain_info.highest_block_checkpoint.checkpoint, chain_info.highest_block_checkpoint.hash_prev_block ); + // Load and construct genesis while the replacement ChainState is still staged. + // Converting this failure to `None` used to let initialization publish the new + // state and return success after merely requesting process shutdown. In + // particular, an online snapshot import could therefore acknowledge a committed + // empty/genesis snapshot whose required local genesis file was unreadable. + let genesis_block = if !resync + && !skip_genesis + && chain_info.highest_block_checkpoint.hash_prev_block + == consensus_constants.bootstrap_hash + { + match GenesisBlockInfo::from_path( + &config.mining.genesis_path, + consensus_constants.bootstrap_hash, + consensus_constants.genesis_hash, + ) { + Ok(info_genesis) => { + log::info!( + "Genesis block successfully created. Hash: {}", + consensus_constants.genesis_hash + ); + Some(info_genesis.build_genesis_block( + consensus_constants.bootstrap_hash, + )) + } + Err(error) => { + log::error!("Failed to create genesis block: {error}"); + log::error!( + "Genesis block could be downloaded in: https://github.com/witnet/genesis_block" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + } + } else { + None + }; + // We may need to update the epoch constants in both the sessions and epoch manager to // correctly calculate the current epoch after wit/2 is activated while the configuration // has not been updated yet. - let activation_epoch_wit2 = get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - if activation_epoch_wit2 != u32::MAX { - let checkpoint_zero_timestamp_wit2 = config.consensus_constants.checkpoint_zero_timestamp - + i64::from(get_protocol_version_activation_epoch(ProtocolVersion::V2_0)) - * i64::from(config.consensus_constants.checkpoints_period); + let (protocol_info, published_epoch_constants, _) = get_protocol_runtime(); + if protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .is_some() + { + let restored_epoch_constants = match published_epoch_constants { + Some(epoch_constants) + if epoch_constants.checkpoint_zero_timestamp + == config.consensus_constants.checkpoint_zero_timestamp + && epoch_constants.checkpoints_period + == config.consensus_constants.checkpoints_period => + { + epoch_constants + } + Some(_) => { + log::error!( + "Certificate-restored protocol clock does not match the configured consensus constants" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + None => { + log::error!( + "Certificate-restored protocol schedule was published without its epoch clock" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + }; + + // The subscription snapshot may also have completed before storage restored + // the shorter Wit/2 period. Recompute locally from the same constants being + // published to the other actors instead of refreshing the protocol version + // from an epoch derived under the stale pre-restoration period. + // A conversion failure at this consensus boundary is not safe to defer until + // the epoch manager's next timer tick. Keep the actor-local clock unchanged + // until this last fallible preparation step has completed. + let restored_epoch = match restored_epoch_constants.epoch_at(get_timestamp()) { + Ok(restored_epoch) => restored_epoch, + Err(error) => { + log::error!( + "Failed to derive the current epoch from certificate-restored Wit/2 constants: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); + } + }; + + // Publish the locally restored values only after every fallible preparation + // step. `started` may already have requested an atomic subscription snapshot + // against the old configured schedule; its callback generation-checks and + // must not overwrite this certificate-restored schedule if that response + // arrives last. + act.epoch_constants = Some(restored_epoch_constants); + act.current_epoch = Some(restored_epoch); + refresh_protocol_version(restored_epoch); SessionsManager::from_registry().do_send(SetEpochConstants { - epoch_constants: EpochConstants { - checkpoint_zero_timestamp: config.consensus_constants.checkpoint_zero_timestamp, - checkpoints_period: config.consensus_constants.checkpoints_period, - checkpoint_zero_timestamp_wit2, - checkpoints_period_wit2: get_protocol_version_period(ProtocolVersion::V2_0), - }, + epoch_constants: restored_epoch_constants, }); EpochManager::from_registry().do_send(SetEpochConstants { - epoch_constants: EpochConstants { - checkpoint_zero_timestamp: config.consensus_constants.checkpoint_zero_timestamp, - checkpoints_period: config.consensus_constants.checkpoints_period, - checkpoint_zero_timestamp_wit2, - checkpoints_period_wit2: get_protocol_version_period(ProtocolVersion::V2_0), - }, + epoch_constants: restored_epoch_constants, }); + } else if published_epoch_constants.is_none() + && !protocol_info.all_versions.is_empty() + { + log::error!( + "Post-storage ChainManager initialization refuses an unpaired protocol schedule" + ); + ctx.stop(); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(())); } - // If hash_prev_block is the bootstrap hash, create and consolidate genesis block. // Consolidating the genesis block is not needed if the chain state has been reset // because of a rewind: the genesis block will be processed with the other blocks. - if !resync && chain_info.highest_block_checkpoint.hash_prev_block == consensus_constants.bootstrap_hash { - let info_genesis = - GenesisBlockInfo::from_path(&config.mining.genesis_path, consensus_constants.bootstrap_hash, consensus_constants.genesis_hash) - .map_err(|e| { - log::error!("Failed to create genesis block: {e}"); - log::error!("Genesis block could be downloaded in: https://github.com/witnet/genesis_block"); - System::current().stop_with_code(1); - }).ok(); - - if let Some(ig) = info_genesis { - log::info!("Genesis block successfully created. Hash: {}", consensus_constants.genesis_hash); - - let genesis_block = ig.build_genesis_block(consensus_constants.bootstrap_hash); - ctx.notify(AddBlocks { - blocks: vec![genesis_block], - sender: None, - }); - } + if let Some(genesis_block) = genesis_block { + ctx.notify(AddBlocks { + blocks: vec![genesis_block], + sender: None, + }); } act.chain_state = chain_state; @@ -428,14 +808,8 @@ impl ChainManager { // Update possible new WIP information let (new_wip_epoch, old_wips) = act.chain_state.tapi_engine.initialize_wip_information(get_environment()); let last_consolidated_epoch = act.get_chain_beacon().checkpoint; - if new_wip_epoch < last_consolidated_epoch { - // Some blocks have been consolidated before this node updated to the latest version, - // so we need to count the missing wip_votes from that blocks - ctx.wait( - act.update_new_wip_votes(new_wip_epoch, last_consolidated_epoch, old_wips) - .map(|_res, _act, _ctx| ()) - ); - } + let wip_replay = (new_wip_epoch < last_consolidated_epoch) + .then_some((new_wip_epoch, last_consolidated_epoch, old_wips)); // initialize_from_storage is also used to implement reorganizations // In that case, we must clear some fields to avoid forks @@ -443,13 +817,24 @@ impl ChainManager { act.candidates.clear(); act.seen_candidates.clear(); - // clean transactions included during an unconfirmed superepoch + // Reinitialization invalidates every in-flight admission and removes retained + // unconfirmed transactions for revalidation. Clear their exact-representation + // guard too, otherwise an evicted transaction (or a fresh retry after its semantic + // reservation was cleared) can be rejected as already seen before it is reinserted. + // Every callback is body-generation checked, and delegated-action callbacks also + // own their semantic nonce slot, so a late stale callback cannot erase bookkeeping + // or publish over the replacement attempt even if the restored snapshot is equal. + // Invalidated Wit/2.1 Commit verifier generations remain capacity-counted until + // those callbacks return because already-dispatched signature work is not cancellable. + act.seen_transactions.clear(); + act.transactions_pool.clear_pending_transaction_admissions(); + act.transactions_pool.clear_pending_delegated_actions(); let mempool_transactions = act.transactions_pool.remove_unconfirmed_transactions(); - act.temp_vts_and_drs.extend(mempool_transactions); - if !act.temp_vts_and_drs.is_empty(){ + act.temp_reinserted_transactions.extend(mempool_transactions); + if !act.temp_reinserted_transactions.is_empty(){ log::debug!( "Re-adding {} transactions into mempool", - act.temp_vts_and_drs.len(), + act.temp_reinserted_transactions.len(), ); } @@ -460,34 +845,63 @@ impl ChainManager { // highest_persisted_superblock to the top consolidated superblock act.chain_state_snapshot.highest_persisted_superblock = act.get_superblock_beacon().checkpoint; - SessionsManager::from_registry().do_send(SetLastBeacon { - beacon: LastBeacon { - highest_block_checkpoint: act.get_chain_beacon(), - highest_superblock_checkpoint: act.get_superblock_beacon(), - }, - }); + // Some blocks may have been consolidated before this node learned about the + // latest WIPs. Make replay part of this initialization future instead of spawning + // a detached `ctx.wait`: snapshot import must not acknowledge publication, or + // reopen the ChainManager mailbox, until every canonical header needed for the + // reconstructed TAPI counters has been loaded successfully. + let replay: ResponseActFuture> = match wip_replay { + Some((init, end, old_wips)) => Box::pin( + act.update_new_wip_votes(init, end, old_wips) + .map_err(|(), _act, ctx| { + log::error!( + "Failed to reconstruct TAPI votes during chain-state publication; stopping" + ); + ctx.stop(); + System::current().stop_with_code(1); + }), + ), + None => Box::pin(actix::fut::ok(())), + }; - act.update_own_utxos(); + Box::pin(replay.and_then(|(), act, _ctx| { + // Advertise the replacement beacon only after every fallible local reload + // step, including historical TAPI reconstruction, has completed. + SessionsManager::from_registry().do_send(SetLastBeacon { + beacon: LastBeacon { + highest_block_checkpoint: act.get_chain_beacon(), + highest_superblock_checkpoint: act.get_superblock_beacon(), + }, + }); + actix::fut::ok(()) + })) + }) }); Box::pin(fut) } /// Ensure that there is only one ChainState in the storage. Old versions of witnet-rust used - /// to allow having multiple ChainStates for multiple testnets. This function will panic in that - /// case because multiple ChainStates are incompatible with storing UTXOs as keys in the - /// database. - pub async fn check_only_one_chain_state_in_storage() { - let config = config_mngr::get().await.unwrap_or_else(|err| { - panic!("Couldn't get config: {err}"); - }); + /// to allow having multiple ChainStates for multiple testnets. A confirmed mixed-environment + /// layout remains a configuration error and is reported with an operator-facing message while + /// the startup actor future stops the node cleanly. Backend acquisition and iterator failures + /// are returned as fail-stop errors instead of being mistaken for a proven layout violation. + pub async fn check_only_one_chain_state_in_storage() -> Result<(), ()> { + let config = match config_mngr::get().await { + Ok(config) => config, + Err(error) => { + log::error!("Couldn't get config while checking ChainState storage: {error}"); + return Err(()); + } + }; - let backend = storage_mngr::get_backend() - .await - .unwrap_or_else(|err| { - panic!("Failed to get storage backend: {err}"); - }) - .as_arc_dyn_storage(); + let backend = match storage_mngr::get_backend().await { + Ok(backend) => backend.as_arc_dyn_storage(), + Err(error) => { + log::error!("Failed to get storage backend while checking ChainState storage: {error}"); + return Err(()); + } + }; let magic = config.consensus_constants.get_magic(); @@ -495,27 +909,46 @@ impl ChainManager { // to check all the possible lengths for numbers between 0 and 65535. // Luckily there are only 5 possible prefixes: let magic_templates = vec![1, 11, 111, 1111, 11111]; - let all_chain_states: Vec> = magic_templates - .into_iter() - .map(storage_keys::chain_state_key) - .map(|key| bincode::serialize(&key).expect("bincode error")) - .map(|mut key_bytes| { - // Truncate key to 14 bytes. If the key is: - // [8 bytes prefix]chain-11111-key - // This will truncate right before the "11111": - // [8 bytes prefix]chain- - // To allow checking all the possible magic numbers - key_bytes.truncate(14); - key_bytes - }) - .flat_map(|prefix| { - backend - .prefix_iterator(&prefix) - .expect("prefix iterator error") - .map(|(k, _v)| k) - .collect::>() - }) - .collect(); + let mut all_chain_states = Vec::new(); + for magic_template in magic_templates { + let key = storage_keys::chain_state_key(magic_template); + let mut prefix = match bincode::serialize(&key) { + Ok(bytes) => bytes, + Err(error) => { + log::error!( + "Failed to serialize ChainState storage prefix while checking the database: {error}" + ); + return Err(()); + } + }; + // Truncate key to 14 bytes. If the key is: + // [8 bytes prefix]chain-11111-key + // This will truncate right before the "11111": + // [8 bytes prefix]chain- + // To allow checking all the possible magic numbers + prefix.truncate(14); + + let iterator = match backend.prefix_iterator(&prefix) { + Ok(iterator) => iterator, + Err(error) => { + log::error!( + "Failed to open ChainState storage iterator while checking the database: {error}" + ); + return Err(()); + } + }; + for entry in iterator { + match entry { + Ok((key, _value)) => all_chain_states.push(key), + Err(error) => { + log::error!( + "Failed to read ChainState storage entry while checking the database: {error}" + ); + return Err(()); + } + } + } + } match all_chain_states.len() { 0 => { @@ -523,7 +956,15 @@ impl ChainManager { } 1 => { let expected_magic = storage_keys::chain_state_key(magic); - let expected_key = bincode::serialize(&expected_magic).expect("bincode error"); + let expected_key = match bincode::serialize(&expected_magic) { + Ok(key) => key, + Err(error) => { + log::error!( + "Failed to serialize expected ChainState storage key while checking the database: {error}" + ); + return Err(()); + } + }; let key = &all_chain_states[0]; if key == &expected_key { // One ChainState in DB and matches magic number, good @@ -533,7 +974,7 @@ impl ChainManager { // reuse this storage, so ask the user to delete the storage. let key_str: String = bincode::deserialize(key).unwrap_or_else(|_e| format!("{key:?}")); - panic!( + log::error!( "Storage already contains a chain state with different magic number.\n\ Expected magic: {expected_magic:?}, found in storage: {key_str:?}.\n\ Please backup the master key if needed, delete the storage and try again.\n\ @@ -542,6 +983,7 @@ impl ChainManager { Help: make sure that the storage folder is not used in multiple environments \ (testnet and mainnet)." ); + return Err(()); } } _ => { @@ -553,7 +995,7 @@ impl ChainManager { .iter() .map(|key| bincode::deserialize(key).unwrap_or_else(|_e| format!("{key:?}"))) .collect(); - panic!( + log::error!( "Storage contains more than one chain state with different magic numbers.\n\ Expected magic: {expected_magic:?}, found in storage: {key_strs:?}.\n\ Please backup the master key if needed, delete the storage and try again.\n\ @@ -561,19 +1003,179 @@ impl ChainManager { Help: make sure that the storage folder is not used in multiple environments \ (testnet and mainnet)." ); + return Err(()); } } + + Ok(()) } /// Actor-aware wrapper around `static_snapshot_export`. pub fn snapshot_export(&mut self) -> ResponseActFuture> { + if self.chain_state.delegation_state.activation_initialized { + if let Err(error) = self.chain_state.stakes.validate_validator_index_checked() { + return Box::pin(actix::fut::err(error.into())); + } + } + let chain_state = match self.chain_state.try_clone_checked() { + Ok(chain_state) => chain_state, + Err(error) => return Box::pin(actix::fut::err(error.into())), + }; Box::pin(actix::fut::wrap_future(Self::static_snapshot_export( self.sm_state, - self.chain_state.clone(), + chain_state, self.export.take(), ))) } + /// Validate every retained superblock boundary against the indexed and decoded blocks before + /// creating any filesystem entry for a new export generation. + async fn preflight_snapshot_export_superblocks( + superblocks: &[SuperBlock], + chain_info: &ChainInfo, + block_chain: &Blockchain, + superblock_state: &SuperBlockState, + inventory_view: storage_mngr::InventoryView, + protocol_info: &ProtocolInfo, + ) -> Result<(), anyhow::Error> { + validate_snapshot_superblock_state_binding(superblock_state, chain_info) + .map_err(|error| anyhow::anyhow!("cannot export inconsistent superblock state: {error}"))?; + let superblock_tip = chain_info.highest_superblock_checkpoint; + let superblock_period = u32::from(chain_info.consensus_constants.superblock_period); + let genesis_hash = chain_info.consensus_constants.genesis_hash; + let mut previous_index = None; + let mut final_superblock_hash = None; + let mut boundary_requests = Vec::new(); + + for superblock in superblocks { + if superblock.index == 0 || superblock.index > superblock_tip.checkpoint { + return Err(anyhow::anyhow!( + "superblock index {} is outside exported tip 1..={}", + superblock.index, + superblock_tip.checkpoint + )); + } + if let Some(previous_index) = previous_index { + if superblock.index <= previous_index { + return Err(anyhow::anyhow!( + "exported superblock indexes are not strictly increasing: {} follows {}", + superblock.index, + previous_index + )); + } + } + previous_index = Some(superblock.index); + + let final_epoch = superblock + .index + .checked_mul(superblock_period) + .ok_or_else(|| { + anyhow::anyhow!( + "superblock index {} overflows its epoch range", + superblock.index + ) + })?; + let initial_epoch = final_epoch.saturating_sub(superblock_period); + let expected_previous_block = block_chain + .range(..initial_epoch) + .next_back() + .map(|(_, hash)| *hash) + .unwrap_or(genesis_hash); + if superblock.last_block_in_previous_superblock != expected_previous_block { + return Err(anyhow::anyhow!( + "superblock #{} previous-block boundary does not match the exported block index", + superblock.index + )); + } + + match block_chain.range(initial_epoch..final_epoch).next_back() { + Some((last_epoch, indexed_hash)) => boundary_requests.push(( + superblock.index, + final_epoch, + *last_epoch, + *indexed_hash, + superblock.last_block, + )), + None if superblock.last_block == expected_previous_block => {} + None => { + return Err(anyhow::anyhow!( + "empty superblock #{} does not carry forward its previous block", + superblock.index + )); + } + } + + if superblock.index == superblock_tip.checkpoint { + final_superblock_hash = Some(superblock.hash()); + } + } + + if superblock_tip.checkpoint == 0 { + if !superblocks.is_empty() + || superblock_tip.hash_prev_block + != chain_info.consensus_constants.bootstrap_hash + { + return Err(anyhow::anyhow!( + "cannot export an inconsistent genesis superblock beacon" + )); + } + } else if final_superblock_hash != Some(superblock_tip.hash_prev_block) { + return Err(anyhow::anyhow!( + "cannot export superblock inventory that does not match ChainState tip #{} {}", + superblock_tip.checkpoint, + superblock_tip.hash_prev_block + )); + } + + // A long-running chain can retain many superblocks. Fetch their boundary blocks in bounded + // chunks while still completing the entire preflight before the generation directory is + // created. + for boundary_chunk in boundary_requests.chunks(1_000) { + let boundary_blocks = InventoryManager::get_multiple_blocks_in_view_for_protocol( + inventory_view, + boundary_chunk + .iter() + .map(|(_, _, _, indexed_hash, _)| *indexed_hash), + protocol_info, + ) + .await?; + if boundary_blocks.len() != boundary_chunk.len() { + return Err(anyhow::anyhow!( + "superblock boundary preflight returned an incomplete block set" + )); + } + for ( + block, + &( + superblock_index, + final_epoch, + expected_epoch, + indexed_hash, + expected_boundary_hash, + ), + ) in boundary_blocks.into_iter().zip(boundary_chunk.iter()) + { + let actual_epoch = block.block_header.beacon.checkpoint; + let actual_index_hash = block.versioned_hash( + protocol_info.all_versions.version_for_epoch(actual_epoch), + ); + let actual_boundary_hash = block + .block_header + .versioned_hash(protocol_info.all_versions.version_for_epoch(final_epoch)); + if actual_epoch != expected_epoch + || actual_index_hash != indexed_hash + || actual_boundary_hash != expected_boundary_hash + { + return Err(anyhow::anyhow!( + "decoded last block does not match superblock #{superblock_index} boundary" + )); + } + } + } + + Ok(()) + } + /// Main business logic for exporting chain snapshots. pub async fn static_snapshot_export( state_machine: StateMachine, @@ -583,59 +1185,227 @@ impl ChainManager { match (state_machine, path) { // Only proceed to export if the node is synced or the export has been forced (StateMachine::Synced, Force::Some(path)) | (_, Force::All(path)) => { - let chain_info = chain_state.chain_info.clone().unwrap_or_default(); + // Reject inconsistent state and inventory before creating even an uncommitted + // generation directory. The manifest is written only after every component. + storage_mngr::validate_delegated_staking_persistence(&chain_state)?; + let chain_info = chain_state.chain_info.clone().ok_or_else(|| { + anyhow::anyhow!("cannot export an uninitialized ChainState without ChainInfo") + })?; + if chain_state.reputation_engine.is_none() { + return Err(anyhow::anyhow!( + "cannot export an incomplete ChainState without ReputationEngine" + )); + } + let block_tip = chain_info.highest_block_checkpoint; + match chain_state.block_chain.iter().next_back() { + Some((epoch, hash)) + if *epoch == block_tip.checkpoint + && *hash == block_tip.hash_prev_block => {} + Some((epoch, hash)) => { + return Err(anyhow::anyhow!( + "cannot export block index tip {epoch} {hash}: ChainInfo names {} {}", + block_tip.checkpoint, + block_tip.hash_prev_block + )); + } + None + if block_tip.checkpoint == 0 + && block_tip.hash_prev_block + == chain_info.consensus_constants.bootstrap_hash => {} + None => { + return Err(anyhow::anyhow!( + "cannot export an empty block index that disagrees with ChainInfo tip {} {}", + block_tip.checkpoint, + block_tip.hash_prev_block + )); + } + } + validate_snapshot_block_index_origin( + &chain_state.block_chain, + chain_info.consensus_constants.genesis_hash, + )?; let environment = chain_info.environment; let checkpoint = chain_info.highest_block_checkpoint.checkpoint; + // Pin every inventory-domain read in this export to one generation. A concurrent + // import may publish a different active descriptor while files are being written; + // mixing its reports, superblocks, or blocks with this ChainState is never valid. + let inventory_view = storage_mngr::get_inventory_view().await?; + let mut transaction_verifier = + SnapshotTransactionVerifier::new(&chain_info.consensus_constants)?; + let snapshot_protocol_info_for_manifest = transaction_verifier.protocol_info().clone(); + + // Reports are part of the portable snapshot set. Validate their keys and + // serialized values before any generation directory is created, and omit records + // proven to belong to an orphaned fork rather than letting stale ordinary + // inventory make every future export unavailable. + let reports = InventoryManager::get_all_data_request_reports_in_view( + inventory_view, + ) + .await?; + let mut report_plan = Self::preflight_snapshot_reports_with_protocol( + reports, + &chain_state.block_chain, + true, + true, + &snapshot_protocol_info_for_manifest, + chain_info.consensus_constants.extra_rounds, + ) + .map_err(|error| anyhow::anyhow!("cannot export data-request reports: {error}"))?; - // Compose the path of the file to write the snapshot to, and create the file - let base_path = path.join(format!( + // The user-facing path is a small, atomically replaced manifest. Components live + // under an immutable generation-qualified sibling directory. + let manifest_path = path.join(format!( "witnet_chain_snapshot_{environment}_{checkpoint}.bin" )); - let base_path_display = base_path.display().to_string(); - log::info!("Snapshot will be created and written into {base_path_display}"); - - // Copy the chain state and bundle UTXO set into it - // TODO: Avoid double-cloning and having the UTXO set twice, by rather constructing - // a new chain state with the cloned pieces - log::info!("Cloning chain state and UTXO set..."); + let manifest_path_display = manifest_path.display().to_string(); + log::info!("Snapshot manifest will be written into {manifest_path_display}"); + + // The exporter owns this ChainState value. Move the database-backed UTXO pool + // into the portable image instead of cloning its pending diff and backend handle + // first; the pool is skipped by ChainState serialization and is not needed by the + // remainder of this export operation. + log::info!("Converting chain state UTXO set to a portable image..."); let mut chain_state = chain_state; + let unspent_outputs_pool = std::mem::take(&mut chain_state.unspent_outputs_pool); chain_state.unspent_outputs_pool_old_migration_db = - OldUnspentOutputsPool::from(chain_state.unspent_outputs_pool.clone()); + unspent_outputs_pool.try_into_portable()?; chain_state.own_utxos = Default::default(); - // Collect superblocks from inventory and write them into file system log::info!("Fetching all superblocks..."); - let superblocks = InventoryManager::get_all_superblocks().await?; + let mut superblocks = + InventoryManager::get_all_superblocks_in_view(inventory_view).await?; log::info!("Done fetching all superblocks..."); - let path = file_name_compose(base_path.clone(), Some("superblocks".into())); - log::info!("Exporting all superblocks into {}", path.display()); - serialize_to_file(&superblocks, &path)?; + let superblock_tip = chain_info.highest_superblock_checkpoint; + let stored_superblocks = superblocks.len(); + superblocks.retain(|superblock| superblock.index <= superblock_tip.checkpoint); + if stored_superblocks != superblocks.len() { + log::warn!( + "Ignoring {} superblock inventory entries beyond exported tip #{}", + stored_superblocks - superblocks.len(), + superblock_tip.checkpoint + ); + } + Self::preflight_snapshot_export_superblocks( + &superblocks, + &chain_info, + &chain_state.block_chain, + &chain_state.superblock_state, + inventory_view, + &snapshot_protocol_info_for_manifest, + ) + .await?; + + // No export write occurs above this point. From here on, an interrupted attempt can + // leave only an unreferenced generation; the previous manifest remains authoritative. + let (generation, generation_dir) = + create_snapshot_generation(&manifest_path)?; + let mut generation_cleanup = SnapshotGenerationCleanup::new( + &manifest_path, + generation, + generation_dir.clone(), + ); + let superblocks_path = snapshot_superblocks_path(&generation_dir); + log::info!( + "Exporting all superblocks into {}", + superblocks_path.display() + ); + serialize_to_file(&superblocks, &superblocks_path)?; + let superblocks_component = snapshot_component_from_file(&superblocks_path)?; // Collect blocks from inventory and write them into the file system // This is done in batches for the sake of performance and reduced memory // footprint - let batch_size = 50_000; + let batch_size = SNAPSHOT_BLOCK_BATCH_SIZE; let total_blocks = chain_state.block_chain.len(); log::info!( "Starting to fetch all {total_blocks} blocks in chunks of {batch_size} blocks..." ); let mut futs = Vec::new(); + let report_observation_filter = Arc::new(report_plan.observation_filter()); + let snapshot_protocol_info = + Arc::new(snapshot_protocol_info_for_manifest.clone()); let batches = chain_state.block_chain.iter().chunks(batch_size); + // Genesis points to the bootstrap beacon. Every later expectation is chained from + // the immediately preceding indexed hash, even when batches or epochs have gaps. + let mut expected_previous_hash = chain_info.consensus_constants.bootstrap_hash; for (i, batch) in (&batches).into_iter().enumerate() { let from = i * batch_size; - let to = total_blocks.min(i * batch_size + batch_size - 1); - let path = - file_name_compose(base_path.clone(), Some(format!("blocks_batch_{i:0>4}"))); - let hashes = batch.map(|(_epoch, hash)| *hash).collect::>(); + let to = total_blocks + .min(i.saturating_add(1).saturating_mul(batch_size)) + .saturating_sub(1); + let batch_index = u64::try_from(i)?; + let path = snapshot_blocks_batch_path(&generation_dir, batch_index); + let expected_entries = batch + .map(|(epoch, hash)| { + let entry = (*epoch, *hash, expected_previous_hash); + expected_previous_hash = *hash; + entry + }) + .collect::>(); + let hashes = expected_entries + .iter() + .map(|(_epoch, hash, _previous_hash)| *hash) + .collect::>(); + let expected_count = u64::try_from(expected_entries.len())?; + let report_observation_filter = Arc::clone(&report_observation_filter); + let snapshot_protocol_info = Arc::clone(&snapshot_protocol_info); let fut = async move { log::info!( "Starting to fetch blocks batch #{i} ({from} to {to} out of {total_blocks})" ); - let batch = InventoryManager::get_multiple_blocks(hashes).await?; + let batch = InventoryManager::get_multiple_blocks_in_view_for_protocol( + inventory_view, + hashes, + &snapshot_protocol_info, + ) + .await?; log::info!( "Successfully fetched blocks batch #{i} ({from} to {to} out of {total_blocks})" ); + let decoded_count = u64::try_from(batch.len())?; + if decoded_count != expected_count { + return Err(anyhow::anyhow!( + "inventory returned {decoded_count} blocks for manifest batch {batch_index}, expected {expected_count}" + )); + } + for ( + position, + (block, (expected_epoch, expected_hash, expected_previous_hash)), + ) in + batch.iter().zip(&expected_entries).enumerate() + { + let absolute_position = from.checked_add(position).ok_or_else(|| { + anyhow::anyhow!("snapshot block position overflow") + })?; + let actual_epoch = block.block_header.beacon.checkpoint; + let actual_protocol = snapshot_protocol_info + .all_versions + .version_for_epoch(actual_epoch); + let actual_hash = block.versioned_hash(actual_protocol); + if actual_epoch != *expected_epoch || actual_hash != *expected_hash { + return Err(anyhow::anyhow!( + "ChainState block index entry {} in batch {batch_index} expects epoch {expected_epoch} hash {expected_hash}, but inventory returned epoch {actual_epoch} hash {actual_hash}", + absolute_position + )); + } + validate_snapshot_block_predecessor( + block, + absolute_position, + *expected_previous_hash, + )?; + validate_snapshot_block_body( + block, + absolute_position, + actual_protocol, + )?; + } + let observations = SnapshotReportObservations::from_blocks( + &batch, + &report_observation_filter, + from, + &snapshot_protocol_info, + )?; log::info!( "Blocks batch #{} ({} to {} out of {}) is now being written into {}", i, @@ -645,6 +1415,7 @@ impl ChainManager { path.display() ); serialize_to_file(&batch, &path)?; + let component = snapshot_component_from_file(&path)?; log::info!( "Successfully exported blocks batch #{} ({} to {} out of {}) into {}", i, @@ -654,29 +1425,135 @@ impl ChainManager { path.display() ); - Result::<(), anyhow::Error>::Ok(()) + Result::< + ( + SnapshotBlockBatch, + SnapshotReportObservations, + Vec, + usize, + ), + anyhow::Error, + >::Ok(( + SnapshotBlockBatch { + index: batch_index, + block_count: decoded_count, + component, + }, + observations, + batch, + from, + )) }; futs.push(fut); } - // Blocks batches are processed concurrently, in no particular order, with a - // limit of 4 at once - futures::stream::iter(futs) - .buffer_unordered(4) - .collect::>() - .await; + // Fetch and serialize up to four batches concurrently, but yield them in canonical + // order so transaction authorization and the historical UTXO image can be replayed + // incrementally without retaining the full block history in memory. + let mut block_batch_stream = futures::stream::iter(futs).buffered(4); + let mut block_batches = Vec::new(); + while let Some(result) = block_batch_stream.next().await { + let (batch, observations, blocks, from) = result?; + for (position, block) in blocks.iter().enumerate() { + let absolute_position = from.checked_add(position).ok_or_else(|| { + anyhow::anyhow!("snapshot block position overflow") + })?; + transaction_verifier.validate_block(block, absolute_position)?; + } + report_plan + .merge_observations(observations) + .map_err(|error| { + anyhow::anyhow!( + "cannot prove exported data-request report coverage: {error}" + ) + })?; + block_batches.push(batch); + } + transaction_verifier + .validate_tip_state( + &chain_state.unspent_outputs_pool_old_migration_db, + chain_info.highest_vrf_output, + &chain_state.stakes, + &chain_state.delegation_state, + &chain_state.data_request_pool, + )?; + block_batches.sort_unstable_by_key(|batch| batch.index); + for (position, batch) in block_batches.iter().enumerate() { + if batch.index != u64::try_from(position)? { + return Err(anyhow::anyhow!( + "export produced a non-consecutive block batch identity" + )); + } + } log::info!( "Succesfully exported all {total_blocks} blocks in chunks of {batch_size} blocks..." ); - // Finally serialize and write the chain state itself - log::info!("Exporting chain state it into {base_path_display}"); - serialize_to_file(&chain_state, &base_path)?; - log::info!("Success! Finished exporting chain snapshot into {base_path_display}"); + // Only a complete one-to-one request/commit/reveal/tally/report proof may + // materialize the Report component. Until this point a failed export has written + // block files only inside its unreferenced generation directory. + let reports = report_plan.finish().map_err(|error| { + anyhow::anyhow!("cannot export data-request reports: {error}") + })?; + let reports_path = snapshot_reports_path(&generation_dir); + log::info!( + "Exporting {} historical data-request reports into {}", + reports.len(), + reports_path.display() + ); + serialize_to_file(&reports, &reports_path)?; + let reports_component = snapshot_component_from_file(&reports_path)?; + + // Serialize the ChainState component, then atomically publish the small manifest as + // the sole commit point for this complete generation. + let chain_state_path = snapshot_chain_state_path(&generation_dir); + log::info!("Exporting ChainState into {}", chain_state_path.display()); + // Snapshot files were historically raw ChainState payloads. Prefix current + // snapshots with the same version used by database persistence so older nodes + // reject, rather than silently truncate, appended consensus state. + serialize_to_file( + &(storage_mngr::CURRENT_CHAIN_STATE_VERSION, &chain_state), + &chain_state_path, + )?; + let chain_state_component = snapshot_component_from_file(&chain_state_path)?; + + let block_count = u64::try_from(total_blocks)?; + let superblock_count = u64::try_from(superblocks.len())?; + let report_count = u64::try_from(reports.len())?; + let manifest = SnapshotManifest { + magic: SNAPSHOT_MANIFEST_MAGIC, + format_version: SNAPSHOT_MANIFEST_VERSION, + chain_state_version: storage_mngr::CURRENT_CHAIN_STATE_VERSION, + generation, + highest_block_checkpoint: chain_info.highest_block_checkpoint, + highest_superblock_checkpoint: chain_info.highest_superblock_checkpoint, + block_count, + superblock_count, + chain_state: chain_state_component, + superblocks: superblocks_component, + block_batches, + reports: Some(reports_component), + report_count, + protocol_info: Some(snapshot_protocol_info_for_manifest), + }; + serialize_to_file(&manifest, &manifest_path)?; + generation_cleanup.disarm(); + match cleanup_snapshot_generation_dirs(&manifest_path, generation) { + Ok(removed) if removed > 0 => log::info!( + "Reclaimed {removed} superseded filesystem snapshot generations" + ), + Ok(_) => {} + Err(error) => log::warn!( + "Committed snapshot generation, but could not reclaim superseded filesystem generations: {error}" + ), + } + log::info!( + "Success! Committed snapshot generation at {manifest_path_display}" + ); - // Return the path of the exported chain state - Ok(base_path_display) + // Return the path of the committed manifest. + Ok(manifest_path_display) } // Fail if not synced or not forced (current_state, _) => { @@ -689,52 +1566,89 @@ impl ChainManager { } } - /// Get epoch constants and current epoch from EpochManager, and subscribe to future epochs + /// Atomically subscribe to future epochs and initialize from the same EpochManager turn. fn subscribe_to_epoch_manager(&mut self, ctx: &mut Context) { - // Get EpochManager address from registry let epoch_manager_addr = EpochManager::from_registry(); - let epoch_manager_addr2 = epoch_manager_addr.clone(); - - // Get epoch constants - epoch_manager_addr.send(GetEpochConstants).into_actor(self).then(move |res, act, _ctx| { - match res { - Ok(f) => act.epoch_constants = f, - error => log::error!("Failed to get epoch constants: {error:?}"), - } - - epoch_manager_addr2 - // Send GetEpoch message to epoch manager actor - // This returns a RequestFuture, representing an asynchronous message sending process - .send(GetEpoch) - // Convert a normal future into an ActorFuture - .into_actor(act) - }) - // Process the response from the EpochManager - // This returns a FutureResult containing the socket address if present - .then(move |res, act, ctx| { - // Get ChainManager address - let chain_manager_addr = ctx.address(); - - // Check GetEpoch result + epoch_manager_addr + .send(Subscribe::to_all_with_protocol_epoch_snapshot( + ctx.address(), + EveryEpochPayload, + )) + .into_actor(self) + .then(move |res, act, _ctx| { match res { - Ok(Ok(epoch)) => { - // Subscribe to all epochs with an EveryEpochPayload - epoch_manager_addr - .do_send(Subscribe::to_all(chain_manager_addr, EveryEpochPayload)); - - // Set current_epoch - act.current_epoch = Some(epoch); + Ok(Ok(snapshot)) => { + // Storage restore or activation can replace the process-wide runtime while + // this response is in flight. Rebase from the latest paired clock before + // initializing consensus state; the subscription is already installed. + let (protocol_info, published, protocol_generation) = + get_protocol_runtime(); + let (epoch_constants, current_epoch) = + if protocol_generation == snapshot.protocol_generation { + let current_epoch = snapshot + .epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); + if current_epoch != snapshot.current_epoch { + log::debug!( + "ChainManager subscription snapshot crossed an epoch while in flight: {:?} -> {:?}", + snapshot.current_epoch, + current_epoch + ); + } + (snapshot.epoch_constants, current_epoch) + } else { + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => { + // Compatibility-only bootstrap has no process-wide clock; + // retain the clock captured by EpochManager itself. + snapshot.epoch_constants + } + None => { + log::error!( + "ChainManager refuses to initialize from an unpaired protocol schedule" + ); + System::current().stop_with_code(1); + return actix::fut::ready(()); + } + }; + log::warn!( + "Rebasing ChainManager epoch initialization from protocol generation {} to {}", + snapshot.protocol_generation, + protocol_generation + ); + let current_epoch = epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); + (epoch_constants, current_epoch) + }; + act.epoch_constants = Some(epoch_constants); + + match current_epoch { + Ok(current_epoch) => { + act.current_epoch = Some(current_epoch); + refresh_protocol_version(current_epoch); + } + Err(CheckpointZeroInTheFuture(zero)) => { + let date = pretty_print(zero, 0); + log::warn!("Network bootstrapping is scheduled for {date:?}. The node will remain idle and delay chain bootstrapping until then. Wait for it!"); + } + Err(error) => { + log::error!( + "Current epoch could not be derived from the subscribed EpochManager snapshot: {error:?}" + ); + System::current().stop_with_code(1); + } + } } - Ok(Err(CheckpointZeroInTheFuture(zero))) => { - let date = pretty_print(zero, 0); - log::warn!("Network bootstrapping is scheduled for {date:?}. The node will remain idle and delay chain bootstrapping until then. Wait for it!"); - - // Subscribe to all epochs with an EveryEpochPayload - epoch_manager_addr - .do_send(Subscribe::to_all(chain_manager_addr, EveryEpochPayload)); + Ok(Err(error)) => { + log::error!( + "Could not atomically subscribe to EpochManager: {error:?}" + ); } - error => { - log::error!("Current epoch could not be retrieved from EpochManager: {error:?}"); + Err(error) => { + log::error!("Could not communicate with EpochManager: {error:?}"); } } @@ -750,8 +1664,19 @@ impl ChainManager { .map_err(|e, _act, _ctx| { log::error!("Error while getting public key hash from signature manager: {e}"); }) - .and_then(|res, act, _ctx| { + .and_then(|res, act, ctx| { act.own_pkh = Some(res); + if let Err(error) = act.update_own_utxos() { + // Startup snapshot import may have committed while the node identity was not + // loaded yet. Do not resume the actor with an empty/stale spendable-output + // index when the required authoritative scan fails. + log::error!( + "Failed to rebuild own UTXO index after loading node identity: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + return actix::fut::err(()); + } log::debug!("Address successfully loaded from signature manager"); log::info!("Node identity / address: {res}"); @@ -781,168 +1706,570 @@ impl ChainManager { .wait(ctx); } - fn process_superblocks_fut( - &mut self, + fn preflight_snapshot_superblocks( superblocks: Vec, - ) -> ResponseActFuture> { - // Write superblocks into storage + imported_block_chain: &Blockchain, + imported_chain_info: &ChainInfo, + protocol_info: &ProtocolInfo, + ) -> Result< + ( + Vec, + BTreeMap, + ), + ImportError, + > { let superblocks_count = superblocks.len(); - log::info!("Importing {superblocks_count} superblocks into storage"); + log::info!("Preflighting {superblocks_count} snapshot superblocks"); + let superblock_period = + u32::from(imported_chain_info.consensus_constants.superblock_period); + let genesis_hash = imported_chain_info.consensus_constants.genesis_hash; + let superblock_tip = imported_chain_info.highest_superblock_checkpoint; + let mut previous_index = None; + let mut final_superblock_hash = None; + let mut boundary_hashes = BTreeMap::new(); + let mut notifications = Vec::with_capacity(superblocks_count); for (i, superblock) in superblocks.into_iter().enumerate() { + if superblock.index == 0 || superblock.index > superblock_tip.checkpoint { + return Err(ImportError::SnapshotContents { + message: format!( + "superblock index {} is outside imported tip 1..={}", + superblock.index, superblock_tip.checkpoint + ), + }); + } + if let Some(previous_index) = previous_index { + if superblock.index <= previous_index { + return Err(ImportError::SnapshotContents { + message: format!( + "superblock indexes must be strictly increasing, found {} after {}", + superblock.index, + previous_index + ), + }); + } + } + previous_index = Some(superblock.index); if i % 10_000 == 0 { log::info!( - "Persisting superblock #{} ({}/{})", + "Preflighting superblock #{} ({}/{})", superblock.index, i, superblocks_count ); } else { log::debug!( - "Persisting superblock #{} ({}/{})", + "Preflighting superblock #{} ({}/{})", superblock.index, i, superblocks_count ); } - self.notify_superblock_consolidation(superblock); + let final_epoch = match superblock.index.checked_mul(superblock_period) { + Some(final_epoch) => final_epoch, + None => { + return Err(ImportError::SnapshotContents { + message: format!( + "superblock index {} overflows its epoch range", + superblock.index + ), + }); + } + }; + let initial_epoch = final_epoch.saturating_sub(superblock_period); + let expected_previous_block = imported_block_chain + .range(..initial_epoch) + .next_back() + .map(|(_, hash)| *hash) + .unwrap_or(genesis_hash); + if superblock.last_block_in_previous_superblock != expected_previous_block { + return Err(ImportError::SnapshotContents { + message: format!( + "superblock #{} previous-block boundary does not match the imported block index", + superblock.index + ), + }); + } + + let consolidated_block_hashes: Vec<_> = imported_block_chain + .range(initial_epoch..final_epoch) + .map(|(_, hash)| *hash) + .collect(); + if let Some((last_epoch, last_hash)) = imported_block_chain + .range(initial_epoch..final_epoch) + .next_back() + { + // A superblock hashes its last header using the protocol active at the superblock + // boundary, while the chain index hashes a block using its own epoch. The indexed + // hash is therefore directly comparable only when both protocol versions agree. + if protocol_info.all_versions.version_for_epoch(*last_epoch) + == protocol_info.all_versions.version_for_epoch(final_epoch) + { + if superblock.last_block != *last_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "superblock #{} last block does not match the imported block index", + superblock.index + ), + }); + } + } + // Verify every non-empty boundary from the decoded block header. This also covers + // protocol-version crossings where the indexed hash is intentionally different. + boundary_hashes.insert( + *last_epoch, + (final_epoch, superblock.index, superblock.last_block), + ); + } else if superblock.last_block != expected_previous_block { + return Err(ImportError::SnapshotContents { + message: format!( + "empty superblock #{} does not carry forward its previous block", + superblock.index + ), + }); + } + + if superblock.index == superblock_tip.checkpoint { + final_superblock_hash = Some(superblock.hash()); + } + notifications.push(SuperBlockNotify { + superblock, + consolidated_block_hashes, + }); } - Box::pin(futures::future::ok(())) + if superblock_tip.checkpoint == 0 { + if !notifications.is_empty() + || superblock_tip.hash_prev_block + != imported_chain_info.consensus_constants.bootstrap_hash + { + return Err(ImportError::SnapshotContents { + message: "genesis superblock beacon is inconsistent with snapshot inventory" + .to_string(), + }); + } + } else if final_superblock_hash != Some(superblock_tip.hash_prev_block) { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot has no superblock matching imported tip #{} {}", + superblock_tip.checkpoint, superblock_tip.hash_prev_block + ), + }); + } + + Ok((notifications, boundary_hashes)) } - /// Process the blocks found inside a single batch by persisting them as inventory items. - fn process_blocks_batch_fut( + fn persist_snapshot_superblocks_fut( &mut self, - blocks: Vec, - ) -> ResponseActFuture> { - // Build inventory items from a vector of blocks - let items = blocks + token: storage_mngr::SnapshotInventoryToken, + notifications: Vec, + ) -> ResponseActFuture> { + let items = notifications .into_iter() - .map(Box::new) - .map(StoreInventoryItem::Block) - .collect::>(); - let count = items.len(); - - // Persist the inventory items - let fut = self.persist_items(items).map(move |_, _, _| Ok(count)); + .map(StoreInventoryItem::Superblock) + .collect(); + Box::pin( + self.persist_snapshot_items(token, items, None) + .map_err(|error, _, _| ImportError::Persistence { + message: error.to_string(), + }), + ) + } - // Return how many blocks where processed - Box::pin(fut) + /// Persist already-validated report records into the import's inactive inventory generation. + fn persist_snapshot_reports_fut( + &mut self, + token: storage_mngr::SnapshotInventoryToken, + reports: Vec, + ) -> ResponseActFuture> { + Box::pin( + async move { + storage_mngr::put_snapshot_inventory_serialized_batch(&token, reports) + .await + .map_err(|error| ImportError::Persistence { + message: error.to_string(), + }) + } + .into_actor(self), + ) } - /// Process multiple block batches efficiently by decoding and loading on the fly. - fn process_blocks_batches_fut( + /// Decode, validate, and persist block batches one at a time. Manifest hashes make stale files + /// unreachable, while bounded batches avoid retaining the whole historical chain in memory. + fn process_snapshot_blocks_batches_fut( &mut self, + token: storage_mngr::SnapshotInventoryToken, batches: Vec, ImportError>>>, - ) -> ResponseActFuture> { + imported_block_chain: Blockchain, + bootstrap_hash: Hash, + boundary_hashes: BTreeMap, + report_plan: SnapshotReportPlan, + transaction_verifier: SnapshotTransactionVerifier, + ) -> ResponseActFuture< + Self, + Result< + ( + Vec, + SnapshotTransactionVerifier, + ), + ImportError, + >, + > { let batches_count = batches.len(); - // A stream is created from the vector of batch-reading futures, then the stream is - // processed sequentially, and only once the stream is finished, the returned future is - // eventually resolved too. - // First we build a native stream that wraps an iterator over the futures. - let stream = futures::stream::iter(batches.into_iter().enumerate()); - // Then we wrap the stream into Actix magic, so that the actor state can be mutated. - let stream = - actix::fut::wrap_stream::<_, Self>(stream).then(move |(i, fut), _act, _ctx| { + let expected = Arc::new(imported_block_chain.into_iter().collect::>()); + let boundary_hashes = Arc::new(boundary_hashes); + let expected_count = expected.len(); + let mut chain: ResponseActFuture< + Self, + Result< + (usize, SnapshotReportPlan, SnapshotTransactionVerifier), + ImportError, + >, + > = Box::pin(actix::fut::ok(( + 0, + report_plan, + transaction_verifier, + ))); + + for (i, batch_fut) in batches.into_iter().enumerate() { + let expected = Arc::clone(&expected); + let boundary_hashes = Arc::clone(&boundary_hashes); + let token = token.clone(); + chain = Box::pin(chain.and_then( + move |(offset, mut report_plan, mut transaction_verifier), act, _ctx| { log::info!("Reading blocks batch {} out of {}", i + 1, batches_count); - actix::fut::wrap_future::<_, Self>(fut).and_then(move |blocks, act, _ctx| { - log::info!( - "Processing blocks batch {} out of {}. This may take several minutes...", - i + 1, - batches_count - ); - act.process_blocks_batch_fut(blocks) - .and_then(move |count, _, _| { - log::info!( - "Imported {} blocks from batch {} out of {}", - count, - i + 1, - batches_count - ); + let decode_and_validate = async move { + let blocks = batch_fut.await?; + let next_offset = offset.checked_add(blocks.len()).ok_or_else(|| { + ImportError::SnapshotContents { + message: "snapshot block count overflow".to_string(), + } + })?; + if next_offset > expected.len() { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot contains more blocks than its chain index: at least {next_offset}, expected {}", + expected.len() + ), + }); + } + for (position, block) in blocks.iter().enumerate() { + let expected_position = offset + position; + let (expected_epoch, expected_hash) = expected[expected_position]; + let actual_epoch = block.block_header.beacon.checkpoint; + let actual_protocol = + transaction_verifier.protocol_version(actual_epoch); + let actual_hash = block.versioned_hash(actual_protocol); + if actual_epoch != expected_epoch || actual_hash != expected_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block #{expected_position} does not match chain index: expected epoch {expected_epoch} hash {expected_hash}, found epoch {actual_epoch} hash {actual_hash}" + ), + }); + } + let expected_previous_hash = expected_position + .checked_sub(1) + .map(|previous_position| expected[previous_position].1) + .unwrap_or(bootstrap_hash); + validate_snapshot_block_predecessor( + block, + expected_position, + expected_previous_hash, + )?; + validate_snapshot_block_body( + block, + expected_position, + actual_protocol, + )?; + if let Some((boundary_epoch, superblock_index, expected_hash)) = + boundary_hashes.get(&actual_epoch) + { + let actual_hash = block + .block_header + .versioned_hash( + transaction_verifier.protocol_version(*boundary_epoch), + ); + if actual_hash != *expected_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block at epoch {actual_epoch} does not match superblock #{superblock_index} boundary hash {expected_hash}" + ), + }); + } + } + report_plan.observe_block(block, expected_position)?; + transaction_verifier.validate_block(block, expected_position)?; + } + Ok((blocks, next_offset, report_plan, transaction_verifier)) + }; - futures::future::ok::<_, ImportError>(()) + actix::fut::wrap_future::<_, Self>(decode_and_validate).and_then( + move |(blocks, next_offset, report_plan, transaction_verifier), act, _ctx| { + let snapshot_protocol_info = transaction_verifier.protocol_info().clone(); + act.process_snapshot_blocks_batch_fut( + token, + blocks, + snapshot_protocol_info, + ) + .and_then( + move |count, _, _| { + log::info!( + "Imported {count} blocks from batch {} out of {}", + i + 1, + batches_count + ); + actix::fut::ok(( + next_offset, + report_plan, + transaction_verifier, + )) + }, + ) + }, + ) + }, + )); + } + + Box::pin(chain.and_then( + move |(count, report_plan, transaction_verifier), _, _| { + if count != expected_count { + return actix::fut::err(ImportError::SnapshotContents { + message: format!( + "snapshot block inventory is incomplete: decoded {count}, expected {expected_count}" + ), + }); + } + + match report_plan.finish() { + Ok(reports) => actix::fut::ok(( + reports + .into_iter() + .map(|(logical_key, value)| { + (storage_mngr::InventoryDomain::Report, logical_key, value) }) - }) - }); + .collect(), + transaction_verifier, + )), + Err(error) => actix::fut::err(error), + } + }, + )) + } - // Return a synthetic future that only resolves once we are done processing every each of - // the futures that were bundled into the stream above - Box::pin(stream.finish().map(|_, _, _| Ok(()))) + fn process_snapshot_blocks_batch_fut( + &mut self, + token: storage_mngr::SnapshotInventoryToken, + blocks: Vec, + protocol_info: ProtocolInfo, + ) -> ResponseActFuture> { + let count = blocks.len(); + let items = blocks + .into_iter() + .map(Box::new) + .map(StoreInventoryItem::Block) + .collect(); + Box::pin( + self.persist_snapshot_items(token, items, Some(protocol_info)) + .map_ok(move |(), _, _| count) + .map_err(|error, _, _| ImportError::Persistence { + message: error.to_string(), + }), + ) } - fn process_chain_state_fut( + fn process_snapshot_chain_state_fut( &mut self, - chain_state: ChainState, + token: storage_mngr::SnapshotInventoryToken, + mut chain_state: ChainState, + wit2_activation_certificate: Option, + imported_protocol_info: Option, ) -> ResponseActFuture> { - // Reuse the existing UTXO database: simply wipe it and insert the new stuff later - let mut old_utxos = self.chain_state.unspent_outputs_pool.clone(); - let drop_count = old_utxos.delete_all_from_db(); - log::info!("Dropped {drop_count} UTXOs from persistent UTXO set"); - // This is exactly where the chain state is replaced - self.chain_state = chain_state; - self.chain_state.unspent_outputs_pool = old_utxos; - self.sm_state = StateMachine::WaitingConsensus; - - // Migrate the UTXO set from its export/import format into its persistent form - self.chain_state - .unspent_outputs_pool - .migrate_old_unspent_outputs_pool_to_db( - &mut self.chain_state.unspent_outputs_pool_old_migration_db, - |i, total| { - if i % 100_000 == 0 { - log::info!("Importing UTXOs into persistent UTXO set ({i}/{total})"); - } else { - log::debug!("Importing UTXOs into persistent UTXO set ({i}/{total})"); - } - }, - ); + // Keep the live state and active inventory view untouched until one synchronous storage + // batch has atomically replaced the UTXO image, versioned ChainState, and active inventory + // descriptor. Earlier inventory writes target only the token's inactive generation. + let mut batch = UtxoWriteBatch::default(); + let mut persistent_pool = self.chain_state.unspent_outputs_pool.clone(); + persistent_pool.diff = Default::default(); + let drop_count = match persistent_pool.try_delete_all_from_db_batch(&mut batch) { + Ok(count) => count, + Err(error) => { + log::error!( + "Cannot stage replacement of the persistent UTXO image; stopping for recovery: {error}" + ); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(ImportError::Persistence { + message: error.to_string(), + })); + } + }; + log::info!("Staged removal of {drop_count} UTXOs from the persistent UTXO set"); - // Find and index our own UTXOs from the UTXO set + let portable_pool = + std::mem::take(&mut chain_state.unspent_outputs_pool_old_migration_db); + let mut staged_imported_pool = UnspentOutputsPool::from(portable_pool); + + // Find and index our own UTXOs from the staged image when the node identity is already + // available. Startup import may precede identity loading; get_pkh refreshes this cache later. let mut own_utxos = OwnUnspentOutputsPool::new(); - let own_pkh = self.own_pkh.unwrap(); - log::info!("Trying to find and index unspent outputs for {own_pkh}"); - self.chain_state.unspent_outputs_pool.visit_with_pkh( - own_pkh, - |(output_pointer, _)| { - own_utxos.insert(*output_pointer, 0); - log::debug!( - "Found a new unspent output for {}. Total found so far: {}", - own_pkh, - own_utxos.len() + if let Some(own_pkh) = self.own_pkh { + log::info!("Trying to find and index imported unspent outputs for {own_pkh}"); + if let Err(error) = staged_imported_pool.try_visit_with_pkh( + own_pkh, + |_| (), + |(output_pointer, _)| { + own_utxos.insert(*output_pointer, 0); + log::debug!( + "Found a new imported unspent output for {}. Total found so far: {}", + own_pkh, + own_utxos.len() + ); + }, + ) { + log::error!( + "Cannot index the imported UTXO image; stopping for recovery: {error}" ); - }, - |_| (), - ); - log::info!( - "Found and indexed a total of {} unspent outputs for {}", - own_utxos.len(), - own_pkh - ); - self.chain_state.own_utxos = own_utxos; - - // Delete any saved copies of the old chain state to avoid accidentally persisting a forked - // state - self.chain_state_snapshot.clear(); - // Time to set the `highest_persisted_superblock` to the latest consolidated superblock - self.chain_state_snapshot.highest_persisted_superblock = - self.get_superblock_beacon().checkpoint; - - // Important: persist the new chain state right away - self.persist_chain_state(None); - - // Finally, we let the sessions manager know about the new chain tip - let highest_block_checkpoint = self.get_chain_beacon(); - let highest_superblock_checkpoint = self.get_superblock_beacon(); - SessionsManager::from_registry().do_send(SetLastBeacon { - beacon: LastBeacon { - highest_block_checkpoint, - highest_superblock_checkpoint, - }, - }); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(ImportError::Persistence { + message: error.to_string(), + })); + } + log::info!( + "Found and indexed a total of {} imported unspent outputs for {}", + own_utxos.len(), + own_pkh + ); + } else { + log::debug!( + "Node identity is not loaded yet; imported own-UTXO index will be refreshed later" + ); + } - self.drop_import(); + if let Err(error) = staged_imported_pool.try_persist_add_to_batch(&mut batch) { + log::error!( + "Cannot stage imported UTXOs for the snapshot commit; stopping for recovery: {error}" + ); + System::current().stop_with_code(1); + return Box::pin(actix::fut::err(ImportError::Persistence { + message: error.to_string(), + })); + } + chain_state.unspent_outputs_pool = persistent_pool; + chain_state.own_utxos = own_utxos; + let imported_consensus_constants = chain_state + .chain_info + .as_ref() + .map(|chain_info| chain_info.consensus_constants.clone()); + + let fut = storage_mngr::put_snapshot_chain_state_in_batch_sync( + &storage_keys::chain_state_key(self.get_magic()), + &chain_state, + batch, + token, + wit2_activation_certificate, + imported_protocol_info.clone(), + ) + .into_actor(self) + .map_err(|error, _, ctx| { + // A lost acknowledgement is not proof that the atomic backend write failed. Do not + // delete the target generation or guess which image is durable; startup resolves the + // active descriptor and ChainState from the same synchronous batch. Stop this actor + // explicitly as well as the process: an online `SnapshotImport` is an AtomicResponse, + // and once this error is returned its mailbox would otherwise be allowed to resume + // briefly against the old in-memory state while system shutdown is still pending. + log::error!( + "Final snapshot commit failed or its acknowledgement was lost; stopping for durable recovery: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + ImportError::Persistence { + message: error.to_string(), + } + }) + .and_then(move |expected_inventory_view, act, _ctx| { + storage_mngr::get_inventory_view() + .into_actor(act) + .map_err(|error, _, ctx| { + log::error!( + "Cannot verify the inventory view after snapshot commit; stopping: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + ImportError::Persistence { + message: error.to_string(), + } + }) + .and_then(move |actual_view, act, ctx| { + if actual_view != expected_inventory_view { + let error = ImportError::Persistence { + message: "active inventory descriptor does not match the committed snapshot generation" + .to_string(), + }; + log::error!("{error}; stopping for durable recovery"); + ctx.stop(); + System::current().stop_with_code(1); + return Either::Left(actix::fut::err(error)); + } - Box::pin(futures::future::ok(highest_block_checkpoint)) + // Publish the schedule authenticated during snapshot preflight only after the + // replacement ChainState and inventory descriptor are durably active. This + // keeps replay side-effect free while ensuring the following reload sees the + // imported clock even when the process had no compatible local schedule. + if let Some(protocol_info) = imported_protocol_info { + let Some(imported_consensus_constants) = + imported_consensus_constants.as_ref() + else { + let error = ImportError::SnapshotContents { + message: "durably imported ChainState has no ChainInfo for protocol schedule publication" + .to_string(), + }; + ctx.stop(); + System::current().stop_with_code(1); + return Either::Left(actix::fut::err(error)); + }; + if let Err(error) = try_load_protocol_info_with_consensus_constants( + protocol_info, + imported_consensus_constants, + ) { + let error = ImportError::Persistence { + message: format!( + "cannot publish the protocol schedule authenticated by the imported snapshot: {error}" + ), + }; + log::error!("{error}; stopping for durable recovery"); + ctx.stop(); + System::current().stop_with_code(1); + return Either::Left(actix::fut::err(error)); + } + } + + // Reload the just-committed image through the ordinary initialization path so + // protocol/TAPI state, candidates, mempool bookkeeping, UTXO indexes, and + // session beacons receive the same refresh as startup recovery. + Either::Right( + act.initialize_from_snapshot_import_fut() + .map_err(|(), _act, ctx| { + log::error!( + "Cannot publish the durably imported snapshot state; stopping" + ); + ctx.stop(); + System::current().stop_with_code(1); + ImportError::Persistence { + message: "failed to reload the committed snapshot ChainState" + .to_string(), + } + }) + .and_then(|(), act, ctx| { + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + let highest_block_checkpoint = act.get_chain_beacon(); + act.drop_import(); + actix::fut::ok(highest_block_checkpoint) + }), + ) + }) + }); + + Box::pin(fut) } fn snapshot_import_fut( @@ -950,64 +2277,396 @@ impl ChainManager { import: ChainImport, force: bool, ) -> ResponseActFuture> { - // Perform all synchronous stuff upfront - let local_checkpoint = self - .chain_state - .chain_info + // Capture the local identity of this chain before starting any snapshot I/O. Import is only + // safe after normal initialization has installed a complete ChainInfo. + let Some(local_chain_info) = self.chain_state.chain_info.clone() else { + return Box::pin(actix::fut::err(ImportError::SnapshotContents { + message: "local ChainState has no ChainInfo".to_string(), + })); + }; + // A portable manifest describes replay history; it does not authorize changing the + // operator's future activation schedule. Capture approvals before asynchronous I/O so an + // import cannot validate itself against the schedule it is about to publish. + let local_protocol_info = get_protocol_runtime().0; + // Capture the exact process-owned schedule decision before asynchronous snapshot I/O. The + // online AtomicResponse handler and startup `Context::wait` both prevent a concurrent chain + // transition from replacing it while this import is in flight. A restart-restored decision + // already has a durable companion and deliberately stays on the existing-certificate branch + // in storage. + let pending_wit2_activation_certificate = self + .pending_protocol_activation_rollback .as_ref() - .map(|chain_info| chain_info.highest_superblock_checkpoint.checkpoint); + .map(|pending| { + storage_mngr::Wit2ActivationCertificate::new( + pending.trigger_epoch, + pending.trigger_hash, + pending.activation_epoch, + pending.checkpoint_period, + ) + }); + let snapshot_trust_anchor = self.snapshot_trust_anchor.clone(); + let snapshot_chain_state_component = self.snapshot_chain_state_component.clone(); + let snapshot_trust_public_keys = self.snapshot_trust_public_keys.clone(); + let ChainImport { + blocks, + chain_state, + superblocks, + reports, + reports_required, + protocol_info: snapshot_protocol_info, + } = import; // Wrap all asynchronous stuff into a future let fut = async move { - let chain_state = import - .chain_state + let chain_state = chain_state .await .map_err(|e| ImportError::AtChainState(Box::new(e)))?; + // Reject incomplete or foreign snapshots before superblock/block inventory can be + // overwritten. A forced import may override tip ordering, never chain identity. + let imported_chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + ImportError::SnapshotContents { + message: "imported ChainState has no ChainInfo".to_string(), + } + })?; + if chain_state.reputation_engine.is_none() { + return Err(ImportError::SnapshotContents { + message: "imported ChainState has no ReputationEngine".to_string(), + }); + } + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported_chain_info, + &chain_state, + pending_wit2_activation_certificate.as_ref(), + )?; + super::validate_snapshot_trust_anchor( + snapshot_trust_anchor.as_ref(), + imported_chain_info, + &chain_state, + snapshot_chain_state_component.as_ref(), + snapshot_protocol_info.as_ref(), + &snapshot_trust_public_keys, + )?; + if let Some(protocol_info) = snapshot_protocol_info.as_ref() { + witnet_data_structures::proto::versioning::ensure_production_protocol_schedule_ready( + protocol_info, + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot requires an unreleased protocol: {error}"), + })?; + storage_mngr::validate_snapshot_protocol_schedule( + &local_protocol_info, + protocol_info, + imported_chain_info, + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot protocol schedule is not approved locally: {error}"), + })?; + } + validate_snapshot_superblock_state_binding( + &chain_state.superblock_state, + imported_chain_info, + )?; + + let imported_block_tip = imported_chain_info.highest_block_checkpoint; + match chain_state.block_chain.iter().next_back() { + Some((epoch, hash)) + if *epoch == imported_block_tip.checkpoint + && *hash == imported_block_tip.hash_prev_block => {} + Some((epoch, hash)) => { + return Err(ImportError::SnapshotContents { + message: format!( + "imported block index tip {epoch} {hash} does not match ChainInfo tip {} {}", + imported_block_tip.checkpoint, imported_block_tip.hash_prev_block + ), + }); + } + None + if imported_block_tip.checkpoint == 0 + && imported_block_tip.hash_prev_block + == imported_chain_info.consensus_constants.bootstrap_hash => {} + None => { + return Err(ImportError::SnapshotContents { + message: "empty imported block index does not match its ChainInfo tip" + .to_string(), + }); + } + } + validate_snapshot_block_index_origin( + &chain_state.block_chain, + imported_chain_info.consensus_constants.genesis_hash, + )?; + // Only run the import if the imported chain is ahead of our own, or the force flag is set - let imported_checkpoint = chain_state - .chain_info - .as_ref() - .map(|chain_info| chain_info.highest_superblock_checkpoint.checkpoint); + let imported_checkpoint = imported_chain_info.highest_superblock_checkpoint.checkpoint; + let local_checkpoint = local_chain_info.highest_superblock_checkpoint.checkpoint; if imported_checkpoint <= local_checkpoint && !force { - let (imported, local) = (imported_checkpoint.unwrap(), local_checkpoint.unwrap()); - - return Err(ImportError::ChainTip { imported, local }); + return Err(ImportError::ChainTip { + imported: imported_checkpoint, + local: local_checkpoint, + }); } - if let Some(chain_info) = &chain_state.chain_info { - log::info!( - "Importing chain snapshot now. After importing, the node should be synced up to superblock #{}.", - chain_info.highest_superblock_checkpoint.checkpoint + if !reports_required { + log::warn!( + "Importing a report-less legacy/version-1 snapshot as an explicit compatibility operation. No historical Reports will be fabricated or backfilled. This operation may be one-way: every later version-2/version-3 export performs a full canonical Report scan and fails if any tally lacks its exact Report; a tally-free or subsequently complete history remains exportable." ); } + log::warn!( + "Portable snapshot admission verifies every block-header signature and chained block-VRF proof, plus every Commit-VRF proof over its exact protocol message. Wit/2 block proposers are checked against the exact block-opening stake rank, and Wit/2 Commits are checked against their exact stake-ranked target and owner salt, with progressive reservation under Wit/2.1; pre-Wit/2 block/Commit reputation targets are replayed from the canonical reputation engine. It exactly replays V1.8/V2.0 stake balances through the canonical Wit/2.1 activation bridge, then authenticates all validator policies, complete independently aged stake entries and nonces, native and cross-fork collateral reservations, delegated rewards, and partial/full exits. Data-request commit/reveal/tally stages remain structurally bound to the pinned extra-round limit; exact same-block TooManyWitnesses admission and exact Wit/2.1 causal successors are checked against their reconstructed opening populations, retained pending Reveals are separately signature-checked against their exact request and canonical commitment, and every canonical tally result is independently re-derived from its request and authenticated reveals. Pre-Wit/2 collateral age and 100-WIT committer qualification are reconstructed from the canonical UTXO image. The imported SuperBlockState beacon and current candidate are checked for consistency with the finalized ChainInfo beacon, but historical superblock vote/committee signatures are not portable and remain outside this bounded replay. Other historical collateral reservation state remains outside this bounded replay; legacy Mint fee/reward semantics are checked against the canonical transaction-fee image and pinned reward schedule." + ); - // Import superblocks - let superblocks = import - .superblocks + log::info!( + "Importing chain snapshot now. After importing, the node should be synced up to superblock #{}.", + imported_checkpoint + ); + + // Decode ChainState and superblocks before registering an inactive database generation. + // Block components remain bounded and are decoded, validated, and staged one manifest + // batch at a time without changing the active inventory view. + let superblocks = superblocks .await .map_err(|e| ImportError::AtSuperblocks(Box::new(e)))?; - - // Import blocks in batches - let blocks_batches = import - .blocks + let report_records = reports + .await + .map_err(|e| ImportError::SnapshotContents { + message: format!("could not read snapshot reports: {e}"), + })?; + let blocks_batches = blocks .map_err(|e| ImportError::AtBlocks(Box::new(e)))?; - - Ok((chain_state, superblocks, blocks_batches)) + let imported_chain_info = imported_chain_info.clone(); + let imported_block_chain = chain_state.block_chain.clone(); + let imported_bootstrap_hash = imported_chain_info.consensus_constants.bootstrap_hash; + let has_snapshot_protocol_info = snapshot_protocol_info.is_some(); + let transaction_verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &imported_chain_info.consensus_constants, + snapshot_protocol_info, + )?; + let protocol_info_for_reload = has_snapshot_protocol_info + .then(|| transaction_verifier.protocol_info().clone()); + storage_mngr::validate_delegated_staking_schedule_provenance( + &chain_state, + transaction_verifier.protocol_info(), + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "snapshot delegated-staking state has no compatible Wit/2 schedule: {error}" + ), + })?; + let report_plan = Self::preflight_snapshot_reports_with_protocol( + report_records, + &imported_block_chain, + false, + reports_required, + transaction_verifier.protocol_info(), + imported_chain_info.consensus_constants.extra_rounds, + )?; + let (superblock_notifications, boundary_hashes) = + Self::preflight_snapshot_superblocks( + superblocks, + &imported_block_chain, + &imported_chain_info, + transaction_verifier.protocol_info(), + ) + .map_err(|e| ImportError::AtSuperblocks(Box::new(e)))?; + let block_count = u64::try_from(imported_block_chain.len()).map_err(|_| { + ImportError::SnapshotContents { + message: "snapshot block count does not fit u64".to_string(), + } + })?; + let superblock_count = + u64::try_from(superblock_notifications.len()).map_err(|_| { + ImportError::SnapshotContents { + message: "snapshot superblock count does not fit u64".to_string(), + } + })?; + let inventory_target = storage_mngr::SnapshotInventoryTarget::new( + imported_chain_info.consensus_constants.get_magic(), + imported_chain_info.environment, + imported_chain_info.highest_block_checkpoint, + imported_chain_info.highest_superblock_checkpoint, + block_count, + superblock_count, + u64::try_from(report_plan.report_count()).map_err(|_| { + ImportError::SnapshotContents { + message: "snapshot report count does not fit u64".to_string(), + } + })?, + ); + Ok(( + chain_state, + superblock_notifications, + blocks_batches, + imported_block_chain, + imported_bootstrap_hash, + boundary_hashes, + inventory_target, + report_plan, + transaction_verifier, + protocol_info_for_reload, + force, + pending_wit2_activation_certificate, + )) }; Box::pin(fut.into_actor(self).and_then( - |(chain_state, superblocks, blocks_batches), act, _ctx| { - // Process superblocks, then process blocks, then finally mutate chain state - act.process_superblocks_fut(superblocks) - .and_then(|_, act, _ctx| act.process_blocks_batches_fut(blocks_batches)) - .and_then(move |_, act, _ctx| act.process_chain_state_fut(chain_state)) - .and_then(|epoch, _, _| { - log::info!("Successfully finished importing snapshot!"); - - futures::future::ok(epoch) + |( + chain_state, + superblocks, + block_batches, + imported_block_chain, + imported_bootstrap_hash, + boundary_hashes, + inventory_target, + report_plan, + transaction_verifier, + protocol_info_for_reload, + force, + pending_wit2_activation_certificate, + ), act, _ctx| { + let Some(local_chain_info) = act.chain_state.chain_info.as_ref() else { + return Either::Left(actix::fut::err(ImportError::SnapshotContents { + message: "local ChainState lost its ChainInfo during snapshot preflight" + .to_string(), + })); + }; + let Some(imported_chain_info) = chain_state.chain_info.as_ref() else { + return Either::Left(actix::fut::err(ImportError::SnapshotContents { + message: "imported ChainState lost its ChainInfo during snapshot preflight" + .to_string(), + })); + }; + let imported_highest_vrf_output = imported_chain_info.highest_vrf_output; + // Preflight can be long enough for the live node to advance. Re-apply the tip-order + // policy to the actor's current ChainInfo immediately before registering any inactive + // inventory generation; the earlier check remains a cheap fast-fail. + let imported_checkpoint = + imported_chain_info.highest_superblock_checkpoint.checkpoint; + let current_local_checkpoint = + local_chain_info.highest_superblock_checkpoint.checkpoint; + if imported_checkpoint <= current_local_checkpoint && !force { + return Either::Left(actix::fut::err(ImportError::ChainTip { + imported: imported_checkpoint, + local: current_local_checkpoint, + })); + } + if let Err(error) = validate_snapshot_common_finalized_prefix( + &act.chain_state.block_chain, + local_chain_info.highest_superblock_checkpoint.checkpoint, + &imported_block_chain, + imported_chain_info.highest_superblock_checkpoint.checkpoint, + imported_chain_info.consensus_constants.superblock_period, + ) { + return Either::Left(actix::fut::err(error)); + } + let superblock_commitment = + match storage_mngr::snapshot_superblock_commitment(&superblocks) { + Ok(commitment) => commitment, + Err(error) => { + return Either::Left(actix::fut::err( + ImportError::SnapshotContents { + message: format!( + "cannot commit the preflighted snapshot superblock sequence: {error}" + ), + }, + )); + } + }; + + Either::Right(storage_mngr::begin_snapshot_inventory_import( + inventory_target, + superblock_commitment, + ) + .into_actor(act) + .map_err(|error, _, _| ImportError::Persistence { + message: format!( + "failed to register an inactive snapshot inventory generation: {error}" + ), }) + .and_then(move |token, act, _ctx| { + let superblock_token = token.clone(); + let block_token = token.clone(); + let report_token = token.clone(); + let commit_token = token.clone(); + let abort_token = token; + + let staging = act + .persist_snapshot_superblocks_fut(superblock_token, superblocks) + .and_then(move |(), act, _ctx| { + act.process_snapshot_blocks_batches_fut( + block_token, + block_batches, + imported_block_chain, + imported_bootstrap_hash, + boundary_hashes, + report_plan, + transaction_verifier, + ) + .and_then(move |(serialized_reports, transaction_verifier), act, _ctx| { + act.persist_snapshot_reports_fut(report_token, serialized_reports) + .and_then(move |(), _, _| { + match chain_state + .reputation_engine + .as_ref() + .ok_or_else(|| ImportError::SnapshotContents { + message: "imported ChainState lost its ReputationEngine during snapshot replay" + .to_string(), + }) + .and_then(|expected_reputation_engine| { + transaction_verifier + .validate_tip_state_with_reputation( + &chain_state + .unspent_outputs_pool_old_migration_db, + imported_highest_vrf_output, + &chain_state.stakes, + &chain_state.delegation_state, + &chain_state.data_request_pool, + expected_reputation_engine, + ) + }) { + Ok(()) => actix::fut::ok(chain_state), + Err(error) => actix::fut::err(error), + } + }) + }) + }); + + staging.then(move |staging_result, act, _ctx| match staging_result { + Ok(chain_state) => Either::Left( + act.process_snapshot_chain_state_fut( + commit_token, + chain_state, + pending_wit2_activation_certificate, + protocol_info_for_reload, + ) + .and_then(|epoch, _, _| { + log::info!("Successfully finished importing snapshot!"); + actix::fut::ok(epoch) + }), + ), + Err(import_error) => Either::Right( + storage_mngr::abort_snapshot_inventory_import(abort_token) + .into_actor(act) + .then(move |abort_result, _act, ctx| match abort_result { + Ok(()) => actix::fut::err(import_error), + Err(abort_error) => { + log::error!( + "Failed to reclaim an inactive snapshot generation after {import_error}; stopping the node: {abort_error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + actix::fut::err(ImportError::Persistence { + message: format!( + "{import_error}; failed to abort inactive generation: {abort_error}" + ), + }) + } + }), + ), + }) + })) }, )) } @@ -1060,11 +2719,228 @@ mod tests { use std::sync::Arc; use witnet_config::config::{Config, StorageBackend}; - - use crate::{actors::storage_keys::chain_state_key, utils::test_actix_system}; + use witnet_crypto::hash::calculate_sha256; + use witnet_data_structures::{ + chain::{OutputPointer, ValueTransferOutput}, + utxo_pool::{UtxoDb, utxo_db::UtxoStorageIterator}, + wit::Wit, + }; + + use crate::{ + actors::{ + chain_manager::{ + snapshot_generation_dir, SnapshotComponent, SnapshotGeneration, + }, + storage_keys::chain_state_key, + }, + utils::{ActorFutureToNormalFuture, test_actix_system}, + }; use super::*; + #[derive(Debug)] + struct SnapshotReloadIteratorFailure; + + impl UtxoDb for SnapshotReloadIteratorFailure { + fn get_utxo( + &self, + _out_ptr: &OutputPointer, + ) -> Result, anyhow::Error> { + Ok(None) + } + + fn utxo_iterator(&self) -> Result, anyhow::Error> { + Err(anyhow::anyhow!( + "injected snapshot-reload UTXO iterator failure" + )) + } + + fn utxo_iterator_by_pkh( + &self, + _pkh: PublicKeyHash, + ) -> Result, anyhow::Error> { + Err(anyhow::anyhow!( + "injected snapshot-reload UTXO iterator failure" + )) + } + + fn write(&self, _batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + Ok(()) + } + } + + #[test] + fn snapshot_reload_stages_own_utxos_before_publication() { + let mut restored_pool = UnspentOutputsPool::default(); + restored_pool.db = Some(Arc::new(SnapshotReloadIteratorFailure)); + + // Preserve a live sentinel across the same failure used by the deferred identity-loading + // path. Snapshot/reorg initialization invokes the shared staging helper before assigning + // its restored ChainState or broadcasting the replacement beacon. + let sentinel = OutputPointer::default(); + let mut actor = ChainManager::default(); + actor.own_pkh = Some(PublicKeyHash::default()); + actor.chain_state.unspent_outputs_pool = restored_pool; + actor.chain_state.own_utxos.insert(sentinel, 77); + let published_before = actor.chain_state.own_utxos.clone(); + + let error = actor.update_own_utxos().unwrap_err(); + assert!( + error + .to_string() + .contains("injected snapshot-reload UTXO iterator failure") + ); + assert_eq!(actor.chain_state.own_utxos, published_before); + assert_eq!(actor.chain_state.own_utxos.get(&sentinel), Some(&77)); + } + + #[test] + fn forced_non_genesis_delegated_manifest_is_rejected_before_import_side_effects_without_anchor() { + test_actix_system(|| async { + let mut local = ChainManager::default(); + let mut local_chain_info = ChainInfo::default(); + local_chain_info.consensus_constants.checkpoints_period = 15; + local.chain_state.chain_info = Some(local_chain_info.clone()); + + let mut imported = ChainState::default(); + let block_hash = Hash::SHA256([7; 32]); + let genesis_hash = local_chain_info.consensus_constants.genesis_hash; + let imported_chain_info = ChainInfo { + highest_block_checkpoint: CheckpointBeacon { + checkpoint: 20, + hash_prev_block: block_hash, + }, + highest_superblock_checkpoint: CheckpointBeacon { + checkpoint: 20, + hash_prev_block: block_hash, + }, + ..local_chain_info.clone() + }; + imported.chain_info = Some(imported_chain_info.clone()); + imported.reputation_engine = Some(ReputationEngine::new(1000)); + let validator = PublicKeyHash::default(); + let owner_key = StakeKey { + validator, + withdrawer: validator, + }; + let minimum_stake = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + imported + .stakes + .add_stake(owner_key, minimum_stake, 0, true, minimum_stake) + .expect("activated snapshot must contain a valid owner stake"); + imported + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .expect("activated snapshot must contain a valid validator policy"); + imported.delegation_state.activation_initialized = true; + imported.block_chain.insert(0, genesis_hash); + imported.block_chain.insert(20, block_hash); + + let mut protocol_info = ProtocolInfo::default(); + protocol_info + .try_register( + 10, + ProtocolVersion::V2_0, + local_chain_info.consensus_constants.checkpoints_period, + ) + .expect("snapshot fixture must contain a valid Wit/2.0 schedule"); + protocol_info + .try_register( + 20, + ProtocolVersion::V2_1, + local_chain_info.consensus_constants.checkpoints_period, + ) + .expect("snapshot fixture must contain a valid Wit/2.1 schedule"); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let manifest_path = std::env::temp_dir().join(format!( + "witnet-forced-delegated-snapshot-without-anchor-{}-{suffix}.bin", + std::process::id() + )); + let generation = SnapshotGeneration { + unix_nanos: suffix, + process_id: std::process::id(), + attempt: 0, + }; + let generation_dir = snapshot_generation_dir(&manifest_path, generation) + .expect("snapshot generation path must be valid"); + std::fs::create_dir_all(&generation_dir).expect("create snapshot generation"); + + let chain_state_bytes = + bincode::serialize(&(storage_mngr::CURRENT_CHAIN_STATE_VERSION, &imported)) + .expect("serialize current ChainState snapshot"); + let chain_state_path = snapshot_chain_state_path(&generation_dir); + std::fs::write(&chain_state_path, &chain_state_bytes) + .expect("write ChainState snapshot component"); + let superblocks_bytes = bincode::serialize(&Vec::::new()) + .expect("serialize empty superblock component"); + let superblocks_path = snapshot_superblocks_path(&generation_dir); + std::fs::write(&superblocks_path, &superblocks_bytes) + .expect("write superblock snapshot component"); + let reports_bytes = bincode::serialize(&Vec::<(Vec, Vec)>::new()) + .expect("serialize empty report component"); + std::fs::write(snapshot_reports_path(&generation_dir), &reports_bytes) + .expect("write report snapshot component"); + + let manifest = SnapshotManifest { + magic: SNAPSHOT_MANIFEST_MAGIC, + format_version: SNAPSHOT_MANIFEST_VERSION, + chain_state_version: storage_mngr::CURRENT_CHAIN_STATE_VERSION, + generation, + highest_block_checkpoint: imported_chain_info.highest_block_checkpoint, + highest_superblock_checkpoint: imported_chain_info.highest_superblock_checkpoint, + block_count: 2, + superblock_count: 0, + chain_state: SnapshotComponent { + byte_len: chain_state_bytes.len() as u64, + sha256: calculate_sha256(&chain_state_bytes).0, + }, + superblocks: SnapshotComponent { + byte_len: superblocks_bytes.len() as u64, + sha256: calculate_sha256(&superblocks_bytes).0, + }, + block_batches: vec![SnapshotBlockBatch { + index: 0, + block_count: 2, + component: SnapshotComponent { + byte_len: 1, + sha256: [0; 32], + }, + }], + reports: Some(SnapshotComponent { + byte_len: reports_bytes.len() as u64, + sha256: calculate_sha256(&reports_bytes).0, + }), + report_count: 0, + protocol_info: Some(protocol_info), + }; + std::fs::write( + &manifest_path, + bincode::serialize(&manifest).expect("serialize snapshot manifest"), + ) + .expect("write snapshot manifest"); + + local.put_import_from_path(Force::All(manifest_path.clone())); + let mut ctx = Context::new(); + let result = local + .snapshot_import() + .into_normal_future(&mut local, &mut ctx) + .await; + + let error = result.expect_err("missing anchor must reject forced delegated import"); + assert!(format!("{error:?}") + .contains("non-genesis delegated snapshot requires an external trust anchor"), "{error:?}"); + assert_eq!(local.chain_state.chain_info, Some(local_chain_info)); + assert!(!local.chain_state.delegation_state.activation_initialized); + assert!(matches!(local.import, Force::None)); + let _ = std::fs::remove_dir_all(&generation_dir); + let _ = std::fs::remove_file(&manifest_path); + }); + } + #[test] fn test_check_only_one_chain_state_in_storage_empty_storage() { let _ = env_logger::builder().is_test(true).try_init(); @@ -1077,7 +2953,9 @@ mod tests { config_mngr::start(config); storage_mngr::start(); - ChainManager::check_only_one_chain_state_in_storage().await; + assert!(ChainManager::check_only_one_chain_state_in_storage() + .await + .is_ok()); }); } @@ -1103,12 +2981,13 @@ mod tests { .await .expect("failed to store chain state"); - ChainManager::check_only_one_chain_state_in_storage().await; + assert!(ChainManager::check_only_one_chain_state_in_storage() + .await + .is_ok()); }); } #[test] - #[should_panic = "Storage already contains a chain state with different magic number"] fn test_check_only_one_chain_state_in_storage_one_different() { let _ = env_logger::builder().is_test(true).try_init(); test_actix_system(|| async { @@ -1132,12 +3011,13 @@ mod tests { .await .expect("failed to store chain state"); - ChainManager::check_only_one_chain_state_in_storage().await; + assert!(ChainManager::check_only_one_chain_state_in_storage() + .await + .is_err()); }); } #[test] - #[should_panic = "Storage contains more than one chain state with different magic numbers"] fn test_check_only_one_chain_state_in_storage_two_chain_states() { let _ = env_logger::builder().is_test(true).try_init(); test_actix_system(|| async { @@ -1167,7 +3047,9 @@ mod tests { .await .expect("failed to store chain state"); - ChainManager::check_only_one_chain_state_in_storage().await; + assert!(ChainManager::check_only_one_chain_state_in_storage() + .await + .is_err()); }); } } diff --git a/node/src/actors/chain_manager/actor/snapshot.rs b/node/src/actors/chain_manager/actor/snapshot.rs new file mode 100644 index 0000000000..e14f9f6136 --- /dev/null +++ b/node/src/actors/chain_manager/actor/snapshot.rs @@ -0,0 +1,8397 @@ +//! Portable snapshot authentication, exact historical replay, and generation/report safety. +//! +//! This private actor child owns replay state and its focused fixtures. The parent retains actor +//! lifecycle, asynchronous import/export orchestration, and durable publication. The facade is +//! restricted to that parent; changing execution order or failure atomicity is not part of this +//! source-only extraction. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + io::BufReader, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use witnet_crypto::hash::{calculate_sha256, calculate_sha256_reader}; +use witnet_data_structures::{ + chain::{ + AltKeys, Block, BlockMerkleRoots, Blockchain, ChainInfo, ChainState, CheckpointVRF, + ConsensusConstantsWit2, DataRequestInfo, DataRequestOutput, DataRequestStage, + Environment, Epoch, EpochConstants, Hash, Hashable, Input, KeyedSignature, OutputPointer, + PublicKeyHash, ReputationEngine, SignaturesToVerify, StakeAuthorization, SuperBlock, + ValueTransferOutput, calculate_backup_witnesses, + tapi::{ActiveWips, TapiEngine}, + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, STAKE_AUTHORIZATION_ACTION_DELEGATE, + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, + }, + data_request::{DataRequestPool, data_request_has_too_many_witnesses_for_protocol}, + get_environment, get_protocol_runtime, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, + staking::prelude::*, + superblock::SuperBlockState, + transaction::{ + CommitTransaction, RevealTransaction, StakeTransaction, TallyTransaction, UnstakeTransaction, + }, + utxo_pool::{OldUnspentOutputsPool, UnspentOutputsPool, UtxoDiff}, + vrf::VrfCtx, + wit::{NANOWITS_PER_WIT, WIT_DECIMAL_PLACES, Wit}, +}; +use witnet_validations::{ + eligibility::legacy::{calculate_randpoe_threshold, calculate_reppoe_threshold}, + validations::{ + add_block_vrf_signature_to_verify, add_dr_vrf_signature_to_verify_for_protocol, + apply_wit2_age_resets_for_protocol, dr_transaction_fee, prevalidate_delegation_snapshot, + resolve_commit_stake_eligibility, st_transaction_fee_for_protocol, + run_tally_panic_safe, tally_bytes_on_encode_error, tally_extra_fee_for_protocol, + try_update_utxo_diff_for_protocol, validate_block_signature_for_protocol, + validate_commit_reveal_signature, validate_genesis_vt_transaction, + validate_mint_transaction_for_protocol, validate_transaction_signature, + validate_unstake_signature_for_protocol, validate_wit2_block_mining_eligibility, + validate_request_protocol_for_block, + verify_signatures, + vt_transaction_fee, + }, +}; + +use crate::{ + actors::chain_manager::{ + ChainManager, ImportError, SnapshotComponent, SnapshotGeneration, SnapshotManifest, + snapshot_blocks_batch_path, snapshot_chain_state_path, snapshot_generation_dir, + snapshot_reports_path, snapshot_superblocks_path, + }, + storage_mngr, + utils::Force, +}; + +/// Prove that a portable snapshot block's protocol-visible transaction identities agree with its +/// canonical header. +/// +/// A block's protocol hash authenticates only its header, so checking the chain index alone is not +/// sufficient before observing or persisting the decoded transaction lists. The bounded canonical +/// history verifier below authenticates the signatures and UTXO-owned inputs that the snapshot can +/// reconstruct; fields needing other historical consensus state remain outside this body check. +pub(super) fn validate_snapshot_block_body( + block: &Block, + position: usize, + protocol_version: ProtocolVersion, +) -> Result<(), ImportError> { + let epoch = block.block_header.beacon.checkpoint; + validate_snapshot_mint(block, position, protocol_version)?; + + // A default protocol-gated root does not commit to a forbidden transaction list. Keep this + // check on the same pinned protocol snapshot as the root construction below instead of + // delegating to the compatibility helper that consults the mutable process-global schedule. + let has_forbidden_transactions = + (protocol_version < ProtocolVersion::V1_8 && !block.txns.stake_txns.is_empty()) + || (protocol_version < ProtocolVersion::V2_0 + && !block.txns.unstake_txns.is_empty()); + let merkle_roots = BlockMerkleRoots::from_transactions(&block.txns, protocol_version); + if has_forbidden_transactions || merkle_roots != block.block_header.merkle_roots { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block #{position} at epoch {epoch} has transaction bodies that do not match its header Merkle roots" + ), + }); + } + + Ok(()) +} + +/// Validate the history-independent portion of Mint consensus at the portable boundary. +/// +/// Header/Merkle authentication proves the Mint's identity and integrity, not that it obeys the +/// protocol's semantic constraints. Wit/2's exact Mint is provable from the block alone: it contains +/// the block epoch and no outputs. Pre-Wit/2 values depend on historical transaction fees and stay +/// outside this helper rather than being guessed. +fn validate_snapshot_mint( + block: &Block, + position: usize, + protocol_version: ProtocolVersion, +) -> Result<(), ImportError> { + let epoch = block.block_header.beacon.checkpoint; + if protocol_version >= ProtocolVersion::V2_0 + && (block.txns.mint.epoch != epoch || !block.txns.mint.outputs.is_empty()) + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block #{position} at epoch {epoch} has a non-canonical Wit/2 mint transaction" + ), + }); + } + + Ok(()) +} + +/// Prove the only supported origin for a non-empty portable snapshot block index. +/// +/// `ChainState.block_chain` is the full canonical block history, not a pruned suffix. A non-empty +/// snapshot therefore starts with the configured genesis block at epoch zero. Accepting an +/// arbitrary first retained entry would leave its predecessor unauthenticated; supporting that +/// shape in the future requires a separately authenticated pruning anchor in the snapshot format. +/// The empty pre-genesis state remains valid and is checked against the bootstrap beacon by the +/// caller's existing ChainInfo-tip guard. +pub(super) fn validate_snapshot_block_index_origin( + block_chain: &Blockchain, + genesis_hash: Hash, +) -> Result<(), ImportError> { + match block_chain.iter().next() { + None => Ok(()), + Some((&0, &hash)) if hash == genesis_hash => Ok(()), + Some((epoch, hash)) => Err(ImportError::SnapshotContents { + message: format!( + "non-empty snapshot block index must start with genesis epoch 0 hash {genesis_hash}, found epoch {epoch} hash {hash}" + ), + }), + } +} + +/// Bind an imported ChainState to local chain identity and any not-yet-durable Wit/2 decision. +/// +/// Snapshot replacement may run before ordinary ChainState persistence has written the activation +/// companion. In that case accepting an imported index without the exact trigger would make the +/// process-global schedule impossible to reconstruct after the import reload. The final storage +/// worker repeats this validation against its immutable context and the live schedule before +/// publishing anything. +pub(super) fn validate_snapshot_identity_and_pending_wit2_activation( + local_chain_info: &ChainInfo, + imported_chain_info: &ChainInfo, + chain_state: &ChainState, + certificate: Option<&storage_mngr::Wit2ActivationCertificate>, +) -> Result<(), ImportError> { + if imported_chain_info.environment != local_chain_info.environment { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot environment {:?} does not match local environment {:?}", + imported_chain_info.environment, local_chain_info.environment + ), + }); + } + // Exact ConsensusConstants equality includes the network magic inputs as well as the two clock + // sources from which the certificate's deterministic activation tuple is derived. + if imported_chain_info.consensus_constants != local_chain_info.consensus_constants { + return Err(ImportError::SnapshotContents { + message: "snapshot consensus constants do not match the local chain".to_string(), + }); + } + + let Some(certificate) = certificate else { + return Ok(()); + }; + + certificate + .validate(chain_state) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "imported ChainState does not exactly match the pending Wit/2 activation certificate: {error}" + ), + }) +} + +/// Prove one decoded canonical header's link to the immediately preceding indexed block. +/// +/// `expected_previous_hash` is deliberately taken from the previous `Blockchain` entry, whose hash +/// was computed using that previous block's own epoch/protocol. Re-hashing the previous header under +/// the current block's protocol would break a valid link at a protocol activation boundary. This +/// linkage helper authenticates neither signatures nor VRF proofs by itself. The bounded +/// canonical-history verifier proves their cryptography separately, while VRF eligibility still +/// requires historical consensus state that the snapshot does not carry independently. +pub(super) fn validate_snapshot_block_predecessor( + block: &Block, + position: usize, + expected_previous_hash: Hash, +) -> Result<(), ImportError> { + let actual_previous_hash = block.block_header.beacon.hash_prev_block; + if actual_previous_hash == expected_previous_hash { + Ok(()) + } else { + Err(ImportError::SnapshotContents { + message: format!( + "snapshot block #{position} at epoch {} names previous block {actual_previous_hash}, expected immediately preceding canonical hash {expected_previous_hash}", + block.block_header.beacon.checkpoint + ), + }) + } +} + +/// Anchor an import to the last canonical block covered by both nodes' finalized superblock tips. +/// +/// This is intentionally a single boundary comparison, not a claim that the imported suffix has +/// been replayed. Once the imported predecessor chain is checked, an equal boundary hash proves the +/// two histories share the prefix finalized by both sides. Superblock zero, or a finalized range in +/// which genesis is still the last block, has no post-genesis anchor. A bootstrap/genesis-only node +/// must therefore continue to trust the configured-genesis and cryptographic history checks rather +/// than receiving a fictitious local-fork guarantee. +pub(super) fn validate_snapshot_common_finalized_prefix( + local_block_chain: &Blockchain, + local_finalized_superblock: u32, + imported_block_chain: &Blockchain, + imported_finalized_superblock: u32, + superblock_period: u16, +) -> Result<(), ImportError> { + let common_superblock = local_finalized_superblock.min(imported_finalized_superblock); + if common_superblock == 0 { + log::warn!( + "Snapshot import has no non-genesis finalized superblock boundary in common with the local node; no local canonical-prefix anchor is available" + ); + return Ok(()); + } + + let boundary_epoch = common_superblock + .checked_mul(u32::from(superblock_period)) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "common finalized superblock #{common_superblock} overflows its block boundary" + ), + })?; + let boundary = |block_chain: &Blockchain, source: &str| { + block_chain + .range(..boundary_epoch) + .next_back() + .map(|(&epoch, &hash)| (epoch, hash)) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "{source} block index has no block before common finalized superblock #{common_superblock}" + ), + }) + }; + let local_boundary = boundary(local_block_chain, "local")?; + let imported_boundary = boundary(imported_block_chain, "imported")?; + if local_boundary != imported_boundary { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot diverges before common finalized superblock #{common_superblock}: local boundary is epoch {} hash {}, imported boundary is epoch {} hash {}", + local_boundary.0, local_boundary.1, imported_boundary.0, imported_boundary.1 + ), + }); + } + if local_boundary.0 == 0 { + log::warn!( + "Snapshot import's highest common finalized boundary resolves only to genesis; no post-genesis local canonical-prefix anchor is available" + ); + } + + Ok(()) +} + +/// Bind the serialized superblock state to the finalized beacon carried by ChainInfo. +/// +/// Portable snapshots contain finalized SuperBlock records but not the historical vote mempool +/// that produced them. We therefore cannot reconstruct committee signatures during import yet; +/// we can, however, reject a state image whose own beacon or in-flight candidate contradicts the +/// ChainInfo finality boundary. The beacon must not regress, and every non-bootstrap beacon must +/// have a matching current candidate. A candidate at a later index is intentionally allowed +/// because the live node may have already built the next superblock while the preceding one was +/// still being voted on. +pub(super) fn validate_snapshot_superblock_state_binding( + superblock_state: &SuperBlockState, + imported_chain_info: &ChainInfo, +) -> Result<(), ImportError> { + let finalized_beacon = imported_chain_info.highest_superblock_checkpoint; + let state_beacon = superblock_state.get_beacon(); + + if state_beacon.checkpoint < finalized_beacon.checkpoint { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot SuperBlockState beacon {:?} is behind finalized ChainInfo beacon {:?}", + state_beacon, finalized_beacon, + ), + }); + } + + if state_beacon.checkpoint == finalized_beacon.checkpoint + && state_beacon.hash_prev_block != finalized_beacon.hash_prev_block + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot SuperBlockState beacon {:?} conflicts with finalized ChainInfo beacon {:?}", + state_beacon, finalized_beacon, + ), + }); + } + + match superblock_state.get_current_superblock() { + None if state_beacon.checkpoint != 0 => { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot SuperBlockState beacon {:?} has no current superblock candidate", + state_beacon, + ), + }); + } + None => {} + Some(current_superblock) => { + let current_hash = current_superblock.hash(); + if current_superblock.index != state_beacon.checkpoint + || current_hash != state_beacon.hash_prev_block + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot current SuperBlock #{} hash {} conflicts with its state beacon {:?}", + current_superblock.index, current_hash, state_beacon, + ), + }); + } + if current_superblock.index == finalized_beacon.checkpoint + && current_hash != finalized_beacon.hash_prev_block + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot current SuperBlock #{} hash {} conflicts with finalized ChainInfo beacon {:?}", + current_superblock.index, current_hash, finalized_beacon, + ), + }); + } + } + } + + Ok(()) +} + +/// Incremental, read-only proof of every portable transaction authorization whose complete +/// prerequisite state is carried by the snapshot itself. +/// +/// The canonical block history is anchored at genesis, so this verifier can reconstruct the UTXO +/// ownership and inclusion-height image without reading or mutating the node's live database. That +/// is sufficient to authenticate every VT/DR/Stake input, legacy Commit collateral ownership, +/// Stake action authorization, Unstake authorization, Commit/Reveal signature binding, canonical +/// Commit change/collateral envelopes and per-block witness populations, every block header +/// signature, every block-VRF proof's cryptography over the reconstructed proof-hash chain, and +/// every Commit-VRF proof's cryptography over its exact protocol message, plus every Wit/2 Commit's +/// exact stake-ranked target and owner salt (with progressive reservation under Wit/2.1), and every +/// Wit/2 block proposer's exact stake rank against the immutable block-opening view. The +/// V1.8/V2.0 stake ledger is replayed through +/// rewards, collateral, tallies, age resets, and manual actions; its +/// canonical live owners seed the one-time Wit/2.1 activation. From that boundary onward, complete +/// independently aged stake entries, nonces, policies, native and cross-fork reservations, +/// delegated rewards, and partial/full exits are replayed for every validator. It is not a +/// replacement for full consensus replay: collateral reservation and other historical admission +/// state remains outside this bounded replay, while pre-Wit/2 collateral age and the 100-WIT +/// committer qualification rule are reconstructed from the canonical UTXO image. Tally result +/// bytes are independently re-derived from each canonical request and its authenticated reveals at +/// the tally boundary; the separate structural chronology checks still handle the +/// activation-compatibility population cases. Legacy Mint fee/reward amounts are checked +/// separately from the canonical transaction-fee image and the pinned initial-reward/halving +/// schedule. +/// Canonical transaction replay enforces structural request/commit/reveal/tally stages and round +/// bounds, while version-2 Report coverage additionally binds persisted counters and complete +/// participant maps to that same block order. `Hash::max()` remains the Wit/2 block-VRF +/// cryptographic target after the separate exact proposer-rank gate; pre-Wit/2 block and Commit +/// targets are derived from the replayed reputation engine. Neither target shortcut weakens their +/// signatures or messages, and the legacy target is never used for a Wit/2 Commit stake target. +/// Wit/2's exact empty Mint is checked separately as its history-independent semantic constraint. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct SnapshotRequestContext { + inclusion_epoch: Epoch, + inclusion_position: usize, + protocol_version: ProtocolVersion, + required_collateral: u64, + commit_and_reveal_fee: u64, + witness_reward: u64, + witnesses: u16, + commit_block_position: Option, + last_reveal_block_position: Option, + reveals: usize, +} + +/// Exact tally label made mandatory by an accepted-block no-Commit transition. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum SnapshotPendingTallyKind { + /// A request born before Wit/2 crossed into the Wit/2 threshold regime. Its inclusion protocol + /// still makes the next tally ordinary even though the containing block moved it to TALLY. + LegacyCrossingOrdinary, + /// A Wit/2 request crossed from an admissible block-opening population to an over-subscribed + /// post-block population before its terminal round. Wit/2.1 admits the byte-exact sentinel in + /// the immediately following canonical block while that opening population remains low. + V21CausalTooManyWitnesses, +} + +/// Exact accepted-block transition that makes one no-Commit tally due in the next canonical block. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind, + transition_position: usize, + tally_position: usize, + current_commit_round: u16, +} + +/// Canonical transaction fields needed to bind the imported unresolved-request pool to block +/// history. Round positions stay in [`SnapshotRequestContext`] because Commit/Reveal verification +/// also consumes them; this companion retains the exact pool payloads without making that hot +/// context non-`Copy`. +#[derive(Clone, Debug, Eq, PartialEq)] +struct SnapshotCanonicalRequestState { + data_request: DataRequestOutput, + requester: PublicKeyHash, + request_block_hash: Hash, + commits: HashMap, + reveals: HashMap, +} + +/// The two validator populations selected by request inclusion protocol at one exact ledger view. +/// Requests born before Wit/2.1 retain the raw stake-ledger population across the fork, while +/// native V2.1 requests use the owner-active delegated population. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct SnapshotTipValidatorCounts { + current: usize, + legacy: usize, +} + +impl SnapshotTipValidatorCounts { + fn for_request(self, protocol_version: ProtocolVersion) -> usize { + if protocol_version < ProtocolVersion::V2_1 { + self.legacy + } else { + self.current + } + } +} + +/// Wit/2's protocol-fixed accepted-block subsidy. This mirrors +/// `ConsensusConstantsWit2::get_validator_block_reward` without consulting the mutable global +/// protocol registry during a pinned portable-history replay. +const SNAPSHOT_WIT2_VALIDATOR_BLOCK_REWARD_NANOWITS: u64 = 50_000_000_000; + +/// Canonical owner, commission, stake-entry, and reservation replay. +/// +/// Before Wit/2.1, legacy identities are recorded conservatively while the exact production ledger +/// is advanced separately. The one-time activation bridge replaces every placeholder from that +/// ledger's canonical live-owner image and clears all legacy opacity. Every later native or +/// cross-fork stake transition is then replayed exactly, including independently averaged and reset +/// capability ages. +#[derive(Clone, Default)] +struct SnapshotAuthenticatedPolicyTracker { + /// Conservative identities observed before the activation handoff; cleared atomically when + /// canonical live-owner policies and exact balances are seeded. + validators_with_legacy_stake_history: HashSet, + policies: BTreeMap, + /// Exact live stake entries after activation, plus the independently authenticated fresh + /// subset before activation. + exact_stakes: BTreeMap, SnapshotAuthenticatedStakeEntry>, + /// Exact native Wit/2.1 and post-activation legacy debits awaiting the block-authenticated tally + /// outcome. Keeping each original debit vector prevents later rewards from re-planning it. + exact_collateral_reservations: BTreeMap, + /// Non-spendable activation-boundary recovery records. These are replayed independently of + /// native escrow so a historical debit or beneficiary is never inferred from a post-fork + /// balance. + exact_legacy_activation_obligations: + BTreeMap, + /// Withdrawer-authenticated persistent drain state, independently reconstructed from actions. + exit_intents: BTreeSet>, + /// Pre-activation conservative balance exclusions. Exact activation seeding clears this set; + /// no accepted post-activation transition may add to it. + balance_opaque_validators: HashSet, + /// Consolidation persists the activation marker at the first Wit/2.1 block, independently of + /// whether that block happens to contain a Stake action. + observed_v21_block: bool, +} + +type SnapshotStakeValue = + Stake; + +#[derive(Copy, Clone, Debug, PartialEq)] +struct SnapshotAuthenticatedStakeEntry { + coins: u64, + epochs: CapabilityMap, + nonce: u64, +} + +impl SnapshotAuthenticatedStakeEntry { + /// Convert an authenticated snapshot stake value into its replay representation. It is used by exact stake projections before they are compared with canonical delegation state. + fn from_stake(value: &SnapshotStakeValue) -> Self { + Self { + coins: value.coins.nanowits(), + epochs: value.epochs, + nonce: value.nonce, + } + } + + /// Convert an authenticated replay entry back into the persisted snapshot stake value. It supplies the wire projection checked by snapshot persistence validation. + fn into_stake(self) -> SnapshotStakeValue { + SnapshotStakeValue { + coins: Wit::from_nanowits(self.coins), + epochs: self.epochs, + nonce: self.nonce, + phantom_address: Default::default(), + phantom_power: Default::default(), + } + } +} + +impl SnapshotAuthenticatedPolicyTracker { + /// Record that a validator has legacy stake evidence in the replay. It updates the policy tracker used to distinguish historical entries from native ownership. + fn note_legacy_stake(&mut self, validator: PublicKeyHash) { + self.validators_with_legacy_stake_history + .insert(validator); + self.mark_stake_balance_opaque(validator); + } + + /// Record the protocol regime observed while replaying a validator. It feeds exact policy and stake projection checks across the snapshot boundary. + fn note_protocol(&mut self, protocol_version: ProtocolVersion) { + if protocol_version >= ProtocolVersion::V2_1 { + self.observed_v21_block = true; + } + } + + /// Report whether a validator's replayed stake balance remains exact. It gates authenticated policy checks before opaque legacy balances can be compared. + fn stake_balance_is_exact(&self, validator: PublicKeyHash) -> bool { + self.policies.contains_key(&validator) + && !self + .validators_with_legacy_stake_history + .contains(&validator) + && !self.balance_opaque_validators.contains(&validator) + } + + /// Mark a validator's replayed stake balance as opaque. It prevents snapshot verification from treating incomplete legacy evidence as an exact native projection. + fn mark_stake_balance_opaque(&mut self, validator: PublicKeyHash) { + self.balance_opaque_validators.insert(validator); + self.exact_stakes + .retain(|key, _entry| key.validator != validator); + self.exact_collateral_reservations + .retain(|key, _reservation| key.validator != validator); + self.exact_legacy_activation_obligations + .retain(|key, _obligation| key.validator != validator); + self.exit_intents.retain(|key| key.validator != validator); + } + + /// Activate pending commission schedules at a replay epoch. It applies the same delegation-state transition used by live block processing before reward allocation. + fn activate_pending_commissions(&mut self, epoch: Epoch) -> Result<(), anyhow::Error> { + // Snapshot replay must not erase malformed pending history before the authenticated + // policy checks run. Stage the entire map and validate both active and pending terms + // before publishing any due commission update. + let mut updated = self.policies.clone(); + for policy in updated.values_mut() { + anyhow::ensure!( + policy.commission_percent <= MAX_COMMISSION_PERCENT, + "authenticated validator policy commission exceeds the fixed {}% cap", + MAX_COMMISSION_PERCENT + ); + if let Some(pending) = policy.pending_commission { + anyhow::ensure!( + pending.commission_percent <= MAX_COMMISSION_PERCENT, + "authenticated pending validator commission exceeds the fixed {}% cap", + MAX_COMMISSION_PERCENT + ); + anyhow::ensure!( + pending.nonce > policy.commission_nonce, + "authenticated pending validator policy nonce {} is not newer than active nonce {}", + pending.nonce, + policy.commission_nonce + ); + if pending.effective_epoch <= epoch { + policy.commission_percent = pending.commission_percent; + policy.commission_nonce = pending.nonce; + policy.pending_commission = None; + } + } + } + self.policies = updated; + Ok(()) + } + + /// Replace every conservative pre-activation placeholder with the canonical one-time + /// activation image. This imports each independently aged stake value verbatim; later temporary + /// production projections are reconstructed from and copied back into that exact image. + fn seed_from_v21_activation( + &mut self, + stakes: &StakesTracker, + delegation_state: &DelegationState, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + !self.observed_v21_block, + "activation seed must precede the first observed Wit/2.1 block" + ); + anyhow::ensure!( + delegation_state.activation_initialized, + "delegated-staking activation marker is absent" + ); + // The activation initializer clears every pre-marker exit sidecar before publishing the + // marker, and no post-marker action can be replayed before this seed runs. Reject a + // marked state that still carries an exit intent instead of authenticating an + // upgrade-history byte as a native drain lock. + anyhow::ensure!( + delegation_state.exit_intents.is_empty(), + "activation seed retains pre-activation exit intent sidecars" + ); + delegation_state.validate_stake_and_reservation_accounting( + stakes, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + + let mut exact_stakes = BTreeMap::new(); + let mut entries = stakes.query_stakes_checked(QueryStakesKey::All)?; + entries.sort_by(|left, right| left.key.cmp(&right.key)); + // The canonical activation initializer rejects an empty eligible population before it + // publishes the marker. A marked snapshot seed with policies but no live owner entry is + // therefore malformed upgrade history, not a valid policy-only post-exit state (those + // states are reached only after the seed and are checked by normal replay). + anyhow::ensure!( + !entries.is_empty(), + "activation seed has no canonical live stake entry" + ); + for entry in entries { + anyhow::ensure!( + delegation_state.validators.contains_key(&entry.key.validator), + "activated stake entry {} has no canonical validator policy", + entry.key, + ); + let previous = exact_stakes.insert( + entry.key.clone(), + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + ); + anyhow::ensure!( + previous.is_none(), + "activated stake entry {} occurs more than once", + entry.key, + ); + } + for reservation_key in delegation_state.collateral_reservations.keys() { + anyhow::ensure!( + delegation_state + .validators + .contains_key(&reservation_key.validator), + "activated reservation {reservation_key:?} has no canonical validator policy", + ); + } + + self.validators_with_legacy_stake_history.clear(); + self.balance_opaque_validators.clear(); + self.policies = delegation_state.validators.clone(); + self.exact_stakes = exact_stakes; + self.exact_collateral_reservations = + delegation_state.collateral_reservations.clone(); + self.exact_legacy_activation_obligations = + delegation_state.legacy_activation_obligations.clone(); + self.exit_intents = delegation_state.exit_intents.clone(); + Ok(()) + } + + /// Return the authenticated owner recorded for a validator during snapshot replay. It is consulted by legacy reward and collateral settlement when live ownership is incomplete. + fn authenticated_owner(&self, validator: PublicKeyHash) -> Option { + self.policies + .get(&validator) + .map(|policy| policy.owner_withdrawer) + } + + /// Read one validator's contiguous primary-key slice without walking unrelated stake entries. + /// `StakeKey` orders validator before withdrawer, and the all-zero default public-key hash is the + /// first possible withdrawer, so the range seek is O(log N) and the retained walk is O(k). + fn exact_stakes_for_validator( + &self, + validator: PublicKeyHash, + ) -> impl Iterator< + Item = ( + &StakeKey, + &SnapshotAuthenticatedStakeEntry, + ), + > + '_ { + let first = StakeKey { + validator, + withdrawer: PublicKeyHash::default(), + }; + self.exact_stakes + .range(first..) + .take_while(move |(key, _entry)| key.validator == validator) + } + + /// Apply one authenticated Wit/2.1 stake authorization to the replayed delegation state. It dispatches delegation, owner registration, commission, and exit actions through the corresponding policy helpers. + fn apply_v21_action( + &mut self, + terms: &StakeAuthorization, + epoch: Epoch, + epoch_constants: EpochConstants, + ) -> Result<(), anyhow::Error> { + terms.validate_structure()?; + let validator = terms.validator; + let commission = u8::try_from(terms.commission_percent) + .map_err(|_| anyhow::anyhow!("validator commission does not fit u8"))?; + + if terms.action == STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT { + anyhow::ensure!(self.stake_balance_is_exact(validator), "exit request has no exact activated stake"); + let key = StakeKey { validator, withdrawer: terms.withdrawer }; + let (stakes, mut delegation_state) = self.exact_validator_projection(validator)?; + delegation_state.request_exit(&stakes, key.clone(), terms.nonce)?; + self.exit_intents.insert(key); + return Ok(()); + } + + let Some(policy) = self.policies.get_mut(&validator) else { + if self + .validators_with_legacy_stake_history + .contains(&validator) + { + // Before the activation handoff, a later signed action cannot replace a + // conservative legacy placeholder. Successful activation clears this set after + // importing the canonical live owner from the exact legacy ledger. + return Ok(()); + } + // After activation, a missing policy may only be filled by explicit registration of a + // validator that has no authenticated stake history yet. If exact entries or + // reservations already reference this validator, silently registering the signed + // withdrawer as a new owner would let malformed persisted state rewrite owner and + // commission provenance during snapshot replay. + anyhow::ensure!( + !self + .exact_stakes_for_validator(validator) + .next() + .is_some() + && !self + .exact_collateral_reservations + .keys() + .any(|key| key.validator == validator), + "authenticated validator policy is missing for existing exact stake history" + ); + anyhow::ensure!( + terms.action == STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + "validator requires an explicit authenticated owner-registration action" + ); + self.policies.insert( + validator, + ValidatorPolicy::with_commission(terms.withdrawer, commission), + ); + return Ok(()); + }; + + // The authenticated replay image is normally populated from already validated + // delegated state, but snapshot/replay callers can also construct it from persisted + // compatibility data. Fail closed before selecting or scheduling a commission when that + // image contains an out-of-range active or pending rate; otherwise a malformed pending + // value could remain hidden until a later activation boundary. + anyhow::ensure!( + policy.commission_percent <= MAX_COMMISSION_PERCENT, + "authenticated validator policy commission exceeds the fixed {}% cap", + MAX_COMMISSION_PERCENT + ); + if let Some(pending) = policy.pending_commission { + anyhow::ensure!( + pending.commission_percent <= MAX_COMMISSION_PERCENT, + "authenticated pending validator commission exceeds the fixed {}% cap", + MAX_COMMISSION_PERCENT + ); + anyhow::ensure!( + pending.nonce > policy.commission_nonce, + "authenticated pending validator policy nonce {} is not newer than active nonce {}", + pending.nonce, + policy.commission_nonce + ); + anyhow::ensure!( + pending.effective_epoch > epoch, + "authenticated pending validator commission effective epoch {} has already arrived at epoch {}", + pending.effective_epoch, + epoch + ); + } + + match terms.action { + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + anyhow::bail!("validator already has an authenticated owner registration"); + } + STAKE_AUTHORIZATION_ACTION_DELEGATE => { + anyhow::ensure!( + terms.commission_percent == u32::from(policy.commission_percent), + "delegation commission {} does not match authenticated effective commission {}", + terms.commission_percent, + policy.commission_percent + ); + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => { + anyhow::ensure!( + terms.withdrawer == policy.owner_withdrawer, + "commission update uses withdrawer {}, expected authenticated owner {}", + terms.withdrawer, + policy.owner_withdrawer + ); + let expected_nonce = policy + .pending_commission + .map_or(policy.commission_nonce, |pending| pending.nonce) + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("commission nonce overflow"))?; + anyhow::ensure!( + terms.nonce == expected_nonce, + "commission update uses nonce {}, expected {}", + terms.nonce, + expected_nonce + ); + + let checkpoint_period = if commission > policy.commission_percent { + u64::from( + epoch_constants + .get_epoch_period(epoch) + .map_err(|_| anyhow::anyhow!("commission clock arithmetic overflow"))?, + ) + } else { + 0 + }; + let effective_epoch = commission_effective_epoch( + epoch, + policy.commission_percent, + commission, + checkpoint_period, + ) + .ok_or_else(|| anyhow::anyhow!("commission effective epoch arithmetic overflow"))?; + policy.pending_commission = Some(PendingCommission { + commission_percent: commission, + effective_epoch, + nonce: terms.nonce, + }); + } + action => anyhow::bail!("unsupported stake authorization action: {action}"), + } + + Ok(()) + } + + /// Replay one fully authenticated delegated deposit while the validator has no unobservable + /// stake mutations. This mirrors the projected block-order cap and active-owner rules. + fn apply_v21_delegation( + &mut self, + key: StakeKey, + value: u64, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.stake_balance_is_exact(key.validator), + "delegated deposit has no exact activated validator balance" + ); + anyhow::ensure!( + value >= DELEGATED_STAKING_MINIMUM_NANOWITS, + "delegation is below the fixed minimum" + ); + + let policy = self + .policies + .get(&key.validator) + .copied() + .ok_or_else(|| anyhow::anyhow!("authenticated validator policy is missing"))?; + anyhow::ensure!(!self.exit_intents.contains(&key), "exiting entry cannot accept a deposit"); + if key.withdrawer != policy.owner_withdrawer { + let owner_key = policy.owner_key(key.validator); + let owner_is_active = self + .exact_stakes + .get(&owner_key) + .is_some_and(|entry| entry.coins >= DELEGATED_STAKING_MINIMUM_NANOWITS) + && !self.exit_intents.contains(&owner_key); + anyhow::ensure!( + owner_is_active, + "delegator deposit precedes the authenticated owner's active self-stake" + ); + } + + let current_total = self + .exact_stakes_for_validator(key.validator) + .try_fold(0u64, |total, (_stake_key, entry)| { + total + .checked_add(entry.coins) + .ok_or_else(|| anyhow::anyhow!("validator stake total overflow")) + })?; + let current_total = self + .exact_collateral_reservations + .iter() + .filter(|(reservation_key, _reservation)| { + reservation_key.validator == key.validator + }) + .try_fold(current_total, |total, (_reservation_key, reservation)| { + total + .checked_add(reservation.total.nanowits()) + .ok_or_else(|| anyhow::anyhow!("validator reserved stake total overflow")) + })?; + let resulting_total = current_total + .checked_add(value) + .ok_or_else(|| anyhow::anyhow!("validator stake total overflow"))?; + anyhow::ensure!( + resulting_total <= DELEGATED_STAKING_MAXIMUM_NANOWITS, + "validator stake total {resulting_total} exceeds the fixed cap {}", + DELEGATED_STAKING_MAXIMUM_NANOWITS + ); + + // Use the production stake primitive so both capability ages are weight-averaged with the + // exact WIT precision rules before the manual-action nonce is advanced. + let (mut stakes, delegation_state) = self.exact_validator_projection(key.validator)?; + stakes.add_stake( + key.clone(), + Wit::from_nanowits(value), + epoch, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + self.sync_exact_validator_projection( + key.validator, + &stakes, + &delegation_state, + Some(&key), + )?; + Ok(()) + } + + /// Materialize one validator's exact stake entries through the production delegated-staking + /// types. Every balance, capability age, and nonce is preserved verbatim. + fn exact_validator_projection( + &self, + validator: PublicKeyHash, + ) -> Result<(StakesTracker, DelegationState), anyhow::Error> { + let policy = self + .policies + .get(&validator) + .copied() + .ok_or_else(|| anyhow::anyhow!("authenticated validator policy is missing"))?; + let entries = self + .exact_stakes_for_validator(validator) + .map(|(key, entry)| { + ( + key.clone(), + SyncStakeEntry::from(StakeEntry { + key: key.clone(), + value: (*entry).into_stake(), + }), + ) + }) + .collect::>(); + let stakes = StakesTracker::with_entries(entries); + let mut delegation_state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + delegation_state.validators.insert(validator, policy); + delegation_state.collateral_reservations.extend( + self.exact_collateral_reservations + .iter() + .filter(|(key, _reservation)| key.validator == validator) + .map(|(key, reservation)| (*key, reservation.clone())), + ); + delegation_state.legacy_activation_obligations.extend( + self.exact_legacy_activation_obligations + .iter() + .filter(|(key, _obligation)| key.validator == validator) + .map(|(key, obligation)| (*key, obligation.clone())), + ); + delegation_state.exit_intents.extend( + self.exit_intents.iter().filter(|key| key.validator == validator).cloned(), + ); + Ok((stakes, delegation_state)) + } + + /// Materialize the complete exact activated image. Mining-age reset ranks are global, so a + /// validator-local projection would omit better-ranked validators that the production reset + /// deliberately penalizes together with the block proposer. + fn exact_full_projection( + &self, + ) -> Result<(StakesTracker, DelegationState), anyhow::Error> { + anyhow::ensure!( + self.observed_v21_block, + "complete delegated projection precedes the Wit/2.1 marker" + ); + anyhow::ensure!( + self.validators_with_legacy_stake_history.is_empty() + && self.balance_opaque_validators.is_empty(), + "complete delegated projection retains an opaque validator" + ); + let entries = self + .exact_stakes + .iter() + .map(|(key, entry)| { + ( + key.clone(), + SyncStakeEntry::from(StakeEntry { + key: key.clone(), + value: (*entry).into_stake(), + }), + ) + }) + .collect::>(); + let stakes = StakesTracker::with_entries(entries); + let delegation_state = DelegationState { + validators: self.policies.clone(), + collateral_reservations: self.exact_collateral_reservations.clone(), + legacy_activation_obligations: self.exact_legacy_activation_obligations.clone(), + exit_intents: self.exit_intents.clone(), + activation_initialized: true, + ..Default::default() + }; + delegation_state.validate_stake_and_reservation_accounting( + &stakes, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + Ok((stakes, delegation_state)) + } + + /// Copy a production transition's complete stake-entry and reservation result back into the + /// authenticated image. Collateral, reward, and legacy tally transitions cannot create, remove, + /// or retarget a live stake entry. A delegated deposit may add only its explicitly supplied key. + fn sync_exact_validator_projection( + &mut self, + validator: PublicKeyHash, + stakes: &StakesTracker, + delegation_state: &DelegationState, + allowed_added_key: Option<&StakeKey>, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + delegation_state.validator_policy(validator)? + == *self + .policies + .get(&validator) + .ok_or_else(|| anyhow::anyhow!( + "authenticated validator policy is missing" + ))?, + "temporary validator projection changed its canonical policy" + ); + anyhow::ensure!( + delegation_state.exit_intents.iter().eq( + self.exit_intents.iter().filter(|key| key.validator == validator) + ), + "temporary validator projection changed persistent exit intent" + ); + + let projected_entries = match stakes + .query_stakes_by_validator_primary_checked(validator) + { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(error.into()), + }; + let projected_values = projected_entries + .into_iter() + .map(|entry| { + ( + entry.key, + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + ) + }) + .collect::>(); + let current_keys = self + .exact_stakes_for_validator(validator) + .map(|(key, _entry)| key) + .cloned() + .collect::>(); + anyhow::ensure!( + current_keys + .iter() + .all(|key| projected_values.contains_key(key)), + "temporary validator projection removed a stake key" + ); + let added_keys = projected_values + .keys() + .filter(|key| !current_keys.contains(*key)) + .collect::>(); + match allowed_added_key { + Some(allowed) => { + let was_present = current_keys.contains(allowed); + let applied_exact_key = if was_present { + added_keys.is_empty() + } else { + added_keys.len() == 1 && added_keys[0] == allowed + }; + anyhow::ensure!( + allowed.validator == validator && applied_exact_key, + "temporary validator projection did not apply exactly its allowed stake key" + ); + } + None => anyhow::ensure!( + added_keys.is_empty(), + "temporary validator projection changed its stake-key set" + ), + } + for (key, projected_entry) in projected_values { + self.exact_stakes.insert(key, projected_entry); + } + + self.exact_collateral_reservations + .retain(|key, _reservation| key.validator != validator); + self.exact_collateral_reservations.extend( + delegation_state + .collateral_reservations + .iter() + .filter(|(key, _reservation)| key.validator == validator) + .map(|(key, reservation)| (*key, reservation.clone())), + ); + self.exact_legacy_activation_obligations + .retain(|key, _obligation| key.validator != validator); + self.exact_legacy_activation_obligations.extend( + delegation_state + .legacy_activation_obligations + .iter() + .filter(|(key, _obligation)| key.validator == validator) + .map(|(key, obligation)| (*key, obligation.clone())), + ); + Ok(()) + } + + /// Copy a complete production projection back after an operation such as mining-age reset that + /// may update entries belonging to several validators. This boundary additionally proves that + /// the operation changed only capability ages: key membership, balances, nonces, policies, and + /// reservations must remain byte-for-byte equivalent. + fn sync_exact_full_age_projection( + &mut self, + stakes: &StakesTracker, + delegation_state: &DelegationState, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + delegation_state.activation_initialized, + "complete age projection lost its activation marker" + ); + anyhow::ensure!( + delegation_state.validators == self.policies, + "complete age projection changed a canonical validator policy" + ); + anyhow::ensure!( + delegation_state.collateral_reservations + == self.exact_collateral_reservations, + "complete age projection changed collateral reservations" + ); + anyhow::ensure!( + delegation_state.legacy_activation_obligations + == self.exact_legacy_activation_obligations, + "complete age projection changed activation-boundary obligations" + ); + anyhow::ensure!( + delegation_state.exit_intents == self.exit_intents, + "complete age projection changed persistent exit intent" + ); + + let projected = stakes + .query_stakes_checked(QueryStakesKey::All)? + .into_iter() + .map(|entry| { + ( + entry.key, + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + ) + }) + .collect::>(); + anyhow::ensure!( + projected.len() == self.exact_stakes.len() + && self + .exact_stakes + .keys() + .all(|key| projected.contains_key(key)), + "complete age projection changed its stake-key set" + ); + // Preflight every invariant before changing the first age so the independent production + // projection remains a failure-atomic staging boundary without another full tracker clone. + for (key, projected_entry) in &projected { + let current = self + .exact_stakes + .get(key) + .ok_or_else(|| anyhow::anyhow!("authenticated stake entry disappeared"))?; + anyhow::ensure!( + current.coins == projected_entry.coins && current.nonce == projected_entry.nonce, + "complete age projection changed stake coins or nonce for {key}" + ); + } + // Both maps are ordered by the same exact prevalidated key set, so their mutable/value + // iterators pair each entry without a second fallible lookup or a panic boundary. + for ((_, current), (_, projected_entry)) in + self.exact_stakes.iter_mut().zip(projected) + { + current.epochs = projected_entry.epochs; + } + Ok(()) + } + + /// Apply the shared post-reward, pre-manual-action age transition to the complete exact + /// activated image. The caller supplies the already pinned block/request protocol mapping, so + /// historical replay never consults a later mutable process-global schedule. + #[allow(clippy::too_many_arguments)] + fn apply_age_resets_for_protocol( + &mut self, + miner: PublicKeyHash, + epoch: Epoch, + block_protocol: ProtocolVersion, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], + request_protocol_for: F, + ) -> Result<(), anyhow::Error> + where + F: Fn(Hash) -> Option, + { + anyhow::ensure!( + block_protocol >= ProtocolVersion::V2_1, + "authenticated delegated age replay requires Wit/2.1" + ); + let (mut stakes, delegation_state) = self.exact_full_projection()?; + apply_wit2_age_resets_for_protocol( + &mut stakes, + &delegation_state, + miner, + epoch, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + block_protocol, + commit_txns, + tally_txns, + request_protocol_for, + )?; + self.sync_exact_full_age_projection(&stakes, &delegation_state)?; + Ok(()) + } + + /// Reserve owner-only collateral for a Wit/2.0 request whose Commit is included after the + /// activation boundary. The production transition is authoritative for the debit and exact + /// sidecar shape; the authenticated nonce is preserved because collateral does not change it. + #[cfg(test)] + fn reserve_v20_collateral_after_activation( + &mut self, + data_request: Hash, + validator: PublicKeyHash, + collateral: u64, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.stake_balance_is_exact(validator), + "post-activation legacy Commit has no exact validator balance" + ); + let (mut stakes, mut delegation_state) = self.exact_validator_projection(validator)?; + delegation_state.reserve_legacy_collateral( + &mut stakes, + CollateralReservationKey { + data_request, + validator, + }, + Wit::from_nanowits(collateral), + epoch, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + self.sync_exact_validator_projection(validator, &stakes, &delegation_state, None)?; + Ok(()) + } + + /// Replay one native reservation with the production immutable 70/30 planner. Exact debits are + /// retained until tally so refunds restore the original entries and slashes consume precisely + /// the stake removed at Commit time. + #[cfg(test)] + fn reserve_v21_collateral( + &mut self, + data_request: Hash, + validator: PublicKeyHash, + collateral: u64, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!(collateral > 0, "native collateral reservation is zero"); + anyhow::ensure!( + self.stake_balance_is_exact(validator), + "native Commit identity has no exact activated validator balance" + ); + let reservation_key = CollateralReservationKey { + data_request, + validator, + }; + anyhow::ensure!( + !self + .exact_collateral_reservations + .contains_key(&reservation_key), + "native collateral is already reserved for this request and validator" + ); + let (stakes, delegation_state) = self.exact_validator_projection(validator)?; + let reservation = delegation_state.plan_collateral_reservation( + &stakes, + validator, + Wit::from_nanowits(collateral), + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + + // Stage only the entries named by the canonical debit vector. No live balance changes until + // every subtraction and minimum check succeeds, retaining direct-call failure atomicity + // without cloning the complete activated stake image for each Commit. + let mut staged_entries = BTreeMap::new(); + for debit in &reservation.debits { + let mut staged_entry = staged_entries + .get(&debit.key) + .copied() + .or_else(|| self.exact_stakes.get(&debit.key).copied()) + .ok_or_else(|| anyhow::anyhow!("collateral debit targets an absent exact entry"))?; + staged_entry.coins = staged_entry + .coins + .checked_sub(debit.amount.nanowits()) + .ok_or_else(|| anyhow::anyhow!("collateral debit exceeds exact entry balance"))?; + anyhow::ensure!( + staged_entry.coins >= DELEGATED_STAKING_MINIMUM_NANOWITS, + "collateral debit leaves an exact entry below the fixed minimum" + ); + staged_entries.insert(debit.key.clone(), staged_entry); + } + for (key, staged_entry) in staged_entries { + self.exact_stakes.insert(key, staged_entry); + } + self.exact_collateral_reservations + .insert(reservation_key, reservation); + Ok(()) + } + + /// Apply one gross delegated reward through the production allocation and commission code, then + /// copy its exact balance and independently averaged capability ages back into the authenticated + /// snapshot image. + fn distribute_v21_reward( + &mut self, + validator: PublicKeyHash, + gross_reward: u64, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + if gross_reward == 0 { + return Ok(()); + } + anyhow::ensure!( + self.stake_balance_is_exact(validator), + "delegated reward target has no exact activated validator balance" + ); + let (mut stakes, mut delegation_state) = self.exact_validator_projection(validator)?; + delegation_state.distribute_reward( + &mut stakes, + validator, + Wit::from_nanowits(gross_reward), + epoch, + )?; + self.sync_exact_validator_projection(validator, &stakes, &delegation_state, None)?; + Ok(()) + } + + /// Credit a cross-fork Wit/2.0 inclusion reward to the authenticated owner only. + /// + /// The exact snapshot image is still represented by the production staking types for this + /// transition, but the legacy owner-only helper keeps delegated entries from receiving a + /// reward for a request that was born under Wit/2.0. Commit and Reveal totals remain separate + /// at the caller so their historical age-averaging operations are not combined. + fn distribute_v20_owner_reward( + &mut self, + validator: PublicKeyHash, + reward: u64, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + if reward == 0 { + return Ok(()); + } + anyhow::ensure!( + self.stake_balance_is_exact(validator), + "legacy inclusion reward target has no exact activated validator balance" + ); + let (mut stakes, delegation_state) = self.exact_validator_projection(validator)?; + delegation_state.distribute_legacy_owner_reward( + &mut stakes, + validator, + Wit::from_nanowits(reward), + epoch, + )?; + self.sync_exact_validator_projection(validator, &stakes, &delegation_state, None)?; + Ok(()) + } + + /// Settle every exact native reservation for one request from the block-authenticated tally + /// labels. Error witnesses receive only their exact refund, honest witnesses receive refund plus + /// delegated reward, and non-error out-of-consensus witnesses consume the already debited 70/30 + /// reservation. The caller owns a block-local tracker clone, and this method additionally stages + /// its multi-validator mutation so a direct failure is atomic as well. + fn settle_v21_tally( + &mut self, + data_request: Hash, + committers: &HashSet, + required_collateral: u64, + witness_reward: u64, + out_of_consensus: &[PublicKeyHash], + error_committers: &[PublicKeyHash], + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + let mut updated = self.clone(); + let reservation_keys = updated + .exact_collateral_reservations + .keys() + .filter(|key| key.data_request == data_request) + .copied() + .collect::>(); + + // The canonical Commit map is the authority for which validators participated in this + // request. A missing reservation must fail closed rather than silently skipping a refund + // or slash, and an extra reservation indicates that replay state has drifted from the + // authenticated Commit set. Native Wit/2.1 collateral is always positive, so every + // committer must have one exact reservation record. + for validator in committers { + let reservation_key = CollateralReservationKey { + data_request, + validator: *validator, + }; + anyhow::ensure!( + updated + .exact_collateral_reservations + .contains_key(&reservation_key), + "Wit/2.1 committer {validator} has no exact collateral reservation" + ); + } + for reservation_key in &reservation_keys { + anyhow::ensure!( + committers.contains(&reservation_key.validator), + "Wit/2.1 collateral reservation has no canonical committer" + ); + } + + for reservation_key in reservation_keys { + let reservation = updated + .exact_collateral_reservations + .get(&reservation_key) + .cloned() + .ok_or_else(|| anyhow::anyhow!("exact collateral reservation disappeared"))?; + anyhow::ensure!( + reservation.total.nanowits() == required_collateral, + "exact collateral total {} does not match request total {required_collateral}", + reservation.total.nanowits() + ); + anyhow::ensure!( + !reservation.reward_weights.is_empty(), + "Wit/2.1 reservation has no committed reward weights" + ); + let is_error = error_committers.contains(&reservation_key.validator); + let is_slashed = out_of_consensus.contains(&reservation_key.validator) && !is_error; + let validator = reservation_key.validator; + let (mut stakes, mut delegation_state) = + updated.exact_validator_projection(validator)?; + delegation_state.validate_committed_reward_keys(&stakes, reservation_key)?; + delegation_state.validate_collateral_reservation_total( + reservation_key, + Wit::from_nanowits(required_collateral), + )?; + if is_slashed { + delegation_state.slash_reserved_collateral_checked(&stakes, reservation_key)?; + } else { + let refunded = delegation_state.refund_collateral( + &mut stakes, + reservation_key, + epoch, + )?; + if !is_error && witness_reward > 0 { + delegation_state.distribute_committed_reward( + &mut stakes, + validator, + Wit::from_nanowits(witness_reward), + epoch, + &refunded.reward_weights, + )?; + } + } + updated.sync_exact_validator_projection( + validator, + &stakes, + &delegation_state, + None, + )?; + } + + *self = updated; + Ok(()) + } + + /// Settle every canonical committer of a Wit/2.0 request whose Tally is included after activation. It reconciles ownerless obligations, legacy reservations, refunds, rewards, and slash outcomes through the replayed delegation state. + #[allow(clippy::too_many_arguments)] + fn settle_v20_tally_after_activation( + &mut self, + data_request: Hash, + committers: &HashSet, + collateral: u64, + witness_reward: u64, + out_of_consensus: &[PublicKeyHash], + error_committers: &[PublicKeyHash], + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + let mut updated = self.clone(); + let mut committers = committers.iter().copied().collect::>(); + committers.sort_unstable(); + + // The canonical Commit set owns reservation membership. A stale sidecar for this request + // must not survive a post-activation legacy tally merely because its validator is absent + // from the authenticated committer set; the shared validation projector and persisted + // state loader reject the same drift. + for reservation_key in updated + .exact_collateral_reservations + .keys() + .filter(|key| key.data_request == data_request) + { + anyhow::ensure!( + committers.binary_search(&reservation_key.validator).is_ok(), + "post-activation legacy collateral reservation has no canonical committer" + ); + } + for obligation_key in updated + .exact_legacy_activation_obligations + .keys() + .filter(|key| key.data_request == data_request) + { + anyhow::ensure!( + committers.binary_search(&obligation_key.validator).is_ok(), + "post-activation activation-boundary obligation has no canonical committer" + ); + } + + for validator in committers { + let reservation_key = CollateralReservationKey { + data_request, + validator, + }; + let has_reservation = updated + .exact_collateral_reservations + .contains_key(&reservation_key); + let has_obligation = updated + .exact_legacy_activation_obligations + .contains_key(&reservation_key); + anyhow::ensure!( + !(has_reservation && has_obligation), + "post-activation legacy tally has both escrow and recovery obligation for one commitment" + ); + if !has_obligation { + anyhow::ensure!( + updated.stake_balance_is_exact(validator), + "post-activation legacy Tally has no exact validator balance" + ); + } + let is_error = error_committers.contains(&validator); + let is_slashed = out_of_consensus.contains(&validator) && !is_error; + + if is_slashed && !has_reservation && !has_obligation { + anyhow::ensure!( + collateral == 0, + "positive post-activation legacy collateral reservation is missing" + ); + continue; + } + + if has_obligation { + // An activation-boundary obligation is deliberately allowed to outlive its + // legacy owner. Live validation settles it without inventing a policy or a + // beneficiary, so snapshot replay must use the same ownerless projection rather + // than requiring `exact_validator_projection` to find a validator policy. + let entries = updated + .exact_stakes_for_validator(validator) + .map(|(key, entry)| { + ( + key.clone(), + SyncStakeEntry::from(StakeEntry { + key: key.clone(), + value: (*entry).into_stake(), + }), + ) + }) + .collect::>(); + let mut stakes = StakesTracker::with_entries(entries); + let mut delegation_state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + let obligation = updated + .exact_legacy_activation_obligations + .get(&reservation_key) + .cloned() + .ok_or_else(|| anyhow::anyhow!("activation-boundary obligation disappeared"))?; + anyhow::ensure!( + obligation.raw_collateral.nanowits() == collateral, + "activation-boundary obligation collateral does not match legacy request" + ); + delegation_state + .legacy_activation_obligations + .insert(reservation_key, obligation); + delegation_state.settle_legacy_activation_obligation( + &mut stakes, + reservation_key, + is_error, + is_slashed, + epoch, + )?; + let projected_entries = match stakes + .query_stakes_by_validator_primary_checked(validator) + { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(error.into()), + }; + for entry in projected_entries { + anyhow::ensure!( + updated.exact_stakes.contains_key(&entry.key), + "ownerless activation obligation created a new stake entry" + ); + updated.exact_stakes.insert( + entry.key, + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + ); + } + updated.exact_legacy_activation_obligations.insert( + reservation_key, + delegation_state + .legacy_activation_obligations + .remove(&reservation_key) + .expect("activation obligation was retained by settlement"), + ); + continue; + } + + let (mut stakes, mut delegation_state) = + updated.exact_validator_projection(validator)?; + if has_reservation { + delegation_state.validate_active_legacy_collateral_owner(reservation_key)?; + delegation_state.validate_collateral_reservation_total( + reservation_key, + Wit::from_nanowits(collateral), + )?; + } + if is_slashed { + delegation_state.slash_legacy_collateral(reservation_key)?; + } else if has_reservation { + delegation_state.settle_legacy_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(if is_error { 0 } else { witness_reward }), + epoch, + )?; + } else { + delegation_state.settle_legacy_tally_without_reservation( + &mut stakes, + validator, + Wit::from_nanowits(collateral), + Wit::from_nanowits(if is_error { 0 } else { witness_reward }), + epoch, + )?; + } + updated.sync_exact_validator_projection( + validator, + &stakes, + &delegation_state, + None, + )?; + } + + *self = updated; + Ok(()) + } + + /// Focused no-pool fixture entry point. Production replay must pass the canonical unresolved + /// legacy committer set to the pool-aware entry point below, including raw-zero commitments. + #[cfg(test)] + fn apply_v21_unstake( + &mut self, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + withdrawal_value: u64, + fee: u64, + nonce: u64, + epoch: Epoch, + ) -> Result<(), anyhow::Error> { + self.apply_v21_unstake_with_legacy_committers( + validator, withdrawer, withdrawal_value, fee, nonce, epoch, &BTreeSet::new(), + ) + } + + /// Replay a Wit/2.1 withdrawal only while every balance input remains exact. A partial exit may + /// use unreserved live stake, but the entry cannot be fully removed while an exact debit or + /// commit-time reward entitlement is awaiting settlement. Raw-zero legacy commitments have no + /// sidecar, so canonical request history independently locks their immutable owner. Drain + /// intent survives partial exits; this new lock never applies to pre-V2.1 legacy Unstake replay. + #[allow(clippy::too_many_arguments)] + fn apply_v21_unstake_with_legacy_committers( + &mut self, + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + withdrawal_value: u64, + fee: u64, + nonce: u64, + epoch: Epoch, + pending_legacy_committers: &BTreeSet, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.stake_balance_is_exact(validator), + "unstake has no exact activated validator balance" + ); + anyhow::ensure!(withdrawal_value > 0, "unstake withdrawal value is zero"); + let requested = withdrawal_value + .checked_add(fee) + .ok_or_else(|| anyhow::anyhow!("unstake value and fee overflow"))?; + let key = StakeKey { + validator, + withdrawer, + }; + let entry = self + .exact_stakes + .get(&key) + .copied() + .ok_or_else(|| anyhow::anyhow!("authenticated stake entry is absent"))?; + anyhow::ensure!( + nonce == entry.nonce, + "unstake nonce {nonce} does not match authenticated nonce {}", + entry.nonce + ); + let remainder = entry + .coins + .checked_sub(requested) + .ok_or_else(|| anyhow::anyhow!("unstake amount exceeds authenticated balance"))?; + anyhow::ensure!( + remainder == 0 || remainder >= DELEGATED_STAKING_MINIMUM_NANOWITS, + "unstake leaves a nonzero balance below the fixed minimum" + ); + + if remainder == 0 { + anyhow::ensure!( + !self + .exact_collateral_reservations + .values() + .any(|reservation| reservation.debits.iter().any(|debit| debit.key == key) + || reservation.reward_weights.iter().any(|(reward_key, _)| reward_key == &key)), + "authenticated stake entry is locked by outstanding collateral or reward entitlement" + ); + let policy = self + .policies + .get(&validator) + .copied() + .ok_or_else(|| anyhow::anyhow!("authenticated validator policy is missing"))?; + if withdrawer == policy.owner_withdrawer { + anyhow::ensure!( + !pending_legacy_committers.contains(&validator), + "authenticated owner cannot fully exit while a legacy commitment awaits settlement" + ); + anyhow::ensure!( + !self + .exact_collateral_reservations + .keys() + .any(|reservation_key| reservation_key.validator == validator), + "authenticated owner cannot fully exit while validator collateral reservations remain" + ); + anyhow::ensure!( + !self + .exact_stakes_for_validator(validator) + .any(|(other_key, other_entry)| { + other_key.withdrawer != withdrawer && other_entry.coins > 0 + }), + "authenticated owner cannot fully exit while third-party stake remains" + ); + } + self.exact_stakes.remove(&key); + self.exit_intents.remove(&key); + } else { + let entry = self + .exact_stakes + .get_mut(&key) + .ok_or_else(|| anyhow::anyhow!("authenticated stake entry disappeared"))?; + entry.coins = remainder; + entry.nonce = u64::from(epoch); + } + Ok(()) + } + + /// Validate replayed validator policies against the expected delegation state. It checks owner, commission, nonce, and pending-transition fields before snapshot persistence is accepted. + fn validate_persisted_policies( + &self, + expected: &DelegationState, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + expected.activation_initialized == self.observed_v21_block, + "persisted activation marker {} does not equal canonical Wit/2.1 block history {}", + expected.activation_initialized, + self.observed_v21_block + ); + if !expected.activation_initialized { + // Pre-activation policy records are explicitly inert compatibility caches and are + // rebuilt from the live legacy ledger at the first Wit/2.1 block. They cannot be held + // to post-activation provenance rules without changing legacy snapshot validity. + return Ok(()); + } + anyhow::ensure!( + expected.exit_intents == self.exit_intents, + "persisted exit intent does not equal authenticated withdrawer action history" + ); + // The activation seed imports canonical owner policies and exact balances in one image, + // then clears both conservative legacy-history sets. Once the marker is observed, + // retaining either set would make the replay image partly opaque and would allow a + // persisted policy to pass without a signed post-activation provenance record. There is + // no valid post-activation transition that can add legacy opacity back, so fail closed + // instead of treating it as an ownership exception. + anyhow::ensure!( + self.validators_with_legacy_stake_history.is_empty() + && self.balance_opaque_validators.is_empty(), + "post-activation policy replay retains opaque legacy validator history" + ); + // The activation initializer rejects an empty eligible population, and post-activation + // validator policies are retained even after their live entries have fully drained. A + // replay image with no authenticated policy is therefore impossible to persist; reject + // it before the exact-stake comparison can bless an empty ownerless chain. + anyhow::ensure!( + !self.policies.is_empty(), + "post-activation policy replay contains no authenticated validator policy" + ); + for (validator, authenticated_policy) in &self.policies { + let persisted_policy = expected.validators.get(validator); + anyhow::ensure!( + persisted_policy == Some(authenticated_policy), + "validator {validator} persisted policy {persisted_policy:?} does not equal the owner/commission history {authenticated_policy:?} authenticated from canonical post-Wit/2.1 actions" + ); + } + for validator in expected.validators.keys() { + anyhow::ensure!( + self.policies.contains_key(validator), + "persisted validator {validator} has no canonical Stake history capable of establishing its policy" + ); + } + + Ok(()) + } + + /// Compare every exact balance, independently aged capability epoch, nonce, and outstanding + /// native or cross-fork reservation to the canonical delegated ledger. + fn validate_persisted_exact_stakes( + &self, + expected_stakes: &StakesTracker, + expected_delegation_state: &DelegationState, + ) -> Result<(), anyhow::Error> { + if self.observed_v21_block { + anyhow::ensure!( + expected_delegation_state.exit_intents == self.exit_intents, + "persisted post-Wit/2.1 exit intent is not authenticated by canonical replay" + ); + expected_stakes.validate_validator_index_checked()?; + let persisted = expected_stakes + .query_stakes_checked(QueryStakesKey::All)? + .into_iter() + .map(|entry| { + ( + entry.key, + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + ) + }) + .collect::>(); + anyhow::ensure!( + persisted == self.exact_stakes, + "persisted post-Wit/2.1 stake ledger contains keys or values not authenticated by canonical replay" + ); + anyhow::ensure!( + expected_delegation_state.collateral_reservations + == self.exact_collateral_reservations, + "persisted post-Wit/2.1 collateral reservations contain keys or values not authenticated by canonical replay" + ); + anyhow::ensure!( + expected_delegation_state.legacy_activation_obligations + == self.exact_legacy_activation_obligations, + "persisted post-Wit/2.1 activation-boundary obligations contain keys or values not authenticated by canonical replay" + ); + } + for validator in self + .policies + .keys() + .copied() + .filter(|validator| self.stake_balance_is_exact(*validator)) + { + let expected_reservations = expected_delegation_state + .collateral_reservations + .iter() + .filter(|(key, _reservation)| key.validator == validator) + .map(|(key, reservation)| (*key, reservation.clone())) + .collect::>(); + let authenticated_reservations = self + .exact_collateral_reservations + .iter() + .filter(|(key, _reservation)| key.validator == validator) + .map(|(key, reservation)| (*key, reservation.clone())) + .collect::>(); + anyhow::ensure!( + expected_reservations == authenticated_reservations, + "validator {validator} persisted collateral reservations {expected_reservations:?} do not equal canonical post-Wit/2.1 replay {authenticated_reservations:?}" + ); + let expected_obligations = expected_delegation_state + .legacy_activation_obligations + .iter() + .filter(|(key, _obligation)| key.validator == validator) + .map(|(key, obligation)| (*key, obligation.clone())) + .collect::>(); + let authenticated_obligations = self + .exact_legacy_activation_obligations + .iter() + .filter(|(key, _obligation)| key.validator == validator) + .map(|(key, obligation)| (*key, obligation.clone())) + .collect::>(); + anyhow::ensure!( + expected_obligations == authenticated_obligations, + "validator {validator} persisted activation-boundary obligations {expected_obligations:?} do not equal canonical replay {authenticated_obligations:?}" + ); + let persisted_entries = + match expected_stakes.query_stakes_by_validator_primary_checked(validator) { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(error.into()), + }; + let persisted = persisted_entries + .into_iter() + .map(|entry| ( + entry.key, + SnapshotAuthenticatedStakeEntry::from_stake(&entry.value), + )) + .collect::>(); + let authenticated = self + .exact_stakes_for_validator(validator) + .map(|(key, entry)| (key.clone(), *entry)) + .collect::>(); + anyhow::ensure!( + persisted == authenticated, + "validator {validator} persisted stake values {persisted:?} do not equal canonical post-Wit/2.1 replay {authenticated:?}" + ); + } + Ok(()) + } +} + +/// Private exact legacy ledger projection. It authenticates a pre-Wit/2.1 imported tip directly; +/// at activation it instead becomes the source for the canonical policy/stake/reservation handoff. +#[derive(Default)] +struct SnapshotLegacyStakeReplay { + stakes: StakesTracker, + delegation_state: DelegationState, +} + +impl SnapshotLegacyStakeReplay { + fn try_clone_checked(&self) -> DelegationResult { + Ok(Self { + stakes: self.stakes.try_clone_checked()?, + delegation_state: self.delegation_state.clone(), + }) + } + + /// Credit a validator reward during legacy snapshot replay. It routes the reward through DelegationState when the replayed protocol uses native delegation and preserves legacy behavior otherwise. + fn reward_validator( + &mut self, + validator: PublicKeyHash, + reward: u64, + epoch: Epoch, + ) -> DelegationResult<()> { + if reward > 0 { + self.stakes + .add_reward(validator, Wit::from_nanowits(reward), epoch)?; + } + Ok(()) + } + + /// Reserve Wit/2.0 collateral while replaying a legacy snapshot. It records the canonical reservation identity and updates the projected stake balances before tally settlement. + fn reserve_v20_collateral( + &mut self, + data_request: Hash, + validator: PublicKeyHash, + collateral: u64, + ) -> DelegationResult<()> { + let collateral = Wit::from_nanowits(collateral); + // Stage the advisory sidecar separately from the legacy balance mutation. The historical + // stake wrapper can still return a fallible lookup/lock error, and publishing the sidecar + // first would leave replay state claiming a debit that never reached the live ledger. + // Cloning only the small delegation map preserves the legacy ledger's release arithmetic + // and avoids normalizing its intentionally compatibility-sensitive indexes. + let mut updated_delegation_state = self.delegation_state.clone(); + updated_delegation_state.track_legacy_collateral( + &self.stakes, + data_request, + validator, + collateral, + )?; + self.stakes.reserve_collateral( + validator, + collateral, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + self.delegation_state = updated_delegation_state; + Ok(()) + } + + /// Settle a Wit/2.0 tally during snapshot replay. It applies slash, refund, reward, and legacy activation-obligation outcomes against the projected delegation state. + fn settle_v20_tally( + &mut self, + data_request: Hash, + committers: &HashSet, + collateral: u64, + witness_reward: u64, + out_of_consensus: &[PublicKeyHash], + error_committers: &[PublicKeyHash], + epoch: Epoch, + ) -> DelegationResult<()> { + let mut committers = committers.iter().copied().collect::>(); + committers.sort_unstable(); + for validator in committers { + let reservation_key = CollateralReservationKey { + data_request, + validator, + }; + let is_error = error_committers.contains(&validator); + let is_slashed = out_of_consensus.contains(&validator) && !is_error; + if is_slashed { + if self + .delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + self.delegation_state + .slash_legacy_collateral(reservation_key)?; + } + continue; + } + + let amount = if is_error { + collateral + } else { + collateral.wrapping_add(witness_reward) + }; + // Pure Wit/2.0 tally settlement called the legacy ledger directly even for a zero + // amount, so a missing validator remains an error instead of becoming a no-op. + self.stakes + .add_reward(validator, Wit::from_nanowits(amount), epoch)?; + // The pre-activation reservation is advisory metadata. Pure Wit/2.0 settlement + // consumes it without making legacy validity depend on its cached shape. + self.delegation_state + .collateral_reservations + .remove(&reservation_key); + } + Ok(()) + } + + /// Run the one-time production activation transition against the exact pre-block legacy + /// ledger and the canonical set of still-outstanding Wit/2.0 commitments. + fn activate_v21( + &mut self, + commitments: &BTreeMap, + epoch: Epoch, + ) -> DelegationResult<()> { + if self.delegation_state.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "legacy snapshot replay activation ran more than once", + }); + } + + // The outer block projection has already taken the one checked full-ledger clone. The + // activation transition does not mutate stakes, so stage only the comparatively small + // delegation state here and publish it after every reconstruction check succeeds. + let mut updated = self.delegation_state.clone(); + let minimum_stake = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + updated.initialize_legacy_validators_for_activation(&self.stakes, minimum_stake)?; + updated.activate_pending_commissions(epoch)?; + + for (reservation_key, (collateral, witness_reward)) in commitments { + updated.record_legacy_activation_obligation( + &self.stakes, + *reservation_key, + *collateral, + *witness_reward, + )?; + } + + let orphaned_keys = updated + .collateral_reservations + .keys() + .filter(|reservation_key| !commitments.contains_key(reservation_key)) + .copied() + .collect::>(); + for reservation_key in orphaned_keys { + updated.discard_inert_legacy_sidecar(reservation_key); + } + if updated + .collateral_reservations + .keys() + .any(|reservation_key| !commitments.contains_key(reservation_key)) + { + return Err(StakesError::InvalidDelegationState { + reason: "pre-activation collateral cache contains an orphaned reservation", + }); + } + + updated.validate_stake_and_reservation_accounting(&self.stakes, minimum_stake)?; + self.delegation_state = updated; + Ok(()) + } + + /// Bind a pre-activation imported tip to the exact canonical legacy stake ledger. + /// + /// Policies and reservation sidecars remain intentionally excluded here: before the marker + /// they are inert compatibility caches whose bytes can depend on when a node upgraded. Stake + /// entries themselves are canonical, including their balances, capability ages, and nonces. + fn validate_persisted_stakes( + &self, + expected_stakes: &StakesTracker, + ) -> DelegationResult<()> { + expected_stakes.validate_validator_index_checked()?; + let mut replayed = self.stakes.query_stakes_checked(QueryStakesKey::All)?; + let mut persisted = expected_stakes.query_stakes_checked(QueryStakesKey::All)?; + replayed.sort_by(|left, right| left.key.cmp(&right.key)); + persisted.sort_by(|left, right| left.key.cmp(&right.key)); + if replayed != persisted { + return Err(StakesError::InvalidDelegationState { + reason: "persisted pre-activation stake ledger does not equal canonical block replay", + }); + } + + Ok(()) + } + +} + +pub(super) struct SnapshotTransactionVerifier { + utxos: UnspentOutputsPool, + /// Exact pre-Wit/2 reputation state used to authenticate legacy block and Commit VRF targets. + /// Wit/2 replaces these thresholds with stake rank, so the engine is intentionally frozen once + /// the first Wit/2 block is observed. + reputation_engine: ReputationEngine, + /// Consensus constants are captured with the verifier so historical target and reputation + /// replay never consults a mutable process-global configuration during snapshot import. + consensus_constants: witnet_data_structures::chain::ConsensusConstants, + /// Exact V1.8/V2.0 production ledger used to derive the one-time activation image. It stays + /// outside the ordinary policy clone, is held through the full first-Wit/2.1 block check, and + /// is discarded after its exact coins/nonces/policies/reservations are handed off successfully. + legacy_stake_replay: SnapshotLegacyStakeReplay, + known_requests: HashMap, + canonical_requests: HashMap, + pending_commitments: HashMap<(Hash, PublicKeyHash), Hash>, + request_committers: HashMap>, + pending_tally_obligations: HashMap, + protocol_info: ProtocolInfo, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, + collateral_minimum: u64, + initial_block_reward: u64, + halving_period: u32, + /// WIP activation epochs authenticated by the imported/exported ChainState. Legacy tally + /// remainders depend on these hard-fork gates even though no RAD execution is performed here. + wip_activation: HashMap, + terminal_data_request_round: u16, + unstaking_delay_seconds: u64, + network_magic: u16, + bootstrap_hash: Hash, + genesis_hash: Hash, + highest_vrf_output: CheckpointVRF, + vrf_ctx: VrfCtx, + authenticated_policies: SnapshotAuthenticatedPolicyTracker, + next_position: usize, +} + +impl SnapshotTransactionVerifier { + /// Derive the authoritative activation set from the unresolved request image before the first + /// Wit/2.1 block mutates it. Sidecars and pending-signature caches are deliberately excluded: + /// canonical request payloads establish the raw collateral and the replayed committer set + /// establishes every reservation identity. + fn canonical_outstanding_v20_commitments( + &self, + ) -> Result, anyhow::Error> { + let mut commitments = BTreeMap::new(); + for (data_request, request) in &self.known_requests { + if request.protocol_version != ProtocolVersion::V2_0 { + continue; + } + let canonical = self + .canonical_requests + .get(data_request) + .ok_or_else(|| { + anyhow::anyhow!( + "unresolved Wit/2.0 request {data_request} has no canonical payload" + ) + })?; + anyhow::ensure!( + canonical.data_request.collateral == request.required_collateral, + "unresolved Wit/2.0 request {data_request} collateral {} does not match canonical payload {}", + request.required_collateral, + canonical.data_request.collateral, + ); + let replayed_committers = self + .request_committers + .get(data_request) + .cloned() + .unwrap_or_default(); + let canonical_committers = + canonical.commits.keys().copied().collect::>(); + anyhow::ensure!( + replayed_committers == canonical_committers, + "unresolved Wit/2.0 request {data_request} committer index does not match its canonical Commit payloads" + ); + for validator in replayed_committers { + let key = CollateralReservationKey { + data_request: *data_request, + validator, + }; + anyhow::ensure!( + commitments + .insert( + key, + ( + Wit::from_nanowits(canonical.data_request.collateral), + Wit::from_nanowits(canonical.data_request.witness_reward), + ), + ) + .is_none(), + "duplicate canonical Wit/2.0 commitment {key:?}" + ); + } + } + Ok(commitments) + } + + /// Replay the manual legacy batches in their historical consolidation order. + /// + /// A normal Stake failure retains its successful prefix and does not prevent the Unstake + /// batch from running. The represented release-mode division-by-zero trap is the sole fatal + /// Stake outcome. Every Unstake error likewise retains its successful prefix and is ignored. + /// V1.8 calls this directly on its block projection. V2.0 calls it only after exact collateral, + /// inclusion-reward, tally, fee/subsidy, and age-reset replay for the same block. + fn replay_legacy_manual_stake_batches( + stakes: &mut StakesTracker, + stake_txns: &[StakeTransaction], + unstake_txns: &[UnstakeTransaction], + epoch: Epoch, + protocol_version: ProtocolVersion, + ) -> Result<(), anyhow::Error> { + anyhow::ensure!( + protocol_version >= ProtocolVersion::V1_8 + && protocol_version < ProtocolVersion::V2_1, + "legacy manual replay only accepts V1.8 or V2.0 blocks" + ); + let stake_result = process_stake_transactions_for_protocol( + stakes, + stake_txns.iter(), + epoch, + DELEGATED_STAKING_MINIMUM_NANOWITS, + protocol_version, + ); + if matches!(stake_result, Err(StakesError::ArithmeticOverflow)) { + anyhow::bail!("legacy Stake batch encountered its release arithmetic trap"); + } + + // Historical consolidation ignored every cumulative Unstake failure after retaining any + // successful prefix. In particular, an ordinary Stake failure above still proceeds here. + let _ = process_unstake_transactions_for_protocol( + stakes, + unstake_txns.iter(), + epoch, + DELEGATED_STAKING_MINIMUM_NANOWITS, + protocol_version, + ); + + Ok(()) + } + + /// Reproduce the Wit/2.0 proposer-fee image. Every lookup uses the same block-opening UTXO + /// view, failed fee derivations contribute zero, and the accumulator wraps in release mode. + fn v20_transaction_fees( + block: &Block, + initial_utxo_diff: &UtxoDiff<'_>, + epoch_constants: EpochConstants, + ) -> u64 { + let epoch = block.block_header.beacon.checkpoint; + let mut total = 0u64; + for transaction in &block.txns.value_transfer_txns { + total = total.wrapping_add( + vt_transaction_fee(transaction, initial_utxo_diff, epoch, epoch_constants) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.data_request_txns { + total = total.wrapping_add( + dr_transaction_fee(transaction, initial_utxo_diff, epoch, epoch_constants) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.stake_txns { + total = total.wrapping_add( + st_transaction_fee_for_protocol( + transaction, + initial_utxo_diff, + epoch, + epoch_constants, + ProtocolVersion::V2_0, + ) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.unstake_txns { + total = total.wrapping_add(transaction.body.fee); + } + total + } + + /// Calculate the collateral required by one replayed data request under a pinned protocol. It applies the same protocol-aware minimum and witness economics used by block validation. + fn required_collateral( + protocol_version: ProtocolVersion, + serialized_collateral: u64, + collateral_minimum: u64, + ) -> u64 { + if serialized_collateral == 0 && protocol_version != ProtocolVersion::V2_0 { + collateral_minimum + } else { + serialized_collateral + } + } + + /// Reconstruct the strict Wit/2.1 fee image against a private sequential UTXO projection before + /// stake rewards are applied. This mirrors consolidation ordering and makes the proposer reward + /// available before replaying this block's Stake and Unstake sections. + #[allow(clippy::too_many_arguments)] + fn v21_transaction_fees( + block: &Block, + position: usize, + initial_utxo_diff: &UtxoDiff<'_>, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, + protocol_version: ProtocolVersion, + ) -> Result { + let epoch = block.block_header.beacon.checkpoint; + let context = "Wit/2.1 transaction fee projection"; + let mut utxo_diff = (*initial_utxo_diff).clone(); + let mut total = 0u64; + let mut add_fee = |fee: u64| -> Result<(), ImportError> { + total = total.checked_add(fee).ok_or_else(|| { + Self::error(block, position, context, "transaction fee total overflow") + })?; + Ok(()) + }; + for transaction in &block.txns.value_transfer_txns { + add_fee( + vt_transaction_fee(transaction, &utxo_diff, epoch, epoch_constants) + .map_err(|error| Self::error(block, position, context, error))?, + )?; + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + transaction.body.inputs.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error))?; + } + for transaction in &block.txns.commit_txns { + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + transaction.body.collateral.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error))?; + } + for transaction in &block.txns.data_request_txns { + add_fee( + dr_transaction_fee(transaction, &utxo_diff, epoch, epoch_constants) + .map_err(|error| Self::error(block, position, context, error))?, + )?; + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + transaction.body.inputs.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error))?; + } + for transaction in &block.txns.tally_txns { + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + std::iter::empty(), + transaction.outputs.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error))?; + } + for transaction in &block.txns.stake_txns { + add_fee(Self::validate_v21_stake_action_envelope( + block, + position, + transaction, + &utxo_diff, + epoch_constants, + )?)?; + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + transaction.body.inputs.iter(), + transaction.body.change.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error))?; + } + for transaction in &block.txns.unstake_txns { + add_fee(transaction.body.fee)?; + } + Ok(total) + } + + /// Initialize a snapshot transaction verifier for one chain state and protocol schedule. It captures the replay inputs used by validation and settlement helpers. + pub(super) fn new( + consensus_constants: &witnet_data_structures::chain::ConsensusConstants, + ) -> Result { + Self::new_with_protocol_info(consensus_constants, None) + } + + /// Build a snapshot verifier with an optional schedule carried by the manifest. A supplied + /// schedule is validated and paired with the imported chain's consensus clock directly, + /// allowing portable replay when the importing process has no compatible global schedule. + /// The `None` path intentionally retains the historical process-runtime behavior for legacy + /// and v1/v2 manifests. + pub(super) fn new_with_protocol_info( + consensus_constants: &witnet_data_structures::chain::ConsensusConstants, + supplied_protocol_info: Option, + ) -> Result { + let (protocol_info, epoch_constants) = match supplied_protocol_info { + Some(protocol_info) => { + protocol_info + .validate_timing_topology(consensus_constants) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "snapshot manifest protocol schedule is incompatible with its consensus constants: {error}" + ), + })?; + let activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoints_period_wit2 = activation_epoch + .and_then(|_| { + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + }) + .unwrap_or(u16::MAX); + let epoch_constants = EpochConstants::try_new( + consensus_constants.checkpoint_zero_timestamp, + consensus_constants.checkpoints_period, + activation_epoch, + checkpoints_period_wit2, + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot manifest epoch clock is invalid: {error}"), + })?; + (protocol_info, epoch_constants) + } + None => { + let (protocol_info, published_epoch_constants, _) = get_protocol_runtime(); + let epoch_constants = match published_epoch_constants { + Some(constants) + if constants.checkpoint_zero_timestamp + == consensus_constants.checkpoint_zero_timestamp + && constants.checkpoints_period + == consensus_constants.checkpoints_period => + { + constants + } + Some(_) => { + return Err(ImportError::SnapshotContents { + message: "snapshot consensus constants do not match the atomically published protocol clock" + .to_string(), + }); + } + None if protocol_info.all_versions.is_empty() => EpochConstants::try_new( + consensus_constants.checkpoint_zero_timestamp, + consensus_constants.checkpoints_period, + None, + u16::MAX, + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot legacy epoch clock is invalid: {error}"), + })?, + None => { + return Err(ImportError::SnapshotContents { + message: "snapshot verifier refuses an unpaired protocol schedule" + .to_string(), + }); + } + }; + (protocol_info, epoch_constants) + } + }; + + let terminal_data_request_round = consensus_constants + .extra_rounds + .checked_add(1) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot consensus extra_rounds {} has no representable terminal data-request round", + consensus_constants.extra_rounds, + ), + })?; + + Ok(Self { + utxos: UnspentOutputsPool::default(), + reputation_engine: ReputationEngine::new(consensus_constants.activity_period as usize), + consensus_constants: consensus_constants.clone(), + legacy_stake_replay: SnapshotLegacyStakeReplay::default(), + known_requests: HashMap::new(), + canonical_requests: HashMap::new(), + pending_commitments: HashMap::new(), + request_committers: HashMap::new(), + pending_tally_obligations: HashMap::new(), + protocol_info, + epoch_constants, + checkpoint_zero_timestamp: consensus_constants.checkpoint_zero_timestamp, + collateral_minimum: consensus_constants.collateral_minimum, + initial_block_reward: consensus_constants.initial_block_reward, + halving_period: consensus_constants.halving_period, + // WIP activation for the historical fee formulas is a fixed environment baseline. Do + // not accept the imported ChainState's mutable TAPI map as consensus input: historical + // vote/committee authentication is intentionally outside this bounded replay. + wip_activation: { + let mut tapi = TapiEngine::default(); + tapi.initialize_wip_information(get_environment()); + tapi.wip_activation + }, + terminal_data_request_round, + // Snapshot environment identity is checked before import and is immutable for a + // running node. Keep the fixed consensus delays explicit here so historical replay + // does not consult the independently mutable protocol-version registry. + unstaking_delay_seconds: match get_environment() { + Environment::Development | Environment::Testnet => 3_600, + Environment::Mainnet => 1_209_600, + }, + network_magic: consensus_constants.get_magic(), + bootstrap_hash: consensus_constants.bootstrap_hash, + genesis_hash: consensus_constants.genesis_hash, + highest_vrf_output: CheckpointVRF { + checkpoint: 0, + hash_prev_vrf: consensus_constants.bootstrap_hash, + }, + vrf_ctx: VrfCtx::secp256k1().map_err(|error| { + ImportError::SnapshotContents { + message: format!( + "cannot initialize snapshot cryptographic verifier: {error}" + ), + } + })?, + authenticated_policies: SnapshotAuthenticatedPolicyTracker::default(), + next_position: 0, + }) + } + + /// Construct a contextual snapshot import error. It preserves the transaction position and verifier phase for callers diagnosing replay divergence. + fn error( + block: &Block, + position: usize, + context: &str, + error: impl std::fmt::Display, + ) -> ImportError { + ImportError::SnapshotContents { + message: format!( + "snapshot block #{position} at epoch {} has invalid {context}: {error}", + block.block_header.beacon.checkpoint + ), + } + } + + /// Borrow the immutable schedule pinned for this complete import/export operation. + pub(super) fn protocol_info(&self) -> &ProtocolInfo { + &self.protocol_info + } + + pub(super) fn protocol_version(&self, epoch: Epoch) -> ProtocolVersion { + self.protocol_info.all_versions.version_for_epoch(epoch) + } + + /// Derive the historical block-VRF target from the replayed Active Reputation Set. The + /// production pre-Wit/2 validator uses this target before checking the block signature; using + /// `Hash::max()` here would authenticate the proof bytes while accepting an ineligible miner. + fn legacy_block_vrf_target( + &self, + block_epoch: Epoch, + active_wips: &ActiveWips, + ) -> Result { + let total_identities = u32::try_from( + self.reputation_engine + .ars() + .active_identities_number(), + ) + .map_err(|_| { + ImportError::SnapshotContents { + message: format!( + "snapshot block reputation population does not fit u32 at epoch {block_epoch}" + ), + } + })?; + Ok(calculate_randpoe_threshold( + total_identities, + self.consensus_constants.mining_backup_factor, + block_epoch, + self.consensus_constants.minimum_difficulty, + self.consensus_constants.epochs_with_minimum_difficulty, + active_wips, + ) + .0) + } + + /// Derive the pre-Wit/2 Commit reputation target using the canonical request age and the + /// round-specific backup-witness count. The request context stores its inclusion position, so + /// the persisted legacy Commit round is `block_position - inclusion_position`: the request's + /// inclusion block is round zero before the stage-advance pass, and the first later block is + /// round one. This mirrors the production `DataRequestState::current_commit_round` value + /// consumed by `backup_witnesses()`. + fn legacy_commit_vrf_target( + &self, + participant: PublicKeyHash, + request: SnapshotRequestContext, + block_position: usize, + active_wips: &ActiveWips, + ) -> Result { + let elapsed = block_position + .checked_sub(request.inclusion_position) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot Commit at block {block_position} precedes its data-request inclusion position" + ), + })?; + let commit_round = u16::try_from(elapsed).map_err(|_| { + ImportError::SnapshotContents { + message: format!( + "snapshot Commit reputation round overflows u16 at block {block_position}" + ), + } + })?; + let num_witnesses = request + .witnesses + .wrapping_add(calculate_backup_witnesses(request.witnesses, commit_round)); + Ok(calculate_reppoe_threshold( + &self.reputation_engine, + &participant, + num_witnesses, + self.consensus_constants.minimum_difficulty, + active_wips, + ) + .0) + } + + /// Add one canonical pre-Wit/2 tally's witness outcomes to the block-level reputation update. + /// This mirrors `ReputationInfo::update` without rebuilding a second hot DataRequestPool: the + /// canonical request map already contains the exact Commit/Reveal identities and is removed + /// only after this method has consumed it. + fn note_legacy_tally_reputation( + &self, + reputation_info: &mut super::super::ReputationInfo, + transaction: &TallyTransaction, + block: &Block, + position: usize, + ) -> Result<(), ImportError> { + let canonical = self + .canonical_requests + .get(&transaction.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "pre-Wit/2 reputation replay", + format!( + "data request {} has no canonical payload", + transaction.dr_pointer + ), + ) + })?; + let reveal_count = u32::try_from(canonical.reveals.len()).map_err(|_| { + Self::error( + block, + position, + "pre-Wit/2 reputation replay", + format!( + "data request {} reveal count does not fit u32", + transaction.dr_pointer + ), + ) + })?; + reputation_info.alpha_diff.0 = reputation_info + .alpha_diff + .0 + .checked_add(reveal_count) + .ok_or_else(|| { + Self::error( + block, + position, + "pre-Wit/2 reputation replay", + "witnessing-act counter overflows u32", + ) + })?; + + for participant in canonical.commits.keys() { + let result = reputation_info + .result_count + .entry(*participant) + .or_default(); + if transaction.error_committers.contains(participant) { + result.errors = result.errors.saturating_add(1); + } else if transaction.out_of_consensus.contains(participant) { + result.lies = result.lies.saturating_add(1); + } else { + result.truths = result.truths.saturating_add(1); + } + } + + Ok(()) + } + + /// Apply the exact legacy reputation transition after the block's signatures, UTXO image, and + /// transaction/accounting checks have succeeded. Wit/2 and later deliberately freeze this + /// engine because their proposer and Commit eligibility is stake-ranked. + fn apply_legacy_reputation( + &self, + reputation_engine: &mut ReputationEngine, + reputation_info: super::super::ReputationInfo, + block: &Block, + position: usize, + ) -> Result<(), ImportError> { + if position == 0 + || self.protocol_version(block.block_header.beacon.checkpoint) + >= ProtocolVersion::V2_0 + { + return Ok(()); + } + let mut alt_keys = AltKeys::default(); + // The production helper retains historical release arithmetic and contains unwraps for + // invariants normally established by live validation. Snapshot contents are untrusted, + // and this transition runs on a private clone, so turn any represented invariant panic + // into an import rejection rather than allowing malformed history to take down the node. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::super::update_reputation( + reputation_engine, + &mut alt_keys, + &self.consensus_constants, + block.block_header.proof.proof.pkh(), + reputation_info, + // Snapshot replay can traverse a full history; retain the production transition + // without emitting one multi-line reputation report per historical block. + log::Level::Trace, + block.block_header.beacon.checkpoint, + PublicKeyHash::default(), + ); + })) + .map_err(|_| { + Self::error( + block, + position, + "pre-Wit/2 reputation replay", + "production reputation transition panicked on snapshot history", + ) + })?; + Ok(()) + } + + /// Compare replayed reputation state with the expected chain-state engine. It protects snapshot imports from silently accepting divergent tally reputation mutations. + fn reputation_state_matches(&self, expected: &ReputationEngine) -> bool { + self.reputation_engine.current_alpha() == expected.current_alpha() + && self.reputation_engine.extra_reputation == expected.extra_reputation + && self.reputation_engine.trs() == expected.trs() + && self.reputation_engine.ars() == expected.ars() + } + + /// Identify whether a tally represents the protocol's too-many-witnesses sentinel. It is used to select the correct historical report and reward replay path. + fn is_too_many_witnesses_tally( + block: &Block, + position: usize, + transaction: &TallyTransaction, + ) -> Result { + let error = witnet_data_structures::radon_error::RadonError::try_from( + witnet_rad::error::RadError::TooManyWitnesses, + ) + .map_err(|error| { + Self::error( + block, + position, + "data-request chronology", + format!("cannot construct TooManyWitnesses sentinel: {error}"), + ) + })?; + let encoded = Vec::::try_from(witnet_rad::types::RadonTypes::RadonError(error)) + .map_err(|error| { + Self::error( + block, + position, + "data-request chronology", + format!("cannot encode TooManyWitnesses sentinel: {error}"), + ) + })?; + Ok(transaction.tally.as_slice() == encoded.as_slice()) + } + + /// Re-derive the RAD tally result from the canonical request and reveal image before the + /// request is removed from the replay maps. Header and transaction identity checks prove that + /// the persisted tally bytes belong to a canonical transaction, but they do not by themselves + /// prove that those bytes are the deterministic result of the authenticated reveals. Keep this + /// check on the same containing-block protocol/WIP view used by live tally validation. + fn validate_tally_result( + &self, + block: &Block, + position: usize, + transaction: &TallyTransaction, + active_wips: &ActiveWips, + too_many_witnesses: bool, + ) -> Result<(), ImportError> { + let pointer = transaction.dr_pointer; + let canonical = self.canonical_requests.get(&pointer).ok_or_else(|| { + Self::error( + block, + position, + "tally result", + format!("data request {pointer} has no retained canonical payload"), + ) + })?; + + // DataRequestPool::get_reveals applies this exact ordering before RAD decoding. The + // WIP-0019 order is hash(revealer || data-request pointer); before that WIP, public-key + // hash order is consensus-visible. Reproduce it here rather than relying on HashMap order. + let mut reveals = canonical.reveals.values().collect::>(); + if active_wips.wip0019() { + reveals.sort_unstable_by_key(|reveal| { + let mut bytes = Vec::with_capacity( + reveal.body.pkh.as_ref().len() + pointer.as_ref().len(), + ); + bytes.extend_from_slice(reveal.body.pkh.as_ref()); + bytes.extend_from_slice(pointer.as_ref()); + calculate_sha256(&bytes).0 + }); + } else { + reveals.sort_unstable_by_key(|reveal| reveal.body.pkh); + } + + let report = run_tally_panic_safe( + &reveals, + &canonical.data_request.data_request.tally, + f64::from(canonical.data_request.min_consensus_percentage) / 100.0, + canonical.commits.len(), + active_wips, + too_many_witnesses, + ); + let expected_tally = + Vec::::try_from(&report).unwrap_or_else(|_| tally_bytes_on_encode_error()); + // An empty value is reserved for the fail-closed encoding fallback. No valid Radon tally + // is empty, so reject it before comparing with the imported transaction bytes rather than + // allowing an encoder failure to make two empty values appear canonical. + if expected_tally.is_empty() { + return Err(Self::error( + block, + position, + "tally result", + format!( + "data request {pointer} could not encode its deterministic tally fallback" + ), + )); + } + if expected_tally != transaction.tally { + return Err(Self::error( + block, + position, + "tally result", + format!( + "data request {pointer} has tally bytes that do not match canonical RAD re-derivation" + ), + )); + } + + Ok(()) + } + + /// Validate commit chronology against the replayed request and block position. It ensures snapshot transaction order matches the same temporal constraints as live validation. + fn validate_commit_chronology( + &self, + block: &Block, + position: usize, + pointer: Hash, + request: SnapshotRequestContext, + ) -> Result<(), ImportError> { + let round = position + .checked_sub(request.inclusion_position) + .filter(|round| *round > 0) + .ok_or_else(|| { + Self::error( + block, + position, + "commit chronology", + format!( + "data request {pointer} does not commit strictly after its inclusion block" + ), + ) + })?; + if round > usize::from(self.terminal_data_request_round) { + return Err(Self::error( + block, + position, + "commit chronology", + format!( + "data request {pointer} commits in round {round}, after terminal round {}", + self.terminal_data_request_round, + ), + )); + } + if request + .commit_block_position + .is_some_and(|commit_position| commit_position != position) + { + return Err(Self::error( + block, + position, + "commit chronology", + format!("data request {pointer} has commits in more than one canonical block"), + )); + } + Ok(()) + } + + /// Validate reveal chronology against the replayed request and block position. It checks that reveal timing and request stage agree before snapshot state advances. + fn validate_reveal_chronology( + &self, + block: &Block, + position: usize, + pointer: Hash, + request: SnapshotRequestContext, + ) -> Result<(), ImportError> { + let commit_position = request.commit_block_position.ok_or_else(|| { + Self::error( + block, + position, + "reveal chronology", + format!("data request {pointer} has no preceding canonical commit block"), + ) + })?; + let round = position + .checked_sub(commit_position) + .filter(|round| *round > 0) + .ok_or_else(|| { + Self::error( + block, + position, + "reveal chronology", + format!( + "data request {pointer} does not reveal strictly after its commit block" + ), + ) + })?; + if round > usize::from(self.terminal_data_request_round) { + return Err(Self::error( + block, + position, + "reveal chronology", + format!( + "data request {pointer} reveals in round {round}, after terminal round {}", + self.terminal_data_request_round, + ), + )); + } + if request.reveals >= usize::from(request.witnesses) { + return Err(Self::error( + block, + position, + "reveal chronology", + format!( + "data request {pointer} already reached its witness target {}", + request.witnesses, + ), + )); + } + Ok(()) + } + + /// Validate tally chronology against the replayed request and block position. It checks inclusion timing and request stage before applying rewards, slashes, or reports. + fn validate_tally_chronology( + &self, + block: &Block, + position: usize, + transaction: &TallyTransaction, + request: SnapshotRequestContext, + opening_validator_counts: SnapshotTipValidatorCounts, + ) -> Result<(), ImportError> { + let pointer = transaction.dr_pointer; + let is_too_many_witnesses = + Self::is_too_many_witnesses_tally(block, position, transaction)?; + let terminal_round = usize::from(self.terminal_data_request_round); + let block_protocol = self.protocol_version(block.block_header.beacon.checkpoint); + let wit2_activation_epoch = self + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); + if request.commit_block_position.is_some() + && self.pending_tally_obligations.contains_key(&pointer) + { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} committed after a no-Commit transition made its next-block tally mandatory" + ), + )); + } + let expected_position = if let Some(commit_position) = request.commit_block_position { + if is_too_many_witnesses { + if request.inclusion_epoch != wit2_activation_epoch { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} combines canonical commits with TooManyWitnesses outside the Wit/2 activation compatibility epoch" + ), + )); + } + // The activation-epoch compatibility exception still uses the containing block's + // exact opening population. Its historical timing exception must not turn a forged + // validator count into an accepted sentinel. + let canonical = self.canonical_requests.get(&pointer).ok_or_else(|| { + Self::error( + block, + position, + "activation TooManyWitnesses population", + format!("data request {pointer} has no retained canonical payload"), + ) + })?; + let validator_count = + opening_validator_counts.for_request(request.protocol_version); + if !data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + request.protocol_version, + ) { + return Err(Self::error( + block, + position, + "activation TooManyWitnesses population", + format!( + "data request {pointer} carries TooManyWitnesses although its block-opening validator population {validator_count} satisfies the request threshold" + ), + )); + } + } + if request.reveals > usize::from(request.witnesses) { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} has {} reveals for witness target {}", + request.reveals, request.witnesses, + ), + )); + } + if request.reveals == usize::from(request.witnesses) { + request + .last_reveal_block_position + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} reached its witness target without a canonical reveal position" + ), + ) + })? + .checked_add(1) + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} final reveal position overflows"), + ) + })? + } else { + commit_position + .checked_add(terminal_round) + .and_then(|position| position.checked_add(1)) + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} terminal reveal position overflows"), + ) + })? + } + } else { + let distance = position + .checked_sub(request.inclusion_position) + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} tallies before its inclusion block"), + ) + })?; + if is_too_many_witnesses + && block.block_header.beacon.checkpoint < wit2_activation_epoch + { + return Err(Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} contains TooManyWitnesses before Wit/2"), + )); + } + if let Some(due) = self.pending_tally_obligations.get(&pointer) { + let expected_round = distance + .checked_sub(1) + .and_then(|round| u16::try_from(round).ok()); + let common_mismatch = due.tally_position != position + || due.transition_position.checked_add(1) != Some(position) + || expected_round != Some(due.current_commit_round); + match due.kind { + SnapshotPendingTallyKind::LegacyCrossingOrdinary => { + if common_mismatch + || request.protocol_version >= ProtocolVersion::V2_0 + || is_too_many_witnesses + { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} must carry its ordinary legacy-crossing tally at canonical block {} with Commit round {}, not block {position}", + due.tally_position, due.current_commit_round, + ), + )); + } + } + SnapshotPendingTallyKind::V21CausalTooManyWitnesses => { + let canonical = self.canonical_requests.get(&pointer).ok_or_else(|| { + Self::error( + block, + position, + "causal TooManyWitnesses obligation", + format!( + "data request {pointer} has no retained canonical payload" + ), + ) + })?; + let validator_count = + opening_validator_counts.for_request(request.protocol_version); + let still_too_many = data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + request.protocol_version, + ); + if common_mismatch + || request.protocol_version < ProtocolVersion::V2_0 + || request.inclusion_epoch <= wit2_activation_epoch + || block_protocol < ProtocolVersion::V2_1 + || !is_too_many_witnesses + || usize::from(due.current_commit_round) >= terminal_round + || !canonical.commits.is_empty() + || !canonical.reveals.is_empty() + || request.reveals != 0 + || request.last_reveal_block_position.is_some() + || !still_too_many + { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} must carry its causal Wit/2.1 TooManyWitnesses tally at canonical block {} with Commit round {} while its opening validator population remains below the request threshold", + due.tally_position, due.current_commit_round, + ), + )); + } + } + } + position + } else if is_too_many_witnesses + && permits_round_zero_too_many_witnesses( + distance, + request.inclusion_epoch, + wit2_activation_epoch, + ) + { + let canonical = self.canonical_requests.get(&pointer).ok_or_else(|| { + Self::error( + block, + position, + "same-block TooManyWitnesses tally", + format!("data request {pointer} has no retained canonical payload"), + ) + })?; + let validator_count = + opening_validator_counts.for_request(request.protocol_version); + if !data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + request.protocol_version, + ) { + return Err(Self::error( + block, + position, + "same-block TooManyWitnesses tally", + format!( + "data request {pointer} carries TooManyWitnesses although its block-opening validator population {validator_count} satisfies the request threshold" + ), + )); + } + position + } else if is_too_many_witnesses + && distance >= 2 + && distance <= terminal_round.saturating_add(1) + && request.inclusion_epoch == wit2_activation_epoch + { + // This historical non-same-block compatibility branch accepts the sentinel only + // for requests included at the exact Wit/2 activation epoch. Keep its + // authenticated chronology bounded independently of Wit/2.1 causal obligations, + // but still require the exact opening population that made the sentinel valid. + let canonical = self.canonical_requests.get(&pointer).ok_or_else(|| { + Self::error( + block, + position, + "activation TooManyWitnesses population", + format!("data request {pointer} has no retained canonical payload"), + ) + })?; + let validator_count = + opening_validator_counts.for_request(request.protocol_version); + if !data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + request.protocol_version, + ) { + return Err(Self::error( + block, + position, + "activation TooManyWitnesses population", + format!( + "data request {pointer} carries TooManyWitnesses although its block-opening validator population {validator_count} satisfies the request threshold" + ), + )); + } + position + } else { + if is_too_many_witnesses { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} places TooManyWitnesses {distance} canonical blocks after inclusion" + ), + )); + } + request + .inclusion_position + .checked_add(terminal_round) + .and_then(|position| position.checked_add(1)) + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} terminal commit position overflows"), + ) + })? + } + }; + if position != expected_position { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} tallies at canonical block {position}, expected block {expected_position}" + ), + )); + } + Ok(()) + } + + /// Reject requests whose structurally due tally is absent from the replayed block. It prevents snapshot imports from skipping a required settlement transition. + fn reject_missing_structurally_due_tallies( + &self, + block: &Block, + position: usize, + ) -> Result<(), ImportError> { + let terminal_round = usize::from(self.terminal_data_request_round); + for (pointer, request) in &self.known_requests { + let due_position = if let Some(transition) = + self.pending_tally_obligations.get(pointer) + { + Some(transition.tally_position) + } else if let Some(commit_position) = request.commit_block_position { + if request.reveals == usize::from(request.witnesses) { + request.last_reveal_block_position.and_then(|position| position.checked_add(1)) + } else { + commit_position + .checked_add(terminal_round) + .and_then(|position| position.checked_add(1)) + } + } else { + request + .inclusion_position + .checked_add(terminal_round) + .and_then(|position| position.checked_add(1)) + } + .ok_or_else(|| { + Self::error( + block, + position, + "tally chronology", + format!("data request {pointer} due position overflows"), + ) + })?; + if position >= due_position { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {pointer} remains unresolved at canonical block {position}, but its structurally mandatory tally was due at block {due_position}" + ), + )); + } + } + Ok(()) + } + + /// Derive every exact accepted-block threshold transition that makes a no-Commit request enter + /// TALLY before its terminal round. + /// + /// Production classifies a newly included request against the immutable block-opening + /// population, then advances unresolved request stages from the complete post-block population. + /// Keeping both views distinguishes a legitimate round-zero post-block crossing from a missing + /// same-block sentinel. Every recorded transition must be consumed in the next canonical block. + fn derive_pending_tally_obligations( + block: &Block, + position: usize, + block_protocol: ProtocolVersion, + opening_validator_counts: SnapshotTipValidatorCounts, + post_block_validator_counts: SnapshotTipValidatorCounts, + terminal_data_request_round: u16, + protocol_info: &ProtocolInfo, + known_requests: &HashMap, + canonical_requests: &HashMap, + pending_tally_obligations: &HashMap, + ) -> Result, ImportError> { + if block_protocol < ProtocolVersion::V2_0 { + return Ok(Vec::new()); + } + + let terminal_round = usize::from(terminal_data_request_round); + let wit2_activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); + let mut transitions = Vec::new(); + for (pointer, request) in known_requests { + if request.commit_block_position.is_some() + || pending_tally_obligations.contains_key(pointer) + { + continue; + } + let Some(elapsed) = position.checked_sub(request.inclusion_position) else { + return Err(Self::error( + block, + position, + "pending tally transition", + format!( + "data request {pointer} inclusion position {} is after the current block", + request.inclusion_position, + ), + )); + }; + if elapsed >= terminal_round { + continue; + } + let canonical = canonical_requests.get(pointer).ok_or_else(|| { + Self::error( + block, + position, + "pending tally transition", + format!("data request {pointer} has no retained canonical payload"), + ) + })?; + if !canonical.commits.is_empty() + || !canonical.reveals.is_empty() + || request.reveals != 0 + || request.last_reveal_block_position.is_some() + { + return Err(Self::error( + block, + position, + "pending tally transition", + format!( + "data request {pointer} has Commit/Reveal state without a canonical Commit position" + ), + )); + } + + let kind = if request.protocol_version < ProtocolVersion::V2_0 { + if elapsed == 0 + || !data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + post_block_validator_counts.legacy, + block_protocol, + ) + { + continue; + } + SnapshotPendingTallyKind::LegacyCrossingOrdinary + } else { + let opening_validator_count = + opening_validator_counts.for_request(request.protocol_version); + let opening_too_many = data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + opening_validator_count, + request.protocol_version, + ); + if elapsed == 0 && opening_too_many { + return Err(Self::error( + block, + position, + "same-block TooManyWitnesses transition", + format!( + "new data request {pointer} was already above its witness threshold in the block-opening population but omitted its mandatory same-block sentinel" + ), + )); + } + // The activation-epoch exception remains its historical independent compatibility + // rule. Exact causal obligations are needed only for later-born requests whose + // non-same-block sentinel becomes valid starting with a Wit/2.1 tally block. + if request.inclusion_epoch <= wit2_activation_epoch { + continue; + } + let post_block_validator_count = + post_block_validator_counts.for_request(request.protocol_version); + let post_block_too_many = data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + post_block_validator_count, + request.protocol_version, + ); + if opening_too_many { + return Err(Self::error( + block, + position, + "causal TooManyWitnesses transition", + format!( + "data request {pointer} was already above its witness threshold at block opening without a preceding next-block tally obligation" + ), + )); + } + if !post_block_too_many { + continue; + } + SnapshotPendingTallyKind::V21CausalTooManyWitnesses + }; + + transitions.push(( + *pointer, + SnapshotPendingTallyObligation { + kind, + transition_position: position, + tally_position: position.checked_add(1).ok_or_else(|| { + Self::error( + block, + position, + "pending tally transition", + format!("data request {pointer} tally position overflows"), + ) + })?, + current_commit_round: u16::try_from(elapsed).map_err(|_| { + Self::error( + block, + position, + "pending tally transition", + format!("data request {pointer} Commit round {elapsed} does not fit u16"), + ) + })?, + }, + )); + } + + Ok(transitions) + } + + #[cfg(test)] + /// Record unresolved tally obligations discovered during snapshot replay. It carries request terms and committer identities into the next canonical settlement step. + fn record_pending_tally_obligations( + &mut self, + block: &Block, + position: usize, + block_protocol: ProtocolVersion, + opening_validator_counts: SnapshotTipValidatorCounts, + post_block_validator_counts: SnapshotTipValidatorCounts, + ) -> Result<(), ImportError> { + let transitions = Self::derive_pending_tally_obligations( + block, + position, + block_protocol, + opening_validator_counts, + post_block_validator_counts, + self.terminal_data_request_round, + &self.protocol_info, + &self.known_requests, + &self.canonical_requests, + &self.pending_tally_obligations, + )?; + self.pending_tally_obligations.extend(transitions); + Ok(()) + } + + /// Resolve the inputs required by a replayed transaction from the snapshot UTXO view. It validates pointers and value ownership before transaction-specific accounting proceeds. + fn require_inputs( + block: &Block, + position: usize, + context: &str, + inputs: &[Input], + expected_owner: Option, + utxo_diff: &UtxoDiff<'_>, + ) -> Result<(), ImportError> { + let mut seen = HashSet::with_capacity(inputs.len()); + for input in inputs { + let pointer = *input.output_pointer(); + if !seen.insert(pointer) { + return Err(Self::error( + block, + position, + context, + format!("input {pointer} occurs more than once"), + )); + } + let output = utxo_diff + .try_get(&pointer) + .map_err(|error| Self::error(block, position, context, error))? + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!("input {pointer} is absent or already spent"), + ) + })?; + if let Some(expected_owner) = expected_owner + && output.pkh != expected_owner + { + return Err(Self::error( + block, + position, + context, + format!( + "input {pointer} belongs to {}, expected {expected_owner}", + output.pkh + ), + )); + } + } + Ok(()) + } + + /// Record transaction outputs into the replayed UTXO view. It checks output pointers and preserves the state consumed by later snapshot transactions. + fn record_outputs( + block: &Block, + position: usize, + context: &str, + tx_hash: Hash, + outputs: &[ValueTransferOutput], + seen_outputs: &mut HashSet, + ) -> Result<(), ImportError> { + for (index, _output) in outputs.iter().enumerate() { + let output_index = u32::try_from(index).map_err(|_| { + Self::error( + block, + position, + context, + format!("output index {index} exceeds u32::MAX"), + ) + })?; + let pointer = OutputPointer { + transaction_id: tx_hash, + output_index, + }; + if !seen_outputs.insert(pointer) { + return Err(Self::error( + block, + position, + context, + format!("output pointer {pointer} was already created"), + )); + } + } + Ok(()) + } + + /// Validate signatures and ownership for replayed transaction inputs. It connects snapshot UTXO evidence with the transaction hash and declared signer identities. + fn validate_signed_inputs( + block: &Block, + position: usize, + context: &str, + inputs: &[Input], + signatures: &[KeyedSignature], + tx_hash: Hash, + utxo_diff: &UtxoDiff<'_>, + signatures_to_verify: &mut Vec, + ) -> Result<(), ImportError> { + if inputs.is_empty() { + return Err(Self::error( + block, + position, + context, + "transaction has no spend inputs", + )); + } + Self::require_inputs(block, position, context, inputs, None, utxo_diff)?; + validate_transaction_signature( + signatures, + inputs, + tx_hash, + utxo_diff, + signatures_to_verify, + ) + .map_err(|error| Self::error(block, position, context, error)) + } + + /// Validate the Wit/2.1 Stake constraints that depend only on its signed body and reconstructed + /// UTXOs. Aggregate validator capacity remains outside this helper because rewards, slashes, + /// unstakes, and outstanding reservations all affect it. + fn validate_v21_stake_action_envelope( + block: &Block, + position: usize, + transaction: &StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch_constants: EpochConstants, + ) -> Result { + let context = "Wit/2.1 stake action envelope"; + let terms = transaction + .body + .output + .authorization_terms + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + context, + "versioned authorization terms are missing", + ) + })?; + + // The authorization covers the validator/withdrawer pair, while the stake output carries + // the ledger key that the block transition will mutate. Bind both representations before + // any action-specific replay. This is especially important for action 3, whose zero-value + // output is otherwise not passed through the delegated-deposit path that performs the same + // check in live consolidation. + if terms.validator != transaction.body.output.key.validator { + return Err(Self::error( + block, + position, + context, + format!( + "stake authorization validator {} does not match stake output {}", + terms.validator, transaction.body.output.key.validator + ), + )); + } + if terms.withdrawer != transaction.body.output.key.withdrawer { + return Err(Self::error( + block, + position, + context, + format!( + "stake authorization withdrawer {} does not match stake output {}", + terms.withdrawer, transaction.body.output.key.withdrawer + ), + )); + } + + match terms.action { + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + let value = transaction.body.output.value; + if value < DELEGATED_STAKING_MINIMUM_NANOWITS { + return Err(Self::error( + block, + position, + context, + format!( + "delegation value {value} is below the fixed minimum {}", + DELEGATED_STAKING_MINIMUM_NANOWITS + ), + )); + } + // This does not prove aggregate capacity, but a single deposit above the aggregate + // hard cap can never be valid for any prior stake/reservation state. + if value > DELEGATED_STAKING_MAXIMUM_NANOWITS { + return Err(Self::error( + block, + position, + context, + format!( + "delegation value {value} exceeds the fixed validator cap {}", + DELEGATED_STAKING_MAXIMUM_NANOWITS + ), + )); + } + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + if transaction.body.output.value != 0 { + return Err(Self::error( + block, + position, + context, + format!( + "policy or exit action carries nonzero stake value {}", + transaction.body.output.value + ), + )); + } + } + action => { + return Err(Self::error( + block, + position, + context, + format!("unsupported stake authorization action: {action}"), + )); + } + } + + if transaction.body.inputs.is_empty() { + return Err(Self::error( + block, + position, + context, + "transaction has no spend inputs", + )); + } + let epoch = block.block_header.beacon.checkpoint; + let (epoch_timestamp, _) = epoch_constants + .epoch_timestamp(epoch) + .map_err(|error| Self::error(block, position, context, error))?; + let mut seen = HashSet::with_capacity(transaction.body.inputs.len()); + let mut input_value = 0u64; + for input in &transaction.body.inputs { + let pointer = *input.output_pointer(); + if !seen.insert(pointer) { + return Err(Self::error( + block, + position, + context, + format!("input {pointer} occurs more than once"), + )); + } + let output = utxo_diff + .try_get(&pointer) + .map_err(|error| Self::error(block, position, context, error))? + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!("input {pointer} is absent or already spent"), + ) + })?; + let time_lock = i64::try_from(output.time_lock).map_err(|error| { + Self::error( + block, + position, + context, + format!("input {pointer} time lock cannot be represented as i64: {error}"), + ) + })?; + if time_lock > epoch_timestamp { + return Err(Self::error( + block, + position, + context, + format!( + "input {pointer} is locked until {time_lock}, after block timestamp {epoch_timestamp}" + ), + )); + } + input_value = input_value.checked_add(output.value).ok_or_else(|| { + Self::error(block, position, context, "stake input value overflow") + })?; + } + let output_value = transaction + .body + .checked_value() + .map_err(|error| Self::error(block, position, context, error))?; + let fee = input_value.checked_sub(output_value).ok_or_else(|| { + Self::error( + block, + position, + context, + format!( + "stake and change value {output_value} exceeds input value {input_value}" + ), + ) + })?; + if matches!(terms.action, STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT) && fee == 0 { + return Err(Self::error( + block, + position, + context, + "policy or exit action pays no transaction fee", + )); + } + + Ok(fee) + } + + /// Validate the absolute Wit/2.1 withdrawal lock against the verifier's pinned epoch clock. + /// The compatibility encoding below checkpoint zero is preserved exactly, without calling the + /// global protocol registry during a multi-block replay. + fn validate_v21_unstake_envelope( + block: &Block, + position: usize, + transaction: &witnet_data_structures::transaction::UnstakeTransaction, + epoch_constants: EpochConstants, + unstaking_delay_seconds: u64, + ) -> Result<(), ImportError> { + let context = "Wit/2.1 unstake envelope"; + if transaction.body.withdrawal.value == 0 { + return Err(Self::error( + block, + position, + context, + "withdrawal value is zero", + )); + } + transaction + .body + .withdrawal + .value + .checked_add(transaction.body.fee) + .ok_or_else(|| { + Self::error( + block, + position, + context, + "withdrawal value and fee overflow", + ) + })?; + + let time_lock = transaction.body.withdrawal.time_lock; + let checkpoint_zero_timestamp = + u64::try_from(epoch_constants.checkpoint_zero_timestamp).map_err(|error| { + Self::error( + block, + position, + context, + format!("checkpoint-zero timestamp cannot be represented as u64: {error}"), + ) + })?; + if time_lock < checkpoint_zero_timestamp { + if time_lock < unstaking_delay_seconds { + return Err(Self::error( + block, + position, + context, + format!( + "compatibility time lock {time_lock} is below delay {unstaking_delay_seconds}" + ), + )); + } + return Ok(()); + } + + let epoch = block.block_header.beacon.checkpoint; + let (epoch_timestamp, _) = epoch_constants + .epoch_timestamp(epoch) + .map_err(|error| Self::error(block, position, context, error))?; + let epoch_timestamp = u64::try_from(epoch_timestamp).map_err(|error| { + Self::error( + block, + position, + context, + format!("epoch timestamp cannot be represented as u64: {error}"), + ) + })?; + let minimum_time_lock = epoch_timestamp + .checked_add(unstaking_delay_seconds) + .ok_or_else(|| Self::error(block, position, context, "minimum time lock overflow"))?; + if time_lock < minimum_time_lock { + return Err(Self::error( + block, + position, + context, + format!("time lock {time_lock} is below minimum {minimum_time_lock}"), + )); + } + + Ok(()) + } + + /// Queue one Commit VRF proof against an already resolved eligibility target and exact message + /// salt. Wit/2 callers obtain both values from the shared production stake-ranking helper; + /// pre-Wit/2 callers supply the replayed reputation target. + fn queue_commit_vrf_with_target( + transaction: &CommitTransaction, + request: SnapshotRequestContext, + vrf_input: CheckpointVRF, + target_hash: Hash, + authenticated_withdrawer: Option, + signatures_to_verify: &mut Vec, + ) -> bool { + let withdrawer = if request.protocol_version >= ProtocolVersion::V2_0 { + let Some(withdrawer) = authenticated_withdrawer else { + return false; + }; + Some(withdrawer) + } else { + None + }; + + add_dr_vrf_signature_to_verify_for_protocol( + signatures_to_verify, + &transaction.body.proof, + vrf_input, + transaction.body.dr_pointer, + target_hash, + withdrawer, + request.protocol_version, + ); + true + } + + /// Queue the exact Commit VRF message when its complete salt is reconstructible, without + /// claiming an eligibility threshold. + /// + /// Pre-Wit/2 requests use the unsalted V1 message, so the canonical request inclusion epoch, + /// request pointer, and containing block's reconstructed VRF beacon are sufficient. The + /// target-aware production caller resolves every Wit/2 owner and threshold from the exact + /// canonical stake view. This compatibility wrapper remains for pre-Wit/2 replay and focused + /// cryptographic fixtures only. + fn queue_reconstructible_commit_vrf( + transaction: &CommitTransaction, + request: SnapshotRequestContext, + vrf_input: CheckpointVRF, + authenticated_withdrawer: Option, + signatures_to_verify: &mut Vec, + ) -> bool { + // `Hash::max()` is retained only for focused cryptographic fixtures. Production snapshot + // replay resolves both legacy reputation and Wit/2 stake targets before queueing a proof. + Self::queue_commit_vrf_with_target( + transaction, + request, + vrf_input, + Hash::max(), + authenticated_withdrawer, + signatures_to_verify, + ) + } + + /// Validate Commit constraints whose complete inputs are in the portable block/UTXO history. + /// + /// This helper stops before collateral age, legacy qualification, or stake-ledger capacity; + /// the first two are applied by the verifier's separate historical-qualification helper and + /// the latter is reconstructed from the delegated ledger. It still proves the canonical + /// change shape, signer ownership, unlocked inputs, checked value accounting, the legacy + /// collateral difference, and Wit/2's protocol-specific UTXO collateral envelope. + fn validate_commit_utxo_semantics( + block: &Block, + position: usize, + transaction: &CommitTransaction, + participant: PublicKeyHash, + request: SnapshotRequestContext, + utxo_diff: &UtxoDiff<'_>, + epoch_constants: EpochConstants, + ) -> Result<(), ImportError> { + // Keep the focused helper's historical signature for source fixtures and callers that + // validate a request in the same protocol that created it. Production replay uses the + // explicit containing-block variant below because a Wit/2.0 request can cross into a + // Wit/2.1 block and must then reject its legacy UTXO collateral envelope. + Self::validate_commit_utxo_semantics_for_block_protocol( + block, + position, + transaction, + participant, + request, + request.protocol_version, + utxo_diff, + epoch_constants, + ) + } + + /// Validate Commit collateral shape against both the request and containing-block + /// protocols. Once a Wit/2.1 block is active, every Wit/2.0-or-later request uses the + /// stake-backed owner/delegated reservation path; only pre-Wit/2.0 requests retain UTXO + /// collateral inputs. + #[allow(clippy::too_many_arguments)] + fn validate_commit_utxo_semantics_for_block_protocol( + block: &Block, + position: usize, + transaction: &CommitTransaction, + participant: PublicKeyHash, + request: SnapshotRequestContext, + block_protocol_version: ProtocolVersion, + utxo_diff: &UtxoDiff<'_>, + epoch_constants: EpochConstants, + ) -> Result<(), ImportError> { + let context = "commit collateral semantics"; + if transaction.body.outputs.len() > 1 { + return Err(Self::error( + block, + position, + context, + "transaction has more than one change output", + )); + } + if let Some(output) = transaction.body.outputs.first() { + if output.value == 0 { + return Err(Self::error( + block, + position, + context, + "change output has zero value", + )); + } + if output.pkh != participant { + return Err(Self::error( + block, + position, + context, + format!( + "change output belongs to {}, expected Commit participant {participant}", + output.pkh + ), + )); + } + } + + if block_protocol_version >= ProtocolVersion::V2_1 + && request.protocol_version >= ProtocolVersion::V2_0 + && !transaction.body.collateral.is_empty() + { + return Err(Self::error( + block, + position, + context, + "post-activation Wit/2-or-later Commit carries forbidden UTXO collateral", + )); + } + + Self::require_inputs( + block, + position, + context, + &transaction.body.collateral, + Some(participant), + utxo_diff, + )?; + let epoch = block.block_header.beacon.checkpoint; + let (epoch_timestamp, _) = epoch_constants + .epoch_timestamp(epoch) + .map_err(|error| Self::error(block, position, context, error))?; + let mut input_value = 0u64; + for input in &transaction.body.collateral { + let pointer = input.output_pointer(); + let output = utxo_diff + .try_get(pointer) + .map_err(|error| Self::error(block, position, context, error))? + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!("collateral input {pointer} is absent or already spent"), + ) + })?; + let time_lock = i64::try_from(output.time_lock).map_err(|error| { + Self::error( + block, + position, + context, + format!("input {pointer} time lock cannot be represented as i64: {error}"), + ) + })?; + if time_lock > epoch_timestamp { + return Err(Self::error( + block, + position, + context, + format!( + "input {pointer} is locked until {time_lock}, after block timestamp {epoch_timestamp}" + ), + )); + } + input_value = input_value.checked_add(output.value).ok_or_else(|| { + Self::error(block, position, context, "collateral input value overflow") + })?; + } + let output_value = transaction.body.outputs.iter().try_fold( + 0u64, + |total, output| { + total.checked_add(output.value).ok_or_else(|| { + Self::error(block, position, context, "commit output value overflow") + }) + }, + )?; + let found_collateral = input_value.checked_sub(output_value).ok_or_else(|| { + Self::error( + block, + position, + context, + format!( + "change value {output_value} exceeds collateral input value {input_value}" + ), + ) + })?; + + let collateral_is_valid = if block_protocol_version >= ProtocolVersion::V2_1 + && request.protocol_version >= ProtocolVersion::V2_0 + { + // Post-activation delegated collateral is reserved from the stake ledger, never from + // UTXOs. This includes a Wit/2.0 request crossing the activation boundary; its + // owner-only semantics are represented by the delegated reservation sidecar. + found_collateral == 0 + } else if request.protocol_version >= ProtocolVersion::V2_0 { + // Wit/2.0 accepts its stake-backed zero difference as well as the legacy exact UTXO + // difference retained by the transition code. + found_collateral == 0 || found_collateral == request.required_collateral + } else { + found_collateral == request.required_collateral + }; + if !collateral_is_valid { + return Err(Self::error( + block, + position, + context, + format!( + "found UTXO collateral {found_collateral}, incompatible with request protocol {:?} and effective required collateral {}", + request.protocol_version, request.required_collateral + ), + )); + } + + Ok(()) + } + + /// Reconstruct the historical pre-Wit/2 collateral admission rules from the portable UTXO + /// image. Collateral inputs must satisfy the configured (WIP-dependent) age, and after the + /// transition threshold every collateral owner must have at least 100 WIT in outputs older + /// than two superblock periods. The production validator visits the durable UTXO image (not + /// same-block pending additions), so snapshot replay uses the same view and strict age + /// comparisons. Its qualification balance uses release-compatible wrapping arithmetic; retain + /// that consensus behavior while keeping backend failures explicit and fail-closed. + fn validate_legacy_committer_qualification( + &self, + block: &Block, + position: usize, + transaction: &CommitTransaction, + utxo_diff: &UtxoDiff<'_>, + block_number: u32, + ) -> Result<(), ImportError> { + if transaction.body.collateral.is_empty() { + return Ok(()); + } + + let committer = transaction.body.proof.proof.pkh(); + let superblock_period = u32::from(self.consensus_constants.superblock_period); + let collateral_age = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: self.consensus_constants.checkpoint_zero_timestamp, + checkpoints_period: self.consensus_constants.checkpoints_period, + } + .get_collateral_age(&ActiveWips { + active_wips: self.wip_activation.clone(), + block_epoch: block.block_header.beacon.checkpoint, + }); + let collateral_boundary = block_number.saturating_sub(collateral_age); + for input in &transaction.body.collateral { + let included_at = utxo_diff + .try_included_in_block_number(input.output_pointer()) + .map_err(|error| { + Self::error(block, position, "legacy collateral age", error) + })? + .ok_or_else(|| { + Self::error( + block, + position, + "legacy collateral age", + format!( + "collateral input {} has no canonical inclusion height", + input.output_pointer() + ), + ) + })?; + if included_at > collateral_boundary { + return Err(Self::error( + block, + position, + "legacy collateral age", + format!( + "collateral input {} is included at block {included_at}, but must be at or before {collateral_boundary} (age {collateral_age})", + input.output_pointer(), + ), + )); + } + } + + const QUALIFICATION_EPOCH: Epoch = 2_245_000; + if block.block_header.beacon.checkpoint <= QUALIFICATION_EPOCH { + return Ok(()); + } + + let maturity_boundary = block_number.saturating_sub(superblock_period.saturating_mul(2)); + let qualification_requirement = 100u64.saturating_mul(NANOWITS_PER_WIT); + let mut balance = 0u64; + let mut maturity_error = None; + + utxo_diff + .get_utxo_set() + .try_visit_with_pkh( + committer, + |_| (), + |(output_pointer, (output, _included_at))| { + if maturity_error.is_some() { + return; + } + match utxo_diff.try_included_in_block_number(output_pointer) { + Ok(Some(included_at)) if included_at < maturity_boundary => { + balance = balance.wrapping_add(output.value); + } + Ok(Some(_)) | Ok(None) => {} + Err(error) => maturity_error = Some(error), + } + }, + ) + .map_err(|error| { + Self::error( + block, + position, + "legacy committer qualification", + error, + ) + })?; + + if let Some(error) = maturity_error { + return Err(Self::error( + block, + position, + "legacy committer qualification", + error, + )); + } + if balance < qualification_requirement { + return Err(Self::error( + block, + position, + "legacy committer qualification", + format!( + "committer {committer} has mature balance {balance}, below required {qualification_requirement}" + ), + )); + } + + Ok(()) + } + + fn update_utxos<'a, I, O>( + block: &Block, + position: usize, + context: &str, + utxo_diff: &mut UtxoDiff<'_>, + inputs: I, + outputs: O, + tx_hash: Hash, + protocol_version: ProtocolVersion, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, + ) -> Result<(), ImportError> + where + I: IntoIterator, + O: IntoIterator, + { + try_update_utxo_diff_for_protocol( + utxo_diff, + inputs, + outputs, + tx_hash, + block.block_header.beacon.checkpoint, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| Self::error(block, position, context, error)) + } + + /// Validate one imported block against the snapshot replay state. It invokes chronology, UTXO, staking, and report checks before the block can mutate the projection. + pub(super) fn validate_block(&mut self, block: &Block, position: usize) -> Result<(), ImportError> { + if position != self.next_position { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot transaction replay received block #{position}, expected #{}", + self.next_position + ), + }); + } + + let epoch = block.block_header.beacon.checkpoint; + let protocol_version = self.protocol_version(epoch); + let block_number = u32::try_from(position).map_err(|_| { + Self::error( + block, + position, + "transaction replay height", + "block position exceeds u32::MAX", + ) + })?; + let epoch_constants = self.epoch_constants; + let checkpoint_zero_timestamp = self.checkpoint_zero_timestamp; + let network_magic = self.network_magic; + let mut utxo_diff = UtxoDiff::new(&self.utxos, block_number); + // Keep collision detection bounded to this block. Cross-block collisions against a live + // output are rejected atomically by the compacting diff application below; retaining every + // historical pointer would otherwise make verification memory grow with chain history. + let mut block_outputs = HashSet::new(); + let mut block_commitments = HashSet::new(); + let mut block_reveals = HashSet::new(); + let mut block_commit_counts: HashMap = HashMap::new(); + let mut block_native_commit_inclusion_reward = 0u64; + let mut block_legacy_commit_inclusion_reward = 0u64; + let mut block_native_reveal_inclusion_reward = 0u64; + let mut block_legacy_reveal_inclusion_reward = 0u64; + let mut legacy_reputation_info = super::super::ReputationInfo::new(); + let mut projected_reputation_engine = self.reputation_engine.clone(); + // Pre-Wit/2 Mint values include every transaction fee in the block. Keep a separate + // release-compatible accumulator while replaying those historical blocks; Wit/2 uses an + // exact empty Mint and the independent proposer-reward image below instead. + let mut pre_wit2_transaction_fees = 0u64; + let active_wips = ActiveWips { + active_wips: self.wip_activation.clone(), + block_epoch: epoch, + }; + // Only action-0/action-2 deposits occupy a live stake-key identity slot. + // Commission and exit policy actions are sequenced by their own state + // rules and may legitimately carry the owner's same output key. + let mut block_stake_keys = HashSet::new(); + let mut block_delegated_deposit_keys = HashSet::new(); + let mut block_unstake_keys = HashSet::new(); + let mut signatures_to_verify = Vec::new(); + let mut legacy_block_request_protocols = HashMap::new(); + let activation_transition = position != 0 + && protocol_version >= ProtocolVersion::V2_1 + && !self.authenticated_policies.observed_v21_block; + // Wit/2.0 has an automatic subsidy and therefore advances the stake ledger in every + // non-genesis block. V1.8 needs a projection only when a manual Stake is present. The first + // Wit/2.1 block additionally consumes the completed legacy image in the one-time activation + // transition. The staged state publishes only after complete signature and UTXO checks. + let project_legacy_stakes = position != 0 + && (activation_transition + || protocol_version == ProtocolVersion::V2_0 + || (protocol_version >= ProtocolVersion::V1_8 + && protocol_version < ProtocolVersion::V2_0 + && (!block.txns.stake_txns.is_empty() + || !block.txns.unstake_txns.is_empty()))); + let mut projected_legacy_stake_replay = if project_legacy_stakes { + // The activation replay is the final pre-Wit/2.1 image that seeds the + // authoritative delegated ledger. Its checked clone rebuilds secondary indexes from + // the canonical map, so validate the live replay source first on this one boundary; + // otherwise an omitted, duplicated, detached, or mis-owned row could be normalized + // while importing a snapshot exactly at activation. Wit/2.0 and earlier projections + // intentionally retain their historical clone compatibility. + if activation_transition { + self.legacy_stake_replay + .stakes + .validate_validator_index_checked() + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 activation legacy stake-ledger source", + error, + ) + })?; + } + Some( + self.legacy_stake_replay + .try_clone_checked() + .map_err(|error| { + Self::error( + block, + position, + "legacy stake-ledger projection", + error, + ) + })?, + ) + } else { + None + }; + let next_vrf_output; + // Consolidation activates due commission changes before Commit validation and before this + // block's Stake actions. Mirror that ordering in a block-local projection. It is published + // only after the batched signatures and compacted UTXO transition both succeed below. + let mut authenticated_policies = self.authenticated_policies.clone(); + if activation_transition { + if authenticated_policies.observed_v21_block { + return Err(Self::error( + block, + position, + "Wit/2.1 activation ordering", + "authenticated replay observed Wit/2.1 before legacy activation", + )); + } + let commitments = self + .canonical_outstanding_v20_commitments() + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 activation commitment reconstruction", + error, + ) + })?; + let replay = projected_legacy_stake_replay.as_mut().ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 activation replay", + "legacy projection is absent", + ) + })?; + replay.activate_v21(&commitments, epoch).map_err(|error| { + Self::error(block, position, "Wit/2.1 activation replay", error) + })?; + authenticated_policies + .seed_from_v21_activation(&replay.stakes, &replay.delegation_state) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 authenticated activation handoff", + error, + ) + })?; + } + authenticated_policies.note_protocol(protocol_version); + authenticated_policies + .activate_pending_commissions(epoch) + .map_err(|error| { + Self::error(block, position, "Wit/2.1 commission activation", error) + })?; + // Production freezes the owner-active population from the source/pre-action block view and + // reuses it for every native Wit/2.1 Commit target. Build the same immutable set once from + // the exact activation/current replay image, before any Commit reservation, inclusion + // reward, tally, Stake, or Unstake can alter that projection. + let (mut commit_eligibility_projection, frozen_active_validators) = + if protocol_version >= ProtocolVersion::V2_1 { + let (stakes, delegation_state) = authenticated_policies + .exact_full_projection() + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 Commit eligibility source projection", + error, + ) + })?; + let (_prevalidated, frozen) = prevalidate_delegation_snapshot( + &stakes, + &delegation_state, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 Commit eligibility source projection", + error, + ) + })?; + (Some((stakes, delegation_state)), Some(frozen)) + } else { + (None, None) + }; + let opening_validator_counts = if protocol_version >= ProtocolVersion::V2_1 { + let (stakes, _delegation_state) = commit_eligibility_projection + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + "block-opening validator population", + "delegated stake projection is absent", + ) + })?; + SnapshotTipValidatorCounts { + current: frozen_active_validators + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + "block-opening validator population", + "owner-active validator set is absent", + ) + })? + .len(), + legacy: stakes.validator_count(), + } + } else { + let raw = self.legacy_stake_replay.stakes.validator_count(); + SnapshotTipValidatorCounts { + current: raw, + legacy: raw, + } + }; + + if position == 0 { + let actual_hash = block.versioned_hash(protocol_version); + if actual_hash != self.genesis_hash { + return Err(Self::error( + block, + position, + "genesis block", + format!( + "epoch-0 hash {actual_hash} does not match configured genesis hash {}", + self.genesis_hash + ), + )); + } + // `validate_genesis_block` hashes with `ProtocolVersion::guess()`, which is the node's + // current protocol and can differ from epoch zero during a historical replay. Compare + // the canonical unused-field shape directly after the epoch-versioned hash check. + let expected_genesis = Block::genesis( + self.bootstrap_hash, + block.txns.value_transfer_txns.clone(), + ); + if block != &expected_genesis { + return Err(Self::error( + block, + position, + "genesis block", + "unused fields do not have their canonical genesis values", + )); + } + // The first non-genesis block builds its VRF message from the configured genesis hash, + // not from the genesis block's deliberately empty proof. + next_vrf_output = CheckpointVRF { + checkpoint: epoch, + hash_prev_vrf: self.genesis_hash, + }; + for transaction in &block.txns.value_transfer_txns { + let (outputs, _value_created) = validate_genesis_vt_transaction(transaction) + .map_err(|error| { + Self::error(block, position, "genesis value transfer", error) + })?; + let tx_hash = transaction.versioned_hash(protocol_version); + Self::record_outputs( + block, + position, + "genesis value transfer", + tx_hash, + &transaction.body.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "genesis value transfer", + &mut utxo_diff, + std::iter::empty(), + outputs, + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + } else { + let vrf_input = CheckpointVRF { + checkpoint: epoch, + hash_prev_vrf: self.highest_vrf_output.hash_prev_vrf, + }; + + // This is the same standalone signer-binding and secp256k1 verification used by normal + // block admission. Wit/2 proposer rank is checked separately against the exact + // block-opening stake projection below; `Hash::max()` remains the protocol's deliberate + // VRF cryptographic target there. Legacy blocks use the replayed reputation/PoE target + // from the same historical engine that is advanced after each canonical block. + validate_block_signature_for_protocol( + block, + &mut signatures_to_verify, + protocol_version, + ) + .map_err(|error| { + Self::error(block, position, "block header signature", error) + })?; + if protocol_version >= ProtocolVersion::V2_0 { + let replication_factor = + ConsensusConstantsWit2::get_replication_factor_for_protocol( + epoch, + self.highest_vrf_output.checkpoint, + protocol_version, + ); + let validator = block.block_sig.public_key.pkh(); + let (stakes, delegation_state, frozen_active_validators) = + if protocol_version >= ProtocolVersion::V2_1 { + let (stakes, delegation_state) = commit_eligibility_projection + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 block eligibility projection", + "block-opening delegated stake projection is absent", + ) + })?; + ( + stakes, + delegation_state, + frozen_active_validators.as_ref(), + ) + } else { + ( + &self.legacy_stake_replay.stakes, + &self.legacy_stake_replay.delegation_state, + None, + ) + }; + validate_wit2_block_mining_eligibility( + validator, + protocol_version, + epoch, + replication_factor, + stakes, + delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + frozen_active_validators, + ) + .map_err(|error| { + Self::error(block, position, "Wit/2 block proposer eligibility", error) + })?; + } + let target_hash = if protocol_version < ProtocolVersion::V2_0 { + self.legacy_block_vrf_target(epoch, &active_wips)? + } else { + Hash::max() + }; + add_block_vrf_signature_to_verify( + &mut signatures_to_verify, + &block.block_header.proof, + vrf_input, + target_hash, + ); + next_vrf_output = CheckpointVRF { + checkpoint: epoch, + hash_prev_vrf: block + .block_header + .proof + .proof_to_hash(&mut self.vrf_ctx) + .map_err(|error| { + Self::error(block, position, "block VRF proof hash", error) + })?, + }; + let block_transaction_fees = match protocol_version { + ProtocolVersion::V2_1 => Self::v21_transaction_fees( + block, + position, + &utxo_diff, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + )?, + ProtocolVersion::V2_0 => { + Self::v20_transaction_fees(block, &utxo_diff, epoch_constants) + } + ProtocolVersion::V1_7 | ProtocolVersion::V1_8 => 0, + }; + + for transaction in &block.txns.value_transfer_txns { + let tx_hash = transaction.versioned_hash(protocol_version); + // Historical block consensus deliberately verifies VT spend signatures over the + // legacy body hash (`validate_block_transactions` passes `None`) even though the + // resulting output pointers use the containing block's protocol-versioned + // identity. Preserve that asymmetric rule exactly here. + Self::validate_signed_inputs( + block, + position, + "value-transfer authorization", + &transaction.body.inputs, + &transaction.signatures, + transaction.hash(), + &utxo_diff, + &mut signatures_to_verify, + )?; + if protocol_version < ProtocolVersion::V2_0 { + let fee = vt_transaction_fee( + transaction, + &utxo_diff, + epoch, + epoch_constants, + ) + .map_err(|error| { + Self::error(block, position, "pre-Wit/2 VT fee", error) + })?; + pre_wit2_transaction_fees = + pre_wit2_transaction_fees.wrapping_add(fee); + } + Self::record_outputs( + block, + position, + "value-transfer outputs", + tx_hash, + &transaction.body.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "value-transfer UTXO transition", + &mut utxo_diff, + &transaction.body.inputs, + &transaction.body.outputs, + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + for transaction in &block.txns.commit_txns { + let context = "commit authorization"; + let keyed_signature = validate_commit_reveal_signature( + transaction.hash(), + &transaction.signatures, + &mut signatures_to_verify, + ) + .map_err(|error| Self::error(block, position, context, error))?; + let participant = transaction.body.proof.proof.pkh(); + if keyed_signature.public_key.pkh() != participant { + return Err(Self::error( + block, + position, + context, + "transaction signer does not match eligibility-proof identity", + )); + } + let request = self + .known_requests + .get(&transaction.body.dr_pointer) + .copied() + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!( + "data request {} has not appeared in canonical history", + transaction.body.dr_pointer + ), + ) + })?; + validate_request_protocol_for_block(protocol_version, request.protocol_version) + .map_err(|error| { + Self::error(block, position, context, error) + })?; + if protocol_version < ProtocolVersion::V2_0 { + // Legacy Commit validation charges one copy of the request's + // commit-and-reveal fee to Mint. + pre_wit2_transaction_fees = pre_wit2_transaction_fees + .wrapping_add(request.commit_and_reveal_fee); + } + legacy_block_request_protocols + .insert(transaction.body.dr_pointer, request.protocol_version); + self.validate_commit_chronology( + block, + position, + transaction.body.dr_pointer, + request, + )?; + if request.commit_block_position.is_none() { + let request = self + .known_requests + .get_mut(&transaction.body.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "commit chronology", + format!( + "data request {} disappeared during canonical commit replay", + transaction.body.dr_pointer, + ), + ) + })?; + request.commit_block_position = Some(position); + } + // Wit/2 replaced reputation eligibility with stake rank. Reuse the production + // resolver instead of authenticating Wit/2 proofs with an unbounded `Hash::max()` + // target. Wit/2.0 validation ranks every Commit against the immutable source ledger; + // V2.1 validation instead uses the progressively reserved block-local projection. + let exact_commit_vrf = if request.protocol_version >= ProtocolVersion::V2_0 { + let current_commit_round = position + .checked_sub(request.inclusion_position) + .and_then(|round| u16::try_from(round).ok()) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2 Commit stake eligibility", + "canonical Commit round does not fit u16", + ) + })?; + let resolved = if protocol_version >= ProtocolVersion::V2_1 { + let (stakes, delegation_state) = commit_eligibility_projection + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 Commit stake eligibility projection", + "block-local eligibility projection is absent", + ) + })?; + resolve_commit_stake_eligibility( + participant, + protocol_version, + request.protocol_version, + epoch, + request.witnesses, + current_commit_round, + self.terminal_data_request_round, + stakes, + delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + frozen_active_validators.as_ref(), + ) + } else { + resolve_commit_stake_eligibility( + participant, + protocol_version, + request.protocol_version, + epoch, + request.witnesses, + current_commit_round, + self.terminal_data_request_round, + &self.legacy_stake_replay.stakes, + &self.legacy_stake_replay.delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + None, + ) + } + .map_err(|error| { + Self::error(block, position, "Wit/2 Commit stake eligibility", error) + })?; + Some(resolved) + } else { + None + }; + let legacy_commit_target = if request.protocol_version < ProtocolVersion::V2_0 { + Some(self.legacy_commit_vrf_target( + participant, + request, + position, + &active_wips, + )?) + } else { + None + }; + if protocol_version >= ProtocolVersion::V2_1 + && request.protocol_version >= ProtocolVersion::V2_0 + { + // Production resolves this Commit against the current block-local projection, + // then immediately reserves its collateral before ranking the next Commit. Keep + // that one progressively debited projection authoritative for both native V2.1 + // requests and cross-fork V2.0 owner-only requests. Synchronizing only the + // affected validator back into the authenticated image avoids reconstructing a + // second, independently advancing eligibility view. + let (stakes, delegation_state) = commit_eligibility_projection + .as_mut() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 Commit stake capacity projection", + "block-local eligibility projection is absent", + ) + })?; + let reservation_key = CollateralReservationKey { + data_request: transaction.body.dr_pointer, + validator: participant, + }; + let reservation = if request.protocol_version >= ProtocolVersion::V2_1 { + delegation_state.reserve_collateral( + stakes, + reservation_key, + Wit::from_nanowits(request.required_collateral), + epoch, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + } else { + delegation_state.reserve_legacy_collateral( + stakes, + reservation_key, + Wit::from_nanowits(request.required_collateral), + epoch, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + }; + reservation.map_err(|error| { + Self::error(block, position, "Wit/2.1 Commit stake capacity", error) + })?; + authenticated_policies + .sync_exact_validator_projection( + participant, + stakes, + delegation_state, + None, + ) + .map_err(|error| { + Self::error(block, position, "Wit/2.1 Commit stake replay", error) + })?; + } + if request.protocol_version >= ProtocolVersion::V2_1 { + block_native_commit_inclusion_reward = if protocol_version + >= ProtocolVersion::V2_1 + { + block_native_commit_inclusion_reward + .checked_add(request.commit_and_reveal_fee) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 Commit inclusion reward", + "reward total overflow", + ) + })? + } else { + block_native_commit_inclusion_reward + .wrapping_add(request.commit_and_reveal_fee) + }; + } else if request.protocol_version >= ProtocolVersion::V2_0 { + block_legacy_commit_inclusion_reward = if protocol_version + >= ProtocolVersion::V2_1 + { + block_legacy_commit_inclusion_reward + .checked_add(request.commit_and_reveal_fee) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 Commit inclusion reward", + "reward total overflow", + ) + })? + } else { + block_legacy_commit_inclusion_reward + .wrapping_add(request.commit_and_reveal_fee) + }; + } + let queued = match exact_commit_vrf { + Some((target_hash, withdrawer)) => Self::queue_commit_vrf_with_target( + transaction, + request, + vrf_input, + target_hash, + withdrawer, + &mut signatures_to_verify, + ), + None => match legacy_commit_target { + Some(target_hash) => Self::queue_commit_vrf_with_target( + transaction, + request, + vrf_input, + target_hash, + None, + &mut signatures_to_verify, + ), + None => Self::queue_reconstructible_commit_vrf( + transaction, + request, + vrf_input, + None, + &mut signatures_to_verify, + ), + }, + }; + if !queued { + return Err(Self::error( + block, + position, + "Wit/2 Commit VRF coverage", + "exact stake eligibility did not resolve an owner salt", + )); + } + if protocol_version < ProtocolVersion::V2_0 { + self.validate_legacy_committer_qualification( + block, + position, + transaction, + &utxo_diff, + block_number, + )?; + } + Self::validate_commit_utxo_semantics_for_block_protocol( + block, + position, + transaction, + participant, + request, + protocol_version, + &utxo_diff, + epoch_constants, + )?; + let commitment_key = (transaction.body.dr_pointer, participant); + if !block_commitments.insert(commitment_key) { + return Err(Self::error( + block, + position, + context, + format!( + "participant {participant} commits more than once to data request {} in one block", + transaction.body.dr_pointer + ), + )); + } + let count = block_commit_counts + .entry(transaction.body.dr_pointer) + .or_insert((0, u32::from(request.witnesses))); + count.0 = count.0.saturating_add(1); + if count.1 != u32::from(request.witnesses) { + return Err(Self::error( + block, + position, + context, + format!( + "data request {} changed its witness target within canonical history", + transaction.body.dr_pointer + ), + )); + } + if !self + .request_committers + .entry(transaction.body.dr_pointer) + .or_default() + .insert(participant) + { + return Err(Self::error( + block, + position, + context, + format!( + "participant {participant} already committed to data request {} in canonical history", + transaction.body.dr_pointer + ), + )); + } + if protocol_version == ProtocolVersion::V2_0 + && request.protocol_version >= ProtocolVersion::V2_0 + { + projected_legacy_stake_replay + .as_mut() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 Commit stake replay", + "legacy projection is absent", + ) + })? + .reserve_v20_collateral( + transaction.body.dr_pointer, + participant, + request.required_collateral, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Commit stake replay", + error, + ) + })?; + } + self.pending_commitments + .insert(commitment_key, transaction.body.commitment); + let canonical_request = self + .canonical_requests + .get_mut(&transaction.body.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "commit canonical pool replay", + format!( + "data request {} has no retained canonical payload", + transaction.body.dr_pointer, + ), + ) + })?; + if canonical_request + .commits + .insert(participant, transaction.clone()) + .is_some() + { + return Err(Self::error( + block, + position, + "commit canonical pool replay", + format!( + "participant {participant} already has a canonical Commit for data request {}", + transaction.body.dr_pointer, + ), + )); + } + let tx_hash = transaction.versioned_hash(protocol_version); + Self::record_outputs( + block, + position, + "commit outputs", + tx_hash, + &transaction.body.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "commit UTXO transition", + &mut utxo_diff, + &transaction.body.collateral, + &transaction.body.outputs, + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + for (pointer, (current, target)) in &block_commit_counts { + if current != target { + return Err(Self::error( + block, + position, + "commit population", + format!( + "data request {pointer} has {current} commitments in this block, expected exactly {target}" + ), + )); + } + } + + for transaction in &block.txns.reveal_txns { + let context = "reveal authorization"; + let keyed_signature = validate_commit_reveal_signature( + transaction.hash(), + &transaction.signatures, + &mut signatures_to_verify, + ) + .map_err(|error| Self::error(block, position, context, error))?; + let participant = keyed_signature.public_key.pkh(); + if participant != transaction.body.pkh { + return Err(Self::error( + block, + position, + context, + "transaction signer does not match reveal participant", + )); + } + let request = self + .known_requests + .get(&transaction.body.dr_pointer) + .copied() + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!( + "data request {} has not appeared in canonical history", + transaction.body.dr_pointer + ), + ) + })?; + validate_request_protocol_for_block(protocol_version, request.protocol_version) + .map_err(|error| { + Self::error(block, position, context, error) + })?; + if protocol_version < ProtocolVersion::V2_0 { + // Legacy Reveal validation charges the same request fee as Commit. + pre_wit2_transaction_fees = pre_wit2_transaction_fees + .wrapping_add(request.commit_and_reveal_fee); + } + self.validate_reveal_chronology( + block, + position, + transaction.body.dr_pointer, + request, + )?; + if request.protocol_version >= ProtocolVersion::V2_1 { + block_native_reveal_inclusion_reward = if protocol_version + >= ProtocolVersion::V2_1 + { + block_native_reveal_inclusion_reward + .checked_add(request.commit_and_reveal_fee) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 Reveal inclusion reward", + "reward total overflow", + ) + })? + } else { + block_native_reveal_inclusion_reward + .wrapping_add(request.commit_and_reveal_fee) + }; + } else if request.protocol_version >= ProtocolVersion::V2_0 { + block_legacy_reveal_inclusion_reward = if protocol_version + >= ProtocolVersion::V2_1 + { + block_legacy_reveal_inclusion_reward + .checked_add(request.commit_and_reveal_fee) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 Reveal inclusion reward", + "reward total overflow", + ) + })? + } else { + block_legacy_reveal_inclusion_reward + .wrapping_add(request.commit_and_reveal_fee) + }; + } + let commitment_key = (transaction.body.dr_pointer, participant); + if block_commitments + .iter() + .any(|(pointer, _participant)| *pointer == transaction.body.dr_pointer) + { + return Err(Self::error( + block, + position, + context, + format!( + "reveal for data request {} participant {participant} occurs in the same block as a commit for that request", + transaction.body.dr_pointer + ), + )); + } + if !block_reveals.insert(commitment_key) { + return Err(Self::error( + block, + position, + context, + format!( + "participant {participant} reveals more than once for data request {} in one block", + transaction.body.dr_pointer + ), + )); + } + let expected_commitment = self + .pending_commitments + .remove(&commitment_key) + .ok_or_else(|| { + Self::error( + block, + position, + context, + format!( + "no preceding commit exists for data request {} participant {participant}", + transaction.body.dr_pointer + ), + ) + })?; + if expected_commitment != keyed_signature.signature.hash() { + return Err(Self::error( + block, + position, + context, + "signature does not match the preceding commitment", + )); + } + let request = self + .known_requests + .get_mut(&transaction.body.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "reveal chronology", + format!( + "data request {} disappeared during canonical reveal replay", + transaction.body.dr_pointer, + ), + ) + })?; + request.reveals = request.reveals.checked_add(1).ok_or_else(|| { + Self::error( + block, + position, + "reveal chronology", + format!( + "data request {} reveal counter overflows", + transaction.body.dr_pointer, + ), + ) + })?; + request.last_reveal_block_position = Some(position); + let canonical_request = self + .canonical_requests + .get_mut(&transaction.body.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "reveal canonical pool replay", + format!( + "data request {} has no retained canonical payload", + transaction.body.dr_pointer, + ), + ) + })?; + if canonical_request + .reveals + .insert(participant, transaction.clone()) + .is_some() + { + return Err(Self::error( + block, + position, + "reveal canonical pool replay", + format!( + "participant {participant} already has a canonical Reveal for data request {}", + transaction.body.dr_pointer, + ), + )); + } + } + + if protocol_version >= ProtocolVersion::V2_1 { + let proposer = block.block_header.proof.proof.pkh(); + // Native V2.1 requests use delegated allocation; cross-fork V2.0 requests retain + // owner-only reward economics. Keep the Commit and Reveal categories separate in + // both branches so legacy age averaging is not combined with another credit. + authenticated_policies + .distribute_v21_reward( + proposer, + block_native_commit_inclusion_reward, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 Commit inclusion reward distribution", + error, + ) + })?; + authenticated_policies + .distribute_v20_owner_reward( + proposer, + block_legacy_commit_inclusion_reward, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Commit inclusion reward distribution", + error, + ) + })?; + authenticated_policies + .distribute_v21_reward( + proposer, + block_native_reveal_inclusion_reward, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 Reveal inclusion reward distribution", + error, + ) + })?; + authenticated_policies + .distribute_v20_owner_reward( + proposer, + block_legacy_reveal_inclusion_reward, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Reveal inclusion reward distribution", + error, + ) + })?; + } else if protocol_version == ProtocolVersion::V2_0 { + let proposer = block.block_header.proof.proof.pkh(); + let replay = projected_legacy_stake_replay.as_mut().ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 inclusion reward replay", + "legacy projection is absent", + ) + })?; + replay + .reward_validator(proposer, block_legacy_commit_inclusion_reward, epoch) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Commit inclusion reward replay", + error, + ) + })?; + replay + .reward_validator(proposer, block_legacy_reveal_inclusion_reward, epoch) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Reveal inclusion reward replay", + error, + ) + })?; + } + + for transaction in &block.txns.data_request_txns { + let tx_hash = transaction.versioned_hash(protocol_version); + Self::validate_signed_inputs( + block, + position, + "data-request authorization", + &transaction.body.inputs, + &transaction.signatures, + tx_hash, + &utxo_diff, + &mut signatures_to_verify, + )?; + if protocol_version < ProtocolVersion::V2_0 { + let fee = dr_transaction_fee( + transaction, + &utxo_diff, + epoch, + epoch_constants, + ) + .map_err(|error| { + Self::error(block, position, "pre-Wit/2 data-request fee", error) + })?; + pre_wit2_transaction_fees = + pre_wit2_transaction_fees.wrapping_add(fee); + } + let request = SnapshotRequestContext { + inclusion_epoch: epoch, + inclusion_position: position, + protocol_version, + // Wit/2.0 alone preserves serialized zero as an owner-stake no-op. The + // historical UTXO protocol and native Wit/2.1 both interpret zero as the + // configured network minimum; only the former uses that value in this UTXO + // envelope check. + required_collateral: Self::required_collateral( + protocol_version, + transaction.body.dr_output.collateral, + self.collateral_minimum, + ), + commit_and_reveal_fee: transaction.body.dr_output.commit_and_reveal_fee, + witness_reward: transaction.body.dr_output.witness_reward, + witnesses: transaction.body.dr_output.witnesses, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + if self.known_requests.insert(tx_hash, request).is_some() { + return Err(Self::error( + block, + position, + "data-request identity", + format!("request {tx_hash} already appeared in canonical history"), + )); + } + let requester = transaction + .signatures + .first() + .map(|signature| signature.public_key.pkh()) + .ok_or_else(|| { + Self::error( + block, + position, + "data-request identity", + format!("request {tx_hash} has no canonical requester signature"), + ) + })?; + let canonical_request = SnapshotCanonicalRequestState { + data_request: transaction.body.dr_output.clone(), + requester, + request_block_hash: block.versioned_hash(protocol_version), + commits: HashMap::new(), + reveals: HashMap::new(), + }; + if self + .canonical_requests + .insert(tx_hash, canonical_request) + .is_some() + { + return Err(Self::error( + block, + position, + "data-request identity", + format!("request {tx_hash} already has a canonical pool payload"), + )); + } + Self::record_outputs( + block, + position, + "data-request outputs", + tx_hash, + &transaction.body.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "data-request UTXO transition", + &mut utxo_diff, + &transaction.body.inputs, + &transaction.body.outputs, + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + // Freeze native-block owner locks after accepted Commits and before Tallies. This + // includes block-start legacy commitments plus accepted same-block legacy Commits. + // Even a Tally below releases the owner only in the next block, matching the shared + // conservative native Unstake context. Raw-zero legacy collateral has no sidecar. + let pending_legacy_committers = if protocol_version >= ProtocolVersion::V2_1 + && !block.txns.unstake_txns.is_empty() + { + self.canonical_outstanding_v20_commitments() + .map_err(|error| Self::error( + block, position, "Wit/2.1 legacy settlement exit locks", error, + ))? + .keys() + .map(|key| key.validator) + .collect::>() + } else { + BTreeSet::new() + }; + for transaction in &block.txns.tally_txns { + if block_commitments + .iter() + .chain(block_reveals.iter()) + .any(|(pointer, _participant)| *pointer == transaction.dr_pointer) + { + return Err(Self::error( + block, + position, + "tally chronology", + format!( + "data request {} has a commit or reveal in the same block as its tally", + transaction.dr_pointer + ), + )); + } + let request = self + .known_requests + .get(&transaction.dr_pointer) + .copied() + .ok_or_else(|| { + Self::error( + block, + position, + "tally linkage", + format!( + "data request {} has not appeared or was already tallied", + transaction.dr_pointer + ), + ) + })?; + validate_request_protocol_for_block(protocol_version, request.protocol_version) + .map_err(|error| { + Self::error(block, position, "tally linkage", error) + })?; + legacy_block_request_protocols + .insert(transaction.dr_pointer, request.protocol_version); + self.validate_tally_chronology( + block, + position, + transaction, + request, + opening_validator_counts, + )?; + let too_many_witnesses = Self::is_too_many_witnesses_tally( + block, + position, + transaction, + )?; + self.validate_tally_result( + block, + position, + transaction, + &active_wips, + too_many_witnesses, + )?; + if protocol_version < ProtocolVersion::V2_0 { + self.note_legacy_tally_reputation( + &mut legacy_reputation_info, + transaction, + block, + position, + )?; + } + for participant in transaction + .out_of_consensus + .iter() + .chain(transaction.error_committers.iter()) + { + if !self + .request_committers + .get(&transaction.dr_pointer) + .is_some_and(|committers| committers.contains(participant)) + { + return Err(Self::error( + block, + position, + "tally participant linkage", + format!( + "participant {participant} did not commit to data request {}", + transaction.dr_pointer + ), + )); + } + } + if protocol_version < ProtocolVersion::V2_0 { + let canonical = self + .canonical_requests + .get(&transaction.dr_pointer) + .ok_or_else(|| { + Self::error( + block, + position, + "pre-Wit/2 tally fee", + format!( + "data request {} has no canonical payload", + transaction.dr_pointer + ), + ) + })?; + let liars_count = transaction + .out_of_consensus + .iter() + .filter(|participant| !transaction.error_committers.contains(*participant)) + .count(); + let errors_count = transaction + .out_of_consensus + .iter() + .filter(|participant| transaction.error_committers.contains(*participant)) + .count(); + let tally_fee = tally_extra_fee_for_protocol( + canonical.commits.len(), + canonical.reveals.len(), + liars_count, + errors_count, + request.witness_reward, + request.required_collateral, + request.protocol_version, + &active_wips, + ) + .map_err(|error| Self::error(block, position, "pre-Wit/2 tally fee", error))?; + pre_wit2_transaction_fees = + pre_wit2_transaction_fees.wrapping_add(tally_fee); + } + if protocol_version == ProtocolVersion::V2_0 + && request.protocol_version >= ProtocolVersion::V2_0 + { + let committers = self + .request_committers + .get(&transaction.dr_pointer) + .cloned() + .unwrap_or_default(); + projected_legacy_stake_replay + .as_mut() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 Tally stake replay", + "legacy projection is absent", + ) + })? + .settle_v20_tally( + transaction.dr_pointer, + &committers, + request.required_collateral, + request.witness_reward, + &transaction.out_of_consensus, + &transaction.error_committers, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 Tally stake replay", + error, + ) + })?; + } + if request.protocol_version >= ProtocolVersion::V2_1 { + let committers = self + .request_committers + .get(&transaction.dr_pointer) + .cloned() + .unwrap_or_default(); + authenticated_policies + .settle_v21_tally( + transaction.dr_pointer, + &committers, + request.required_collateral, + request.witness_reward, + &transaction.out_of_consensus, + &transaction.error_committers, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 tally stake settlement", + error, + ) + })?; + } else if protocol_version >= ProtocolVersion::V2_1 + && request.protocol_version >= ProtocolVersion::V2_0 + { + let committers = self + .request_committers + .get(&transaction.dr_pointer) + .cloned() + .unwrap_or_default(); + authenticated_policies + .settle_v20_tally_after_activation( + transaction.dr_pointer, + &committers, + request.required_collateral, + request.witness_reward, + &transaction.out_of_consensus, + &transaction.error_committers, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "post-activation Wit/2.0 tally stake settlement", + error, + ) + })?; + } + self.known_requests.remove(&transaction.dr_pointer); + self.pending_tally_obligations + .remove(&transaction.dr_pointer); + if self + .canonical_requests + .remove(&transaction.dr_pointer) + .is_none() + { + return Err(Self::error( + block, + position, + "tally canonical pool replay", + format!( + "data request {} has no retained canonical payload", + transaction.dr_pointer, + ), + )); + } + self.request_committers.remove(&transaction.dr_pointer); + self.pending_commitments + .retain(|(pointer, _participant), _| *pointer != transaction.dr_pointer); + let tx_hash = transaction.versioned_hash(protocol_version); + Self::record_outputs( + block, + position, + "tally outputs", + tx_hash, + &transaction.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "tally UTXO transition", + &mut utxo_diff, + std::iter::empty(), + &transaction.outputs, + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + // Consolidation credits the combined strict transaction-fee and fixed block-subsidy + // reward after tally settlement and before manual Stake/Unstake actions. Applying the + // same single gross distribution is important: splitting it would change proportional + // dust and commission rounding. + if protocol_version >= ProtocolVersion::V2_1 { + let proposer = block.block_header.proof.proof.pkh(); + let proposer_reward = block_transaction_fees + .checked_add(SNAPSHOT_WIT2_VALIDATOR_BLOCK_REWARD_NANOWITS) + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 proposer reward", + "transaction fees plus block subsidy overflow", + ) + })?; + authenticated_policies + .distribute_v21_reward(proposer, proposer_reward, epoch) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 proposer reward distribution", + error, + ) + })?; + authenticated_policies + .apply_age_resets_for_protocol( + proposer, + epoch, + protocol_version, + &block.txns.commit_txns, + &block.txns.tally_txns, + |pointer| legacy_block_request_protocols.get(&pointer).copied(), + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 stake-age replay", + error, + ) + })?; + } else if protocol_version == ProtocolVersion::V2_0 { + let proposer = block.block_header.proof.proof.pkh(); + let replay = projected_legacy_stake_replay.as_mut().ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.0 proposer transition replay", + "legacy projection is absent", + ) + })?; + replay + .reward_validator( + proposer, + block_transaction_fees + .wrapping_add(SNAPSHOT_WIT2_VALIDATOR_BLOCK_REWARD_NANOWITS), + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 proposer reward replay", + error, + ) + })?; + apply_wit2_age_resets_for_protocol( + &mut replay.stakes, + &replay.delegation_state, + proposer, + epoch, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + protocol_version, + &block.txns.commit_txns, + &block.txns.tally_txns, + |pointer| legacy_block_request_protocols.get(&pointer).copied(), + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.0 stake-age replay", + error, + ) + })?; + + // Policy provenance remains deliberately opaque until the exact activation + // transition consumes this private legacy projection. + authenticated_policies.mark_stake_balance_opaque(proposer); + } + + for transaction in &block.txns.stake_txns { + let is_delegated_deposit = protocol_version >= ProtocolVersion::V2_1 + && transaction + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| { + matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE + | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + ) + }); + if is_delegated_deposit + && !block_stake_keys.insert(transaction.body.output.key.clone()) + { + return Err(Self::error( + block, + position, + "Wit/2.1 stake action ordering", + format!( + "stake key {} occurs more than once in one block", + transaction.body.output.key + ), + )); + } + if protocol_version < ProtocolVersion::V2_1 { + authenticated_policies + .note_legacy_stake(transaction.body.output.key.validator); + } + let tx_hash = transaction.versioned_hash(protocol_version); + Self::validate_signed_inputs( + block, + position, + "stake input authorization", + &transaction.body.inputs, + &transaction.signatures, + tx_hash, + &utxo_diff, + &mut signatures_to_verify, + )?; + transaction + .body + .authorization_is_valid_at(protocol_version, network_magic, epoch) + .map_err(|error| { + Self::error(block, position, "stake action authorization", error) + })?; + if protocol_version < ProtocolVersion::V2_0 { + let fee = st_transaction_fee_for_protocol( + transaction, + &utxo_diff, + epoch, + epoch_constants, + protocol_version, + ) + .map_err(|error| Self::error(block, position, "pre-Wit/2 stake fee", error))?; + pre_wit2_transaction_fees = + pre_wit2_transaction_fees.wrapping_add(fee); + } + if protocol_version >= ProtocolVersion::V2_1 { + Self::validate_v21_stake_action_envelope( + block, + position, + transaction, + &utxo_diff, + epoch_constants, + )?; + let terms = transaction + .body + .output + .authorization_terms + .as_ref() + .ok_or_else(|| { + Self::error( + block, + position, + "Wit/2.1 stake policy transition", + "versioned authorization terms are missing", + ) + })?; + authenticated_policies + .apply_v21_action(terms, epoch, epoch_constants) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 stake policy transition", + error, + ) + })?; + if matches!(terms.action, STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER) { + block_delegated_deposit_keys + .insert(transaction.body.output.key.clone()); + authenticated_policies + .apply_v21_delegation( + transaction.body.output.key.clone(), + transaction.body.output.value, + epoch, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 stake balance transition", + error, + ) + })?; + } + } + if let Some(change) = transaction.body.change.as_ref() { + Self::record_outputs( + block, + position, + "stake change", + tx_hash, + std::slice::from_ref(change), + &mut block_outputs, + )?; + } + Self::update_utxos( + block, + position, + "stake UTXO transition", + &mut utxo_diff, + &transaction.body.inputs, + transaction.body.change.iter(), + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + for transaction in &block.txns.unstake_txns { + validate_unstake_signature_for_protocol( + transaction, + transaction.body.operator, + protocol_version, + ).map_err( + |error| Self::error(block, position, "unstake authorization", error), + )?; + if protocol_version >= ProtocolVersion::V2_1 { + let withdrawer = transaction.signature.public_key.pkh(); + let key = StakeKey { + validator: transaction.body.operator, + withdrawer, + }; + if !block_unstake_keys.insert(key.clone()) { + return Err(Self::error( + block, + position, + "Wit/2.1 unstake action ordering", + format!("unstake key {key} occurs more than once in one block"), + )); + } + if block_delegated_deposit_keys.contains(&key) { + return Err(Self::error( + block, + position, + "Wit/2.1 stake action ordering", + format!( + "stake key {key} cannot be delegated and unstaked in one block" + ), + )); + } + Self::validate_v21_unstake_envelope( + block, + position, + transaction, + epoch_constants, + self.unstaking_delay_seconds, + )?; + authenticated_policies + .apply_v21_unstake_with_legacy_committers( + transaction.body.operator, + withdrawer, + transaction.body.withdrawal.value, + transaction.body.fee, + transaction.body.nonce, + epoch, + &pending_legacy_committers, + ) + .map_err(|error| { + Self::error( + block, + position, + "Wit/2.1 unstake balance transition", + error, + ) + })?; + } + let tx_hash = transaction.versioned_hash(protocol_version); + Self::record_outputs( + block, + position, + "unstake withdrawal", + tx_hash, + std::slice::from_ref(&transaction.body.withdrawal), + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "unstake UTXO transition", + &mut utxo_diff, + std::iter::empty(), + std::iter::once(&transaction.body.withdrawal), + tx_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + // Manual legacy actions follow every Wit/2.0 automatic collateral, reward, tally, and + // age transition above. Preserve the historical independent batch behavior: an + // ordinary Stake error retains its prefix but still proceeds into Unstake. + if protocol_version >= ProtocolVersion::V1_8 + && protocol_version < ProtocolVersion::V2_1 + && (!block.txns.stake_txns.is_empty() || !block.txns.unstake_txns.is_empty()) + { + let replay = projected_legacy_stake_replay.as_mut().ok_or_else(|| { + Self::error( + block, + position, + "legacy manual stake-ledger replay", + "legacy projection is absent", + ) + })?; + Self::replay_legacy_manual_stake_batches( + &mut replay.stakes, + &block.txns.stake_txns, + &block.txns.unstake_txns, + epoch, + protocol_version, + ) + .map_err(|error| { + Self::error( + block, + position, + "legacy manual stake-ledger replay", + error, + ) + })?; + } + + if protocol_version < ProtocolVersion::V2_0 { + validate_mint_transaction_for_protocol( + &block.txns.mint, + pre_wit2_transaction_fees, + epoch, + self.initial_block_reward, + self.halving_period, + protocol_version, + ) + .map_err(|error| Self::error(block, position, "pre-Wit/2 mint economics", error))?; + } + let mint_hash = block.txns.mint.versioned_hash(protocol_version); + Self::record_outputs( + block, + position, + "mint outputs", + mint_hash, + &block.txns.mint.outputs, + &mut block_outputs, + )?; + Self::update_utxos( + block, + position, + "mint UTXO transition", + &mut utxo_diff, + std::iter::empty(), + &block.txns.mint.outputs, + mint_hash, + protocol_version, + epoch_constants, + checkpoint_zero_timestamp, + )?; + } + + // Normal no-commit expiry, incomplete-reveal expiry, and reveal completion each make the + // tally mandatory in one exact canonical block. An exact replayed threshold transition may + // make a no-Commit request due earlier, but can never extend this structural upper bound. + self.reject_missing_structurally_due_tallies(block, position)?; + + // Accepted-block consolidation runs after every automatic and manual stake transition. + // Reconstruct both exact population views from the staged post-block ledger. The opening + // view above authenticates same-block admission; their difference proves a causal early + // transition without trusting the imported active-pool label. + let terminal_round = usize::from(self.terminal_data_request_round); + let has_pending_transition_candidate = protocol_version >= ProtocolVersion::V2_0 + && self.known_requests.iter().any(|(pointer, request)| { + request.commit_block_position.is_none() + && !self.pending_tally_obligations.contains_key(pointer) + && position + .checked_sub(request.inclusion_position) + .is_some_and(|elapsed| { + elapsed < terminal_round + && (request.protocol_version >= ProtocolVersion::V2_0 + || elapsed > 0) + }) + }); + let pending_tally_transitions = if has_pending_transition_candidate { + let post_block_validator_counts = if protocol_version >= ProtocolVersion::V2_1 { + let (stakes, delegation_state) = authenticated_policies + .exact_full_projection() + .map_err(|error| { + Self::error( + block, + position, + "post-block validator population", + error, + ) + })?; + let legacy = stakes.validator_count(); + let current = delegation_state + .active_validator_count_checked( + &stakes, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .map_err(|error| { + Self::error( + block, + position, + "post-block owner-active validator population", + error, + ) + })?; + SnapshotTipValidatorCounts { current, legacy } + } else if let Some(replay) = projected_legacy_stake_replay.as_ref() { + let raw = replay.stakes.validator_count(); + SnapshotTipValidatorCounts { + current: raw, + legacy: raw, + } + } else { + let raw = self.legacy_stake_replay.stakes.validator_count(); + SnapshotTipValidatorCounts { + current: raw, + legacy: raw, + } + }; + Self::derive_pending_tally_obligations( + block, + position, + protocol_version, + opening_validator_counts, + post_block_validator_counts, + self.terminal_data_request_round, + &self.protocol_info, + &self.known_requests, + &self.canonical_requests, + &self.pending_tally_obligations, + )? + } else { + Vec::new() + }; + + verify_signatures(signatures_to_verify, &mut self.vrf_ctx).map_err(|error| { + Self::error( + block, + position, + "cryptographic signature or VRF proof", + error, + ) + })?; + + // Apply the fallible reputation transition while all consensus state is still private. + // The UTXO image is published only after this trusted-state helper has returned, so a + // malformed historical reputation update cannot leave a partially advanced verifier + // behind if a caller inspects the error and attempts recovery. + self.apply_legacy_reputation( + &mut projected_reputation_engine, + legacy_reputation_info, + block, + position, + )?; + utxo_diff + .take_diff() + .try_apply_in_memory_compacting(&mut self.utxos) + .map_err(|error| { + Self::error( + block, + position, + "compacted UTXO replay transition", + error, + ) + })?; + if activation_transition { + // The exact activated stake entries, nonces, policies, and reservation vectors now live + // in `authenticated_policies`. Drop the duplicate full legacy ledger only after every + // block check and UTXO publication succeeds; `observed_v21_block` is the durable replay + // marker for all later blocks in this verifier. + self.legacy_stake_replay = SnapshotLegacyStakeReplay::default(); + } else if let Some(projected) = projected_legacy_stake_replay { + self.legacy_stake_replay = projected; + } + self.pending_tally_obligations + .extend(pending_tally_transitions); + self.authenticated_policies = authenticated_policies; + self.reputation_engine = projected_reputation_engine; + self.highest_vrf_output = next_vrf_output; + self.next_position = self.next_position.checked_add(1).ok_or_else(|| { + Self::error( + block, + position, + "transaction replay position", + "position overflow", + ) + })?; + Ok(()) + } + + /// Build the typed import error used when a snapshot data-request tip is inconsistent. It keeps the caller-facing failure distinct from generic transaction replay errors. + fn data_request_tip_error(message: impl Into) -> ImportError { + ImportError::SnapshotContents { + message: format!( + "snapshot tip DataRequestPool does not equal canonical unresolved-request replay: {}", + message.into() + ), + } + } + + /// Bind every consensus-reconstructible field in the imported active data-request pool to the + /// canonical block history. + /// + /// `waiting_for_reveal` is intentionally not reconstructed: it is a node-local queue holding a + /// reveal that may not have appeared on chain yet. Its entries are nevertheless required to be + /// cryptographically authenticated, structurally bound to an exact active request, and (once in + /// Reveal) bound to the canonical Commit. + /// `to_be_stored` has no such exception: accepted-block handling drains completed reports before + /// a ChainState can be published, and portable version-2 Reports are authenticated separately. + fn validate_data_request_tip_state( + &mut self, + expected: &DataRequestPool, + validator_counts: SnapshotTipValidatorCounts, + ) -> Result<(), ImportError> { + if expected.extra_rounds.checked_add(1) != Some(self.terminal_data_request_round) { + return Err(Self::data_request_tip_error(format!( + "pool extra_rounds {} does not produce pinned terminal round {}", + expected.extra_rounds, self.terminal_data_request_round, + ))); + } + if !expected.to_be_stored.is_empty() { + return Err(Self::data_request_tip_error(format!( + "completed-report staging queue contains {} entries", + expected.to_be_stored.len(), + ))); + } + if self.known_requests.len() != self.canonical_requests.len() { + return Err(Self::data_request_tip_error(format!( + "internal replay retained {} chronology entries but {} canonical request payloads", + self.known_requests.len(), + self.canonical_requests.len(), + ))); + } + if let Some(pointer) = self + .pending_tally_obligations + .keys() + .find(|pointer| !self.known_requests.contains_key(*pointer)) + { + return Err(Self::data_request_tip_error(format!( + "pending tally obligation references resolved or unknown request {pointer}" + ))); + } + if expected.data_request_pool.len() != self.known_requests.len() { + return Err(Self::data_request_tip_error(format!( + "pool contains {} active requests, canonical replay contains {}", + expected.data_request_pool.len(), + self.known_requests.len(), + ))); + } + + let tip_position = self.next_position.checked_sub(1); + let terminal_round = usize::from(self.terminal_data_request_round); + let wit2_activation_epoch = self + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); + let mut expected_epoch_index = BTreeMap::>::new(); + let mut expected_committers = HashMap::>::new(); + let mut expected_pending = HashMap::<(Hash, PublicKeyHash), Hash>::new(); + + for (pointer, request) in &self.known_requests { + let canonical = self.canonical_requests.get(pointer).ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} has chronology but no canonical payload" + )) + })?; + let actual = expected.data_request_pool.get(pointer).ok_or_else(|| { + Self::data_request_tip_error(format!( + "canonical unresolved request {pointer} is absent from the pool" + )) + })?; + let tip_position = tip_position.ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} exists although canonical replay contains no blocks" + )) + })?; + + if actual.data_request != canonical.data_request { + return Err(Self::data_request_tip_error(format!( + "request {pointer} output differs from its canonical transaction" + ))); + } + if actual.pkh != canonical.requester { + return Err(Self::data_request_tip_error(format!( + "request {pointer} requester {} differs from canonical signer {}", + actual.pkh, canonical.requester, + ))); + } + if actual.epoch != request.inclusion_epoch { + return Err(Self::data_request_tip_error(format!( + "request {pointer} inclusion epoch {} differs from canonical epoch {}", + actual.epoch, request.inclusion_epoch, + ))); + } + if actual.info.block_hash_dr_tx != Some(canonical.request_block_hash) { + return Err(Self::data_request_tip_error(format!( + "request {pointer} block hash {:?} differs from canonical hash {}", + actual.info.block_hash_dr_tx, canonical.request_block_hash, + ))); + } + if actual.info.commits != canonical.commits { + return Err(Self::data_request_tip_error(format!( + "request {pointer} Commit map differs from canonical transactions" + ))); + } + if actual.info.reveals != canonical.reveals { + return Err(Self::data_request_tip_error(format!( + "request {pointer} Reveal map differs from canonical transactions" + ))); + } + if actual.info.tally.is_some() || actual.info.block_hash_tally_tx.is_some() { + return Err(Self::data_request_tip_error(format!( + "unresolved request {pointer} carries a tally or tally-block hash" + ))); + } + if request.reveals != canonical.reveals.len() { + return Err(Self::data_request_tip_error(format!( + "request {pointer} replay counted {} reveals but retained {} canonical payloads", + request.reveals, + canonical.reveals.len(), + ))); + } + if request.commit_block_position.is_some() + && self.pending_tally_obligations.contains_key(pointer) + { + return Err(Self::data_request_tip_error(format!( + "request {pointer} has canonical Commits after its no-Commit tally became mandatory" + ))); + } + + let (stage, current_commit_round, current_reveal_round) = + if let Some(commit_position) = request.commit_block_position { + let commit_round = commit_position + .checked_sub(request.inclusion_position) + .filter(|round| *round > 0 && *round <= terminal_round) + .ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} has invalid canonical Commit position {commit_position}" + )) + })?; + let current_commit_round = u16::try_from(commit_round).map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} Commit round {commit_round} does not fit u16" + )) + })?; + if canonical.commits.len() != usize::from(request.witnesses) { + return Err(Self::data_request_tip_error(format!( + "request {pointer} retained {} canonical Commits, expected witness target {}", + canonical.commits.len(), request.witnesses, + ))); + } + let elapsed = tip_position.checked_sub(commit_position).ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} Commit position {commit_position} is after tip {tip_position}" + )) + })?; + if request.reveals == usize::from(request.witnesses) { + let last_reveal_position = request.last_reveal_block_position.ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} completed reveals without a canonical reveal position" + )) + })?; + if last_reveal_position != tip_position { + return Err(Self::data_request_tip_error(format!( + "request {pointer} remains unresolved at tip {tip_position}, after completing reveals at {last_reveal_position}" + ))); + } + let reveal_round = last_reveal_position + .checked_sub(commit_position) + .filter(|round| *round > 0 && *round <= terminal_round) + .ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} completed reveals outside rounds 1..={terminal_round}" + )) + })?; + ( + DataRequestStage::TALLY, + current_commit_round, + u16::try_from(reveal_round).map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} Reveal round {reveal_round} does not fit u16" + )) + })?, + ) + } else if elapsed < terminal_round { + let reveal_round = elapsed.checked_add(1).ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} Reveal round overflows" + )) + })?; + ( + DataRequestStage::REVEAL, + current_commit_round, + u16::try_from(reveal_round).map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} Reveal round {reveal_round} does not fit u16" + )) + })?, + ) + } else if elapsed == terminal_round { + ( + DataRequestStage::TALLY, + current_commit_round, + self.terminal_data_request_round, + ) + } else { + return Err(Self::data_request_tip_error(format!( + "request {pointer} remains unresolved {elapsed} blocks after its Commit, beyond terminal round {terminal_round}" + ))); + } + } else { + if !canonical.commits.is_empty() + || !canonical.reveals.is_empty() + || request.reveals != 0 + || request.last_reveal_block_position.is_some() + { + return Err(Self::data_request_tip_error(format!( + "request {pointer} retains Commit/Reveal state without a canonical Commit block" + ))); + } + let distance = tip_position + .checked_sub(request.inclusion_position) + .filter(|distance| *distance <= terminal_round) + .ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} remains unresolved beyond terminal Commit round {terminal_round}" + )) + })?; + if let Some(transition) = self.pending_tally_obligations.get(pointer) { + let expected_round = u16::try_from(distance).map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} pending Commit round {distance} does not fit u16" + )) + })?; + let common_mismatch = transition.transition_position != tip_position + || transition.tally_position != self.next_position + || transition.current_commit_round != expected_round; + match transition.kind { + SnapshotPendingTallyKind::LegacyCrossingOrdinary => { + if common_mismatch + || request.protocol_version >= ProtocolVersion::V2_0 + { + return Err(Self::data_request_tip_error(format!( + "request {pointer} has an inconsistent legacy-crossing tally obligation {:?} at tip {tip_position}", + transition, + ))); + } + } + SnapshotPendingTallyKind::V21CausalTooManyWitnesses => { + let validator_count = + validator_counts.for_request(request.protocol_version); + let still_too_many = + data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + request.protocol_version, + ); + if common_mismatch + || request.protocol_version < ProtocolVersion::V2_0 + || request.inclusion_epoch <= wit2_activation_epoch + || usize::from(transition.current_commit_round) + >= terminal_round + || !still_too_many + { + return Err(Self::data_request_tip_error(format!( + "request {pointer} has an inconsistent causal TooManyWitnesses obligation {:?} at tip {tip_position} with opening validator population {validator_count}", + transition, + ))); + } + } + } + ( + DataRequestStage::TALLY, + transition.current_commit_round, + 0, + ) + } else { + match actual.stage { + DataRequestStage::COMMIT if distance < terminal_round => ( + DataRequestStage::COMMIT, + u16::try_from(distance.checked_add(1).ok_or_else(|| { + Self::data_request_tip_error(format!( + "request {pointer} Commit round overflows" + )) + })?) + .map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} Commit round does not fit u16" + )) + })?, + 0, + ), + DataRequestStage::TALLY => { + if distance < terminal_round { + if request.inclusion_epoch != wit2_activation_epoch { + return Err(Self::data_request_tip_error(format!( + "request {pointer} enters TALLY early without an exact canonical pending-tally transition" + ))); + } + let validator_count = + validator_counts.for_request(request.protocol_version); + let tip_protocol = + self.protocol_version(self.highest_vrf_output.checkpoint); + if !data_request_has_too_many_witnesses_for_protocol( + &canonical.data_request, + validator_count, + tip_protocol, + ) { + return Err(Self::data_request_tip_error(format!( + "request {pointer} enters TALLY early with {} witnesses, but its post-block validator population {validator_count} does not trigger TooManyWitnesses", + canonical.data_request.witnesses, + ))); + } + } + ( + DataRequestStage::TALLY, + u16::try_from(distance).map_err(|_| { + Self::data_request_tip_error(format!( + "request {pointer} early TALLY round does not fit u16" + )) + })?, + 0, + ) + } + DataRequestStage::COMMIT => { + return Err(Self::data_request_tip_error(format!( + "request {pointer} remains in Commit at terminal distance {distance}" + ))); + } + DataRequestStage::REVEAL => { + return Err(Self::data_request_tip_error(format!( + "request {pointer} is in Reveal without canonical Commits" + ))); + } + } + } + }; + + if actual.stage != stage + || actual.info.current_stage != Some(stage) + || actual.info.current_commit_round != current_commit_round + || actual.info.current_reveal_round != current_reveal_round + { + return Err(Self::data_request_tip_error(format!( + "request {pointer} stage/rounds {:?}/{}/{} differ from canonical {:?}/{}/{}", + actual.stage, + actual.info.current_commit_round, + actual.info.current_reveal_round, + stage, + current_commit_round, + current_reveal_round, + ))); + } + if stage == DataRequestStage::COMMIT { + expected_epoch_index + .entry(request.inclusion_epoch) + .or_default() + .insert(*pointer); + } + + if !canonical.commits.is_empty() { + expected_committers.insert(*pointer, canonical.commits.keys().copied().collect()); + } + for (participant, commit) in &canonical.commits { + if !canonical.reveals.contains_key(participant) { + expected_pending.insert((*pointer, *participant), commit.body.commitment); + } + } + } + + if expected.data_requests_by_epoch != expected_epoch_index { + return Err(Self::data_request_tip_error( + "commit-eligible epoch index differs from canonical active stages", + )); + } + if self.request_committers != expected_committers { + return Err(Self::data_request_tip_error( + "internal canonical committer index differs from retained Commit payloads", + )); + } + if self.pending_commitments != expected_pending { + return Err(Self::data_request_tip_error( + "internal pending-commitment index differs from canonical unrevealed Commits", + )); + } + + let mut pending_reveal_signatures = + Vec::with_capacity(expected.waiting_for_reveal.len()); + for (pointer, reveal) in &expected.waiting_for_reveal { + let request = expected.data_request_pool.get(pointer).ok_or_else(|| { + Self::data_request_tip_error(format!( + "node-local pending Reveal names inactive request {pointer}" + )) + })?; + if request.stage == DataRequestStage::TALLY + || reveal.body.dr_pointer != *pointer + { + return Err(Self::data_request_tip_error(format!( + "node-local pending Reveal for {pointer} is incompatible with its active stage or body pointer" + ))); + } + let signature = validate_commit_reveal_signature( + reveal.hash(), + &reveal.signatures, + &mut pending_reveal_signatures, + ) + .map_err(|error| { + Self::data_request_tip_error(format!( + "node-local pending Reveal for {pointer} has an invalid signature envelope: {error}" + )) + })?; + let participant = signature.public_key.pkh(); + if reveal.body.pkh != participant { + return Err(Self::data_request_tip_error(format!( + "node-local pending Reveal for {pointer} has different body and signature participants" + ))); + } + match request.info.commits.get(&participant) { + Some(commitment) + if commitment.body.commitment != signature.signature.hash() => { + return Err(Self::data_request_tip_error(format!( + "node-local pending Reveal for {pointer} does not open its canonical commitment" + ))); + } + Some(_) => {} + None if request.stage == DataRequestStage::REVEAL => { + return Err(Self::data_request_tip_error(format!( + "node-local pending Reveal for {pointer} has no canonical Commit" + ))); + } + // A reveal is precomputed together with its Commit. While the request remains in + // COMMIT, that Commit may still be local-only and therefore absent from canonical + // state. Its Reveal is still body-signature authenticated above; once a matching + // canonical Commit exists, this branch is no longer available. + None => {} + } + } + + verify_signatures(pending_reveal_signatures, &mut self.vrf_ctx).map_err(|error| { + Self::data_request_tip_error(format!( + "node-local pending Reveal signature verification failed: {error}" + )) + })?; + + Ok(()) + } + + /// Reconstruct the exact post-block population split used by accepted-block stage updates. + /// The raw ledger count remains authoritative for requests included before V2.1. Native V2.1 + /// requests use only validators whose persisted owner retains the fixed minimum self-stake. + fn tip_validator_counts( + &self, + expected_stakes: &StakesTracker, + expected_delegation_state: &DelegationState, + ) -> Result { + let legacy = expected_stakes.validator_count(); + let current = if self.protocol_version(self.highest_vrf_output.checkpoint) + >= ProtocolVersion::V2_1 + { + expected_delegation_state + .active_validator_count_checked( + expected_stakes, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "cannot derive snapshot tip owner-active validator population: {error}" + ), + })? + } else { + legacy + }; + + Ok(SnapshotTipValidatorCounts { current, legacy }) + } + + /// Validate the final snapshot tip against the expected chain state. It delegates detailed comparison to validate_tip_state_inner after checking the imported boundary. + pub(super) fn validate_tip_state( + self, + expected_utxos: &OldUnspentOutputsPool, + expected_vrf_output: CheckpointVRF, + expected_stakes: &StakesTracker, + expected_delegation_state: &DelegationState, + expected_data_request_pool: &DataRequestPool, + ) -> Result<(), ImportError> { + self.validate_tip_state_inner( + expected_utxos, + expected_vrf_output, + expected_stakes, + expected_delegation_state, + expected_data_request_pool, + None, + ) + } + + /// Validate the complete imported tip and compare its replayed legacy reputation state with + /// the serialized ChainState. The optional comparison is kept separate for old focused + /// fixtures that construct only the transaction verifier; production snapshot import always + /// supplies the authenticated ChainState reputation engine. + pub(super) fn validate_tip_state_with_reputation( + self, + expected_utxos: &OldUnspentOutputsPool, + expected_vrf_output: CheckpointVRF, + expected_stakes: &StakesTracker, + expected_delegation_state: &DelegationState, + expected_data_request_pool: &DataRequestPool, + expected_reputation_engine: &ReputationEngine, + ) -> Result<(), ImportError> { + self.validate_tip_state_inner( + expected_utxos, + expected_vrf_output, + expected_stakes, + expected_delegation_state, + expected_data_request_pool, + Some(expected_reputation_engine), + ) + } + + /// Compare final replayed chain components with the expected tip state. It verifies UTXOs, stakes, delegation policies, reports, and protocol metadata before import completes. + fn validate_tip_state_inner( + mut self, + expected_utxos: &OldUnspentOutputsPool, + expected_vrf_output: CheckpointVRF, + expected_stakes: &StakesTracker, + expected_delegation_state: &DelegationState, + expected_data_request_pool: &DataRequestPool, + expected_reputation_engine: Option<&ReputationEngine>, + ) -> Result<(), ImportError> { + if !self.authenticated_policies.observed_v21_block { + self.legacy_stake_replay + .validate_persisted_stakes(expected_stakes) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "snapshot pre-activation stake replay is invalid: {error}" + ), + })?; + } + let validator_counts = + self.tip_validator_counts(expected_stakes, expected_delegation_state)?; + self.validate_data_request_tip_state(expected_data_request_pool, validator_counts)?; + if self.highest_vrf_output != expected_vrf_output { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot ChainInfo VRF tip {:?} does not equal the checkpoint/proof hash {:?} reconstructed from canonical block history", + expected_vrf_output, self.highest_vrf_output + ), + }); + } + self.authenticated_policies + .validate_persisted_policies(expected_delegation_state) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot delegated policy provenance is invalid: {error}"), + })?; + self.authenticated_policies + .validate_persisted_exact_stakes(expected_stakes, expected_delegation_state) + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot delegated stake replay is invalid: {error}"), + })?; + if let Some(expected_reputation_engine) = expected_reputation_engine + && !self.reputation_state_matches(expected_reputation_engine) + { + return Err(ImportError::SnapshotContents { + message: + "snapshot ReputationEngine does not equal the canonical pre-Wit/2 replay" + .to_string(), + }); + } + + let actual = self + .utxos + .try_into_portable() + .map_err(|error| ImportError::SnapshotContents { + message: format!("cannot materialize replayed snapshot UTXO image: {error}"), + })?; + if &actual == expected_utxos { + Ok(()) + } else { + Err(ImportError::SnapshotContents { + message: "snapshot tip UTXO image does not equal the image reconstructed from canonical transaction history" + .to_string(), + }) + } + } +} + +/// Load one snapshot component from its generation file. It supplies the deserialized component used by manifest verification and replay import. +pub(super) fn snapshot_component_from_file(path: &Path) -> Result { + // Hash exported components incrementally so metadata generation does not retain a second + // in-memory copy of potentially large ChainState, block-batch, or report files. + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let (byte_len, sha256) = calculate_sha256_reader(&mut reader)?; + Ok(SnapshotComponent { + byte_len, + sha256: sha256.0, + }) +} + +/// Create a new snapshot generation and its component writers. It coordinates temporary paths, manifest metadata, and cleanup state for atomic snapshot publication. +pub(super) fn create_snapshot_generation( + manifest_path: &Path, +) -> Result<(SnapshotGeneration, PathBuf), anyhow::Error> { + let unix_nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let process_id = std::process::id(); + for attempt in 0..=u32::MAX { + let generation = SnapshotGeneration { + unix_nanos, + process_id, + attempt, + }; + let generation_dir = snapshot_generation_dir(manifest_path, generation)?; + match std::fs::create_dir(&generation_dir) { + Ok(()) => return Ok((generation, generation_dir)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + + Err(anyhow::anyhow!( + "could not allocate a unique snapshot generation for {}", + manifest_path.display() + )) +} + +/// Prove that the generation named by a committed manifest is self-contained before deleting any +/// older generation. Hash validation alone is not enough here: following a component symlink into +/// an older generation and then deleting that generation would leave the committed manifest +/// broken immediately after a nominally successful cleanup. This protects the actor's +/// single-writer lifecycle, not hostile concurrent filesystem replacement between inspection and +/// open or between verification and deletion. +fn verify_snapshot_generation_components( + manifest_path: &Path, + manifest: &SnapshotManifest, +) -> Result<(), anyhow::Error> { + let generation_dir = snapshot_generation_dir(manifest_path, manifest.generation)?; + let generation_metadata = std::fs::symlink_metadata(&generation_dir).map_err(|error| { + anyhow::anyhow!( + "cannot inspect committed snapshot generation {}: {error}", + generation_dir.display() + ) + })?; + anyhow::ensure!( + generation_metadata.file_type().is_dir() + && !generation_metadata.file_type().is_symlink(), + "committed snapshot generation {} is not a regular directory", + generation_dir.display() + ); + + let verify_component = + |label: &str, path: PathBuf, component: &SnapshotComponent| -> Result<(), anyhow::Error> { + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + anyhow::anyhow!( + "cannot inspect committed snapshot {label} component {}: {error}", + path.display() + ) + })?; + anyhow::ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "committed snapshot {label} component {} is not a regular file", + path.display() + ); + super::super::open_verified_snapshot_component(&path, component).map_err(|error| { + anyhow::anyhow!( + "cannot verify committed snapshot {label} component {}: {error}", + path.display() + ) + })?; + Ok(()) + }; + + verify_component( + "ChainState", + snapshot_chain_state_path(&generation_dir), + &manifest.chain_state, + )?; + verify_component( + "superblocks", + snapshot_superblocks_path(&generation_dir), + &manifest.superblocks, + )?; + if let Some(reports) = manifest.reports.as_ref() { + verify_component( + "reports", + snapshot_reports_path(&generation_dir), + reports, + )?; + } + for batch in &manifest.block_batches { + verify_component( + "block batch", + snapshot_blocks_batch_path(&generation_dir, batch.index), + &batch.component, + )?; + } + + Ok(()) +} + +/// Reclaim old filesystem generations after a new manifest has been durably published. +/// +/// Only names with the exact generation-token shape emitted by [`SnapshotGeneration::file_token`] +/// are considered, and symlinks are never followed. The current generation is retained. This is +/// deliberately best-effort after publication: a cleanup failure must not report an otherwise +/// committed snapshot as failed or risk deleting the generation named by the manifest. +pub(super) fn cleanup_snapshot_generation_dirs( + manifest_path: &Path, + current_generation: SnapshotGeneration, +) -> Result { + // Use the same version-dispatching loader as import so cleanup remains safe across the + // frozen v1/v2 layouts after the live manifest gained its self-contained protocol schedule. + let manifest = super::super::load_snapshot_manifest(manifest_path) + .map_err(|error| anyhow::anyhow!("cannot validate committed snapshot manifest: {error}"))?; + anyhow::ensure!( + manifest.generation == current_generation, + "committed snapshot manifest no longer references the exported generation" + ); + // Cleanup is destructive and the manifest is only useful if the generation it names remains + // independently readable. Verify every component before removing a possible last-known-good + // predecessor. + verify_snapshot_generation_components(manifest_path, &manifest)?; + + let parent = manifest_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let file_name = manifest_path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| anyhow::anyhow!("snapshot manifest file name is not valid UTF-8"))?; + let prefix = format!("{file_name}.generation-"); + let current_dir = snapshot_generation_dir(manifest_path, current_generation)?; + let mut removed = 0_usize; + + for entry in std::fs::read_dir(parent)? { + let entry = entry?; + let path = entry.path(); + if path == current_dir { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Some(token) = name.strip_prefix(&prefix) else { + continue; + }; + // `file_token` emits 32 hex digits, an underscore, 8 hex digits, another underscore, + // and 8 hex digits. Refuse every other similarly prefixed path as operator-owned data. + let token_bytes = token.as_bytes(); + let valid_token = token_bytes.len() == 50 + && token_bytes[32] == b'_' + && token_bytes[41] == b'_' + && token_bytes[..32] + .iter() + .chain(token_bytes[33..41].iter()) + .chain(token_bytes[42..].iter()) + .all(|byte| byte.is_ascii_hexdigit()); + if !valid_token { + continue; + } + + let metadata = std::fs::symlink_metadata(&path)?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + continue; + } + std::fs::remove_dir_all(&path)?; + removed = removed + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("snapshot generation cleanup count overflow"))?; + } + + Ok(removed) +} + +/// Recover filesystem generations left by an interrupted export during ordinary startup. +/// +/// The configured export directory may contain a valid manifest for the current chain tip and +/// one or more generation directories from attempts that crashed before publishing their +/// manifest. Only that exact, tip-derived manifest is considered; a missing, malformed, legacy, +/// or symlinked manifest leaves every directory untouched because there is no trustworthy commit +/// point from which to decide which generation is safe to remove. +pub(super) fn cleanup_configured_snapshot_generation_orphans( + export: &Force, + chain_state: &ChainState, +) -> Result { + let export_path = match export { + Force::All(path) | Force::Some(path) => path, + Force::None => return Ok(0), + }; + let chain_info = match chain_state.chain_info.as_ref() { + Some(chain_info) => chain_info, + None => return Ok(0), + }; + let manifest_path = export_path.join(format!( + "witnet_chain_snapshot_{}_{}.bin", + chain_info.environment, + chain_info.highest_block_checkpoint.checkpoint + )); + + let metadata = match std::fs::symlink_metadata(&manifest_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + return Ok(0); + } + + // `load_snapshot_manifest` performs exact decoding and structural manifest validation. The + // cleanup boundary below additionally verifies every current component's filesystem type, + // length, and hash before it authorizes deletion. + let manifest = super::super::load_snapshot_manifest(&manifest_path) + .map_err(|error| anyhow::anyhow!("cannot recover snapshot generations: {error}"))?; + cleanup_snapshot_generation_dirs(&manifest_path, manifest.generation) +} + +/// Reclaim a generation only when an export fails before its manifest can reference it. +/// +/// A process crash cannot run this guard, and a failed `sync_all` after the manifest rename leaves +/// publication ambiguous. In both cases the guard deliberately keeps the directory: startup or +/// an operator can recover it, while deleting it could leave a durable manifest pointing at +/// missing components. If the manifest is unreadable (including a legacy layout), the guard also +/// keeps the directory because it cannot prove that removal is safe. +pub(super) struct SnapshotGenerationCleanup { + manifest_path: PathBuf, + generation: SnapshotGeneration, + generation_dir: PathBuf, + armed: bool, +} + +impl SnapshotGenerationCleanup { + pub(super) fn new( + manifest_path: &Path, + generation: SnapshotGeneration, + generation_dir: PathBuf, + ) -> Self { + Self { + manifest_path: manifest_path.to_path_buf(), + generation, + generation_dir, + armed: true, + } + } + + /// Disable cleanup after a snapshot generation has been committed successfully. It prevents Drop from removing files that the manifest now references. + pub(super) fn disarm(&mut self) { + self.armed = false; + } + + /// Return `Some(true)` only when the current manifest definitely names this generation. + /// `None` means that the manifest cannot be trusted for cleanup decisions. + fn manifest_references_generation(&self) -> Option { + // Match the import path's exact version-dispatching decoder and validation. A legacy, + // truncated, trailing-byte, or operator-modified manifest must not authorize deleting an + // otherwise recoverable generation merely because its bytes happen to decode as the + // current struct. + let manifest = super::super::load_snapshot_manifest(&self.manifest_path).ok()?; + Some(manifest.generation == self.generation) + } +} + +impl Drop for SnapshotGenerationCleanup { + /// Remove an uncommitted snapshot generation when cleanup ownership is still armed. It keeps partial component files from being mistaken for an importable snapshot. + fn drop(&mut self) { + if !self.armed { + return; + } + + match self.manifest_references_generation() { + Some(true) | None => { + log::warn!( + "Preserving possibly published snapshot generation {} after an incomplete export", + self.generation_dir.display() + ); + } + Some(false) => { + // The path was created by `create_snapshot_generation` for this attempt. Do not + // follow symlinks or remove an unexpected filesystem entry if an operator changed + // the directory while the export was running. + match std::fs::symlink_metadata(&self.generation_dir) { + Ok(metadata) + if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => + { + if let Err(error) = std::fs::remove_dir_all(&self.generation_dir) { + log::warn!( + "Could not reclaim failed snapshot generation {}: {error}", + self.generation_dir.display() + ); + } + } + Ok(_) => log::warn!( + "Not reclaiming failed snapshot generation path {} because it is not a directory", + self.generation_dir.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!( + "Could not inspect failed snapshot generation {}: {error}", + self.generation_dir.display() + ), + } + } + } + } +} + +/// One serialized historical report and the exact canonical block observations required to prove +/// it. Reports are loaded before block batches, but are not staged or exported until every batch +/// has demonstrated the request, commit, reveal, and tally identities below. +struct SnapshotReportExpectation { + logical_key: Vec, + value: Vec, + request_block_hash: Hash, + request_block_position: usize, + request_epoch: Epoch, + tally_block_hash: Hash, + tally_block_position: usize, + tally_epoch: Epoch, + /// Epoch of the immediately preceding canonical block, if one exists. This authenticates the + /// stage-update protocol that can make a pre-Wit/2 request tally-ready before normal expiry. + tally_predecessor_epoch: Option, + tally: TallyTransaction, + current_commit_round: u16, + current_reveal_round: u16, + /// Witness target authenticated by the exact canonical request transaction. This is populated + /// while blocks are observed rather than trusted from the serialized report. + witnesses: Option, + commits: Vec, + reveals: Vec, + commit_block_position: Option, + request_seen: bool, + tally_seen: bool, +} + +/// Classify the only two round-zero TooManyWitnesses chronologies admitted by consensus. +fn permits_round_zero_too_many_witnesses( + request_to_tally: usize, + request_epoch: Epoch, + wit2_activation_epoch: Epoch, +) -> bool { + if wit2_activation_epoch == Epoch::MAX { + return false; + } + match request_to_tally { + // TooManyWitnesses is not defined before Wit/2.0, but from activation onward a new request + // may be resolved in the block that includes it. + 0 => request_epoch >= wit2_activation_epoch, + // The historical non-same-block compatibility gate is exact: only requests included in + // the Wit/2 activation epoch may carry this sentinel in a later block. + 1 => request_epoch == wit2_activation_epoch, + _ => false, + } +} + +/// Return whether a legacy no-commit request can have entered TALLY early at a Wit/2 boundary. +/// +/// Stage consolidation classifies `TooManyWitnesses` using the block being consolidated, while +/// tally construction and validation classify the same request using its inclusion epoch. A +/// pre-Wit/2 request can therefore transition early after a low-population Wit/2 block, then carry +/// an ordinary (non-sentinel) tally in the immediately following canonical block. The +/// transaction-history verifier separately records and authenticates the exact post-block +/// population transition. This Report-only predicate proves the remaining bounded chronology and +/// persisted counter shape without duplicating that ledger replay. +fn permits_pre_wit2_crossing_ordinary_tally( + request_to_tally: usize, + request_epoch: Epoch, + tally_predecessor_epoch: Option, + wit2_activation_epoch: Epoch, + terminal_round: usize, +) -> bool { + wit2_activation_epoch != Epoch::MAX + && request_epoch < wit2_activation_epoch + && tally_predecessor_epoch + .is_some_and(|predecessor_epoch| predecessor_epoch >= wit2_activation_epoch) + && request_to_tally >= 2 + && request_to_tally <= terminal_round +} + +/// Return whether a Report has the bounded shape of a causal Wit/2.1 TooManyWitnesses tally. +/// +/// Reports authenticate the exact request/tally blocks and persisted counters but do not carry +/// validator populations. The transaction-history verifier separately proves the opening-high to +/// post-block-low transition, its pending next-block obligation, and the still-low tally-block +/// opening population. +fn permits_v21_causal_too_many_witnesses( + request_to_tally: usize, + request_epoch: Epoch, + request_protocol: ProtocolVersion, + tally_protocol: ProtocolVersion, + wit2_activation_epoch: Epoch, + terminal_round: usize, +) -> bool { + wit2_activation_epoch != Epoch::MAX + && request_epoch > wit2_activation_epoch + && request_protocol >= ProtocolVersion::V2_0 + && tally_protocol >= ProtocolVersion::V2_1 + && request_to_tally >= 1 + && request_to_tally <= terminal_round +} + +struct SnapshotReportCommitExpectation { + participant: PublicKeyHash, + transaction: CommitTransaction, + seen: bool, +} + +struct SnapshotReportRevealExpectation { + participant: PublicKeyHash, + transaction: RevealTransaction, + block_position: Option, + seen: bool, +} + +/// Coverage plan for a portable snapshot's Report domain. +/// +/// Version-1 and legacy snapshots deliberately disable this plan. Version-2/version-3 import and +/// every new export require a one-to-one mapping between canonical tally transactions and complete reports, +/// exact canonical occurrences for the report's request, commits, and reveals, and round counters +/// that agree with their canonical block chronology. Re-running this proof for every export is the +/// authoritative capability check after a report-less compatibility import; a persisted coverage +/// marker would be redundant and could become stale as canonical history or inventory changes. +pub(super) struct SnapshotReportPlan { + required: bool, + protocol_info: ProtocolInfo, + wit2_activation_epoch: Epoch, + /// `extra_rounds + 1`, checked once at the snapshot boundary and then used as the exact final + /// commit/reveal round for every historical request. + terminal_round: u16, + by_pointer: BTreeMap, + by_commit: BTreeMap<(Hash, PublicKeyHash), (usize, usize)>, + by_reveal: BTreeMap<(Hash, PublicKeyHash), (usize, usize)>, + expectations: Vec, +} + +impl SnapshotReportPlan { + fn disabled(protocol_info: ProtocolInfo) -> Self { + Self { + required: false, + protocol_info, + wit2_activation_epoch: Epoch::MAX, + terminal_round: 1, + by_pointer: BTreeMap::new(), + by_commit: BTreeMap::new(), + by_reveal: BTreeMap::new(), + expectations: Vec::new(), + } + } + + /// Return the number of reports observed by the snapshot plan. It supports bounded report draining and final manifest accounting. + pub(super) fn report_count(&self) -> usize { + self.expectations.len() + } + + /// Return the report-observation filter selected for this snapshot plan. It tells callers which request, tally, commit, and reveal events are retained. + pub(super) fn observation_filter(&self) -> SnapshotReportObservationFilter { + SnapshotReportObservationFilter { + reported_pointers: self.by_pointer.keys().copied().collect(), + } + } + + /// Observe one data request while building a snapshot report plan. It updates the request-side projection used to serialize report components. + fn observe_request( + &mut self, + pointer: Hash, + block_hash: Hash, + witnesses: u16, + ) -> Result<(), ImportError> { + if !self.required { + return Ok(()); + } + let Some(index) = self.by_pointer.get(&pointer).copied() else { + // A request may still be unresolved at the snapshot tip and therefore have no report. + return Ok(()); + }; + let expectation = &mut self.expectations[index]; + if expectation.request_block_hash != block_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "data request {pointer} occurs in canonical block {block_hash}, but its report names {}", + expectation.request_block_hash + ), + }); + } + if expectation.request_seen { + return Err(ImportError::SnapshotContents { + message: format!( + "data request {pointer} occurs more than once in canonical snapshot blocks" + ), + }); + } + expectation.request_seen = true; + expectation.witnesses = Some(witnesses); + Ok(()) + } + + /// Observe one tally while building a snapshot report plan. It records settlement metadata needed by later report and reward projections. + fn observe_tally( + &mut self, + tally: &TallyTransaction, + block_hash: Hash, + ) -> Result<(), ImportError> { + if !self.required { + return Ok(()); + } + let pointer = tally.dr_pointer; + let Some(index) = self.by_pointer.get(&pointer).copied() else { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical tally for data request {pointer} has no version-2/version-3 snapshot report; version-2/version-3 export requires complete canonical Report coverage, including after a report-less legacy/version-1 import" + ), + }); + }; + let expectation = &mut self.expectations[index]; + if expectation.tally_block_hash != block_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "tally for data request {pointer} occurs in canonical block {block_hash}, but its report names {}", + expectation.tally_block_hash + ), + }); + } + if &expectation.tally != tally { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical tally for data request {pointer} does not equal its snapshot report tally" + ), + }); + } + if expectation.tally_seen { + return Err(ImportError::SnapshotContents { + message: format!( + "tally for data request {pointer} occurs more than once in canonical snapshot blocks" + ), + }); + } + expectation.tally_seen = true; + Ok(()) + } + + /// Observe one commit while building a snapshot report plan. It records commitment data needed to connect collateral and request history during import. + fn observe_commit( + &mut self, + transaction: &CommitTransaction, + block_position: usize, + ) -> Result<(), ImportError> { + if !self.required { + return Ok(()); + } + let key = ( + transaction.body.dr_pointer, + transaction.body.proof.proof.pkh(), + ); + let Some((report_index, participant_index)) = self.by_commit.get(&key).copied() else { + if self.by_pointer.contains_key(&key.0) { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical commit for data request {} participant {} is absent from its snapshot report", + key.0, key.1 + ), + }); + } + return Ok(()); + }; + let expectation = &mut self.expectations[report_index].commits[participant_index]; + if &expectation.transaction != transaction { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical commit for data request {} participant {} does not equal its snapshot report commit", + key.0, key.1 + ), + }); + } + if expectation.seen { + return Err(ImportError::SnapshotContents { + message: format!( + "commit for data request {} participant {} occurs more than once in canonical snapshot blocks", + key.0, key.1 + ), + }); + } + expectation.seen = true; + let report = &mut self.expectations[report_index]; + if let Some(previous_position) = report.commit_block_position { + if previous_position != block_position { + return Err(ImportError::SnapshotContents { + message: format!( + "commits for data request {} occur in more than one canonical snapshot block", + key.0 + ), + }); + } + } else { + report.commit_block_position = Some(block_position); + } + Ok(()) + } + + /// Observe one reveal while building a snapshot report plan. It records witness data needed to reconstruct the request's report history. + fn observe_reveal( + &mut self, + transaction: &RevealTransaction, + block_position: usize, + ) -> Result<(), ImportError> { + if !self.required { + return Ok(()); + } + let key = (transaction.body.dr_pointer, transaction.body.pkh); + let Some((report_index, participant_index)) = self.by_reveal.get(&key).copied() else { + if self.by_pointer.contains_key(&key.0) { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical reveal for data request {} participant {} is absent from its snapshot report", + key.0, key.1 + ), + }); + } + return Ok(()); + }; + let expectation = &mut self.expectations[report_index].reveals[participant_index]; + if &expectation.transaction != transaction { + return Err(ImportError::SnapshotContents { + message: format!( + "canonical reveal for data request {} participant {} does not equal its snapshot report reveal", + key.0, key.1 + ), + }); + } + if expectation.seen { + return Err(ImportError::SnapshotContents { + message: format!( + "reveal for data request {} participant {} occurs more than once in canonical snapshot blocks", + key.0, key.1 + ), + }); + } + expectation.seen = true; + expectation.block_position = Some(block_position); + Ok(()) + } + + /// Observe all report-bearing transactions in one block. It dispatches to the request, commit, reveal, and tally observers while preserving block order. + pub(super) fn observe_block( + &mut self, + block: &Block, + block_position: usize, + ) -> Result<(), ImportError> { + if !self.required { + return Ok(()); + } + let epoch = block.block_header.beacon.checkpoint; + let protocol = self.protocol_info.all_versions.version_for_epoch(epoch); + let block_hash = block.versioned_hash(protocol); + for transaction in &block.txns.data_request_txns { + self.observe_request( + transaction.versioned_hash(protocol), + block_hash, + transaction.body.dr_output.witnesses, + )?; + } + for transaction in &block.txns.commit_txns { + self.observe_commit(transaction, block_position)?; + } + for transaction in &block.txns.reveal_txns { + self.observe_reveal(transaction, block_position)?; + } + for tally in &block.txns.tally_txns { + self.observe_tally(tally, block_hash)?; + } + Ok(()) + } + + /// Merge report observations from another snapshot plan. It combines compatible component projections without changing canonical ordering. + pub(super) fn merge_observations( + &mut self, + observations: SnapshotReportObservations, + ) -> Result<(), ImportError> { + for (pointer, block_hash, witnesses) in observations.requests { + self.observe_request(pointer, block_hash, witnesses)?; + } + for (transaction, block_position) in observations.commits { + self.observe_commit(&transaction, block_position)?; + } + for (transaction, block_position) in observations.reveals { + self.observe_reveal(&transaction, block_position)?; + } + for (tally, block_hash) in observations.tallies { + self.observe_tally(&tally, block_hash)?; + } + Ok(()) + } + + /// Finish the report plan and return serialized snapshot report components. It validates accumulated observations before handing bytes to generation publication. + pub(super) fn finish(self) -> Result, Vec)>, ImportError> { + if !self.required { + return Ok(Vec::new()); + } + for expectation in &self.expectations { + let pointer = expectation.tally.dr_pointer; + if !expectation.request_seen { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has no exact canonical request transaction" + ), + }); + } + if !expectation.tally_seen { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has no exact canonical tally transaction" + ), + }); + } + for commit in &expectation.commits { + if !commit.seen { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains commit by {} absent from canonical snapshot blocks", + commit.participant + ), + }); + } + } + for reveal in &expectation.reveals { + if !reveal.seen { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains reveal by {} absent from canonical snapshot blocks", + reveal.participant + ), + }); + } + } + + let witnesses = expectation.witnesses.ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has no authenticated canonical witness target" + ), + } + })?; + let too_many_witnesses = + witnet_data_structures::radon_error::RadonError::try_from( + witnet_rad::error::RadError::TooManyWitnesses, + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "cannot construct the TooManyWitnesses report sentinel: {error}" + ), + })?; + let too_many_witnesses_tally = Vec::::try_from( + witnet_rad::types::RadonTypes::RadonError(too_many_witnesses), + ) + .map_err(|error| ImportError::SnapshotContents { + message: format!( + "cannot encode the TooManyWitnesses report sentinel: {error}" + ), + })?; + let is_too_many_witnesses = expectation.tally.tally.as_slice() + == too_many_witnesses_tally.as_slice(); + if is_too_many_witnesses + && expectation.tally_epoch < self.wit2_activation_epoch + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains TooManyWitnesses at pre-Wit/2 epoch {}", + expectation.tally_epoch, + ), + }); + } + let terminal_round = usize::from(self.terminal_round); + let request_protocol = self + .protocol_info + .all_versions + .version_for_epoch(expectation.request_epoch); + let tally_protocol = self + .protocol_info + .all_versions + .version_for_epoch(expectation.tally_epoch); + + // Stage updates run once after each consolidated block, regardless of numeric epoch + // gaps. The request transaction authenticates the witness target, canonical reports + // authenticate the complete commit/reveal maps, and the pinned consensus constants + // authenticate the terminal round. Together those values determine every structural + // transition. Report chronology permits the same-block TooManyWitnesses shape for any + // Wit/2 request; transaction replay separately proves that request's exact opening + // threshold. The historical compatibility gate retains activation-epoch requests, and + // Wit/2.1 additionally admits one exact next-block causal form before terminal expiry. + // Transaction replay proves the exact validator populations for both bounded forms. + let (derived_commit_round, derived_reveal_round) = if expectation.commits.is_empty() { + if !expectation.reveals.is_empty() { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has reveals without commits" + ), + }); + } + let request_to_tally = expectation + .tally_block_position + .checked_sub(expectation.request_block_position) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} tallies before its canonical request" + ), + })?; + let commit_round = match request_to_tally { + // A newly included TooManyWitnesses request is forced to TALLY while its + // counter is still zero and must carry its tally in that same block. + 0 if is_too_many_witnesses + && permits_round_zero_too_many_witnesses( + request_to_tally, + expectation.request_epoch, + self.wit2_activation_epoch, + ) => + { + 0 + } + 0 => { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} tallies in its request block without a post-activation TooManyWitnesses result" + ), + }); + } + // This historical non-same-block compatibility branch accepts the round-zero + // sentinel only for a request included at the exact Wit/2 activation epoch. + 1 if is_too_many_witnesses + && permits_round_zero_too_many_witnesses( + request_to_tally, + expectation.request_epoch, + self.wit2_activation_epoch, + ) => + { + 0 + } + distance if is_too_many_witnesses + && expectation.request_epoch == self.wit2_activation_epoch + && distance >= 2 + && distance <= terminal_round.saturating_add(1) => + { + // The exact activation request and bounded canonical ordinal authenticate + // this historical later TooManyWitnesses exception independently of the + // Wit/2.1 causal branch below. + distance - 1 + } + distance + if is_too_many_witnesses + && permits_v21_causal_too_many_witnesses( + distance, + expectation.request_epoch, + request_protocol, + tally_protocol, + self.wit2_activation_epoch, + terminal_round, + ) => + { + distance - 1 + } + distance if is_too_many_witnesses => { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} places a TooManyWitnesses tally {distance} canonical blocks after its request, beyond terminal commit round {}", + self.terminal_round, + ), + }); + } + distance + if permits_pre_wit2_crossing_ordinary_tally( + distance, + expectation.request_epoch, + expectation.tally_predecessor_epoch, + self.wit2_activation_epoch, + terminal_round, + ) => + { + // Consolidation advances the legacy request after the preceding Wit/2 + // block; validation then constructs an ordinary tally from the request's + // pre-Wit/2 inclusion protocol. + distance - 1 + } + distance => { + let expected_distance = terminal_round.checked_add(1).ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} terminal commit chronology overflows" + ), + } + })?; + if distance != expected_distance { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} tallies {distance} canonical blocks after its request without commits, expected {expected_distance} for terminal round {}", + self.terminal_round, + ), + }); + } + terminal_round + } + }; + (commit_round, 0_usize) + } else { + if is_too_many_witnesses + && expectation.request_epoch != self.wit2_activation_epoch + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} combines commits with a TooManyWitnesses tally outside the Wit/2 activation compatibility epoch" + ), + }); + } + if expectation.commits.len() != usize::from(witnesses) { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains {} canonical commits, expected its authenticated witness target {witnesses}", + expectation.commits.len(), + ), + }); + } + let commit_position = expectation.commit_block_position.ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has no canonical commit block position" + ), + } + })?; + let commit_round = commit_position + .checked_sub(expectation.request_block_position) + .filter(|round| *round > 0) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} does not commit strictly after its request" + ), + })?; + if commit_round > terminal_round { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} commits in round {commit_round}, after terminal round {}", + self.terminal_round, + ), + }); + } + let mut last_reveal_position = None; + for reveal in &expectation.reveals { + let reveal_position = reveal.block_position.ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} reveal by {} has no canonical block position", + reveal.participant + ), + } + })?; + if reveal_position <= commit_position + || reveal_position >= expectation.tally_block_position + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} reveal by {} does not occur strictly between commit and tally", + reveal.participant + ), + }); + } + last_reveal_position = Some( + last_reveal_position.map_or(reveal_position, |previous: usize| { + previous.max(reveal_position) + }), + ); + } + let reveal_round = if expectation.reveals.len() == usize::from(witnesses) { + let last_reveal_position = last_reveal_position.ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} completed its witness target without a canonical reveal position" + ), + } + })?; + let expected_tally_position = + last_reveal_position.checked_add(1).ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} completed-reveal chronology overflows" + ), + } + })?; + if expectation.tally_block_position != expected_tally_position { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} tallies at canonical block {}, expected immediately after its final reveal at block {last_reveal_position}", + expectation.tally_block_position, + ), + }); + } + let round = last_reveal_position + .checked_sub(commit_position) + .filter(|round| *round > 0 && *round <= terminal_round) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} completes reveals outside rounds 1..={} after its commit", + self.terminal_round, + ), + })?; + round + } else { + let expected_tally_position = commit_position + .checked_add(terminal_round) + .and_then(|position| position.checked_add(1)) + .ok_or_else(|| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} terminal reveal chronology overflows" + ), + })?; + if expectation.tally_block_position != expected_tally_position { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} tallies at canonical block {} with only {}/{} reveals, expected block {expected_tally_position} after terminal reveal round {}", + expectation.tally_block_position, + expectation.reveals.len(), + witnesses, + self.terminal_round, + ), + }); + } + terminal_round + }; + (commit_round, reveal_round) + }; + let derived_commit_round = + u16::try_from(derived_commit_round).map_err(|_| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} commit round does not fit u16" + ), + })?; + let derived_reveal_round = + u16::try_from(derived_reveal_round).map_err(|_| ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} reveal round does not fit u16" + ), + })?; + if expectation.current_commit_round != derived_commit_round + || expectation.current_reveal_round != derived_reveal_round + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} has rounds {}/{}, expected {}/{} from canonical block order", + expectation.current_commit_round, + expectation.current_reveal_round, + derived_commit_round, + derived_reveal_round, + ), + }); + } + } + + Ok(self + .expectations + .into_iter() + .map(|expectation| (expectation.logical_key, expectation.value)) + .collect()) + } +} + +/// Bounded observations emitted by one export batch. Requests, commits, and reveals are retained +/// only for pointers that have reports; every tally is retained so omission of a report remains +/// detectable. Retaining all participants for a reported pointer also makes report omissions +/// detectable without accumulating unrelated in-flight data requests. Commit and reveal entries +/// carry absolute canonical block positions because export batches complete out of order. +#[derive(Default)] +pub(super) struct SnapshotReportObservations { + requests: Vec<(Hash, Hash, u16)>, + commits: Vec<(CommitTransaction, usize)>, + reveals: Vec<(RevealTransaction, usize)>, + tallies: Vec<(TallyTransaction, Hash)>, +} + +#[derive(Default)] +pub(super) struct SnapshotReportObservationFilter { + reported_pointers: HashSet, +} + +impl SnapshotReportObservations { + /// Build snapshot report observations from a block sequence. It drives SnapshotReportPlan in canonical order and returns serialized report components for export. + pub(super) fn from_blocks( + blocks: &[Block], + filter: &SnapshotReportObservationFilter, + first_block_position: usize, + protocol_info: &ProtocolInfo, + ) -> Result { + let mut observations = Self::default(); + for (local_position, block) in blocks.iter().enumerate() { + let block_position = first_block_position.checked_add(local_position).ok_or_else(|| { + ImportError::SnapshotContents { + message: "snapshot block position overflow".to_string(), + } + })?; + let epoch = block.block_header.beacon.checkpoint; + let protocol = protocol_info.all_versions.version_for_epoch(epoch); + let block_hash = block.versioned_hash(protocol); + for transaction in &block.txns.data_request_txns { + let pointer = transaction.versioned_hash(protocol); + if filter.reported_pointers.contains(&pointer) { + observations.requests.push(( + pointer, + block_hash, + transaction.body.dr_output.witnesses, + )); + } + } + observations.commits.extend( + block + .txns + .commit_txns + .iter() + .filter(|transaction| { + filter + .reported_pointers + .contains(&transaction.body.dr_pointer) + }) + .cloned() + .map(|transaction| (transaction, block_position)), + ); + observations.reveals.extend( + block + .txns + .reveal_txns + .iter() + .filter(|transaction| { + filter + .reported_pointers + .contains(&transaction.body.dr_pointer) + }) + .cloned() + .map(|transaction| (transaction, block_position)), + ); + observations.tallies.extend( + block + .txns + .tally_txns + .iter() + .cloned() + .map(|tally| (tally, block_hash)), + ); + } + Ok(observations) + } +} + + +impl ChainManager { + /// Validate portable historical data-request reports against the supplied block index. + /// + /// Snapshot import is strict. Export may omit a structurally valid record whose referenced + /// block is provably absent from the canonical index because ordinary inventory can retain + /// reports from a pre-superblock fork until a later generation replaces it. + pub(super) fn preflight_snapshot_reports_with_protocol( + reports: Vec<(Vec, Vec)>, + imported_block_chain: &Blockchain, + omit_noncanonical_reports: bool, + reports_required: bool, + protocol_info: &ProtocolInfo, + extra_rounds: u16, + ) -> Result { + if !reports_required { + if !reports.is_empty() { + return Err(ImportError::SnapshotContents { + message: "report-less snapshot format unexpectedly supplied report records" + .to_string(), + }); + } + return Ok(SnapshotReportPlan::disabled(protocol_info.clone())); + } + + let imported_block_epochs = imported_block_chain.keys().copied().collect::>(); + let imported_block_positions = imported_block_chain + .iter() + .enumerate() + .map(|(position, (&epoch, &hash))| (hash, (position, epoch))) + .collect::>(); + if imported_block_positions.len() != imported_block_chain.len() { + return Err(ImportError::SnapshotContents { + message: "snapshot block index contains the same canonical block hash at multiple epochs" + .to_string(), + }); + } + let terminal_round = extra_rounds.checked_add(1).ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "snapshot consensus extra_rounds {extra_rounds} has no representable terminal data-request round" + ), + } + })?; + let mut plan = SnapshotReportPlan { + required: true, + protocol_info: protocol_info.clone(), + wit2_activation_epoch: protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX), + terminal_round, + by_pointer: BTreeMap::new(), + by_commit: BTreeMap::new(), + by_reveal: BTreeMap::new(), + expectations: Vec::with_capacity(reports.len()), + }; + let mut previous_key: Option> = None; + for (logical_key, value) in reports { + if previous_key + .as_ref() + .is_some_and(|previous| logical_key.as_slice() <= previous.as_slice()) + { + return Err(ImportError::SnapshotContents { + message: "snapshot report keys are not strictly increasing".to_string(), + }); + } + previous_key = Some(logical_key.clone()); + let report: DataRequestInfo = + witnet_data_structures::bincode_compatibility::deserialize_exact(&value).map_err( + |_| ImportError::SnapshotContents { + message: "snapshot report value is not an exact DataRequestInfo payload" + .to_string(), + }, + )?; + let tally = report.tally.as_ref().ok_or_else(|| ImportError::SnapshotContents { + message: "snapshot report has no tally".to_string(), + })?; + let expected_key = format!("DR-REPORT-{}", tally.dr_pointer).into_bytes(); + if logical_key != expected_key { + return Err(ImportError::SnapshotContents { + message: "snapshot report key does not match its tally pointer".to_string(), + }); + } + if report.current_stage.is_some() { + return Err(ImportError::SnapshotContents { + message: "snapshot report is not in the finished stage".to_string(), + }); + } + let block_hash_dr_tx = + report + .block_hash_dr_tx + .ok_or_else(|| ImportError::SnapshotContents { + message: "snapshot report has no data-request block hash".to_string(), + })?; + let block_hash_tally_tx = + report + .block_hash_tally_tx + .ok_or_else(|| ImportError::SnapshotContents { + message: "snapshot report has no tally block hash".to_string(), + })?; + let (request_block_position, request_epoch) = match imported_block_positions + .get(&block_hash_dr_tx) + .copied() + { + Some(position) => position, + None if omit_noncanonical_reports => { + log::warn!( + "Omitting orphaned data-request report {} from snapshot export because it references non-canonical block {}", + tally.dr_pointer, + block_hash_dr_tx, + ); + continue; + } + None => { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report references block {block_hash_dr_tx} absent from imported block index" + ), + }); + } + }; + let (tally_block_position, tally_epoch) = match imported_block_positions + .get(&block_hash_tally_tx) + .copied() + { + Some(position) => position, + None if omit_noncanonical_reports => { + log::warn!( + "Omitting orphaned data-request report {} from snapshot export because it references non-canonical block {}", + tally.dr_pointer, + block_hash_tally_tx, + ); + continue; + } + None => { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report references block {block_hash_tally_tx} absent from imported block index" + ), + }); + } + }; + let tally_predecessor_epoch = tally_block_position + .checked_sub(1) + .and_then(|position| imported_block_epochs.get(position).copied()); + let pointer = tally.dr_pointer; + let mut commits = Vec::with_capacity(report.commits.len()); + for (&participant, transaction) in &report.commits { + if transaction.body.dr_pointer != pointer { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains a commit for {}", + transaction.body.dr_pointer + ), + }); + } + let [signature] = transaction.signatures.as_slice() else { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report commit for data request {pointer} participant {participant} does not have exactly one signature" + ), + }); + }; + let signature_participant = signature.public_key.pkh(); + let proof_participant = transaction.body.proof.proof.pkh(); + if signature_participant != participant || proof_participant != participant { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report commit for data request {pointer} is keyed by {participant}, but its signature/proof identities are {signature_participant}/{proof_participant}" + ), + }); + } + commits.push(SnapshotReportCommitExpectation { + participant, + transaction: transaction.clone(), + seen: false, + }); + } + commits.sort_unstable_by_key(|commit| commit.participant); + + let mut reveals = Vec::with_capacity(report.reveals.len()); + for (&participant, transaction) in &report.reveals { + if transaction.body.dr_pointer != pointer { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report for data request {pointer} contains a reveal for {}", + transaction.body.dr_pointer + ), + }); + } + let [signature] = transaction.signatures.as_slice() else { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report reveal for data request {pointer} participant {participant} does not have exactly one signature" + ), + }); + }; + let signature_participant = signature.public_key.pkh(); + if signature_participant != participant || transaction.body.pkh != participant { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report reveal for data request {pointer} is keyed by {participant}, but its signature/body identities are {signature_participant}/{}", + transaction.body.pkh + ), + }); + } + reveals.push(SnapshotReportRevealExpectation { + participant, + transaction: transaction.clone(), + block_position: None, + seen: false, + }); + } + reveals.sort_unstable_by_key(|reveal| reveal.participant); + + let commit_indexes = commits + .iter() + .enumerate() + .map(|(index, commit)| (commit.participant, index)) + .collect::>(); + for reveal in &reveals { + let Some(commit_index) = commit_indexes.get(&reveal.participant).copied() else { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report reveal for data request {pointer} participant {} has no matching commit", + reveal.participant + ), + }); + }; + let reveal_signature_hash = reveal.transaction.signatures[0].signature.hash(); + if commits[commit_index].transaction.body.commitment != reveal_signature_hash { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot report reveal for data request {pointer} participant {} does not match its commitment", + reveal.participant + ), + }); + } + } + + let index = plan.expectations.len(); + if plan.by_pointer.insert(pointer, index).is_some() { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot contains more than one report for data request {pointer}" + ), + }); + } + for (participant_index, commit) in commits.iter().enumerate() { + if plan + .by_commit + .insert((pointer, commit.participant), (index, participant_index)) + .is_some() + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot contains more than one commit for data request {pointer} participant {}", + commit.participant + ), + }); + } + } + for (participant_index, reveal) in reveals.iter().enumerate() { + if plan + .by_reveal + .insert((pointer, reveal.participant), (index, participant_index)) + .is_some() + { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot contains more than one reveal for data request {pointer} participant {}", + reveal.participant + ), + }); + } + } + plan.expectations.push(SnapshotReportExpectation { + logical_key, + value, + request_block_hash: block_hash_dr_tx, + request_block_position, + request_epoch, + tally_block_hash: block_hash_tally_tx, + tally_block_position, + tally_epoch, + tally_predecessor_epoch, + tally: tally.clone(), + current_commit_round: report.current_commit_round, + current_reveal_round: report.current_reveal_round, + witnesses: None, + commits, + reveals, + commit_block_position: None, + request_seen: false, + tally_seen: false, + }); + } + Ok(plan) + } + + /// Test-only compatibility wrapper. Production snapshot boundaries pass the ProtocolInfo that + /// was captured with their epoch clock so no multi-step verifier consults a later generation. + #[cfg(test)] + fn preflight_snapshot_reports( + reports: Vec<(Vec, Vec)>, + imported_block_chain: &Blockchain, + omit_noncanonical_reports: bool, + reports_required: bool, + ) -> Result { + let (protocol_info, _, _) = get_protocol_runtime(); + Self::preflight_snapshot_reports_with_protocol( + reports, + imported_block_chain, + omit_noncanonical_reports, + reports_required, + &protocol_info, + 0, + ) + } + +} + +#[cfg(test)] +mod tests; diff --git a/node/src/actors/chain_manager/actor/snapshot/tests.rs b/node/src/actors/chain_manager/actor/snapshot/tests.rs new file mode 100644 index 0000000000..30a68d2979 --- /dev/null +++ b/node/src/actors/chain_manager/actor/snapshot/tests.rs @@ -0,0 +1,4736 @@ +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use witnet_crypto::{ + secp256k1::{ + PublicKey as Secp256k1PublicKey, SecretKey as Secp256k1SecretKey, + }, + signature::sign, +}; +use witnet_data_structures::{ + chain::{CheckpointBeacon, ConsensusConstantsWit2, PublicKey, SecretKey, Signature}, + get_protocol_version, + transaction::RevealTransactionBody, + vrf::{DataRequestEligibilityClaim, VrfMessage, VrfProof}, +}; +use witnet_protected::Protected; + +use crate::{ + actors::{ + chain_manager::{ + SNAPSHOT_BLOCK_BATCH_SIZE, SNAPSHOT_MANIFEST_MAGIC, SNAPSHOT_MANIFEST_VERSION, + SNAPSHOT_MANIFEST_VERSION_V2, SnapshotManifestV2, + }, + epoch_manager::EpochManagerError::CheckpointZeroInTheFuture, + }, + utils::serialize_to_file, +}; + +use super::*; + +const TEST_WIT2_ACTIVATION_EPOCH: Epoch = 10; +const TEST_WIT21_ACTIVATION_EPOCH: Epoch = 20; + +struct SnapshotFilesystemFixture { + root: PathBuf, +} + +impl SnapshotFilesystemFixture { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "witnet-snapshot-cleanup-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir(&root).expect("snapshot fixture root must be unique"); + Self { root } + } +} + +impl Drop for SnapshotFilesystemFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn snapshot_manifest_v2_conversion_preserves_legacy_schedule_absence() { + let legacy = SnapshotManifestV2 { + magic: SNAPSHOT_MANIFEST_MAGIC, + format_version: SNAPSHOT_MANIFEST_VERSION_V2, + chain_state_version: storage_mngr::CURRENT_CHAIN_STATE_VERSION, + generation: SnapshotGeneration { + unix_nanos: 1, + process_id: 1, + attempt: 0, + }, + highest_block_checkpoint: CheckpointBeacon::default(), + highest_superblock_checkpoint: CheckpointBeacon::default(), + block_count: 0, + superblock_count: 0, + chain_state: SnapshotComponent { + byte_len: 1, + sha256: [0; 32], + }, + superblocks: SnapshotComponent { + byte_len: 1, + sha256: [0; 32], + }, + block_batches: Vec::new(), + reports: Some(SnapshotComponent { + byte_len: 1, + sha256: [0; 32], + }), + report_count: 0, + }; + let current: SnapshotManifest = legacy.into(); + assert_eq!(current.format_version, SNAPSHOT_MANIFEST_VERSION_V2); + assert!(current.protocol_info.is_none()); +} + +#[test] +fn snapshot_cleanup_preserves_old_generation_until_current_components_verify() { + let fixture = SnapshotFilesystemFixture::new(); + let manifest_path = fixture.root.join("snapshot.bin"); + let current_generation = SnapshotGeneration { + unix_nanos: 2, + process_id: 1, + attempt: 0, + }; + let old_generation = SnapshotGeneration { + unix_nanos: 1, + process_id: 1, + attempt: 0, + }; + let current_dir = snapshot_generation_dir(&manifest_path, current_generation).unwrap(); + let old_dir = snapshot_generation_dir(&manifest_path, old_generation).unwrap(); + std::fs::create_dir(¤t_dir).unwrap(); + std::fs::create_dir(&old_dir).unwrap(); + serialize_to_file(&"prior generation candidate", &old_dir.join("marker.bin")).unwrap(); + + let chain_state_path = snapshot_chain_state_path(¤t_dir); + let superblocks_path = snapshot_superblocks_path(¤t_dir); + let reports_path = snapshot_reports_path(¤t_dir); + let chain_state_payload = 1_u8; + let superblocks_payload = Vec::::new(); + let reports_payload = Vec::<(Vec, Vec)>::new(); + serialize_to_file(&chain_state_payload, &chain_state_path).unwrap(); + serialize_to_file(&superblocks_payload, &superblocks_path).unwrap(); + serialize_to_file(&reports_payload, &reports_path).unwrap(); + + let manifest = SnapshotManifest { + magic: SNAPSHOT_MANIFEST_MAGIC, + format_version: SNAPSHOT_MANIFEST_VERSION, + chain_state_version: storage_mngr::CURRENT_CHAIN_STATE_VERSION, + generation: current_generation, + highest_block_checkpoint: CheckpointBeacon::default(), + highest_superblock_checkpoint: CheckpointBeacon::default(), + block_count: 0, + superblock_count: 0, + chain_state: snapshot_component_from_file(&chain_state_path).unwrap(), + superblocks: snapshot_component_from_file(&superblocks_path).unwrap(), + block_batches: Vec::new(), + reports: Some(snapshot_component_from_file(&reports_path).unwrap()), + report_count: 0, + protocol_info: Some(ProtocolInfo::default()), + }; + serialize_to_file(&manifest, &manifest_path).unwrap(); + + // A published manifest with a missing current component must not authorize deletion of + // the prior generation candidate. + std::fs::remove_file(&reports_path).unwrap(); + let missing_error = cleanup_snapshot_generation_dirs(&manifest_path, current_generation) + .unwrap_err() + .to_string(); + assert!(missing_error.contains("reports")); + assert!(old_dir.is_dir()); + assert!(current_dir.is_dir()); + + // A present component is still insufficient when its bytes no longer match the committed + // descriptor. + serialize_to_file(&vec![vec![1_u8]], &reports_path).unwrap(); + let corrupt_error = cleanup_snapshot_generation_dirs(&manifest_path, current_generation) + .unwrap_err() + .to_string(); + assert!(corrupt_error.contains("reports")); + assert!(corrupt_error.contains("does not match its manifest length/hash")); + assert!(old_dir.is_dir()); + + // Once every advertised current component matches its descriptor, cleanup may reclaim the + // predecessor. + serialize_to_file(&reports_payload, &reports_path).unwrap(); + assert_eq!( + cleanup_snapshot_generation_dirs(&manifest_path, current_generation).unwrap(), + 1 + ); + assert!(current_dir.is_dir()); + assert!(!old_dir.exists()); +} + +fn test_protocol_info_with_wit21() -> ProtocolInfo { + let mut protocol_info = ProtocolInfo::default(); + protocol_info.register( + TEST_WIT2_ACTIVATION_EPOCH, + ProtocolVersion::V2_0, + 45, + ); + protocol_info.register( + TEST_WIT21_ACTIVATION_EPOCH, + ProtocolVersion::V2_1, + 45, + ); + protocol_info +} + +#[test] +fn restored_wit2_epoch_constants_fail_closed_on_activation_timestamp_overflow() { + let legacy = EpochConstants::try_new(1_000, 45, None, u16::MAX).unwrap(); + assert_eq!(legacy.checkpoint_zero_timestamp_wit2, i64::MAX); + assert_eq!(legacy.checkpoints_period_wit2, u16::MAX); + assert!(matches!( + legacy.epoch_at(999), + Err( + witnet_data_structures::error::EpochCalculationError::CheckpointZeroInTheFuture( + 1_000 + ) + ) + )); + + let restored = EpochConstants::try_new(1_000, 45, Some(10), 20).unwrap(); + assert_eq!(restored.checkpoint_zero_timestamp, 1_000); + assert_eq!(restored.checkpoints_period, 45); + assert_eq!(restored.checkpoint_zero_timestamp_wit2, 1_450); + assert_eq!(restored.checkpoints_period_wit2, 20); + assert_eq!(restored.epoch_timestamp(11).unwrap(), (1_470, true)); + + // The old direct `base + epoch * period` expression overflowed here (panicking in debug + // builds and wrapping in optimized builds) before the invalid constants reached actors. + assert!(matches!( + EpochConstants::try_new(i64::MAX, 1, Some(1), 1), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + assert!(matches!( + EpochConstants::try_new(0, 0, None, u16::MAX), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + assert!(matches!( + EpochConstants::try_new(0, 1, Some(0), 0), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + + // Post-Wit/2 timestamp conversion used to multiply in the `u32` epoch domain. Widening + // only after that multiplication made this representable timestamp panic or wrap. + let wide_post_v2 = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 1, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 2, + }; + let wide_post_v2_timestamp = i64::from(u32::MAX) * 2; + assert_eq!( + wide_post_v2.epoch_timestamp(u32::MAX).unwrap(), + (wide_post_v2_timestamp, true) + ); + assert_eq!(wide_post_v2.epoch_at(wide_post_v2_timestamp).unwrap(), u32::MAX); + let wide_legacy_probe = EpochConstants { + checkpoints_period: 45, + ..wide_post_v2 + }; + assert_eq!( + wide_legacy_probe.epoch_timestamp(u32::MAX).unwrap(), + (wide_post_v2_timestamp, true) + ); + assert_eq!( + wide_legacy_probe + .epoch_at(wide_post_v2_timestamp) + .unwrap(), + u32::MAX + ); + + let wide_legacy = EpochConstants::try_new(0, 45, None, u16::MAX).unwrap(); + let wide_legacy_timestamp = i64::from(u32::MAX) * 45; + assert_eq!( + wide_legacy.epoch_timestamp(u32::MAX).unwrap(), + (wide_legacy_timestamp, false) + ); + assert_eq!(wide_legacy.epoch_at(wide_legacy_timestamp).unwrap(), u32::MAX); + + let zero_legacy_period = EpochConstants { + checkpoints_period: 0, + ..wide_post_v2 + }; + assert!(matches!( + zero_legacy_period.epoch_timestamp(0), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + assert!(matches!( + zero_legacy_period.epoch_at(0), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + + let unrepresentable_elapsed_span = EpochConstants { + checkpoint_zero_timestamp: i64::MIN, + checkpoints_period: 1, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2: 1, + }; + assert!(matches!( + unrepresentable_elapsed_span.epoch_at(0), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + + let fields = |constants: &EpochConstants| { + ( + constants.checkpoint_zero_timestamp, + constants.checkpoints_period, + constants.checkpoint_zero_timestamp_wit2, + constants.checkpoints_period_wit2, + ) + }; + let mut failed_update = EpochConstants { + checkpoint_zero_timestamp: i64::MAX, + checkpoints_period: 1, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2: 7, + }; + let before_failed_update = fields(&failed_update); + assert!(matches!( + failed_update.set_values_for_wit2(2, 1), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + assert_eq!(fields(&failed_update), before_failed_update); + assert!(matches!( + failed_update.set_values_for_wit2(0, 0), + Err(witnet_data_structures::error::EpochCalculationError::Overflow) + )); + assert_eq!(fields(&failed_update), before_failed_update); +} + +fn report_plan( + pointer: Hash, + request_block_hash: Hash, + tally_block_hash: Hash, + tally: TallyTransaction, +) -> SnapshotReportPlan { + let (protocol_info, _, _) = get_protocol_runtime(); + SnapshotReportPlan { + required: true, + protocol_info, + wit2_activation_epoch: TEST_WIT2_ACTIVATION_EPOCH, + terminal_round: 1, + by_pointer: BTreeMap::from([(pointer, 0)]), + by_commit: BTreeMap::new(), + by_reveal: BTreeMap::new(), + expectations: vec![SnapshotReportExpectation { + logical_key: format!("DR-REPORT-{pointer}").into_bytes(), + value: vec![1], + request_block_hash, + request_block_position: 0, + request_epoch: 1, + tally_block_hash, + tally_block_position: 2, + tally_epoch: 2, + tally_predecessor_epoch: Some(1), + tally, + current_commit_round: 1, + current_reveal_round: 0, + witnesses: None, + commits: Vec::new(), + reveals: Vec::new(), + commit_block_position: None, + request_seen: false, + tally_seen: false, + }], + } +} + +fn request_and_tally_blocks() -> (Hash, Block, Block, TallyTransaction) { + let request_epoch = 1; + let request_protocol = get_protocol_version(Some(request_epoch)); + let mut request = witnet_data_structures::transaction::DRTransaction::default(); + request.body.dr_output.witnesses = 1; + let pointer = request.versioned_hash(request_protocol); + let mut request_block = Block::default(); + request_block.block_header.beacon.checkpoint = request_epoch; + request_block.txns.data_request_txns.push(request); + request_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&request_block.txns, request_protocol); + + let tally = TallyTransaction::new(pointer, vec![1], Vec::new(), Vec::new(), Vec::new()); + let mut tally_block = Block::default(); + tally_block.block_header.beacon.checkpoint = 2; + tally_block.txns.tally_txns.push(tally.clone()); + tally_block.block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &tally_block.txns, + get_protocol_version(Some(tally_block.block_header.beacon.checkpoint)), + ); + + (pointer, request_block, tally_block, tally) +} + +fn too_many_witnesses_tally_bytes() -> Vec { + Vec::::try_from(witnet_rad::types::RadonTypes::RadonError( + witnet_data_structures::radon_error::RadonError::try_from( + witnet_rad::error::RadError::TooManyWitnesses, + ) + .unwrap(), + )) + .unwrap() +} + +fn complete_snapshot_report_fixture() -> (Hash, DataRequestInfo, Blockchain, Vec) { + let (pointer, request_block, mut tally_block, tally) = request_and_tally_blocks(); + tally_block.block_header.beacon.checkpoint = 4; + tally_block.block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &tally_block.txns, + get_protocol_version(Some(tally_block.block_header.beacon.checkpoint)), + ); + + let signature = witnet_data_structures::chain::KeyedSignature::default(); + let participant = signature.public_key.pkh(); + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = pointer; + commit.body.commitment = signature.signature.hash(); + commit.signatures.push(signature.clone()); + assert_eq!(commit.body.proof.proof.pkh(), participant); + + let mut reveal = RevealTransaction::default(); + reveal.body.dr_pointer = pointer; + reveal.body.pkh = participant; + reveal.signatures.push(signature); + + let mut commit_block = Block::default(); + commit_block.block_header.beacon.checkpoint = 2; + commit_block.txns.commit_txns.push(commit.clone()); + commit_block.block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &commit_block.txns, + get_protocol_version(Some(commit_block.block_header.beacon.checkpoint)), + ); + let mut reveal_block = Block::default(); + reveal_block.block_header.beacon.checkpoint = 3; + reveal_block.txns.reveal_txns.push(reveal.clone()); + reveal_block.block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &reveal_block.txns, + get_protocol_version(Some(reveal_block.block_header.beacon.checkpoint)), + ); + + let blocks = vec![request_block, commit_block, reveal_block, tally_block]; + let block_chain = blocks + .iter() + .map(|block| { + let epoch = block.block_header.beacon.checkpoint; + (epoch, block.versioned_hash(get_protocol_version(Some(epoch)))) + }) + .collect::(); + let mut report = DataRequestInfo { + tally: Some(tally), + block_hash_dr_tx: block_chain.get(&1).copied(), + block_hash_tally_tx: block_chain.get(&4).copied(), + current_commit_round: 1, + current_reveal_round: 1, + current_stage: None, + ..DataRequestInfo::default() + }; + report.commits.insert(participant, commit); + report.reveals.insert(participant, reveal); + + (pointer, report, block_chain, blocks) +} + +#[test] +fn snapshot_report_plan_requires_exact_request_and_tally_coverage() { + let (pointer, request_block, tally_block, tally) = request_and_tally_blocks(); + let request_hash = request_block.versioned_hash(get_protocol_version(Some(1))); + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some(2))); + let mut plan = report_plan(pointer, request_hash, tally_hash, tally); + + plan.observe_block(&request_block, 0).unwrap(); + plan.observe_block(&tally_block, 2).unwrap(); + assert_eq!(plan.finish().unwrap().len(), 1); +} + +#[test] +fn snapshot_report_plan_rejects_next_block_tally_without_a_commit_round() { + let (pointer, request_block, tally_block, tally) = request_and_tally_blocks(); + let request_hash = request_block.versioned_hash(get_protocol_version(Some(1))); + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some(2))); + let mut plan = report_plan(pointer, request_hash, tally_hash, tally); + plan.expectations[0].tally_block_position = 1; + plan.expectations[0].current_commit_round = 0; + + plan.observe_block(&request_block, 0).unwrap(); + plan.observe_block(&tally_block, 1).unwrap(); + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_accepts_next_block_round_zero_too_many_at_wit2_activation() { + let request_epoch = TEST_WIT2_ACTIVATION_EPOCH; + let request_protocol = get_protocol_version(Some(request_epoch)); + let request = witnet_data_structures::transaction::DRTransaction::default(); + let pointer = request.versioned_hash(request_protocol); + let mut request_block = Block::default(); + request_block.block_header.beacon.checkpoint = request_epoch; + request_block.txns.data_request_txns.push(request); + request_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&request_block.txns, request_protocol); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut tally_block = Block::default(); + tally_block.block_header.beacon.checkpoint = request_epoch.saturating_add(1); + tally_block.txns.tally_txns.push(tally.clone()); + tally_block.block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &tally_block.txns, + get_protocol_version(Some(tally_block.block_header.beacon.checkpoint)), + ); + let request_hash = request_block.versioned_hash(request_protocol); + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some( + tally_block.block_header.beacon.checkpoint, + ))); + let mut plan = report_plan(pointer, request_hash, tally_hash, tally); + plan.expectations[0].request_epoch = request_epoch; + plan.expectations[0].tally_block_position = 1; + plan.expectations[0].tally_epoch = request_epoch.saturating_add(1); + plan.expectations[0].current_commit_round = 0; + + plan.observe_block(&request_block, 0).unwrap(); + plan.observe_block(&tally_block, 1).unwrap(); + assert_eq!(plan.finish().unwrap().len(), 1); +} + +#[test] +fn snapshot_report_plan_accepts_later_bounded_too_many_from_wit2_activation() { + let (_, mut request_block, mut tally_block, _) = request_and_tally_blocks(); + let request_epoch = TEST_WIT2_ACTIVATION_EPOCH; + let request_protocol = get_protocol_version(Some(request_epoch)); + request_block.block_header.beacon.checkpoint = request_epoch; + request_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&request_block.txns, request_protocol); + let pointer = request_block.txns.data_request_txns[0].versioned_hash(request_protocol); + let request_hash = request_block.versioned_hash(request_protocol); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + tally_block.block_header.beacon.checkpoint = request_epoch.saturating_add(3); + tally_block.txns.tally_txns.clear(); + tally_block.txns.tally_txns.push(tally.clone()); + let tally_epoch = tally_block.block_header.beacon.checkpoint; + let tally_protocol = get_protocol_version(Some(tally_epoch)); + tally_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&tally_block.txns, tally_protocol); + let tally_hash = tally_block.versioned_hash(tally_protocol); + let mut plan = report_plan(pointer, request_hash, tally_hash, tally); + plan.terminal_round = 4; + plan.expectations[0].request_epoch = request_epoch; + plan.expectations[0].tally_block_position = 3; + plan.expectations[0].tally_epoch = tally_epoch; + plan.expectations[0].current_commit_round = 2; + + plan.observe_block(&request_block, 0).unwrap(); + plan.observe_block(&tally_block, 3).unwrap(); + assert_eq!(plan.finish().unwrap().len(), 1); +} + +#[test] +fn snapshot_report_plan_rejects_non_same_block_too_many_before_v21() { + for (distance, commit_round) in [(1_usize, 0_u16), (3, 2)] { + let request_epoch = TEST_WIT2_ACTIVATION_EPOCH.saturating_add(1); + let pointer = Hash::with_first_u32(u32::try_from(distance).unwrap()); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut plan = report_plan( + pointer, + Hash::with_first_u32(0xD2_000101), + Hash::with_first_u32(0xD2_000102), + tally, + ); + plan.terminal_round = 4; + let expectation = &mut plan.expectations[0]; + expectation.request_epoch = request_epoch; + expectation.tally_block_position = distance; + expectation.tally_epoch = + request_epoch.saturating_add(Epoch::try_from(distance).unwrap()); + expectation.current_commit_round = commit_round; + expectation.witnesses = Some(1); + expectation.request_seen = true; + expectation.tally_seen = true; + + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); + } +} + +#[test] +fn snapshot_report_plan_bounds_v21_causal_too_many_before_terminal_expiry() { + let make_plan = |distance: usize, tally_epoch: Epoch| { + let pointer = Hash::with_first_u32( + 0xD2_000140_u32.saturating_add(u32::try_from(distance).unwrap()), + ); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut plan = report_plan( + pointer, + Hash::with_first_u32(0xD2_000150), + Hash::with_first_u32(0xD2_000151), + tally, + ); + plan.protocol_info = test_protocol_info_with_wit21(); + plan.wit2_activation_epoch = TEST_WIT2_ACTIVATION_EPOCH; + plan.terminal_round = 2; + let expectation = &mut plan.expectations[0]; + expectation.request_epoch = TEST_WIT2_ACTIVATION_EPOCH + 1; + expectation.tally_epoch = tally_epoch; + expectation.tally_block_position = distance; + expectation.current_commit_round = + u16::try_from(distance.saturating_sub(1)).unwrap(); + expectation.current_reveal_round = 0; + expectation.witnesses = Some(1); + expectation.request_seen = true; + expectation.tally_seen = true; + plan + }; + + for distance in 1..=2 { + assert_eq!( + make_plan(distance, TEST_WIT21_ACTIVATION_EPOCH) + .finish() + .unwrap() + .len(), + 1 + ); + } + + // The causal exception is gated by the tally block, not merely by the request protocol. + assert!(matches!( + make_plan(1, TEST_WIT21_ACTIVATION_EPOCH - 1).finish(), + Err(ImportError::SnapshotContents { .. }) + )); + // Distance terminal+1 is ordinary no-Commit expiry and cannot use the sentinel. + assert!(matches!( + make_plan(3, TEST_WIT21_ACTIVATION_EPOCH).finish(), + Err(ImportError::SnapshotContents { .. }) + )); + let mut wrong_round = make_plan(2, TEST_WIT21_ACTIVATION_EPOCH); + wrong_round.expectations[0].current_commit_round = 0; + assert!(matches!( + wrong_round.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_accepts_only_a_proven_pre_wit2_crossing_ordinary_tally() { + let make_plan = |predecessor_epoch: Option| { + let pointer = Hash::with_first_u32(0xD2_000181); + let tally = TallyTransaction::new(pointer, vec![1], Vec::new(), Vec::new(), Vec::new()); + let mut plan = report_plan( + pointer, + Hash::with_first_u32(0xD2_000182), + Hash::with_first_u32(0xD2_000183), + tally, + ); + plan.terminal_round = 4; + let expectation = &mut plan.expectations[0]; + expectation.request_epoch = TEST_WIT2_ACTIVATION_EPOCH.saturating_sub(1); + expectation.tally_block_position = 2; + expectation.tally_epoch = TEST_WIT2_ACTIVATION_EPOCH.saturating_add(1); + expectation.tally_predecessor_epoch = predecessor_epoch; + expectation.current_commit_round = 1; + expectation.witnesses = Some(1); + expectation.request_seen = true; + expectation.tally_seen = true; + plan + }; + + if TEST_WIT2_ACTIVATION_EPOCH == 0 { + return; + } + assert_eq!( + make_plan(Some(TEST_WIT2_ACTIVATION_EPOCH)) + .finish() + .unwrap() + .len(), + 1 + ); + for predecessor_epoch in [ + None, + Some(TEST_WIT2_ACTIVATION_EPOCH.saturating_sub(1)), + ] { + assert!(matches!( + make_plan(predecessor_epoch).finish(), + Err(ImportError::SnapshotContents { .. }) + )); + } + for invalid_round in [0, 2] { + let mut plan = make_plan(Some(TEST_WIT2_ACTIVATION_EPOCH)); + plan.expectations[0].current_commit_round = invalid_round; + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); + } +} + +#[test] +fn snapshot_report_plan_accepts_activation_too_many_with_completed_or_expired_reveals() { + let make_plan = |request_epoch: Epoch, complete_reveals: bool| { + let pointer = Hash::with_first_u32(if complete_reveals { + 0xD2_000201 + } else { + 0xD2_000202 + }); + let signature = KeyedSignature::default(); + let participant = signature.public_key.pkh(); + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = pointer; + commit.body.commitment = signature.signature.hash(); + commit.signatures.push(signature.clone()); + let mut reveal = RevealTransaction::default(); + reveal.body.dr_pointer = pointer; + reveal.body.pkh = participant; + reveal.signatures.push(signature); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut plan = report_plan( + pointer, + Hash::with_first_u32(0xD2_000203), + Hash::with_first_u32(0xD2_000204), + tally, + ); + plan.terminal_round = 2; + let expectation = &mut plan.expectations[0]; + expectation.request_epoch = request_epoch; + expectation.tally_epoch = + request_epoch.saturating_add(if complete_reveals { 3 } else { 4 }); + expectation.tally_block_position = if complete_reveals { 3 } else { 4 }; + expectation.current_commit_round = 1; + expectation.current_reveal_round = if complete_reveals { 1 } else { 2 }; + expectation.witnesses = Some(1); + expectation.commits = vec![SnapshotReportCommitExpectation { + participant, + transaction: commit, + seen: true, + }]; + expectation.reveals = if complete_reveals { + vec![SnapshotReportRevealExpectation { + participant, + transaction: reveal, + block_position: Some(2), + seen: true, + }] + } else { + Vec::new() + }; + expectation.commit_block_position = Some(1); + expectation.request_seen = true; + expectation.tally_seen = true; + plan + }; + + assert_eq!( + make_plan(TEST_WIT2_ACTIVATION_EPOCH, true) + .finish() + .unwrap() + .len(), + 1 + ); + assert_eq!( + make_plan(TEST_WIT2_ACTIVATION_EPOCH, false) + .finish() + .unwrap() + .len(), + 1 + ); + assert!(matches!( + make_plan(TEST_WIT2_ACTIVATION_EPOCH.saturating_add(1), true).finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn reportless_import_is_compatible_but_v2_export_capability_is_dynamic() { + let (_, _, tally_block, _) = request_and_tally_blocks(); + let tally_epoch = tally_block.block_header.beacon.checkpoint; + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some(tally_epoch))); + let tally_chain = Blockchain::from([(tally_epoch, tally_hash)]); + + // Legacy/version-1 import remains intentionally report-less and does not fabricate history. + let mut compatibility_import = ChainManager::preflight_snapshot_reports( + Vec::new(), + &tally_chain, + false, + false, + ) + .unwrap(); + compatibility_import.observe_block(&tally_block, 0).unwrap(); + assert!(compatibility_import.finish().unwrap().is_empty()); + + // Every new export independently enables strict v2 coverage over the current canonical + // history, so the same report-less tally makes that point-in-time capability check fail. + let mut strict_export = ChainManager::preflight_snapshot_reports( + Vec::new(), + &tally_chain, + false, + true, + ) + .unwrap(); + match strict_export.observe_block(&tally_block, 0) { + Err(ImportError::SnapshotContents { message }) => { + assert!(message.contains("report-less legacy/version-1 import")); + } + result => panic!("missing Report coverage unexpectedly accepted: {result:?}"), + } + + // A report-less origin does not poison the state permanently: a genuinely tally-free + // canonical history can prove complete empty coverage and export a v2 Report component. + let empty_block = Block::default(); + let empty_epoch = empty_block.block_header.beacon.checkpoint; + let empty_hash = empty_block.versioned_hash(get_protocol_version(Some(empty_epoch))); + let empty_chain = Blockchain::from([(empty_epoch, empty_hash)]); + let mut tally_free_export = ChainManager::preflight_snapshot_reports( + Vec::new(), + &empty_chain, + false, + true, + ) + .unwrap(); + tally_free_export.observe_block(&empty_block, 0).unwrap(); + assert!(tally_free_export.finish().unwrap().is_empty()); +} + +#[test] +fn snapshot_report_plan_rejects_an_unobserved_extra_report() { + let (pointer, request_block, tally_block, tally) = request_and_tally_blocks(); + let request_hash = request_block.versioned_hash(get_protocol_version(Some(1))); + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some(2))); + let plan = report_plan(pointer, request_hash, tally_hash, tally); + + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_requires_exact_commit_and_reveal_coverage() { + let (pointer, report, block_chain, blocks) = complete_snapshot_report_fixture(); + let encoded_report = bincode::serialize(&report).unwrap(); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + encoded_report, + )]; + let mut complete_plan = ChainManager::preflight_snapshot_reports( + reports.clone(), + &block_chain, + false, + true, + ) + .unwrap(); + for (position, block) in blocks.iter().enumerate() { + complete_plan.observe_block(block, position).unwrap(); + } + assert_eq!(complete_plan.finish().unwrap().len(), 1); + + let mut missing_reveal_plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + for (position, block) in blocks.iter().enumerate().filter(|(_, block)| { + block.block_header.beacon.checkpoint != 3 + }) { + missing_reveal_plan.observe_block(block, position).unwrap(); + } + assert!(matches!( + missing_reveal_plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_rejects_canonical_reveal_omitted_from_report() { + let (pointer, mut report, block_chain, blocks) = complete_snapshot_report_fixture(); + report.reveals.clear(); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + + assert!(matches!( + blocks + .iter() + .enumerate() + .try_for_each(|(position, block)| plan.observe_block(block, position)), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_rejects_rounds_that_disagree_with_canonical_block_order() { + let (pointer, mut report, block_chain, blocks) = complete_snapshot_report_fixture(); + report.current_commit_round = 2; + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + for (position, block) in blocks.iter().enumerate() { + plan.observe_block(block, position).unwrap(); + } + + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_enforces_the_pinned_terminal_commit_round() { + let (pointer, request_block, tally_block, tally) = request_and_tally_blocks(); + let request_hash = request_block.versioned_hash(get_protocol_version(Some(1))); + let tally_hash = tally_block.versioned_hash(get_protocol_version(Some(2))); + + // Three extra rounds make round four terminal. With no commits, consolidation reaches + // TALLY after canonical block four and the mandatory tally belongs in block five. + let mut exact = report_plan(pointer, request_hash, tally_hash, tally.clone()); + exact.terminal_round = 4; + exact.expectations[0].tally_block_position = 5; + exact.expectations[0].current_commit_round = 4; + exact.observe_block(&request_block, 0).unwrap(); + exact.observe_block(&tally_block, 5).unwrap(); + assert_eq!(exact.finish().unwrap().len(), 1); + + // The same ordinary tally one block early is not a possible stage transition, even when + // its serialized counter is adjusted to agree with the shorter ordinal distance. + let mut early = report_plan(pointer, request_hash, tally_hash, tally); + early.terminal_round = 4; + early.expectations[0].tally_block_position = 4; + early.expectations[0].current_commit_round = 3; + early.observe_block(&request_block, 0).unwrap(); + early.observe_block(&tally_block, 4).unwrap(); + assert!(matches!( + early.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_rejects_a_commit_after_the_terminal_round() { + let (pointer, report, block_chain, blocks) = complete_snapshot_report_fixture(); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + plan.expectations[0].tally_block_position = 4; + plan.expectations[0].current_commit_round = 2; + + plan.observe_block(&blocks[0], 0).unwrap(); + plan.observe_block(&blocks[1], 2).unwrap(); + plan.observe_block(&blocks[2], 3).unwrap(); + plan.observe_block(&blocks[3], 4).unwrap(); + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_requires_tally_immediately_after_complete_reveals() { + let (pointer, report, block_chain, blocks) = complete_snapshot_report_fixture(); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + plan.terminal_round = 3; + plan.expectations[0].tally_block_position = 4; + plan.expectations[0].current_reveal_round = 2; + + plan.observe_block(&blocks[0], 0).unwrap(); + plan.observe_block(&blocks[1], 1).unwrap(); + plan.observe_block(&blocks[2], 2).unwrap(); + plan.observe_block(&blocks[3], 4).unwrap(); + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_waits_through_terminal_round_for_incomplete_reveals() { + let (pointer, mut report, _, mut blocks) = complete_snapshot_report_fixture(); + report.reveals.clear(); + blocks[2].txns.reveal_txns.clear(); + let reveal_epoch = blocks[2].block_header.beacon.checkpoint; + blocks[2].block_header.merkle_roots = BlockMerkleRoots::from_transactions( + &blocks[2].txns, + get_protocol_version(Some(reveal_epoch)), + ); + let block_chain = blocks + .iter() + .map(|block| { + let epoch = block.block_header.beacon.checkpoint; + (epoch, block.versioned_hash(get_protocol_version(Some(epoch)))) + }) + .collect::(); + report.block_hash_dr_tx = block_chain.get(&1).copied(); + report.block_hash_tally_tx = block_chain.get(&4).copied(); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + + // With terminal round one, the empty canonical reveal-round block advances the request to + // TALLY and the following block may contain the tally. + let mut exact = ChainManager::preflight_snapshot_reports( + reports.clone(), + &block_chain, + false, + true, + ) + .unwrap(); + for (position, block) in blocks.iter().enumerate() { + exact.observe_block(block, position).unwrap(); + } + assert_eq!(exact.finish().unwrap().len(), 1); + + // Omitting that reveal-round block puts the tally immediately after the commit, while the + // request is still REVEAL and therefore cannot yet be consumed. + let early_blocks = vec![blocks[0].clone(), blocks[1].clone(), blocks[3].clone()]; + let early_chain = early_blocks + .iter() + .map(|block| { + let epoch = block.block_header.beacon.checkpoint; + (epoch, block.versioned_hash(get_protocol_version(Some(epoch)))) + }) + .collect::(); + let mut early_report = report; + early_report.block_hash_dr_tx = early_chain.get(&1).copied(); + early_report.block_hash_tally_tx = early_chain.get(&4).copied(); + let early_reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&early_report).unwrap(), + )]; + let mut early = ChainManager::preflight_snapshot_reports( + early_reports, + &early_chain, + false, + true, + ) + .unwrap(); + for (position, block) in early_blocks.iter().enumerate() { + early.observe_block(block, position).unwrap(); + } + assert!(matches!( + early.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_plan_requires_too_many_witnesses_for_same_block_rounds() { + let epoch = TEST_WIT2_ACTIVATION_EPOCH; + let protocol = get_protocol_version(Some(epoch)); + let mut request = witnet_data_structures::transaction::DRTransaction::default(); + request.body.dr_output.witnesses = 1; + let pointer = request.versioned_hash(protocol); + let tally_bytes = too_many_witnesses_tally_bytes(); + let tally = + TallyTransaction::new(pointer, tally_bytes, Vec::new(), Vec::new(), Vec::new()); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = epoch; + block.txns.data_request_txns.push(request); + block.txns.tally_txns.push(tally.clone()); + block.block_header.merkle_roots = BlockMerkleRoots::from_transactions(&block.txns, protocol); + let block_hash = block.versioned_hash(protocol); + let block_chain = Blockchain::from([(epoch, block_hash)]); + let report = DataRequestInfo { + tally: Some(tally), + block_hash_dr_tx: Some(block_hash), + block_hash_tally_tx: Some(block_hash), + current_commit_round: 0, + current_reveal_round: 0, + current_stage: None, + ..DataRequestInfo::default() + }; + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &block_chain, + false, + true, + ) + .unwrap(); + plan.wit2_activation_epoch = TEST_WIT2_ACTIVATION_EPOCH; + + plan.observe_block(&block, 0).unwrap(); + assert_eq!(plan.finish().unwrap().len(), 1); + + // The same 0/0 chronology must not make an arbitrary tally look like the immediate + // TooManyWitnesses exception. Keep the report and canonical transaction identical so the + // round-specific sentinel check is what rejects this fixture. + let mut non_too_many_report = report; + non_too_many_report.tally.as_mut().unwrap().tally = vec![0]; + let mut non_too_many_block = block; + non_too_many_block.txns.tally_txns[0].tally = vec![0]; + non_too_many_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&non_too_many_block.txns, protocol); + let non_too_many_block_hash = non_too_many_block.versioned_hash(protocol); + non_too_many_report.block_hash_dr_tx = Some(non_too_many_block_hash); + non_too_many_report.block_hash_tally_tx = Some(non_too_many_block_hash); + let non_too_many_block_chain = Blockchain::from([(epoch, non_too_many_block_hash)]); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&non_too_many_report).unwrap(), + )]; + let mut plan = ChainManager::preflight_snapshot_reports( + reports, + &non_too_many_block_chain, + false, + true, + ) + .unwrap(); + plan.observe_block(&non_too_many_block, 0).unwrap(); + assert!(matches!( + plan.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_round_zero_too_many_epoch_boundaries_are_exact() { + let activation_epoch = TEST_WIT2_ACTIVATION_EPOCH; + + assert!(!permits_round_zero_too_many_witnesses( + 0, + activation_epoch - 1, + activation_epoch, + )); + assert!(permits_round_zero_too_many_witnesses( + 0, + activation_epoch, + activation_epoch, + )); + assert!(permits_round_zero_too_many_witnesses( + 0, + activation_epoch + 1, + activation_epoch, + )); + assert!(!permits_round_zero_too_many_witnesses( + 1, + activation_epoch - 1, + activation_epoch, + )); + assert!(permits_round_zero_too_many_witnesses( + 1, + activation_epoch, + activation_epoch, + )); + assert!(!permits_round_zero_too_many_witnesses( + 1, + activation_epoch + 1, + activation_epoch, + )); + assert!(!permits_round_zero_too_many_witnesses( + 0, + Epoch::MAX, + Epoch::MAX, + )); + assert!(!permits_round_zero_too_many_witnesses( + 1, + Epoch::MAX, + Epoch::MAX, + )); + + let (pointer, request_block, mut tally_block, _) = request_and_tally_blocks(); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + tally_block.txns.tally_txns.clear(); + tally_block.txns.tally_txns.push(tally.clone()); + tally_block.block_header.beacon.checkpoint = activation_epoch - 1; + let tally_protocol = get_protocol_version(Some( + tally_block.block_header.beacon.checkpoint, + )); + tally_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&tally_block.txns, tally_protocol); + let request_hash = request_block.versioned_hash(get_protocol_version(Some(1))); + let tally_hash = tally_block.versioned_hash(tally_protocol); + let mut pre_activation = report_plan(pointer, request_hash, tally_hash, tally); + pre_activation.expectations[0].tally_epoch = activation_epoch - 1; + pre_activation.observe_block(&request_block, 0).unwrap(); + pre_activation.observe_block(&tally_block, 2).unwrap(); + assert!(matches!( + pre_activation.finish(), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_preflight_rejects_an_unrepresentable_extra_round_bound() { + let (protocol_info, _, _) = get_protocol_runtime(); + assert!(matches!( + ChainManager::preflight_snapshot_reports_with_protocol( + Vec::new(), + &Blockchain::default(), + false, + true, + &protocol_info, + u16::MAX, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_block_body_must_match_header_merkle_roots() { + let epoch = 1; + let protocol = get_protocol_version(Some(epoch)); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = epoch; + block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&block.txns, protocol); + assert!(validate_snapshot_block_body(&block, 0, protocol).is_ok()); + + // Block identity commits only to the unchanged header. A substituted transaction body must + // therefore be rejected explicitly at the snapshot boundary. + let header_hash = block.versioned_hash(protocol); + block + .txns + .value_transfer_txns + .push(witnet_data_structures::transaction::VTTransaction::new( + witnet_data_structures::transaction::VTTransactionBody::new( + Vec::new(), + vec![ValueTransferOutput { + value: 1, + ..Default::default() + }], + ), + Vec::new(), + )); + assert_eq!(block.versioned_hash(protocol), header_hash); + assert!(matches!( + validate_snapshot_block_body(&block, 0, protocol), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_wit2_mint_must_have_its_exact_consensus_shape() { + let epoch = 42; + let protocol = ProtocolVersion::V2_0; + let mut canonical = Block::default(); + canonical.block_header.beacon.checkpoint = epoch; + canonical.txns.mint.epoch = epoch; + canonical.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&canonical.txns, protocol); + assert!(validate_snapshot_mint(&canonical, 0, protocol).is_ok()); + + // Rebuilding the root can make the forged transaction body internally self-consistent, but + // identity/integrity alone does not prove the V2 semantic rule that Mint is exactly empty. + let mut forged = canonical; + forged.txns.mint.outputs.push(ValueTransferOutput { + value: 1, + ..Default::default() + }); + forged.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&forged.txns, protocol); + assert_eq!( + forged.block_header.merkle_roots, + BlockMerkleRoots::from_transactions(&forged.txns, protocol) + ); + assert!(matches!( + validate_snapshot_mint(&forged, 0, protocol), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_block_index_origin_is_genesis_or_the_empty_bootstrap_state() { + let genesis_hash = Hash::with_first_u32(1); + assert!( + validate_snapshot_block_index_origin(&Blockchain::default(), genesis_hash).is_ok() + ); + assert!( + validate_snapshot_block_index_origin( + &Blockchain::from([(0, genesis_hash)]), + genesis_hash, + ) + .is_ok() + ); + + assert!(matches!( + validate_snapshot_block_index_origin( + &Blockchain::from([(1, Hash::with_first_u32(2))]), + genesis_hash, + ), + Err(ImportError::SnapshotContents { .. }) + )); + assert!(matches!( + validate_snapshot_block_index_origin( + &Blockchain::from([(0, Hash::with_first_u32(2))]), + genesis_hash, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_import_binds_an_undurable_wit2_certificate_to_the_exact_chain() { + let trigger_epoch: Epoch = 10; + let trigger_hash = Hash::with_first_u32(41); + let mut chain_info = ChainInfo::default(); + chain_info.consensus_constants.superblock_period = 10; + let wit2_constants = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: chain_info + .consensus_constants + .checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, + }; + let activation_epoch = trigger_epoch + .checked_add(wit2_constants.get_wit2_activation_delay_epochs()) + .unwrap(); + let checkpoint_period = wit2_constants.get_checkpoints_period_wit2(); + let mut imported = ChainState { + chain_info: Some(chain_info), + ..ChainState::default() + }; + imported.block_chain.insert(trigger_epoch, trigger_hash); + let local_chain_info = imported.chain_info.as_ref().unwrap().clone(); + + // No process-owned certificate retains the legacy/existing-certificate storage path and + // imposes no new portable ChainState requirement. + assert!(validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported.chain_info.as_ref().unwrap(), + &imported, + None, + ) + .is_ok()); + + let mut foreign_network = imported.clone(); + foreign_network + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .genesis_hash = Hash::with_first_u32(43); + assert!(matches!( + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + foreign_network.chain_info.as_ref().unwrap(), + &foreign_network, + None, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let exact = storage_mngr::Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + activation_epoch, + checkpoint_period, + ); + assert!( + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported.chain_info.as_ref().unwrap(), + &imported, + Some(&exact), + ) + .is_ok() + ); + + let wrong_hash = storage_mngr::Wit2ActivationCertificate::new( + trigger_epoch, + Hash::with_first_u32(42), + activation_epoch, + checkpoint_period, + ); + assert!(matches!( + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported.chain_info.as_ref().unwrap(), + &imported, + Some(&wrong_hash), + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let wrong_activation = storage_mngr::Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + activation_epoch.saturating_add(1), + checkpoint_period, + ); + assert!(matches!( + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported.chain_info.as_ref().unwrap(), + &imported, + Some(&wrong_activation), + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let wrong_period = storage_mngr::Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + activation_epoch, + checkpoint_period.saturating_add(1), + ); + assert!(matches!( + validate_snapshot_identity_and_pending_wit2_activation( + &local_chain_info, + imported.chain_info.as_ref().unwrap(), + &imported, + Some(&wrong_period), + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_import_is_anchored_at_the_highest_common_finalized_boundary() { + let genesis = Hash::with_first_u32(30); + let shared_boundary = Hash::with_first_u32(31); + let later_local = Hash::with_first_u32(32); + let local = Blockchain::from([(0, genesis), (9, shared_boundary), (19, later_local)]); + let imported = Blockchain::from([(0, genesis), (9, shared_boundary)]); + + // A forced import to an older but identically finalized prefix remains admissible. + assert!(validate_snapshot_common_finalized_prefix(&local, 2, &imported, 1, 10).is_ok()); + + let fork = Blockchain::from([ + (0, genesis), + (9, Hash::with_first_u32(33)), + (19, Hash::with_first_u32(34)), + ]); + assert!(matches!( + validate_snapshot_common_finalized_prefix(&local, 2, &fork, 2, 10), + Err(ImportError::SnapshotContents { .. }) + )); + + // Superblock zero supplies no non-genesis finalized boundary and must not be described as a + // local canonical-prefix proof. + assert!(validate_snapshot_common_finalized_prefix(&local, 0, &fork, 0, 10).is_ok()); + + // A node can also be genesis-only even if its finalized-superblock counter has advanced. + // Equality here proves only the configured genesis identity, not a post-genesis local fork. + let genesis_only = Blockchain::from([(0, genesis)]); + assert!( + validate_snapshot_common_finalized_prefix(&genesis_only, 1, &genesis_only, 1, 10) + .is_ok() + ); +} + +#[test] +fn snapshot_predecessor_links_use_the_prior_index_hash_across_protocols() { + let bootstrap_hash = Hash::with_first_u32(10); + let mut genesis = Block::default(); + genesis.block_header.beacon = CheckpointBeacon { + checkpoint: 0, + hash_prev_block: bootstrap_hash, + }; + assert!(validate_snapshot_block_predecessor(&genesis, 0, bootstrap_hash).is_ok()); + + // This synthetic header isolates the V1.7/V1.8 versioning boundary: its predecessor-own + // V1.7 identity uses the legacy header layout and omits the Stake root, while a V1.8 re-hash + // uses the current layout. A successor must carry the already-indexed V1.7 hash. + let mut predecessor = Block::default(); + predecessor.block_header.beacon.checkpoint = 7; + predecessor + .block_header + .merkle_roots + .stake_hash_merkle_root = Hash::with_first_u32(11); + let indexed_predecessor_hash = predecessor.versioned_hash(ProtocolVersion::V1_7); + let incorrectly_rehashed_at_successor_protocol = + predecessor.versioned_hash(ProtocolVersion::V1_8); + assert_ne!( + indexed_predecessor_hash, + incorrectly_rehashed_at_successor_protocol + ); + + // Epochs may be sparse: "immediately preceding" means the prior retained canonical entry, + // not arithmetically `current_epoch - 1`, and the rule is unchanged at a batch boundary. + let mut successor = Block::default(); + successor.block_header.beacon = CheckpointBeacon { + checkpoint: 10, + hash_prev_block: indexed_predecessor_hash, + }; + assert!(validate_snapshot_block_predecessor( + &successor, + SNAPSHOT_BLOCK_BATCH_SIZE, + indexed_predecessor_hash, + ) + .is_ok()); + successor.block_header.beacon.hash_prev_block = + incorrectly_rehashed_at_successor_protocol; + assert!(matches!( + validate_snapshot_block_predecessor( + &successor, + SNAPSHOT_BLOCK_BATCH_SIZE, + indexed_predecessor_hash, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +fn snapshot_transaction_genesis_fixture() -> ( + witnet_data_structures::chain::ConsensusConstants, + Block, +) { + let bootstrap_hash = Hash::with_first_u32(20); + let transaction = witnet_data_structures::transaction::VTTransaction::genesis(vec![ + ValueTransferOutput { + value: 1, + ..Default::default() + }, + ]); + let block = Block::genesis(bootstrap_hash, vec![transaction]); + let consensus_constants = witnet_data_structures::chain::ConsensusConstants { + bootstrap_hash, + genesis_hash: block.versioned_hash(get_protocol_version(Some(0))), + checkpoints_period: 45, + ..Default::default() + }; + (consensus_constants, block) +} + +fn signed_pending_reveal( + pointer: Hash, + reveal_bytes: Vec, + secret_bytes: [u8; 32], +) -> RevealTransaction { + let secret_key = Secp256k1SecretKey::from_slice(&secret_bytes) + .expect("fixture secret key must be inside the secp256k1 order"); + let public_key = PublicKey::from(Secp256k1PublicKey::from_secret_key_global(&secret_key)); + let body = RevealTransactionBody::new(pointer, reveal_bytes, public_key.pkh()); + let Hash::SHA256(message) = body.hash(); + let signature = sign(secret_key, &message).expect("fixture Reveal signature must succeed"); + RevealTransaction::new( + body, + vec![KeyedSignature { + signature: Signature::from(signature), + public_key, + }], + ) +} + +#[test] +fn snapshot_commit_vrf_uses_exact_v1_and_only_authenticated_v2_owner_salts() { + let mut transaction = CommitTransaction::default(); + transaction.body.dr_pointer = Hash::with_first_u32(40); + let vrf_input = CheckpointVRF { + checkpoint: 91, + hash_prev_vrf: Hash::with_first_u32(41), + }; + let request = SnapshotRequestContext { + inclusion_epoch: 0, + inclusion_position: 0, + protocol_version: ProtocolVersion::V1_7, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + let mut signatures = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + request, + vrf_input, + None, + &mut signatures, + )); + assert_eq!(signatures.len(), 1); + match &signatures[0] { + SignaturesToVerify::VrfDr { + proof, + vrf_input: queued_input, + dr_hash, + target_hash, + withdrawer, + request_protocol_version, + } => { + assert_eq!(proof, &transaction.body.proof); + assert_eq!(*queued_input, vrf_input); + assert_eq!(*dr_hash, transaction.body.dr_pointer); + assert_eq!(*target_hash, Hash::max()); + assert_eq!(*withdrawer, None); + // Message version is pinned from the snapshot request context, not looked up from + // the mutable runtime registry when the queued proof is eventually verified. + assert_eq!(*request_protocol_version, request.protocol_version); + } + queued => panic!("unexpected snapshot Commit verification item: {queued:?}"), + } + + for protocol_version in [ProtocolVersion::V2_0, ProtocolVersion::V2_1] { + let mut salted = Vec::new(); + assert!(!SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + SnapshotRequestContext { + protocol_version, + ..request + }, + vrf_input, + None, + &mut salted, + )); + assert!(salted.is_empty()); + } + + let authenticated_owner = PublicKeyHash::from_bytes(&[0x45; 20]).unwrap(); + let mut authenticated = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + SnapshotRequestContext { + protocol_version: ProtocolVersion::V2_1, + ..request + }, + vrf_input, + Some(authenticated_owner), + &mut authenticated, + )); + assert_eq!(authenticated.len(), 1); + match &authenticated[0] { + SignaturesToVerify::VrfDr { + withdrawer, + target_hash, + request_protocol_version, + .. + } => { + assert_eq!(*withdrawer, Some(authenticated_owner)); + assert_eq!(*target_hash, Hash::max()); + assert_eq!(*request_protocol_version, ProtocolVersion::V2_1); + } + queued => panic!("unexpected authenticated salted verification item: {queued:?}"), + } + + // Production Wit/2 snapshot replay supplies the exact stake-ranked target instead of the + // compatibility wrapper's unbounded target. Keep that handoff independently visible here; + // the actor-specific progressive projection seam is covered by the following fixture, and + // the shared resolver's rank arithmetic is covered in `witnet_validations`. + let resolved_target = Hash::with_first_u32(0x1234); + let mut ranked = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_commit_vrf_with_target( + &transaction, + SnapshotRequestContext { + protocol_version: ProtocolVersion::V2_1, + ..request + }, + vrf_input, + resolved_target, + Some(authenticated_owner), + &mut ranked, + )); + match &ranked[0] { + SignaturesToVerify::VrfDr { + withdrawer, + target_hash, + request_protocol_version, + .. + } => { + assert_eq!(*withdrawer, Some(authenticated_owner)); + assert_eq!(*target_hash, resolved_target); + assert_eq!(*request_protocol_version, ProtocolVersion::V2_1); + } + queued => panic!("unexpected ranked salted verification item: {queued:?}"), + } + + // Exercise the queued item through the same batch verifier used by snapshot replay. A + // proof created for the reconstructed beacon/request succeeds, while changing the request + // pointer after proof creation fails even though the threshold remains disabled. + let mut create_ctx = VrfCtx::secp256k1().unwrap(); + let secret_key = SecretKey { + bytes: Protected::from(vec![0x44; 32]), + }; + transaction.body.proof = DataRequestEligibilityClaim::create( + &mut create_ctx, + &secret_key, + vrf_input, + transaction.body.dr_pointer, + ) + .unwrap(); + let mut exact = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + request, + vrf_input, + None, + &mut exact, + )); + let mut exact_ctx = VrfCtx::secp256k1().unwrap(); + assert!(verify_signatures(exact, &mut exact_ctx).is_ok()); + + let mut wrong_request = transaction.clone(); + wrong_request.body.dr_pointer = Hash::with_first_u32(43); + let mut forged = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &wrong_request, + request, + vrf_input, + None, + &mut forged, + )); + let mut forged_ctx = VrfCtx::secp256k1().unwrap(); + assert!(verify_signatures(forged, &mut forged_ctx).is_err()); + + // The owner is part of the exact V2 message: a proof created with the authenticated owner + // verifies against that salt and fails against another address. Eligibility is deliberately + // not exercised by this cryptographic-only fixture. + let mut salted_create_ctx = VrfCtx::secp256k1().unwrap(); + let salted_message = VrfMessage::data_request_v2( + vrf_input, + transaction.body.dr_pointer, + Some(authenticated_owner), + ); + let salted_proof = VrfProof::create( + &mut salted_create_ctx, + &secret_key, + &salted_message, + ) + .unwrap() + .0; + let salted_claim = DataRequestEligibilityClaim { + proof: salted_proof, + }; + let mut salted_verify_ctx = VrfCtx::secp256k1().unwrap(); + assert!(salted_claim + .verify_v2( + &mut salted_verify_ctx, + vrf_input, + transaction.body.dr_pointer, + Some(authenticated_owner), + ) + .is_ok()); + let wrong_owner = PublicKeyHash::from_bytes(&[0x46; 20]).unwrap(); + let mut wrong_salt_ctx = VrfCtx::secp256k1().unwrap(); + assert!(salted_claim + .verify_v2( + &mut wrong_salt_ctx, + vrf_input, + transaction.body.dr_pointer, + Some(wrong_owner), + ) + .is_err()); + + // The snapshot queue carries that V2.1 choice into the generic batch verifier. Reusing the + // same proof with an explicitly pinned V1 request fails even though no epoch lookup occurs + // inside verification. + transaction.body.proof = salted_claim; + let mut pinned_v2 = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + SnapshotRequestContext { + protocol_version: ProtocolVersion::V2_1, + ..request + }, + vrf_input, + Some(authenticated_owner), + &mut pinned_v2, + )); + let mut pinned_v2_ctx = VrfCtx::secp256k1().unwrap(); + assert!(verify_signatures(pinned_v2, &mut pinned_v2_ctx).is_ok()); + + let mut pinned_v1 = Vec::new(); + assert!(SnapshotTransactionVerifier::queue_reconstructible_commit_vrf( + &transaction, + SnapshotRequestContext { + protocol_version: ProtocolVersion::V1_8, + ..request + }, + vrf_input, + None, + &mut pinned_v1, + )); + let mut pinned_v1_ctx = VrfCtx::secp256k1().unwrap(); + assert!(verify_signatures(pinned_v1, &mut pinned_v1_ctx).is_err()); +} + +#[test] +fn snapshot_wit21_commit_eligibility_projection_advances_between_same_block_commits() { + let validator = PublicKeyHash::from_bytes(&[0x47; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x48; 20]).unwrap(); + let competing_validator = PublicKeyHash::from_bytes(&[0x49; 20]).unwrap(); + let competing_owner = PublicKeyHash::from_bytes(&[0x4a; 20]).unwrap(); + let owner_key = StakeKey { + validator, + withdrawer: owner, + }; + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let validator_stake = minimum.checked_mul(10).unwrap(); + let competing_stake = minimum.checked_mul(5).unwrap(); + let reserved = minimum.checked_mul(6).unwrap(); + let epoch = 100; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + for (validator, withdrawer, stake) in [ + (validator, owner, validator_stake), + (competing_validator, competing_owner, competing_stake), + ] { + let terms = StakeAuthorization::register_owner( + 0, + validator, + withdrawer, + Epoch::MAX, + 0, + ); + tracker + .apply_v21_action(&terms, 1, epoch_constants) + .unwrap(); + tracker + .apply_v21_delegation( + StakeKey { + validator, + withdrawer, + }, + stake, + 1, + ) + .unwrap(); + } + + // Materialize the block-opening view exactly once. The owner-active population stays + // frozen, while the same stake/delegation pair advances after each accepted Commit. + let (mut stakes, mut delegation_state) = tracker.exact_full_projection().unwrap(); + let (_prevalidated, frozen_active_validators) = prevalidate_delegation_snapshot( + &stakes, + &delegation_state, + Wit::from_nanowits(minimum), + ) + .unwrap(); + let (target_before, owner_salt) = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + epoch, + 1, + 1, + 4, + &stakes, + &delegation_state, + minimum, + Some(&frozen_active_validators), + ) + .unwrap(); + assert!(target_before > Hash::min()); + assert_eq!(owner_salt, Some(owner)); + + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(47), + validator, + }; + delegation_state + .reserve_collateral( + &mut stakes, + reservation_key, + Wit::from_nanowits(reserved), + epoch, + Wit::from_nanowits(minimum), + ) + .unwrap(); + tracker + .sync_exact_validator_projection(validator, &stakes, &delegation_state, None) + .unwrap(); + + let error = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + epoch, + 1, + 1, + 4, + &stakes, + &delegation_state, + minimum, + Some(&frozen_active_validators), + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(witnet_data_structures::error::TransactionError::ValidatorNotEligible { + validator: rejected, + }) if *rejected == validator + )); + assert_eq!( + tracker.exact_stakes[&owner_key].coins, + minimum.checked_mul(4).unwrap() + ); + assert_eq!( + tracker.exact_collateral_reservations[&reservation_key] + .total + .nanowits(), + reserved + ); +} + +#[test] +fn snapshot_policy_tracker_never_guesses_an_owner_after_legacy_stake_history() { + let validator = PublicKeyHash::from_bytes(&[0x51; 20]).unwrap(); + let guessed_owner = PublicKeyHash::from_bytes(&[0x52; 20]).unwrap(); + let terms = StakeAuthorization::delegation( + 0, + validator, + guessed_owner, + Epoch::MAX, + 7, + ); + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_legacy_stake(validator); + tracker + .apply_v21_action(&terms, 100, epoch_constants) + .unwrap(); + assert_eq!(tracker.authenticated_owner(validator), None); + + let mut fresh = SnapshotAuthenticatedPolicyTracker::default(); + assert!(fresh.apply_v21_action(&terms, 100, epoch_constants).is_err()); + let registration = StakeAuthorization::register_owner(0, validator, guessed_owner, Epoch::MAX, 7); + fresh + .apply_v21_action(®istration, 100, epoch_constants) + .unwrap(); + assert_eq!( + fresh.authenticated_owner(validator), + Some(guessed_owner) + ); + let update = StakeAuthorization::commission_update( + 0, + validator, + guessed_owner, + Epoch::MAX, + 15, + 1, + ); + fresh + .apply_v21_action(&update, 100, epoch_constants) + .unwrap(); + let expected_effective_epoch = commission_effective_epoch(100, 7, 15, 45).unwrap(); + assert_eq!( + fresh.policies[&validator].pending_commission, + Some(PendingCommission { + commission_percent: 15, + effective_epoch: expected_effective_epoch, + nonce: 1, + }) + ); + fresh + .activate_pending_commissions(expected_effective_epoch - 1) + .unwrap(); + assert_eq!(fresh.policies[&validator].commission_percent, 7); + fresh + .activate_pending_commissions(expected_effective_epoch) + .unwrap(); + assert_eq!(fresh.policies[&validator].commission_percent, 15); + assert_eq!(fresh.policies[&validator].commission_nonce, 1); +} + +#[test] +fn snapshot_policy_tracker_rejects_missing_policy_for_existing_exact_stake_history() { + let validator = PublicKeyHash::from_bytes(&[0x56; 20]).unwrap(); + let original_owner = PublicKeyHash::from_bytes(&[0x57; 20]).unwrap(); + let forged_owner = PublicKeyHash::from_bytes(&[0x58; 20]).unwrap(); + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + tracker + .apply_v21_action( + &StakeAuthorization::register_owner(0, validator, original_owner, Epoch::MAX, 0), + 1, + epoch_constants, + ) + .unwrap(); + tracker + .apply_v21_delegation( + StakeKey { + validator, + withdrawer: original_owner, + }, + minimum, + 1, + ) + .unwrap(); + tracker.policies.remove(&validator); + + let forged = StakeAuthorization::delegation(0, validator, forged_owner, Epoch::MAX, 0); + assert!(tracker + .apply_v21_action(&forged, 2, epoch_constants) + .is_err()); +} + +#[test] +fn snapshot_exact_v21_rejects_persisted_stake_or_reservation_for_untracked_validator() { + let validator = PublicKeyHash::from_bytes(&[0x59; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x5a; 20]).unwrap(); + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + + let persisted_state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + assert!(tracker + .validate_persisted_policies(&persisted_state) + .is_err()); + let mut forged_stakes = StakesTracker::default(); + forged_stakes + .add_stake( + StakeKey { + validator, + withdrawer: owner, + }, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + 1, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .unwrap(); + + assert!(tracker + .validate_persisted_exact_stakes(&forged_stakes, &persisted_state) + .is_err()); + + let seeded_owner = PublicKeyHash::from_bytes(&[0x5c; 20]).unwrap(); + let seeded_validator = PublicKeyHash::from_bytes(&[0x5d; 20]).unwrap(); + let mut ownerless_activation = DelegationState { + activation_initialized: true, + ..Default::default() + }; + ownerless_activation.validators.insert( + seeded_validator, + ValidatorPolicy::new(seeded_owner), + ); + let mut seed_tracker = SnapshotAuthenticatedPolicyTracker::default(); + assert!(seed_tracker + .seed_from_v21_activation(&StakesTracker::default(), &ownerless_activation) + .is_err()); + assert!(seed_tracker.policies.is_empty()); + + let mut forged_reservation_state = persisted_state.clone(); + forged_reservation_state.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(0x5b), + validator, + }, + CollateralReservation { + total: Wit::from_nanowits(1), + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: StakeKey { + validator, + withdrawer: owner, + }, + amount: Wit::from_nanowits(1), + }], + }, + ); + assert!(tracker + .validate_persisted_exact_stakes(&StakesTracker::default(), &forged_reservation_state) + .is_err()); +} + +#[test] +fn snapshot_exact_v21_rejects_owner_shortfall_instead_of_charging_delegators() { + let validator = PublicKeyHash::from_bytes(&[0x53; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x54; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[0x55; 20]).unwrap(); + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + tracker + .apply_v21_action( + &StakeAuthorization::register_owner(0, validator, owner, Epoch::MAX, 0), + 10, + epoch_constants, + ) + .unwrap(); + tracker + .apply_v21_delegation( + StakeKey { + validator, + withdrawer: owner, + }, + minimum, + 10, + ) + .unwrap(); + tracker + .apply_v21_action( + &StakeAuthorization::delegation(0, validator, delegator, Epoch::MAX, 0), + 11, + epoch_constants, + ) + .unwrap(); + tracker + .apply_v21_delegation( + StakeKey { + validator, + withdrawer: delegator, + }, + minimum + 100, + 11, + ) + .unwrap(); + + // The mandatory owner share cannot spill to delegators even when they have spare capacity. + assert!(tracker.reserve_v21_collateral(Hash::with_first_u32(0x53), validator, 50).is_err()); + assert!(tracker.exact_collateral_reservations.is_empty()); + assert!(tracker + .apply_v21_unstake(validator, owner, minimum, 0, 10, 12) + .is_err()); +} + +#[test] +fn snapshot_exact_v21_stake_window_checks_cap_capacity_nonce_and_full_exit() { + let validator = PublicKeyHash::from_bytes(&[0x56; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x57; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[0x58; 20]).unwrap(); + let owner_key = StakeKey { + validator, + withdrawer: owner, + }; + let delegator_key = StakeKey { + validator, + withdrawer: delegator, + }; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + + let owner_terms = StakeAuthorization::register_owner( + 0, + validator, + owner, + Epoch::MAX, + 7, + ); + tracker + .apply_v21_action(&owner_terms, 10, epoch_constants) + .unwrap(); + tracker + .apply_v21_delegation( + owner_key.clone(), + DELEGATED_STAKING_MINIMUM_NANOWITS + 105, + 10, + ) + .unwrap(); + + let delegator_terms = StakeAuthorization::delegation( + 0, + validator, + delegator, + Epoch::MAX, + 7, + ); + tracker + .apply_v21_action(&delegator_terms, 11, epoch_constants) + .unwrap(); + tracker + .apply_v21_delegation( + delegator_key.clone(), + DELEGATED_STAKING_MINIMUM_NANOWITS + 45, + 11, + ) + .unwrap(); + + let request_1 = Hash::with_first_u32(56); + let request_2 = Hash::with_first_u32(57); + tracker + .reserve_v21_collateral(request_1, validator, 100) + .unwrap(); + tracker + .reserve_v21_collateral(request_2, validator, 50) + .unwrap(); + assert_eq!( + tracker.exact_stakes[&owner_key].coins, + DELEGATED_STAKING_MINIMUM_NANOWITS + ); + assert_eq!( + tracker.exact_stakes[&delegator_key].coins, + DELEGATED_STAKING_MINIMUM_NANOWITS + ); + assert!( + tracker + .reserve_v21_collateral(Hash::with_first_u32(58), validator, 1) + .is_err() + ); + + let current_total = DELEGATED_STAKING_MINIMUM_NANOWITS + .checked_mul(2) + .unwrap() + + 150; + assert!( + tracker + .apply_v21_delegation( + owner_key.clone(), + DELEGATED_STAKING_MAXIMUM_NANOWITS - current_total + 1, + 12, + ) + .is_err() + ); + assert_eq!(tracker.exact_stakes[&owner_key].nonce, 10); + + assert!( + tracker + .apply_v21_unstake(validator, delegator, 1, 0, 10, 12) + .is_err() + ); + assert!( + tracker + .apply_v21_unstake( + validator, + owner, + DELEGATED_STAKING_MINIMUM_NANOWITS + 99, + 1, + 10, + 12, + ) + .is_err() + ); + assert!( + tracker + .apply_v21_unstake( + validator, + delegator, + DELEGATED_STAKING_MINIMUM_NANOWITS - 1, + 1, + 11, + 12, + ) + .is_err() + ); + let committers = HashSet::from([validator]); + tracker + .settle_v21_tally(request_1, &committers, 100, 0, &[], &[validator], 12) + .unwrap(); + tracker + .settle_v21_tally(request_2, &committers, 50, 0, &[], &[validator], 12) + .unwrap(); + tracker + .apply_v21_unstake( + validator, + delegator, + DELEGATED_STAKING_MINIMUM_NANOWITS + 44, + 1, + 11, + 12, + ) + .unwrap(); + tracker + .apply_v21_unstake( + validator, + owner, + DELEGATED_STAKING_MINIMUM_NANOWITS + 104, + 1, + 10, + 12, + ) + .unwrap(); + assert!(tracker.exact_stakes.is_empty()); + + let mut persisted_state = DelegationState { + activation_initialized: true, + ..Default::default() + }; + persisted_state + .validators + .insert(validator, tracker.policies[&validator]); + assert!(tracker + .validate_persisted_policies(&persisted_state) + .is_ok()); + assert!(tracker + .validate_persisted_exact_stakes(&StakesTracker::default(), &persisted_state) + .is_ok()); + + let mut forged_stakes = StakesTracker::default(); + forged_stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + 13, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .unwrap(); + assert!(tracker + .validate_persisted_exact_stakes(&forged_stakes, &persisted_state) + .is_err()); + + let mut forged_reservation_state = persisted_state.clone(); + forged_reservation_state.collateral_reservations.insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(59), + validator, + }, + CollateralReservation { + total: Wit::from_nanowits(1), + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: owner_key, + amount: Wit::from_nanowits(1), + }], + }, + ); + assert!(tracker + .validate_persisted_exact_stakes( + &StakesTracker::default(), + &forged_reservation_state, + ) + .is_err()); +} + +#[test] +fn snapshot_exact_v21_replays_native_70_30_refund_reward_and_slash() { + let validator = PublicKeyHash::from_bytes(&[0x76; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x77; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[0x78; 20]).unwrap(); + let owner_key = StakeKey { + validator, + withdrawer: owner, + }; + let delegator_key = StakeKey { + validator, + withdrawer: delegator, + }; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + for (withdrawer, key, value, epoch) in [ + ( + owner, + owner_key.clone(), + DELEGATED_STAKING_MINIMUM_NANOWITS + 700, + 10, + ), + ( + delegator, + delegator_key.clone(), + DELEGATED_STAKING_MINIMUM_NANOWITS + 300, + 11, + ), + ] { + let mut terms = StakeAuthorization::delegation( + 0, + validator, + withdrawer, + Epoch::MAX, + 10, + ); + if withdrawer == owner { + terms.action = STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER; + } + tracker + .apply_v21_action(&terms, epoch, epoch_constants) + .unwrap(); + tracker + .apply_v21_delegation(key, value, epoch) + .unwrap(); + } + + let total = |tracker: &SnapshotAuthenticatedPolicyTracker| { + tracker + .exact_stakes + .iter() + .filter(|(key, _entry)| key.validator == validator) + .map(|(_key, entry)| entry.coins) + .sum::() + }; + let original_total = total(&tracker); + let honest_request = Hash::with_first_u32(76); + tracker + .reserve_v21_collateral(honest_request, validator, 1_000) + .unwrap(); + let reservation = &tracker.exact_collateral_reservations[&CollateralReservationKey { + data_request: honest_request, + validator, + }]; + let debits = reservation + .debits + .iter() + .map(|debit| (debit.key.clone(), debit.amount.nanowits())) + .collect::>(); + assert_eq!(debits[&owner_key], 700); + assert_eq!(debits[&delegator_key], 300); + assert_eq!(total(&tracker), original_total - 1_000); + assert_eq!(tracker.exact_stakes[&owner_key].nonce, 10); + assert_eq!(tracker.exact_stakes[&delegator_key].nonce, 11); + + let (persisted_stakes, persisted_state) = tracker.exact_full_projection().unwrap(); + assert!(tracker + .validate_persisted_exact_stakes(&persisted_stakes, &persisted_state) + .is_ok()); + + let committers = HashSet::from([validator]); + tracker + .settle_v21_tally(honest_request, &committers, 1_000, 1_000, &[], &[], 12) + .unwrap(); + assert_eq!(total(&tracker), original_total + 1_000); + assert!(tracker.exact_collateral_reservations.is_empty()); + assert_eq!(tracker.exact_stakes[&owner_key].nonce, 10); + assert_eq!(tracker.exact_stakes[&delegator_key].nonce, 11); + + let slashed_request = Hash::with_first_u32(77); + let before_slash = total(&tracker); + tracker + .reserve_v21_collateral(slashed_request, validator, 1_000) + .unwrap(); + tracker + .settle_v21_tally( + slashed_request, + &committers, + 1_000, + 1_000, + &[validator], + &[], + 13, + ) + .unwrap(); + assert_eq!(total(&tracker), before_slash - 1_000); + assert!(tracker.exact_collateral_reservations.is_empty()); +} + +#[test] +fn snapshot_exact_v21_exit_stops_new_participation_but_preserves_committed_rewards() { + let validator = PublicKeyHash::from_bytes(&[0x71; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x72; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[0x73; 20]).unwrap(); + let owner_key = StakeKey { validator, withdrawer: owner }; + let delegated_key = StakeKey { validator, withdrawer: delegator }; + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + tracker.apply_v21_action( + &StakeAuthorization::register_owner(0, validator, owner, Epoch::MAX, 0), + 10, epoch_constants, + ).unwrap(); + tracker.apply_v21_delegation(owner_key.clone(), minimum + 700, 10).unwrap(); + tracker.apply_v21_action( + &StakeAuthorization::delegation(0, validator, delegator, Epoch::MAX, 0), + 11, epoch_constants, + ).unwrap(); + tracker.apply_v21_delegation(delegated_key.clone(), minimum + 300, 11).unwrap(); + let request = Hash::with_first_u32(0x71); + tracker.reserve_v21_collateral(request, validator, 1_000).unwrap(); + + let exit = StakeAuthorization::request_exit(0, validator, delegator, Epoch::MAX, 11); + tracker.apply_v21_action(&exit, 12, epoch_constants).unwrap(); + assert!(tracker.exit_intents.contains(&delegated_key)); + assert!(tracker.apply_v21_action(&exit, 12, epoch_constants).is_err()); + assert!(tracker.apply_v21_delegation(delegated_key.clone(), minimum, 12).is_err()); + assert!(tracker.apply_v21_action( + &StakeAuthorization::request_exit(0, validator, owner, Epoch::MAX, 10), + 12, epoch_constants, + ).is_err()); + assert!(tracker.apply_v21_unstake(validator, delegator, minimum, 0, 11, 12).is_err()); + + let (stakes, mut forged) = tracker.exact_full_projection().unwrap(); + assert!(tracker.validate_persisted_policies(&forged).is_ok()); + forged.exit_intents.clear(); + assert!(tracker.validate_persisted_policies(&forged).is_err()); + assert!(tracker.validate_persisted_exact_stakes(&stakes, &forged).is_err()); + + // A post-intent block/inclusion reward excludes the draining entry. Its previously earned + // witness share still uses the pre-debit Commit weights, even though intent is now set. + let delegated_before = tracker.exact_stakes[&delegated_key].coins; + tracker.distribute_v21_reward(validator, 1_000, 13).unwrap(); + assert_eq!(tracker.exact_stakes[&delegated_key].coins, delegated_before); + tracker.settle_v21_tally( + request, &HashSet::from([validator]), 1_000, 1_000, &[], &[], 14, + ).unwrap(); + let delegated_after = tracker.exact_stakes[&delegated_key].coins; + assert!(delegated_after > minimum + 300); + assert!(tracker.exit_intents.contains(&delegated_key)); + tracker.apply_v21_unstake(validator, delegator, delegated_after, 0, 11, 15).unwrap(); + assert!(!tracker.exact_stakes.contains_key(&delegated_key)); + assert!(!tracker.exit_intents.contains(&delegated_key)); + assert_eq!(tracker.authenticated_owner(validator), Some(owner)); +} + +#[test] +fn snapshot_exact_v21_replays_age_averaging_and_rejects_a_forged_tip_age() { + let validator = PublicKeyHash::from_bytes(&[0x79; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x7a; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + tracker + .apply_v21_action( + &StakeAuthorization::register_owner( + 0, + validator, + owner, + Epoch::MAX, + 0, + ), + 10, + epoch_constants, + ) + .unwrap(); + tracker + .apply_v21_delegation(key.clone(), minimum * 2, 10) + .unwrap(); + assert_eq!(tracker.exact_stakes[&key].epochs.mining, 10); + assert_eq!(tracker.exact_stakes[&key].epochs.witnessing, 10); + + let request = Hash::with_first_u32(0x79); + tracker + .reserve_v21_collateral(request, validator, minimum) + .unwrap(); + let committers = HashSet::from([validator]); + tracker + .settle_v21_tally(request, &committers, minimum, 0, &[], &[validator], 20) + .unwrap(); + assert_eq!(tracker.exact_stakes[&key].coins, minimum * 2); + assert_eq!(tracker.exact_stakes[&key].epochs.mining, 15); + assert_eq!(tracker.exact_stakes[&key].epochs.witnessing, 15); + assert_eq!(tracker.exact_stakes[&key].nonce, 10); + + tracker + .distribute_v21_reward(validator, minimum * 2, 25) + .unwrap(); + assert_eq!(tracker.exact_stakes[&key].coins, minimum * 4); + assert_eq!(tracker.exact_stakes[&key].epochs.mining, 20); + assert_eq!(tracker.exact_stakes[&key].epochs.witnessing, 20); + assert_eq!(tracker.exact_stakes[&key].nonce, 10); + + tracker + .apply_v21_unstake(validator, owner, minimum - 1, 1, 10, 30) + .unwrap(); + assert_eq!(tracker.exact_stakes[&key].coins, minimum * 3); + assert_eq!(tracker.exact_stakes[&key].epochs.mining, 20); + assert_eq!(tracker.exact_stakes[&key].epochs.witnessing, 20); + assert_eq!(tracker.exact_stakes[&key].nonce, 30); + + let (persisted_stakes, persisted_state) = tracker.exact_full_projection().unwrap(); + assert!(tracker + .validate_persisted_exact_stakes(&persisted_stakes, &persisted_state) + .is_ok()); + let mut forged_stakes = persisted_stakes.try_clone_checked().unwrap(); + forged_stakes + .reset_age_for_key(key, Capability::Mining, 21, 1) + .unwrap(); + assert!(tracker + .validate_persisted_exact_stakes(&forged_stakes, &persisted_state) + .is_err()); +} + +#[test] +fn snapshot_exact_v21_age_reset_uses_global_rank_and_cross_fork_owner_key() { + let stronger_validator = PublicKeyHash::from_bytes(&[0x7b; 20]).unwrap(); + let stronger_owner = PublicKeyHash::from_bytes(&[0x7c; 20]).unwrap(); + let weaker_validator = PublicKeyHash::from_bytes(&[0x7d; 20]).unwrap(); + let weaker_owner = PublicKeyHash::from_bytes(&[0x7e; 20]).unwrap(); + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let minimum = DELEGATED_STAKING_MINIMUM_NANOWITS; + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_protocol(ProtocolVersion::V2_1); + for (validator, owner, amount) in [ + (stronger_validator, stronger_owner, minimum * 2), + (weaker_validator, weaker_owner, minimum), + ] { + tracker + .apply_v21_action( + &StakeAuthorization::register_owner( + 0, + validator, + owner, + Epoch::MAX, + 0, + ), + 0, + epoch_constants, + ) + .unwrap(); + tracker + .apply_v21_delegation( + StakeKey { + validator, + withdrawer: owner, + }, + amount, + 0, + ) + .unwrap(); + } + assert_eq!( + tracker + .exact_stakes_for_validator(stronger_validator) + .count(), + 1 + ); + assert_eq!( + tracker + .exact_stakes_for_validator(weaker_validator) + .count(), + 1 + ); + tracker + .apply_age_resets_for_protocol( + weaker_validator, + 10, + ProtocolVersion::V2_1, + &[], + &[], + |_| None, + ) + .unwrap(); + let stronger_key = StakeKey { + validator: stronger_validator, + withdrawer: stronger_owner, + }; + let weaker_key = StakeKey { + validator: weaker_validator, + withdrawer: weaker_owner, + }; + assert_eq!(tracker.exact_stakes[&stronger_key].epochs.mining, 12); + assert_eq!(tracker.exact_stakes[&weaker_key].epochs.mining, 11); + + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = Hash::with_first_u32(0x7f); + let legacy_validator = commit.body.proof.proof.pkh(); + let legacy_owner = PublicKeyHash::from_bytes(&[0x80; 20]).unwrap(); + let legacy_delegator = PublicKeyHash::from_bytes(&[0x81; 20]).unwrap(); + let mut cross_fork = SnapshotAuthenticatedPolicyTracker::default(); + cross_fork.note_protocol(ProtocolVersion::V2_1); + for withdrawer in [legacy_owner, legacy_delegator] { + let mut terms = StakeAuthorization::delegation( + 0, legacy_validator, withdrawer, Epoch::MAX, 0, + ); + if withdrawer == legacy_owner { + terms.action = STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER; + } + cross_fork + .apply_v21_action( + &terms, + 0, + epoch_constants, + ) + .unwrap(); + cross_fork + .apply_v21_delegation( + StakeKey { + validator: legacy_validator, + withdrawer, + }, + minimum, + 0, + ) + .unwrap(); + } + let absent_miner = PublicKeyHash::from_bytes(&[0x82; 20]).unwrap(); + assert_ne!(absent_miner, legacy_validator); + cross_fork + .apply_age_resets_for_protocol( + absent_miner, + 10, + ProtocolVersion::V2_1, + std::slice::from_ref(&commit), + &[], + |_| Some(ProtocolVersion::V2_0), + ) + .unwrap(); + let owner_key = StakeKey { + validator: legacy_validator, + withdrawer: legacy_owner, + }; + let delegator_key = StakeKey { + validator: legacy_validator, + withdrawer: legacy_delegator, + }; + assert_eq!(cross_fork.exact_stakes[&owner_key].epochs.witnessing, 11); + assert_eq!(cross_fork.exact_stakes[&delegator_key].epochs.witnessing, 0); + + let before_overflow = cross_fork.exact_stakes.clone(); + assert!(cross_fork + .apply_age_resets_for_protocol( + absent_miner, + Epoch::MAX, + ProtocolVersion::V2_1, + &[commit], + &[], + |_| Some(ProtocolVersion::V2_0), + ) + .is_err()); + assert_eq!(cross_fork.exact_stakes, before_overflow); +} + +#[test] +fn snapshot_pre_wit2_seed_replays_only_the_successful_stake_prefix() { + let validator = PublicKeyHash::from_bytes(&[0x59; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x5a; 20]).unwrap(); + let skipped_validator = PublicKeyHash::from_bytes(&[0x5b; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let mut first = StakeTransaction::default(); + first.body.output.key = key.clone(); + first.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + let second = first.clone(); + let mut rejected = first.clone(); + rejected.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS - 1; + let mut skipped = first.clone(); + skipped.body.output.key = StakeKey { + validator: skipped_validator, + withdrawer: owner, + }; + + let mut stakes = StakesTracker::default(); + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut stakes, + &[first, second, rejected, skipped], + &[], + 9, + ProtocolVersion::V1_8, + ) + .unwrap(); + + let entries = stakes + .query_stakes_by_validator_primary_checked(validator) + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].value.coins.nanowits(), + DELEGATED_STAKING_MINIMUM_NANOWITS * 2 + ); + assert_eq!(entries[0].value.nonce, 9); + assert_eq!(stakes.stakes_count(), 1); + + let before_guard = stakes.clone(); + assert!( + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut stakes, + &[], + &[], + 10, + ProtocolVersion::V2_1, + ) + .is_err() + ); + assert_eq!(stakes, before_guard); +} + +#[test] +fn snapshot_v20_replay_reserves_and_settles_canonical_tally_classes() { + let validator = PublicKeyHash::from_bytes(&[0x5c; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x5d; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let initial = DELEGATED_STAKING_MINIMUM_NANOWITS + 10_000; + let collateral = 1_000; + let reward = 250; + let mut stake = StakeTransaction::default(); + stake.body.output.key = key.clone(); + stake.body.output.value = initial; + + let mut baseline = SnapshotLegacyStakeReplay::default(); + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut baseline.stakes, + &[stake], + &[], + 1, + ProtocolVersion::V1_8, + ) + .unwrap(); + let committers = HashSet::from([validator]); + let balance = |replay: &SnapshotLegacyStakeReplay| { + replay + .stakes + .query_stakes_by_validator_primary_checked(validator) + .unwrap()[0] + .value + .coins + .nanowits() + }; + + let mut honest = baseline.try_clone_checked().unwrap(); + let honest_request = Hash::with_first_u32(0x5e); + honest + .reserve_v20_collateral(honest_request, validator, collateral) + .unwrap(); + assert_eq!(balance(&honest), initial - collateral); + let reservation = &honest.delegation_state.collateral_reservations + [&CollateralReservationKey { + data_request: honest_request, + validator, + }]; + assert_eq!(reservation.debits[0].key, key); + honest + .settle_v20_tally( + honest_request, + &committers, + collateral, + reward, + &[], + &[], + 2, + ) + .unwrap(); + assert_eq!(balance(&honest), initial + reward); + assert!(honest.delegation_state.collateral_reservations.is_empty()); + + let mut error = baseline.try_clone_checked().unwrap(); + let error_request = Hash::with_first_u32(0x5f); + error + .reserve_v20_collateral(error_request, validator, collateral) + .unwrap(); + error + .settle_v20_tally( + error_request, + &committers, + collateral, + reward, + &[validator], + &[validator], + 2, + ) + .unwrap(); + assert_eq!(balance(&error), initial); + assert!(error.delegation_state.collateral_reservations.is_empty()); + + let mut liar = baseline.try_clone_checked().unwrap(); + let liar_request = Hash::with_first_u32(0x60); + liar.reserve_v20_collateral(liar_request, validator, collateral) + .unwrap(); + liar.settle_v20_tally( + liar_request, + &committers, + collateral, + reward, + &[validator], + &[], + 2, + ) + .unwrap(); + assert_eq!(balance(&liar), initial - collateral); + assert!(liar.delegation_state.collateral_reservations.is_empty()); +} + +#[test] +fn snapshot_pre_activation_tip_requires_the_exact_replayed_stake_ledger() { + let validator = PublicKeyHash::from_bytes(&[0x64; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x65; 20]).unwrap(); + let mut stake = StakeTransaction::default(); + stake.body.output.key = StakeKey { + validator, + withdrawer: owner, + }; + stake.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + + let mut replay = SnapshotLegacyStakeReplay::default(); + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut replay.stakes, + &[stake], + &[], + 7, + ProtocolVersion::V1_8, + ) + .unwrap(); + let mut persisted = replay.stakes.try_clone_checked().unwrap(); + assert!(replay.validate_persisted_stakes(&persisted).is_ok()); + + persisted + .add_reward(validator, Wit::from_nanowits(1), 8) + .unwrap(); + assert!(replay.validate_persisted_stakes(&persisted).is_err()); +} + +/// Verify that snapshot replay preserves activation nonces while settling ownerless legacy obligations. It exercises the snapshot replay path and `settle_v20_tally_after_activation` against missing-validator history. +#[test] +fn snapshot_activation_handoff_rejects_orphans_and_preserves_exact_nonce() { + let validator = PublicKeyHash::from_bytes(&[0x66; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x67; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer: owner, + }; + let initial = DELEGATED_STAKING_MINIMUM_NANOWITS + 10_000; + let collateral = 1_000; + let reward = 250; + let request = Hash::with_first_u32(0x68); + let missing_validator = PublicKeyHash::from_bytes(&[0x6e; 20]).unwrap(); + let missing_request = Hash::with_first_u32(0x6f); + let reservation_key = CollateralReservationKey { + data_request: request, + validator, + }; + let missing_reservation_key = CollateralReservationKey { + data_request: missing_request, + validator: missing_validator, + }; + let mut stake = StakeTransaction::default(); + stake.body.output.key = key.clone(); + stake.body.output.value = initial; + + let mut replay = SnapshotLegacyStakeReplay::default(); + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut replay.stakes, + &[stake], + &[], + 7, + ProtocolVersion::V1_8, + ) + .unwrap(); + replay + .reserve_v20_collateral(request, validator, collateral) + .unwrap(); + + let mut orphaned = replay.try_clone_checked().unwrap(); + // Pre-activation reservation rows are advisory cache state. An orphaned row must be + // discarded rather than becoming native escrow or blocking activation. + orphaned.activate_v21(&BTreeMap::new(), 8).unwrap(); + assert!(orphaned.delegation_state.activation_initialized); + assert!(orphaned.delegation_state.collateral_reservations.is_empty()); + assert!(orphaned.delegation_state.legacy_activation_obligations.is_empty()); + + let commitments = BTreeMap::from([( + reservation_key, + ( + Wit::from_nanowits(collateral), + Wit::from_nanowits(reward), + ), + ), ( + missing_reservation_key, + ( + Wit::from_nanowits(collateral), + Wit::from_nanowits(reward), + ), + )]); + replay.activate_v21(&commitments, 8).unwrap(); + assert!(replay.delegation_state.activation_initialized); + assert_eq!( + replay.delegation_state.validators[&validator], + ValidatorPolicy::new(owner) + ); + assert!(replay.delegation_state.collateral_reservations.is_empty()); + assert_eq!( + replay.delegation_state.legacy_activation_obligations[&reservation_key] + .raw_collateral, + Wit::from_nanowits(collateral) + ); + + // A canonical activation image always clears pre-marker exit sidecars. The authenticated + // snapshot handoff must reject a forged marked state rather than importing that optional byte + // as a native drain lock. + let mut forged_activation = replay.delegation_state.clone(); + forged_activation.exit_intents.insert(key.clone()); + let mut forged_tracker = SnapshotAuthenticatedPolicyTracker::default(); + assert!(forged_tracker + .seed_from_v21_activation(&replay.stakes, &forged_activation) + .is_err()); + assert!(forged_tracker.policies.is_empty()); + assert!(!forged_tracker.observed_v21_block); + + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.note_legacy_stake(validator); + tracker + .seed_from_v21_activation(&replay.stakes, &replay.delegation_state) + .unwrap(); + assert!(tracker.stake_balance_is_exact(validator)); + assert_eq!(tracker.exact_stakes[&key].coins, initial - collateral); + assert_eq!(tracker.exact_stakes[&key].nonce, 7); + + let committers = HashSet::from([validator]); + tracker + .settle_v20_tally_after_activation( + request, + &committers, + collateral, + reward, + &[], + &[], + 8, + ) + .unwrap(); + assert_eq!(tracker.exact_stakes[&key].coins, initial - collateral); + assert_eq!(tracker.exact_stakes[&key].nonce, 7); + assert!(tracker.exact_collateral_reservations.is_empty()); + assert!(matches!( + tracker.exact_legacy_activation_obligations[&missing_reservation_key].settlement, + LegacyActivationSettlement::Pending + )); + tracker + .settle_v20_tally_after_activation( + missing_request, + &HashSet::from([missing_validator]), + collateral, + reward, + &[], + &[], + 8, + ) + .unwrap(); + assert!(matches!( + tracker.exact_legacy_activation_obligations[&missing_reservation_key].settlement, + LegacyActivationSettlement::Deferred { collateral: deferred_collateral, witness_reward: deferred_reward } + if deferred_collateral == Wit::from_nanowits(collateral) + && deferred_reward == Wit::from_nanowits(reward) + )); + + let slashed_request = Hash::with_first_u32(0x69); + tracker + .reserve_v20_collateral_after_activation( + slashed_request, + validator, + collateral, + 9, + ) + .unwrap(); + tracker + .settle_v20_tally_after_activation( + slashed_request, + &committers, + collateral, + reward, + &[validator], + &[], + 9, + ) + .unwrap(); + assert_eq!( + tracker.exact_stakes[&key].coins, + initial - collateral - collateral + ); + assert_eq!(tracker.exact_stakes[&key].nonce, 7); + assert!(tracker.exact_collateral_reservations.is_empty()); +} + +#[test] +fn snapshot_zero_collateral_legacy_owner_drain_waits_until_block_after_settlement() { + let (constants, _genesis) = snapshot_transaction_genesis_fixture(); + let mut verifier = SnapshotTransactionVerifier::new(&constants).unwrap(); + let validator = PublicKeyHash::from_bytes(&[0x6a; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x6b; 20]).unwrap(); + let key = StakeKey { validator, withdrawer: owner }; + let pointer = Hash::with_first_u32(0x6c); + let request_block_hash = Hash::with_first_u32(0x6d); + let reward = 250; + let initial = DELEGATED_STAKING_MINIMUM_NANOWITS + 10_000; + let mut request = DataRequestOutput::default(); + request.collateral = 0; + request.witness_reward = reward; + verifier.known_requests.insert(pointer, SnapshotRequestContext { + inclusion_epoch: 6, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_0, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: reward, + witnesses: 1, + commit_block_position: Some(1), + last_reveal_block_position: None, + reveals: 0, + }); + verifier.canonical_requests.insert(pointer, SnapshotCanonicalRequestState { + data_request: request, + requester: owner, + request_block_hash, + commits: HashMap::from([(validator, CommitTransaction::default())]), + reveals: HashMap::new(), + }); + verifier.request_committers.insert(pointer, HashSet::from([validator])); + let commitments = verifier.canonical_outstanding_v20_commitments().unwrap(); + assert_eq!(commitments[&CollateralReservationKey { + data_request: pointer, validator, + }].0, Wit::from_nanowits(0)); + let frozen_locks = commitments.keys().map(|key| key.validator).collect::>(); + + let mut legacy = SnapshotLegacyStakeReplay::default(); + let mut stake = StakeTransaction::default(); + stake.body.output.key = key.clone(); + stake.body.output.value = initial; + SnapshotTransactionVerifier::replay_legacy_manual_stake_batches( + &mut legacy.stakes, &[stake], &[], 7, ProtocolVersion::V1_8, + ).unwrap(); + legacy.reserve_v20_collateral(pointer, validator, 0).unwrap(); + assert!(legacy.delegation_state.collateral_reservations.is_empty()); + legacy.activate_v21(&commitments, 8).unwrap(); + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + tracker.seed_from_v21_activation(&legacy.stakes, &legacy.delegation_state).unwrap(); + tracker.note_protocol(ProtocolVersion::V2_1); + tracker.apply_v21_action( + &StakeAuthorization::request_exit(0, validator, owner, Epoch::MAX, 7), + 8, verifier.epoch_constants, + ).unwrap(); + assert!(tracker.exact_collateral_reservations.is_empty()); + let before = tracker.exact_stakes.clone(); + assert!(tracker.apply_v21_unstake_with_legacy_committers( + validator, owner, initial, 0, 7, 9, &frozen_locks, + ).is_err()); + assert_eq!(tracker.exact_stakes, before); + tracker.apply_v21_unstake_with_legacy_committers( + validator, owner, 100, 0, 7, 9, &frozen_locks, + ).unwrap(); + assert!(tracker.exit_intents.contains(&key)); + + tracker.settle_v20_tally_after_activation( + pointer, &HashSet::from([validator]), 0, reward, &[], &[], 10, + ).unwrap(); + let final_balance = initial - 100 + reward; + assert_eq!(tracker.exact_stakes[&key].coins, final_balance); + // Production Tallies remove these maps, but the current block's already frozen lock is + // retained. Next block captures the now-empty canonical set and permits final withdrawal. + verifier.known_requests.remove(&pointer); + verifier.canonical_requests.remove(&pointer); + verifier.request_committers.remove(&pointer); + assert!(tracker.apply_v21_unstake_with_legacy_committers( + validator, owner, final_balance, 0, 9, 10, &frozen_locks, + ).is_err()); + let next_locks = verifier.canonical_outstanding_v20_commitments().unwrap() + .keys().map(|key| key.validator).collect::>(); + assert!(next_locks.is_empty()); + tracker.apply_v21_unstake_with_legacy_committers( + validator, owner, final_balance, 0, 9, 11, &next_locks, + ).unwrap(); + assert!(!tracker.exact_stakes.contains_key(&key)); + assert!(!tracker.exit_intents.contains(&key)); +} + +#[test] +fn snapshot_v21_stake_envelope_checks_fixed_bounds_funding_and_policy_fee() { + let validator = PublicKeyHash::from_bytes(&[0x61; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x62; 20]).unwrap(); + let pointer = OutputPointer { + transaction_id: Hash::with_first_u32(63), + output_index: 0, + }; + let input_value = DELEGATED_STAKING_MINIMUM_NANOWITS + 9; + let mut pool = UnspentOutputsPool::default(); + pool.insert( + pointer, + ValueTransferOutput { + pkh: owner, + value: input_value, + time_lock: 0, + }, + 0, + ); + let diff = UtxoDiff::new(&pool, 1); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = 1; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let mut transaction = StakeTransaction::default(); + transaction.body.inputs.push(Input::new(pointer)); + transaction.body.output.key = StakeKey { validator, withdrawer: owner }; + transaction.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS; + transaction.body.output.authorization_terms = Some(StakeAuthorization::delegation( + 0, + validator, + owner, + Epoch::MAX, + 0, + )); + assert_eq!( + SnapshotTransactionVerifier::validate_v21_stake_action_envelope( + &block, + 1, + &transaction, + &diff, + epoch_constants, + ) + .unwrap(), + 9 + ); + + transaction.body.output.value = DELEGATED_STAKING_MINIMUM_NANOWITS - 1; + assert!(matches!( + SnapshotTransactionVerifier::validate_v21_stake_action_envelope( + &block, + 1, + &transaction, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + transaction.body.output.value = 0; + transaction.body.output.authorization_terms = Some(StakeAuthorization::commission_update( + 0, + validator, + owner, + Epoch::MAX, + 1, + 1, + )); + transaction.body.change = Some(ValueTransferOutput { + pkh: owner, + value: input_value, + time_lock: 0, + }); + assert!(matches!( + SnapshotTransactionVerifier::validate_v21_stake_action_envelope( + &block, + 1, + &transaction, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + transaction.body.change.as_mut().unwrap().value -= 1; + assert_eq!( + SnapshotTransactionVerifier::validate_v21_stake_action_envelope( + &block, + 1, + &transaction, + &diff, + epoch_constants, + ) + .unwrap(), + 1 + ); + + let mut unstake = witnet_data_structures::transaction::UnstakeTransaction::default(); + unstake.body.withdrawal.value = 1; + unstake.body.withdrawal.time_lock = 144; + assert!(matches!( + SnapshotTransactionVerifier::validate_v21_unstake_envelope( + &block, + 1, + &unstake, + epoch_constants, + 100, + ), + Err(ImportError::SnapshotContents { .. }) + )); + unstake.body.withdrawal.time_lock = 145; + assert!(SnapshotTransactionVerifier::validate_v21_unstake_envelope( + &block, + 1, + &unstake, + epoch_constants, + 100, + ) + .is_ok()); +} + +#[test] +fn snapshot_policy_tip_rejects_a_persisted_policy_without_canonical_provenance() { + let validator = PublicKeyHash::from_bytes(&[0x71; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[0x72; 20]).unwrap(); + let mut persisted = DelegationState::default(); + persisted + .validators + .insert(validator, ValidatorPolicy::new(owner)); + + let mut tracker = SnapshotAuthenticatedPolicyTracker::default(); + assert!(tracker.validate_persisted_policies(&persisted).is_ok()); + persisted.activation_initialized = true; + tracker.note_protocol(ProtocolVersion::V2_1); + assert!(tracker.validate_persisted_policies(&persisted).is_err()); + + // Once the activation marker is observed, legacy-history opacity is no longer a valid + // provenance exception. The activation seed must have imported exact owner policies and + // cleared both conservative history sets before this persisted image can be accepted. + tracker.note_legacy_stake(validator); + assert!(tracker.validate_persisted_policies(&persisted).is_err()); +} + +#[test] +fn snapshot_commit_utxo_checks_exact_legacy_value_and_native_v21_envelope() { + assert_eq!( + SnapshotTransactionVerifier::required_collateral(ProtocolVersion::V1_8, 0, 40), + 40 + ); + assert_eq!( + SnapshotTransactionVerifier::required_collateral(ProtocolVersion::V2_0, 0, 40), + 0 + ); + assert_eq!( + SnapshotTransactionVerifier::required_collateral(ProtocolVersion::V2_1, 0, 40), + 40 + ); + + let mut transaction = CommitTransaction::default(); + let participant = transaction.body.proof.proof.pkh(); + let pointer = OutputPointer { + transaction_id: Hash::with_first_u32(42), + output_index: 0, + }; + let mut pool = UnspentOutputsPool::default(); + pool.insert( + pointer, + ValueTransferOutput { + pkh: participant, + value: 100, + time_lock: 0, + }, + 0, + ); + transaction.body.collateral.push(Input::new(pointer)); + transaction.body.outputs.push(ValueTransferOutput { + pkh: participant, + value: 60, + time_lock: 0, + }); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = 2; + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let diff = UtxoDiff::new(&pool, 1); + let legacy_request = SnapshotRequestContext { + inclusion_epoch: 1, + inclusion_position: 0, + protocol_version: ProtocolVersion::V1_8, + required_collateral: 40, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + assert!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics( + &block, + 1, + &transaction, + participant, + legacy_request, + &diff, + epoch_constants, + ) + .is_ok() + ); + + let wrong_difference = SnapshotRequestContext { + required_collateral: 41, + ..legacy_request + }; + assert!(matches!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics( + &block, + 1, + &transaction, + participant, + wrong_difference, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let mut wrong_change_owner = transaction.clone(); + let wrong_owner = PublicKeyHash::from_bytes(&[0x55; 20]).unwrap(); + assert_ne!(wrong_owner, participant); + wrong_change_owner.body.outputs[0].pkh = wrong_owner; + assert!(matches!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics( + &block, + 1, + &wrong_change_owner, + participant, + legacy_request, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let native_request = SnapshotRequestContext { + protocol_version: ProtocolVersion::V2_1, + ..legacy_request + }; + assert!(matches!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics( + &block, + 1, + &transaction, + participant, + native_request, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + // A Wit/2.0 request can cross the activation boundary, but its commit must then use the + // owner-only stake reservation path and carry no legacy UTXO collateral. Exercise the + // containing-block-aware helper directly because the compatibility wrapper above models + // focused same-protocol fixtures only. + let crossing_request = SnapshotRequestContext { + protocol_version: ProtocolVersion::V2_0, + ..legacy_request + }; + assert!(matches!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics_for_block_protocol( + &block, + 1, + &transaction, + participant, + crossing_request, + ProtocolVersion::V2_1, + &diff, + epoch_constants, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + let stake_backed = CommitTransaction::default(); + let stake_backed_participant = stake_backed.body.proof.proof.pkh(); + for protocol_version in [ProtocolVersion::V2_0, ProtocolVersion::V2_1] { + assert!( + SnapshotTransactionVerifier::validate_commit_utxo_semantics( + &block, + 1, + &stake_backed, + stake_backed_participant, + SnapshotRequestContext { + protocol_version, + ..legacy_request + }, + &diff, + epoch_constants, + ) + .is_ok() + ); + } +} + +#[test] +fn snapshot_history_replay_proves_genesis_tip_state() { + let (consensus_constants, genesis) = snapshot_transaction_genesis_fixture(); + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + verifier.validate_block(&genesis, 0).unwrap(); + let expected = verifier.utxos.clone().try_into_portable().unwrap(); + let expected_vrf_output = CheckpointVRF { + checkpoint: 0, + hash_prev_vrf: consensus_constants.genesis_hash, + }; + assert!( + verifier + .validate_tip_state( + &expected, + expected_vrf_output, + &StakesTracker::default(), + &DelegationState::default(), + &DataRequestPool::new(consensus_constants.extra_rounds), + ) + .is_ok() + ); +} + +#[test] +fn snapshot_unresolved_pool_reconciles_terminal_and_bounds_early_tally() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + let wit2_activation_epoch = verifier + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .expect("the local fixture schedule includes Wit/2 activation"); + + let pointer = Hash::with_first_u32(0xD2_200001); + let request_block_hash = Hash::with_first_u32(0xD2_200002); + let requester = PublicKeyHash::default(); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + verifier.known_requests.insert( + pointer, + SnapshotRequestContext { + inclusion_epoch: 0, + inclusion_position: 0, + protocol_version: ProtocolVersion::V1_8, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }, + ); + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output.clone(), + requester, + request_block_hash, + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + + let mut pool = DataRequestPool::new(consensus_constants.extra_rounds); + let mut state = witnet_data_structures::chain::DataRequestState::new( + output, + requester, + 0, + Some(request_block_hash), + ); + state.info.current_commit_round = 1; + state.set_stage(DataRequestStage::TALLY); + pool.data_request_pool.insert(pointer, state); + + // At distance one of terminal round two, a legacy request needs the exact accepted-block + // transition recorded by canonical replay. An epoch and a low imported tip count alone do + // not prove when that transition occurred. + verifier.next_position = 2; + verifier.highest_vrf_output.checkpoint = wit2_activation_epoch - 1; + let too_many_counts = SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }; + assert!(matches!( + verifier.validate_data_request_tip_state(&pool, too_many_counts), + Err(ImportError::SnapshotContents { .. }) + )); + + verifier.highest_vrf_output.checkpoint = wit2_activation_epoch; + assert!(matches!( + verifier.validate_data_request_tip_state(&pool, too_many_counts), + Err(ImportError::SnapshotContents { .. }) + )); + + // Consolidation can leave this early TALLY state once the stage-update block is under + // Wit/2. Canonical replay records both the exact low-population transition and the ordinary + // tally's mandatory next-block position. + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::LegacyCrossingOrdinary, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + verifier.highest_vrf_output.checkpoint = wit2_activation_epoch; + assert!(verifier + .validate_data_request_tip_state(&pool, too_many_counts) + .is_ok()); + + // A forged or missing transition cannot be replaced by an imported population claim. + verifier.pending_tally_obligations.remove(&pointer); + assert!(matches!( + verifier.validate_data_request_tip_state( + &pool, + SnapshotTipValidatorCounts { + current: 0, + legacy: 8, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + // Native V2.1 requests select the owner-active current population, not the larger raw + // legacy ledger population retained for cross-fork requests. + let split_counts = SnapshotTipValidatorCounts { + current: 0, + legacy: 8, + }; + assert_eq!(split_counts.for_request(ProtocolVersion::V2_0), 8); + assert_eq!(split_counts.for_request(ProtocolVersion::V2_1), 0); + + // Ordinary no-Commit expiry remains protocol-neutral at the exact terminal distance. + verifier.next_position = 3; + verifier.highest_vrf_output.checkpoint = wit2_activation_epoch - 1; + pool.data_request_pool + .get_mut(&pointer) + .unwrap() + .info + .current_commit_round = 2; + assert!(verifier + .validate_data_request_tip_state( + &pool, + SnapshotTipValidatorCounts { + current: 8, + legacy: 8, + }, + ) + .is_ok()); + + pool.data_request_pool + .get_mut(&pointer) + .unwrap() + .info + .current_commit_round = 1; + assert!(matches!( + verifier.validate_data_request_tip_state( + &pool, + SnapshotTipValidatorCounts { + current: 8, + legacy: 8, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_unresolved_pool_requires_exact_v21_causal_tally_obligation() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + + let pointer = Hash::with_first_u32(0xD2_200011); + let request_block_hash = Hash::with_first_u32(0xD2_200012); + let requester = PublicKeyHash::default(); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + verifier.known_requests.insert( + pointer, + SnapshotRequestContext { + inclusion_epoch: TEST_WIT2_ACTIVATION_EPOCH + 1, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_0, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }, + ); + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output.clone(), + requester, + request_block_hash, + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::V21CausalTooManyWitnesses, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + verifier.next_position = 2; + verifier.highest_vrf_output.checkpoint = TEST_WIT21_ACTIVATION_EPOCH - 1; + + let mut state = witnet_data_structures::chain::DataRequestState::new( + output, + requester, + TEST_WIT2_ACTIVATION_EPOCH + 1, + Some(request_block_hash), + ); + state.info.current_commit_round = 1; + state.set_stage(DataRequestStage::TALLY); + let mut pool = DataRequestPool::new(consensus_constants.extra_rounds); + pool.data_request_pool.insert(pointer, state); + let low_counts = SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }; + assert!(verifier + .validate_data_request_tip_state(&pool, low_counts) + .is_ok()); + + // A low imported population alone does not prove which accepted block caused TALLY. + verifier.pending_tally_obligations.remove(&pointer); + assert!(matches!( + verifier.validate_data_request_tip_state(&pool, low_counts), + Err(ImportError::SnapshotContents { .. }) + )); + + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::V21CausalTooManyWitnesses, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + assert!(matches!( + verifier.validate_data_request_tip_state( + &pool, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_pending_reveal_requires_a_valid_body_signature() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + let pointer = Hash::with_first_u32(0xD2_300001); + let request_block_hash = Hash::with_first_u32(0xD2_300002); + let requester = PublicKeyHash::default(); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + verifier.known_requests.insert( + pointer, + SnapshotRequestContext { + inclusion_epoch: 0, + inclusion_position: 0, + protocol_version: ProtocolVersion::V1_8, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }, + ); + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output.clone(), + requester, + request_block_hash, + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + verifier.next_position = 1; + + let mut state = witnet_data_structures::chain::DataRequestState::new( + output, + requester, + 0, + Some(request_block_hash), + ); + state.info.current_commit_round = 1; + let mut pool = DataRequestPool::new(consensus_constants.extra_rounds); + pool.data_request_pool.insert(pointer, state); + pool.data_requests_by_epoch + .entry(0) + .or_default() + .insert(pointer); + + let signed = signed_pending_reveal(pointer, vec![1, 2, 3], [0x63; 32]); + pool.waiting_for_reveal.insert(pointer, signed.clone()); + let counts = SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }; + assert!(verifier + .validate_data_request_tip_state(&pool, counts) + .is_ok()); + + // Retaining a real public key and signature is not enough: the signature must authenticate + // the exact body that the imported node-local queue may later rebroadcast. + let forged = RevealTransaction::new( + RevealTransactionBody::new(pointer, vec![9, 9, 9], signed.body.pkh), + signed.signatures, + ); + pool.waiting_for_reveal.insert(pointer, forged); + match verifier.validate_data_request_tip_state(&pool, counts) { + Err(ImportError::SnapshotContents { message }) => { + assert!(message.contains("pending Reveal signature verification failed")); + } + result => panic!("forged pending Reveal unexpectedly accepted: {result:?}"), + } +} + +#[test] +fn snapshot_transaction_chronology_rejects_pre_wit2_too_many_sentinel() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 3; + let verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + let activation_epoch = verifier + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .expect("the local fixture schedule includes Wit/2 activation"); + let pointer = Hash::with_first_u32(0xD2_100001); + let request = SnapshotRequestContext { + inclusion_epoch: 0, + inclusion_position: 0, + protocol_version: ProtocolVersion::V1_8, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = activation_epoch - 1; + + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 2, + &tally, + request, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_same_block_too_many_requires_opening_population_proof() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + + let pointer = Hash::with_first_u32(0xD2_100080); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output, + requester: PublicKeyHash::default(), + request_block_hash: Hash::with_first_u32(0xD2_10007F), + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + let request = SnapshotRequestContext { + inclusion_epoch: TEST_WIT21_ACTIVATION_EPOCH, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_1, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + let sentinel = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = TEST_WIT21_ACTIVATION_EPOCH; + + assert!(verifier + .validate_tally_chronology( + &block, + 0, + &sentinel, + request, + SnapshotTipValidatorCounts { + current: 0, + legacy: 4, + }, + ) + .is_ok()); + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 0, + &sentinel, + request, + SnapshotTipValidatorCounts { + current: 4, + legacy: 0, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_tally_chronology_accepts_a_proven_pre_wit2_crossing_ordinary_tally() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 3; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + let activation_epoch = verifier + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .expect("the local fixture schedule includes Wit/2 activation"); + + let pointer = Hash::with_first_u32(0xD2_100081); + let request = SnapshotRequestContext { + inclusion_epoch: activation_epoch - 1, + inclusion_position: 0, + protocol_version: verifier.protocol_version(activation_epoch - 1), + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + let tally = TallyTransaction::new(pointer, vec![1], Vec::new(), Vec::new(), Vec::new()); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = activation_epoch.saturating_add(1); + let opening_counts = SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }; + + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::LegacyCrossingOrdinary, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + assert!( + verifier + .validate_tally_chronology(&block, 2, &tally, request, opening_counts) + .is_ok() + ); + + let sentinel = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 2, + &sentinel, + request, + opening_counts, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + verifier.pending_tally_obligations.remove(&pointer); + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 2, + &tally, + request, + opening_counts, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::LegacyCrossingOrdinary, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 3, + &tally, + request, + opening_counts, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_tally_chronology_requires_exact_v21_causal_sentinel_proof() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + + let pointer = Hash::with_first_u32(0xD2_100089); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + let request = SnapshotRequestContext { + inclusion_epoch: TEST_WIT2_ACTIVATION_EPOCH + 1, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_0, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }; + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output, + requester: PublicKeyHash::default(), + request_block_hash: Hash::with_first_u32(0xD2_100090), + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + verifier.pending_tally_obligations.insert( + pointer, + SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::V21CausalTooManyWitnesses, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }, + ); + let sentinel = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let ordinary = + TallyTransaction::new(pointer, vec![1], Vec::new(), Vec::new(), Vec::new()); + let mut tally_block = Block::default(); + tally_block.block_header.beacon.checkpoint = TEST_WIT21_ACTIVATION_EPOCH; + let low_opening = SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }; + assert!(verifier + .validate_tally_chronology(&tally_block, 2, &sentinel, request, low_opening) + .is_ok()); + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 2, + &ordinary, + request, + low_opening, + ), + Err(ImportError::SnapshotContents { .. }) + )); + let activation_request = SnapshotRequestContext { + inclusion_epoch: TEST_WIT2_ACTIVATION_EPOCH, + ..request + }; + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 2, + &sentinel, + activation_request, + low_opening, + ), + Err(ImportError::SnapshotContents { .. }) + )); + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 2, + &sentinel, + request, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + // The same causal shape is not retroactively valid in a V2.0 tally block and cannot be + // delayed beyond its exact next canonical position. + tally_block.block_header.beacon.checkpoint = TEST_WIT21_ACTIVATION_EPOCH - 1; + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 2, + &sentinel, + request, + low_opening, + ), + Err(ImportError::SnapshotContents { .. }) + )); + tally_block.block_header.beacon.checkpoint = TEST_WIT21_ACTIVATION_EPOCH; + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 3, + &sentinel, + request, + low_opening, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + // Terminal no-Commit expiry remains ordinary even while the opening population is low. + verifier.pending_tally_obligations.remove(&pointer); + assert!(verifier + .validate_tally_chronology(&tally_block, 3, &ordinary, request, low_opening) + .is_ok()); + assert!(matches!( + verifier.validate_tally_chronology( + &tally_block, + 3, + &sentinel, + request, + low_opening, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_records_exact_pre_wit2_crossing_tally_transition() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + let activation_epoch = verifier + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .expect("the local fixture schedule includes Wit/2 activation"); + + let pointer = Hash::with_first_u32(0xD2_100082); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + let request_protocol = verifier.protocol_version(activation_epoch - 1); + verifier.known_requests.insert( + pointer, + SnapshotRequestContext { + inclusion_epoch: activation_epoch - 1, + inclusion_position: 0, + protocol_version: request_protocol, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }, + ); + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output, + requester: PublicKeyHash::default(), + request_block_hash: Hash::with_first_u32(0xD2_100083), + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + let mut transition_block = Block::default(); + transition_block.block_header.beacon.checkpoint = activation_epoch; + + verifier + .record_pending_tally_obligations( + &transition_block, + 1, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + ) + .unwrap(); + assert_eq!( + verifier.pending_tally_obligations.get(&pointer), + Some(&SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::LegacyCrossingOrdinary, + transition_position: 1, + tally_position: 2, + current_commit_round: 1, + }) + ); + assert!(matches!( + verifier.reject_missing_structurally_due_tallies(&transition_block, 2), + Err(ImportError::SnapshotContents { .. }) + )); + + verifier.pending_tally_obligations.clear(); + verifier + .record_pending_tally_obligations( + &transition_block, + 1, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + ) + .unwrap(); + assert!(verifier.pending_tally_obligations.is_empty()); + + verifier + .record_pending_tally_obligations( + &transition_block, + 1, + ProtocolVersion::V1_8, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + ) + .unwrap(); + assert!(verifier.pending_tally_obligations.is_empty()); +} + +#[test] +fn snapshot_records_exact_round_zero_v21_causal_tally_transition() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + + let pointer = Hash::with_first_u32(0xD2_100091); + let mut output = DataRequestOutput::default(); + output.witnesses = 1; + verifier.known_requests.insert( + pointer, + SnapshotRequestContext { + inclusion_epoch: TEST_WIT2_ACTIVATION_EPOCH + 1, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_0, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: None, + last_reveal_block_position: None, + reveals: 0, + }, + ); + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request: output, + requester: PublicKeyHash::default(), + request_block_hash: Hash::with_first_u32(0xD2_100092), + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + let mut transition_block = Block::default(); + transition_block.block_header.beacon.checkpoint = + TEST_WIT2_ACTIVATION_EPOCH + 1; + + // A V2.0 request keeps using the raw ledger population even if the delegated current + // population is already low. + verifier + .record_pending_tally_obligations( + &transition_block, + 0, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 0, + legacy: 4, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 4, + }, + ) + .unwrap(); + assert!(verifier.pending_tally_obligations.is_empty()); + + // A same-block raw population fall is a real round-zero transition: the request was valid + // at block opening and becomes tally-ready only after complete accepted-block staking. + verifier + .record_pending_tally_obligations( + &transition_block, + 0, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 4, + legacy: 4, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + ) + .unwrap(); + assert_eq!( + verifier.pending_tally_obligations.get(&pointer), + Some(&SnapshotPendingTallyObligation { + kind: SnapshotPendingTallyKind::V21CausalTooManyWitnesses, + transition_position: 0, + tally_position: 1, + current_commit_round: 0, + }) + ); + + // Opening-low means the containing block owed the unchanged same-block sentinel. Clearing + // the proof must not let replay manufacture a causal successor for that invalid block. + verifier.pending_tally_obligations.clear(); + assert!(matches!( + verifier.record_pending_tally_obligations( + &transition_block, + 0, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); + + // The activation-era later-tally compatibility exception does not waive the unchanged + // same-block sentinel requirement at the activation block itself. + verifier + .known_requests + .get_mut(&pointer) + .unwrap() + .inclusion_epoch = TEST_WIT2_ACTIVATION_EPOCH; + transition_block.block_header.beacon.checkpoint = TEST_WIT2_ACTIVATION_EPOCH; + assert!(matches!( + verifier.record_pending_tally_obligations( + &transition_block, + 0, + ProtocolVersion::V2_0, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_tally_chronology_limits_commit_bearing_too_many_to_activation_requests() { + let (mut consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + consensus_constants.extra_rounds = 1; + let mut verifier = SnapshotTransactionVerifier::new_with_protocol_info( + &consensus_constants, + Some(test_protocol_info_with_wit21()), + ) + .unwrap(); + let activation_epoch = verifier + .protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .expect("the local fixture schedule includes Wit/2 activation"); + + let pointer = Hash::with_first_u32(0xD2_100101); + let tally = TallyTransaction::new( + pointer, + too_many_witnesses_tally_bytes(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut data_request = DataRequestOutput::default(); + data_request.witnesses = 1; + verifier.canonical_requests.insert( + pointer, + SnapshotCanonicalRequestState { + data_request, + requester: PublicKeyHash::default(), + request_block_hash: Hash::default(), + commits: HashMap::new(), + reveals: HashMap::new(), + }, + ); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = activation_epoch.saturating_add(3); + let complete = SnapshotRequestContext { + inclusion_epoch: activation_epoch, + inclusion_position: 0, + protocol_version: ProtocolVersion::V2_0, + required_collateral: 0, + commit_and_reveal_fee: 0, + witness_reward: 0, + witnesses: 1, + commit_block_position: Some(1), + last_reveal_block_position: Some(2), + reveals: 1, + }; + let opening_counts = SnapshotTipValidatorCounts { + current: 0, + legacy: 0, + }; + verifier + .validate_tally_chronology(&block, 3, &tally, complete, opening_counts) + .unwrap_or_else(|error| panic!("activation tally chronology unexpectedly failed: {error:?}")); + + let incomplete = SnapshotRequestContext { + last_reveal_block_position: None, + reveals: 0, + ..complete + }; + block.block_header.beacon.checkpoint = activation_epoch.saturating_add(4); + assert!( + verifier + .validate_tally_chronology(&block, 4, &tally, incomplete, opening_counts) + .is_ok() + ); + + let post_activation = SnapshotRequestContext { + inclusion_epoch: activation_epoch.saturating_add(1), + ..complete + }; + assert!(matches!( + verifier.validate_tally_chronology( + &block, + 3, + &tally, + post_activation, + opening_counts, + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_history_replay_preserves_the_empty_bootstrap_tip() { + let (consensus_constants, _genesis) = snapshot_transaction_genesis_fixture(); + let verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + let bootstrap_vrf_output = CheckpointVRF { + checkpoint: 0, + hash_prev_vrf: consensus_constants.bootstrap_hash, + }; + assert!( + verifier + .validate_tip_state( + &OldUnspentOutputsPool::default(), + bootstrap_vrf_output, + &StakesTracker::default(), + &DelegationState::default(), + &DataRequestPool::new(consensus_constants.extra_rounds), + ) + .is_ok() + ); +} + +#[test] +fn snapshot_history_rejects_a_forged_chain_info_vrf_tip() { + let (consensus_constants, genesis) = snapshot_transaction_genesis_fixture(); + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + verifier.validate_block(&genesis, 0).unwrap(); + let expected_utxos = verifier.utxos.clone().try_into_portable().unwrap(); + let forged_vrf_output = CheckpointVRF { + checkpoint: 1, + hash_prev_vrf: Hash::with_first_u32(22), + }; + assert!(matches!( + verifier.validate_tip_state( + &expected_utxos, + forged_vrf_output, + &StakesTracker::default(), + &DelegationState::default(), + &DataRequestPool::new(consensus_constants.extra_rounds), + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_history_rejects_an_unsigned_non_genesis_header() { + let (consensus_constants, genesis) = snapshot_transaction_genesis_fixture(); + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + verifier.validate_block(&genesis, 0).unwrap(); + + let mut unsigned = Block::default(); + unsigned.block_header.beacon.checkpoint = 1; + match verifier.validate_block(&unsigned, 1) { + Err(ImportError::SnapshotContents { message }) => { + assert!(message.contains("block header signature")); + } + result => panic!("unsigned historical block unexpectedly accepted: {result:?}"), + } +} + +#[test] +fn snapshot_transaction_replay_rejects_missing_inputs_and_wrong_tip_image() { + let (consensus_constants, genesis) = snapshot_transaction_genesis_fixture(); + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + verifier.validate_block(&genesis, 0).unwrap(); + + let missing_pointer = OutputPointer { + transaction_id: Hash::with_first_u32(21), + output_index: 0, + }; + let transaction = witnet_data_structures::transaction::VTTransaction::new( + witnet_data_structures::transaction::VTTransactionBody::new( + vec![Input::new(missing_pointer)], + vec![ValueTransferOutput { + value: 1, + ..Default::default() + }], + ), + vec![KeyedSignature::default()], + ); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = 1; + block.txns.value_transfer_txns.push(transaction); + assert!(matches!( + verifier.validate_block(&block, 1), + Err(ImportError::SnapshotContents { .. }) + )); + + let mut verifier = SnapshotTransactionVerifier::new(&consensus_constants).unwrap(); + verifier.validate_block(&genesis, 0).unwrap(); + let expected_vrf_output = CheckpointVRF { + checkpoint: 0, + hash_prev_vrf: consensus_constants.genesis_hash, + }; + assert!(matches!( + verifier.validate_tip_state( + &OldUnspentOutputsPool::default(), + expected_vrf_output, + &StakesTracker::default(), + &DelegationState::default(), + &DataRequestPool::new(consensus_constants.extra_rounds), + ), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_block_body_rejects_transaction_lists_hidden_by_legacy_roots() { + let epoch = 1; + let protocol = ProtocolVersion::V1_7; + let mut stake_block = Block::default(); + stake_block.block_header.beacon.checkpoint = epoch; + stake_block + .txns + .stake_txns + .push(witnet_data_structures::transaction::StakeTransaction::default()); + // Before V1.8 the canonical builder deliberately leaves the Stake root at its default, so + // root equality alone cannot distinguish this forbidden list from an empty one. + stake_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&stake_block.txns, protocol); + assert!(matches!( + validate_snapshot_block_body(&stake_block, 0, protocol), + Err(ImportError::SnapshotContents { .. }) + )); + + let mut unstake_block = Block::default(); + unstake_block.block_header.beacon.checkpoint = epoch; + unstake_block + .txns + .unstake_txns + .push(witnet_data_structures::transaction::UnstakeTransaction::default()); + // The Unstake list is likewise outside legacy roots until V2.0. + unstake_block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&unstake_block.txns, protocol); + assert!(matches!( + validate_snapshot_block_body(&unstake_block, 0, protocol), + Err(ImportError::SnapshotContents { .. }) + )); +} + +#[test] +fn snapshot_report_preflight_rejects_commit_map_key_identity_mismatch() { + let (pointer, mut report, block_chain, _) = complete_snapshot_report_fixture(); + let transaction = report.commits.values().next().unwrap().clone(); + report.commits.clear(); + let wrong_participant = witnet_data_structures::chain::PublicKey { + compressed: 2, + bytes: [7; 32], + } + .pkh(); + assert_ne!(wrong_participant, transaction.body.proof.proof.pkh()); + report.commits.insert(wrong_participant, transaction); + let reports = vec![( + format!("DR-REPORT-{pointer}").into_bytes(), + bincode::serialize(&report).unwrap(), + )]; + + assert!(matches!( + ChainManager::preflight_snapshot_reports(reports, &block_chain, false, true), + Err(ImportError::SnapshotContents { .. }) + )); +} diff --git a/node/src/actors/chain_manager/handlers.rs b/node/src/actors/chain_manager/handlers.rs index 63f31e1845..d9fc90a5be 100644 --- a/node/src/actors/chain_manager/handlers.rs +++ b/node/src/actors/chain_manager/handlers.rs @@ -1,13 +1,13 @@ use std::{ - collections::{BTreeMap, HashMap, HashSet, VecDeque}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, convert::{TryFrom, TryInto}, - future, net::SocketAddr, + sync::{Arc, Mutex}, time::Duration, }; use actix::{ActorFutureExt, WrapFuture, prelude::*}; -use futures::future::Either; +use futures::{channel::oneshot, future::Either}; use itertools::Itertools; use witnet_data_structures::{ @@ -16,49 +16,69 @@ use witnet_data_structures::{ Hashable, NodeStats, PublicKeyHash, SuperBlockVote, SupplyInfo, SupplyInfo2, ValueTransferOutput, tapi::ActiveWips, }, - error::{ChainInfoError, TransactionError::DataRequestNotFound}, - get_protocol_info, get_protocol_version, get_protocol_version_activation_epoch, - proto::versioning::ProtocolVersion, + error::{ + ChainInfoError, + TransactionError::{DataRequestNotFound, ZeroValueUnstakeOutput}, + }, + get_protocol_info, get_protocol_runtime, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, refresh_protocol_version, staking::{ - prelude::{Power, StakeKey}, - stakes::QueryStakesKey, + prelude::{ + ActivationBoundaryReport, DELEGATED_STAKING_MINIMUM_NANOWITS, + Power, StakeEntry, StakeKey, StakesError, + ValidatorPolicy, + }, + stakes::{QueryStakesKey, Stakes}, }, transaction::{ DRTransaction, StakeTransaction, Transaction, UnstakeTransaction, VTTransaction, }, transaction_factory::{self, NodeBalance, NodeBalance2}, types::LastBeacon, - utxo_pool::{UtxoDiff, UtxoInfo, get_utxo_info}, - wit::Wit, + utxo_pool::{UtxoDiff, UtxoInfo, try_get_utxo_info}, + wit::{WIT_DECIMAL_PLACES, Wit}, }; use witnet_util::timestamp::get_timestamp; use witnet_validations::validations::{ - validate_rad_request, validate_stake_transaction, validate_unstake_transaction, + LegacyRewardLocks, + delegated_staking_activation_preflight_for_protocol, + prepare_standalone_delegation_state_for_protocol, + validate_rad_request, validate_stake_transaction_for_protocol, + validate_unstake_transaction_for_protocol, }; use crate::{ actors::{ chain_manager::{BlockCandidate, handlers::BlockBatches::*}, + epoch_manager::{classify_epoch_notification, EpochNotificationDisposition}, messages::{ AddBlocks, AddCandidates, AddCommitReveal, AddSuperBlock, AddSuperBlockVote, AddTransaction, Broadcast, BuildDrt, BuildStake, BuildUnstake, BuildVtt, EpochNotification, EstimatePriority, GetBalance, GetBalance2, GetBalanceTarget, GetBlocksEpochRange, GetDataRequestInfo, GetHighestCheckpointBeacon, + GetDelegatedStakingActivationClassification, GetDelegatedStakingActivationPreflight, GetMemoryTransaction, GetMempool, GetMempoolResult, GetNodeStats, GetProtocolInfo, GetReputation, GetReputationResult, GetSignalingInfo, GetState, GetSuperBlockVotes, GetSupplyInfo, GetSupplyInfo2, GetUtxoInfo, IsConfirmedBlock, PeersBeacons, - QueryStakes, QueryStakesOrderByOptions, QueryStakingPowers, ReputationStats, Rewind, - SearchDataRequests, SendLastBeacon, SessionUnitResult, SetLastBeacon, SetPeersLimits, + PendingCommissionInfo, QueryStakeEntriesAtTip, QueryStakeEntriesAtTipByWithdrawers, + QueryStakeRecoveryEvidence, StakeRecoveryEvidence, StakeRecoveryInput, + QueryStakes, QueryStakesAtTip, QueryStakesFilter, QueryStakesOrderByOptions, + QueryStakesParams, QueryStakingPowers, QueryValidatorPolicy, + StakeEntriesAtTip, ValidatorPolicyInfo, ValidatorPolicyReservationDebit, + ValidatorPolicyStake, ValidatorPolicyStakeEntry, + ReputationStats, Rewind, SearchDataRequests, SendLastBeacon, SessionUnitResult, + SetLastBeacon, SetPeersLimits, ValidateTransactionReadOnly, SignalingInfo, SnapshotExport, SnapshotImport, TryMineBlock, try_do_magic_into_pkh, }, + inventory_manager::{InventoryManager, InventoryManagerError}, sessions_manager::SessionsManager, }, config_mngr, signature_mngr, storage_mngr, utils::mode_consensus, }; -use super::{ChainManager, ChainManagerError, StateMachine, SyncTarget}; +use super::{ChainManager, ChainManagerError, StakeRecoveryError, StateMachine, SyncTarget}; pub const SYNCED_BANNER: &str = r" ███████╗██╗ ██╗███╗ ██╗ ██████╗███████╗██████╗ ██╗ @@ -94,17 +114,92 @@ impl Handler> for ChainManager { #[allow(clippy::cognitive_complexity)] fn handle(&mut self, msg: EpochNotification, ctx: &mut Context) { - log::debug!("Periodic epoch notification received {:?}", msg.checkpoint); let current_timestamp = get_timestamp(); + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => { + let Some(constants) = self.epoch_constants else { + log::error!("Chain manager received an epoch notification without a clock"); + ctx.stop(); + System::current().stop_with_code(1); + return; + }; + constants + } + None => { + log::error!( + "Chain manager received an epoch notification for an unpaired protocol schedule" + ); + ctx.stop(); + System::current().stop_with_code(1); + return; + } + }; + self.epoch_constants = Some(epoch_constants); + let (current_epoch, epoch_timestamp) = if msg.protocol_generation == protocol_generation { + (msg.checkpoint, msg.timestamp) + } else { + let current_epoch = match epoch_constants.epoch_at(current_timestamp) { + Ok(epoch) => epoch, + Err(error) => { + log::error!("Chain manager could not rebase a stale epoch notification: {error}"); + ctx.stop(); + System::current().stop_with_code(1); + return; + } + }; + let epoch_timestamp = match epoch_constants.epoch_timestamp(current_epoch) { + Ok((timestamp, _)) => timestamp, + Err(error) => { + log::error!( + "Chain manager could not timestamp rebased epoch {current_epoch}: {error}" + ); + ctx.stop(); + System::current().stop_with_code(1); + return; + } + }; + log::warn!( + "Rebased queued ChainManager epoch notification from protocol generation {} to {} (epoch {} -> {})", + msg.protocol_generation, + protocol_generation, + msg.checkpoint, + current_epoch + ); + (current_epoch, epoch_timestamp) + }; + // Keep all epoch-transition admission and projection decisions on the runtime schedule + // snapshot captured above. A protocol publication from another thread must not make the + // same actor turn classify its stake, unstake, and activation paths differently. + let epoch_protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + log::debug!("Periodic epoch notification received {current_epoch:?}"); log::debug!( "Timestamp diff: {}, Epoch timestamp: {}. Current timestamp: {}", - current_timestamp - msg.timestamp, - msg.timestamp, + current_timestamp - epoch_timestamp, + epoch_timestamp, current_timestamp ); let last_checked_epoch = self.current_epoch; - let current_epoch = msg.checkpoint; + let notification_disposition = + classify_epoch_notification(last_checked_epoch, current_epoch); + if notification_disposition == EpochNotificationDisposition::Replay { + // The clock was refreshed above, but an equal notification carries no new epoch work. + // This includes an old-generation message rebased onto the atomically initialized + // subscription snapshot. + log::debug!( + "Discarding equal/replayed ChainManager epoch notification {current_epoch}" + ); + return; + } + + if notification_disposition == EpochNotificationDisposition::ClockRebase { + log::warn!( + "Epoch clock moved backwards from {:?} to {current_epoch}; resetting consensus deliberately", + last_checked_epoch + ); + } self.current_epoch = Some(current_epoch); log::debug!( @@ -120,6 +215,16 @@ impl Handler> for ChainManager { // Clear pending transactions HashSet self.seen_transactions.clear(); + if last_checked_epoch != Some(current_epoch) { + // Every in-flight admission captured the previous epoch and will fail its post-signature + // snapshot check. Release body-hash ownership and delegated-action slots now; late + // callbacks are compare-and-release token checked and cannot touch a retry. Physical + // Wit/2.1 Commit verification is still counted against its request until the stale + // callback returns, because the already-dispatched signature work cannot be cancelled. + self.transactions_pool + .clear_pending_transaction_admissions(); + self.transactions_pool.clear_pending_delegated_actions(); + } // Handle case consensus not achieved if !self.peers_beacons_received { @@ -130,24 +235,41 @@ impl Handler> for ChainManager { self.seen_candidates.clear(); } - if let Some(last_checked_epoch) = last_checked_epoch - && msg.checkpoint - last_checked_epoch != 1 - { - log::warn!("Missed epoch notification {}.", last_checked_epoch + 1); - self.update_state_machine(StateMachine::WaitingConsensus, ctx); + match (last_checked_epoch, notification_disposition) { + (Some(_), EpochNotificationDisposition::ClockRebase) => { + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + self.candidates.clear(); + self.seen_candidates.clear(); + } + (Some(last_checked_epoch), EpochNotificationDisposition::Advance) + if current_epoch.saturating_sub(last_checked_epoch) != 1 => + { + log::warn!( + "Missed one or more epoch notifications after {last_checked_epoch}." + ); + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + } + _ => {} } self.peers_beacons_received = false; // The best candidate must be cleared on every epoch let best_candidate = self.best_candidate.take(); - // Update the global protocol version state if necessary - let expected_protocol_version = get_protocol_version(self.current_epoch); - let current_protocol_version = get_protocol_version(None); - if expected_protocol_version != current_protocol_version { + // Keep the refresh decision on the same atomic schedule snapshot used for every other + // epoch-transition decision above. Re-reading the process-global schedule here could mix + // protocol generations within one actor turn if activation publication races this handler. + if epoch_protocol_version != protocol_info.current_version { refresh_protocol_version(current_epoch); } + // Commitments are valid for one epoch. Wit/2.1 candidate selection deliberately leaves + // them in the pool while projection, signing, and validation remain fallible, so expire + // them unconditionally at the next epoch before any state-specific branch or early return. + // Successful consolidation may already have cleared the same maps; that repeat is + // intentionally idempotent. + self.transactions_pool.clear_commits(); + match self.sm_state { StateMachine::WaitingConsensus => { if let Some(chain_info) = &self.chain_state.chain_info { @@ -182,13 +304,45 @@ impl Handler> for ChainManager { if let Some(BlockCandidate { block, utxo_diff, + block_number, reputation: _, vrf_proof: _, priorities, + protocol_generation: candidate_protocol_generation, }) = best_candidate { - // Persist block and update ChainState - self.consolidate_block(ctx, block, utxo_diff, priorities, false); + if candidate_protocol_generation != protocol_generation { + // The candidate was validated under a superseded schedule. Do not + // reinterpret its bytes using the current fork rules; release the + // retry marker so the block can be admitted again under the new + // generation. + log::debug!( + "Discarding best block candidate from superseded protocol generation {candidate_protocol_generation} (current {protocol_generation})" + ); + self.seen_candidates.remove(&block); + } else { + let block_protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + // Persist block and update ChainState + if let Err(error) = self.consolidate_block( + ctx, + block, + utxo_diff, + priorities, + block_number, + block_protocol_version, + &protocol_info, + protocol_generation, + false, + ) + { + self.recover_after_chain_commit_failure(ctx, &error); + self.candidates.clear(); + self.seen_candidates.clear(); + return; + } + } } // Send last beacon on block consolidation @@ -207,10 +361,6 @@ impl Handler> for ChainManager { only_inbound: true, }); - // TODO: Review time since commits are clear and new ones are received before to mining - // Remove commits because they expire every epoch - self.transactions_pool.clear_commits(); - // Mining if self.mining_enabled && self.sm_state == StateMachine::Synced { // Block mining is now triggered by SessionsManager on peers beacon timeout @@ -236,79 +386,180 @@ impl Handler> for ChainManager { self.candidates.clear(); self.seen_candidates.clear(); + // The epoch can already select Wit/2.1 while chain state still represents + // the preceding Wit/2.0 tip. Revalidate stake actions against the same + // canonical live-owner and legacy-collateral projection that the first + // Wit/2.1 block must persist. If that projection is unsafe, retain pending + // actions for a later epoch instead of classifying them from inert caches. + let needs_standalone_projection = epoch_protocol_version + >= ProtocolVersion::V2_1 + && !self.chain_state.delegation_state.activation_initialized; + let (standalone_delegation_state, delegated_revalidation_ready) = + if needs_standalone_projection { + if let Some(chain_info) = self.chain_state.chain_info.as_ref() { + match prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + current_epoch, + chain_info.consensus_constants.collateral_minimum, + Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol( + epoch_protocol_version, + ), + ), + epoch_protocol_version, + &protocol_info, + ) { + Ok(state) => (state, true), + Err(error) => { + log::warn!( + "Skipping delegated-staking mempool revalidation because the canonical Wit/2.1 activation projection failed: {error}" + ); + (None, false) + } + } + } else { + log::warn!( + "Skipping delegated-staking mempool revalidation because chain information is unavailable" + ); + (None, false) + } + } else { + (None, true) + }; + // Periodically revalidate pending stake transactions since they can become invalid - if get_protocol_version(Some(current_epoch)) >= ProtocolVersion::V1_8 + if epoch_protocol_version >= ProtocolVersion::V1_8 && current_epoch.is_multiple_of(10) + && delegated_revalidation_ready { - let utxo_diff = UtxoDiff::new( - &self.chain_state.unspent_outputs_pool, - self.chain_state.block_number(), - ); - let min_stake = self - .consensus_constants_wit2 - .get_validator_min_stake_nanowits(current_epoch); - let max_stake = self - .consensus_constants_wit2 - .get_validator_max_stake_nanowits(current_epoch); - - let mut invalid_stake_transactions = Vec::::new(); - for st_tx in self.transactions_pool.st_iter() { - if let Err(e) = validate_stake_transaction( - st_tx, - &utxo_diff, - current_epoch, - self.epoch_constants.unwrap(), - &mut vec![], - &self.chain_state.stakes, - min_stake, - max_stake, - ) { - log::debug!( - "Removing stake transaction {} as it became invalid: {}", - st_tx.hash(), - e + if get_protocol_runtime().2 == protocol_generation { + // Reuse the paired clock captured at the top of this epoch turn. + // The actor-local cache may still describe the preceding schedule + // while a publication notification is queued. + let epoch_constants = epoch_constants; + let utxo_diff = UtxoDiff::new( + &self.chain_state.unspent_outputs_pool, + self.chain_state.block_number(), + ); + let min_stake = self + .consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol( + epoch_protocol_version, + ); + let max_stake = self + .consensus_constants_wit2 + .get_validator_max_stake_nanowits_for_protocol( + epoch_protocol_version, ); - invalid_stake_transactions.push(st_tx.hash()); - continue; + + let mut invalid_stake_transactions = Vec::::new(); + for st_tx in self.transactions_pool.st_iter() { + if let Err(e) = validate_stake_transaction_for_protocol( + st_tx, + &utxo_diff, + current_epoch, + epoch_constants, + &mut vec![], + &self.chain_state.stakes, + standalone_delegation_state + .as_ref() + .unwrap_or(&self.chain_state.delegation_state), + self.get_magic(), + min_stake, + max_stake, + epoch_protocol_version, + ) { + log::debug!( + "Removing stake transaction {} as it became invalid: {}", + st_tx.versioned_hash(epoch_protocol_version), + e + ); + invalid_stake_transactions + .push(st_tx.versioned_hash(epoch_protocol_version)); + continue; + } } - } - self.transactions_pool - .remove_invalid_stake_transactions(invalid_stake_transactions); + self.transactions_pool + .remove_invalid_stake_transactions(invalid_stake_transactions); + } else { + log::warn!( + "Skipping periodic stake revalidation because the protocol schedule changed during the epoch turn" + ); + } } // Periodically revalidate pending unstake transactions since they can become invalid - if get_protocol_version(Some(current_epoch)) >= ProtocolVersion::V2_0 + if epoch_protocol_version >= ProtocolVersion::V2_0 && current_epoch.is_multiple_of(10) + && delegated_revalidation_ready { - let min_stake = self - .consensus_constants_wit2 - .get_validator_min_stake_nanowits(current_epoch); - let unstake_delay = self - .consensus_constants_wit2 - .get_unstaking_delay_seconds(current_epoch); - - let mut invalid_unstake_transactions = Vec::::new(); - for ut_tx in self.transactions_pool.ut_iter() { - if let Err(e) = validate_unstake_transaction( - ut_tx, - current_epoch, - &self.chain_state.stakes, - min_stake, - unstake_delay, - ) { - log::debug!( - "Removing unstake transaction {} as it became invalid: {}", - ut_tx.hash(), - e + if get_protocol_runtime().2 == protocol_generation { + // Keep unstake timelocks on the same immutable schedule/clock pair + // used to classify this notification. + let epoch_constants = epoch_constants; + let min_stake = self + .consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol( + epoch_protocol_version, + ); + let unstake_delay = self + .consensus_constants_wit2 + .get_unstaking_delay_seconds_for_protocol( + epoch_protocol_version, ); - invalid_unstake_transactions.push(ut_tx.hash()); - continue; + + let revalidation_state = standalone_delegation_state.as_ref() + .unwrap_or(&self.chain_state.delegation_state); + let legacy_reward_locks = if epoch_protocol_version >= ProtocolVersion::V2_1 { + LegacyRewardLocks::from_pool( + &self.chain_state.data_request_pool, revalidation_state, &protocol_info, + ) + } else { + LegacyRewardLocks::for_legacy_protocol(epoch_protocol_version) + }; + // Incomplete request evidence must not turn into an empty lock + // set or evict transactions based on a fabricated state view. + match legacy_reward_locks { + Ok(legacy_reward_locks) => { + let mut invalid_unstake_transactions = Vec::::new(); + for ut_tx in self.transactions_pool.ut_iter() { + if let Err(e) = validate_unstake_transaction_for_protocol( + ut_tx, + current_epoch, + epoch_constants, + &self.chain_state.stakes, + revalidation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + epoch_protocol_version, + ) { + log::debug!( + "Removing unstake transaction {} as it became invalid: {}", + ut_tx.versioned_hash(epoch_protocol_version), + e + ); + invalid_unstake_transactions + .push(ut_tx.versioned_hash(epoch_protocol_version)); + } + } + self.transactions_pool.remove_invalid_unstake_transactions( + invalid_unstake_transactions, + ); + } + Err(error) => log::warn!( + "Skipping periodic unstake revalidation: legacy reward evidence is unavailable: {error}" + ), } + } else { + log::warn!( + "Skipping periodic unstake revalidation because the protocol schedule changed during the epoch turn" + ); } - - self.transactions_pool - .remove_invalid_unstake_transactions(invalid_unstake_transactions); } log::debug!( @@ -336,13 +587,14 @@ impl Handler> for ChainManager { }); } - // Include value transfers and data requests that were recovered from a rewind - if !self.temp_vts_and_drs.is_empty() && self.sm_state == StateMachine::Synced { + // Include transactions recovered from a rewind (including delegated stake/unstake + // actions) once the restored node is synced again. + if !self.temp_reinserted_transactions.is_empty() && self.sm_state == StateMachine::Synced { let max_txs = std::cmp::min( self.max_reinserted_transactions, - self.temp_vts_and_drs.len(), + self.temp_reinserted_transactions.len(), ); - for transaction in self.temp_vts_and_drs.drain(..max_txs) { + for transaction in self.temp_reinserted_transactions.drain(..max_txs) { ctx.notify(AddTransaction { transaction, broadcast_flag: false, @@ -396,9 +648,9 @@ impl Handler for ChainManager { /// Handler for AddBlocks message impl Handler for ChainManager { - type Result = ResponseActFuture; + type Result = AtomicResponse; - fn handle(&mut self, msg: AddBlocks, _ctx: &mut Context) -> ResponseActFuture { + fn handle(&mut self, msg: AddBlocks, _ctx: &mut Context) -> Self::Result { let fut = actix::fut::ok(()).into_actor(self).and_then(|(), act, ctx| -> ResponseActFuture> { log::debug!( "AddBlocks received while StateMachine is in state {:?}", @@ -406,30 +658,46 @@ impl Handler for ChainManager { ); let consensus_constants = act.consensus_constants(); + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let genesis_protocol = protocol_info.all_versions.version_for_epoch(0); let sender = msg.sender; match act.sm_state { StateMachine::WaitingConsensus | StateMachine::AlmostSynced => { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Ignoring AddBlocks message after protocol generation {protocol_generation} was superseded" + ); + return Box::pin(actix::fut::err(())); + } // In WaitingConsensus state, only allow AddBlocks when the argument is // the genesis block - if msg.blocks.len() == 1 && msg.blocks[0].hash() == consensus_constants.genesis_hash + if msg.blocks.len() == 1 + && msg.blocks[0].is_genesis_for_protocol( + &consensus_constants.genesis_hash, + genesis_protocol, + ) { - let block = msg.blocks.into_iter().next().unwrap(); - match act.process_requested_block(ctx, block, false) { - Ok(()) => { - log::debug!("Successfully consolidated genesis block"); - - // Set last beacon because otherwise the network cannot bootstrap - let sessions_manager = SessionsManager::from_registry(); - let last_beacon = LastBeacon { - highest_block_checkpoint: act.get_chain_beacon(), - highest_superblock_checkpoint: act.get_superblock_beacon(), - }; - sessions_manager.do_send(SetLastBeacon { - beacon: last_beacon, - }); + if let Some(block) = msg.blocks.into_iter().next() { + match act.process_requested_block(ctx, block, false) { + Ok(()) => { + log::debug!("Successfully consolidated genesis block"); + + // Set last beacon because otherwise the network cannot bootstrap + let sessions_manager = SessionsManager::from_registry(); + let last_beacon = LastBeacon { + highest_block_checkpoint: act.get_chain_beacon(), + highest_superblock_checkpoint: act.get_superblock_beacon(), + }; + sessions_manager.do_send(SetLastBeacon { + beacon: last_beacon, + }); + } + Err(e) => log::error!("Failed to consolidate genesis block: {e}"), } - Err(e) => log::error!("Failed to consolidate genesis block: {e}"), + } else { + // Keep malformed peer-controlled input from becoming an actor panic. + log::warn!("Ignoring malformed AddBlocks genesis message"); } } else { log::debug!("Unhandled AddBlocks message"); @@ -469,9 +737,17 @@ impl Handler for ChainManager { return Box::pin(actix::fut::err(())); } - let sync_target = act - .sync_target - .expect("The sync target should be defined for synchronizing"); + let sync_target = match act.sync_target { + Some(sync_target) => sync_target, + None => { + // The state machine normally establishes this invariant before + // entering the Synchronizing branch. Keep a malformed or restored + // actor state from unwinding while handling a peer-supplied batch. + log::warn!("Synchronizing ChainManager has no sync target"); + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + return Box::pin(actix::fut::err(())); + } + }; let sync_superblock = act.sync_superblock @@ -496,6 +772,17 @@ impl Handler for ChainManager { } let superblock_period = u32::from(consensus_constants.superblock_period); + let current_epoch = match act.current_epoch { + Some(current_epoch) => current_epoch, + None => { + log::warn!( + "Synchronizing ChainManager has no current epoch; restoring durable state" + ); + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + act.initialize_from_storage(ctx); + return Box::pin(actix::fut::err(())); + } + }; // Split received blocks into batches according to 3 different cases: // 1. TargetNotReached: superblock target not reachable with the received block batch. @@ -506,7 +793,7 @@ impl Handler for ChainManager { let block_batches = split_blocks_batch_at_target( |b| b.block_header.beacon.checkpoint, msg.blocks, - act.current_epoch.unwrap(), + current_epoch, &sync_target, superblock_period, ); @@ -524,18 +811,33 @@ impl Handler for ChainManager { return Box::pin(actix::fut::err(())); } - // Persist blocks batch when target not reached - act.persist_blocks_batch(ctx, blocks); let to_be_stored = act.chain_state.data_request_pool.finished_data_requests(); - act.persist_data_requests(ctx, to_be_stored); - - log::debug!("TargetNotReached: superblock target #{} not reached, requesting more blocks. ({} processed blocks)", - sync_target.superblock.checkpoint, num_processed_blocks); - act.request_blocks_batch(ctx); - - // Copy current chain state into previous chain state, and persist it - return act.persist_chain_state(None); + let target_checkpoint = sync_target.superblock.checkpoint; + + // Inventory and completed reports must be durable before the matching + // ChainState can advance. Compose every acknowledgement into this same + // AtomicResponse future; a nested `ctx.wait` is not a prerequisite of it. + return Box::pin( + act.persist_blocks_and_data_requests(blocks, to_be_stored) + .map_err(|error, act, ctx| { + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(|(), act, _| { + act.persist_chain_state(None, None).map_err(|(), act, ctx| { + let error = anyhow::anyhow!( + "failed to persist synchronized ChainState" + ); + act.recover_after_chain_commit_failure(ctx, &error); + }) + }) + .and_then(move |(), act, ctx| { + log::debug!("TargetNotReached: superblock target #{} not reached, requesting more blocks. ({} processed blocks)", + target_checkpoint, num_processed_blocks); + act.request_blocks_batch(ctx); + actix::fut::ok(()) + }), + ); } // SyncWithoutCandidate: // 1. process blocks @@ -556,18 +858,29 @@ impl Handler for ChainManager { "SyncWithoutCandidate(consolidation)", ); - if let Some(consolidate_epoch) = act.superblock_consolidation_is_needed(&sync_target, superblock_period) { + let fut = if let Some(consolidate_epoch) = act.superblock_consolidation_is_needed(&sync_target, superblock_period) { // We need to persist blocks in order to be able to construct the // superblock - act.persist_blocks_batch(ctx, consolidate_blocks); let to_be_stored = act.chain_state.data_request_pool.finished_data_requests(); - act.persist_data_requests(ctx, to_be_stored); // Create superblocks while synchronizing but do not broadcast them // This is needed to ensure that we can validate the received superblocks later on log::debug!("Will construct superblock during synchronization. Superblock index: {} Epoch {}", sync_target.superblock.checkpoint, consolidate_epoch); Either::Left( - act.try_consolidate_superblock(consolidate_epoch, sync_target, sync_superblock) + act.persist_blocks_and_data_requests( + consolidate_blocks, + to_be_stored, + ) + .map_err(|error, act, ctx| { + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(move |(), act, _| { + act.try_consolidate_superblock( + consolidate_epoch, + sync_target, + sync_superblock, + ) + }), ) } else { // No need to construct a superblock again, @@ -586,8 +899,9 @@ impl Handler for ChainManager { actix::fut::ok(()) }) - .map(|_res: Result<(), ()>, _act, _ctx| ()) - .wait(ctx); + .map(|_res: Result<(), ()>, _act, _ctx| Ok(())); + + return Box::pin(fut); } // SyncWithCandidate: // 1. process blocks @@ -613,25 +927,36 @@ impl Handler for ChainManager { "SyncWithCandidate(consolidation)", ); - if let Some(consolidate_superblock_epoch) = act.superblock_consolidation_is_needed(&sync_target, superblock_period) { + let fut = if let Some(consolidate_superblock_epoch) = act.superblock_consolidation_is_needed(&sync_target, superblock_period) { // We need to persist blocks in order to be able to construct the // superblock - act.persist_blocks_batch(ctx, consolidate_blocks); let to_be_stored = act.chain_state.data_request_pool.finished_data_requests(); - act.persist_data_requests(ctx, to_be_stored); // Create superblocks while synchronizing but do not broadcast them // This is needed to ensure that we can validate the received superblocks later on log::debug!("Will construct superblock during synchronization. Superblock index: {} Epoch {}", sync_target.superblock.checkpoint, consolidate_superblock_epoch); Either::Left( - act.try_consolidate_superblock(consolidate_superblock_epoch, sync_target, sync_superblock) + act.persist_blocks_and_data_requests( + consolidate_blocks, + to_be_stored, + ) + .map_err(|error, act, ctx| { + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(move |(), act, _| { + act.try_consolidate_superblock( + consolidate_superblock_epoch, + sync_target, + sync_superblock, + ) + }), ) } else { // No need to construct a superblock again, Either::Right(actix::fut::ok(())) } .and_then({ - move |(), act, ctx| { + move |(), act, ctx| -> ResponseActFuture> { // Process remaining blocks let (batch_succeeded, num_processed_blocks) = act.process_blocks_batch(ctx, &sync_target, &candidate_blocks); if !batch_succeeded { @@ -639,38 +964,66 @@ impl Handler for ChainManager { act.update_state_machine(StateMachine::WaitingConsensus, ctx); - return actix::fut::err(()); + return Box::pin(actix::fut::err(())); } log_sync_progress(&sync_target, &candidate_blocks, num_processed_blocks, "SyncWithCandidate(candidate)"); // Update ARS if there were no blocks right before the epoch during // which we should construct the target superblock - let candidate_superblock_checkpoint = act.current_epoch.unwrap() / superblock_period; + let candidate_superblock_checkpoint = match act.current_epoch { + Some(current_epoch) => current_epoch / superblock_period, + None => { + log::warn!( + "Synchronizing ChainManager lost its current epoch before candidate persistence" + ); + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + act.initialize_from_storage(ctx); + return Box::pin(actix::fut::err(())); + } + }; - // We need to persist blocks in order to be able to construct the - // superblock - act.persist_blocks_batch(ctx, candidate_blocks); let to_be_stored = act.chain_state.data_request_pool.finished_data_requests(); - act.persist_data_requests(ctx, to_be_stored); - - log::info!("Block sync target achieved"); - // Target achieved, go back to state 1 - act.update_state_machine(StateMachine::WaitingConsensus, ctx); - - // We must construct the second superblock in order to be able - // to validate the votes for this superblock later - log::debug!("Will construct the second superblock during synchronization. Superblock index: {} Epoch {}", sync_target.superblock.checkpoint + 1, candidate_superblock_checkpoint * superblock_period); - actix::fut::ok(candidate_superblock_checkpoint) + Box::pin( + act.persist_blocks_and_data_requests( + candidate_blocks, + to_be_stored, + ) + .map_err(|error, act, ctx| { + act.recover_after_chain_commit_failure( + ctx, + &error, + ); + }) + .map_ok(move |(), act, ctx| { + log::info!("Block sync target achieved"); + // Target achieved, go back to state 1 + act.update_state_machine( + StateMachine::WaitingConsensus, + ctx, + ); + + // We must construct the second superblock in order to be able + // to validate the votes for this superblock later + log::debug!("Will construct the second superblock during synchronization. Superblock index: {} Epoch {}", sync_target.superblock.checkpoint.saturating_add(1), candidate_superblock_checkpoint.saturating_mul(superblock_period)); + + candidate_superblock_checkpoint + }), + ) } }) .and_then(move |candidate_superblock_checkpoint, act, _ctx| { if let Some(candidate_superblock_epoch) = act.superblock_candidate_is_needed(candidate_superblock_checkpoint, superblock_period) { - Either::Left(act.build_and_vote_candidate_superblock(candidate_superblock_epoch).map_ok(move |_, act, _| { + Either::Left(act.build_and_vote_candidate_superblock(candidate_superblock_epoch).and_then(move |_, act, _| { let superblock_index = candidate_superblock_epoch / superblock_period; - // Copy current chain state into previous chain state, but do not persist it yet - act.move_chain_state_forward(superblock_index); + // Copy current chain state into previous chain state, but do not persist it yet. + // A rejected snapshot must stop this synchronization branch before + // remaining blocks are processed against an unjournaled state. + match act.move_chain_state_forward(superblock_index) { + Ok(()) => actix::fut::ok(()), + Err(_) => actix::fut::err(()), + } })) } else{ @@ -695,8 +1048,9 @@ impl Handler for ChainManager { act.update_state_machine(StateMachine::WaitingConsensus, ctx); actix::fut::ok(()) }) - .map(|_res: Result<(), ()>, _act, _ctx| ()) - .wait(ctx); + .map(|_res: Result<(), ()>, _act, _ctx| Ok(())); + + return Box::pin(fut); } Err(ChainManagerError::WrongBlocksForSuperblock { wrong_index, @@ -734,7 +1088,7 @@ impl Handler for ChainManager { }) .map(|_res: Result<(), ()>, _act, _ctx| ()); - Box::pin(fut) + AtomicResponse::new(Box::pin(fut)) } } @@ -795,6 +1149,21 @@ impl Handler for ChainManager { } } +/// Handler for a delegated-action dry run that explicitly asks whether its signed preview is +/// admissible under the node's current chain and local mempool policy. The implementation is +/// synchronous so the complete read-only answer uses one atomic ChainManager view. +impl Handler for ChainManager { + type Result = Result<(), anyhow::Error>; + + fn handle( + &mut self, + msg: ValidateTransactionReadOnly, + _ctx: &mut Context, + ) -> Self::Result { + self.validate_transaction_read_only(msg.transaction) + } +} + /// Handler for GetBlocksEpochRange impl Handler for ChainManager { type Result = Result, ChainManagerError>; @@ -837,17 +1206,25 @@ impl PeersBeacons { pub fn superblock_consensus(&self, consensus_threshold: usize) -> Option<(LastBeacon, bool)> { // We need to add `num_missing_peers` times NO BEACON, to take into account // missing outbound peers. - let num_missing_peers = self.outbound_limit - .map(|outbound_limit| { + let num_missing_peers = match self.outbound_limit { + Some(outbound_limit) if self.pb.len() > outbound_limit as usize => { // TODO: is it possible to receive more than outbound_limit beacons? - // (it shouldn't be possible) - assert!(self.pb.len() <= outbound_limit as usize, "Received more beacons than the outbound_limit. Check the code for race conditions."); - usize::from(outbound_limit) - self.pb.len() - }) + // (it shouldn't be possible). Treat an overfull map as malformed state rather + // than asserting: a race or stale session update must not unwind the actor while + // computing a best-effort beacon consensus. + log::warn!( + "Received {} beacons, exceeding the configured outbound limit {}", + self.pb.len(), + outbound_limit + ); + return None; + } + Some(outbound_limit) => usize::from(outbound_limit) - self.pb.len(), // The outbound limit is set when the SessionsManager actor is initialized, so here it // cannot be None. But if it is None, set num_missing_peers to 0 in order to calculate // consensus with the existing beacons. - .unwrap_or(0); + None => 0, + }; mode_consensus( self.pb @@ -1284,17 +1661,28 @@ impl Handler for ChainManager { if self.sm_state == StateMachine::Synchronizing { // Request target superblock from a random peer - let superblock_consensus = self - .sync_target - .as_ref() - .expect("sync_target must be always set in Synchronizing state") - .superblock; + let superblock_consensus = match self.sync_target.as_ref() { + Some(sync_target) => sync_target.superblock, + None => { + log::warn!( + "Synchronizing ChainManager has no target beacon while handling peer beacons" + ); + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + return peers_to_unregister; + } + }; self.request_sync_target_superblock(ctx, superblock_consensus); if let Some(sync_start_epoch) = self.sync_waiting_for_add_blocks_since { - let current_epoch = self.current_epoch.unwrap(); + let Some(current_epoch) = self.current_epoch else { + log::warn!( + "Synchronizing ChainManager has no current epoch while waiting for blocks" + ); + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + return peers_to_unregister; + }; let how_many_epochs_are_we_willing_to_wait_for_one_block_batch = 10; - if current_epoch - sync_start_epoch + if current_epoch.saturating_sub(sync_start_epoch) >= how_many_epochs_are_we_willing_to_wait_for_one_block_batch { log::warn!("Timeout for waiting for blocks achieved. Requesting blocks again."); @@ -1307,9 +1695,18 @@ impl Handler for ChainManager { // Create and broadcast a superblock in case of superblock period. // Everyone creates superblocks, but only ARS members sign and broadcast them let superblock_period = u32::from(self.consensus_constants().superblock_period); - let current_epoch = self.current_epoch.unwrap(); + let Some(current_epoch) = self.current_epoch else { + log::warn!( + "ChainManager has no current epoch while handling synced peer beacons" + ); + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + return peers_to_unregister; + }; // During epoch 0 there is no need to create the superblock 0 - if current_epoch != 0 && current_epoch.is_multiple_of(superblock_period) { + if superblock_period != 0 + && current_epoch != 0 + && current_epoch.is_multiple_of(superblock_period) + { self.create_and_broadcast_superblock(ctx, current_epoch); } } @@ -1330,13 +1727,30 @@ impl Handler for ChainManager { .into(), )); } - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let timestamp = match u64::try_from(get_timestamp()) { + Ok(timestamp) => timestamp, + Err(_) => { + log::warn!("Cannot build a value transfer before the system timestamp is non-negative"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let own_pkh = match self.own_pkh { + Some(own_pkh) => own_pkh, + None => { + log::warn!("Cannot build a value transfer without the node identity"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; let max_vt_weight = self.consensus_constants().max_vt_weight; match transaction_factory::build_vtt( msg.vto, msg.fee, &mut self.chain_state.own_utxos, - self.own_pkh.unwrap(), + own_pkh, &self.chain_state.unspent_outputs_pool, timestamp, self.tx_pending_timeout, @@ -1395,29 +1809,95 @@ impl Handler for ChainManager { .into(), )); } - let timestamp = u64::try_from(get_timestamp()).unwrap(); - let current_epoch = self.current_epoch.unwrap(); + let timestamp = match u64::try_from(get_timestamp()) { + Ok(timestamp) => timestamp, + Err(_) => { + log::warn!("Cannot build a stake transaction before the system timestamp is non-negative"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let current_epoch = match self.current_epoch { + Some(current_epoch) => current_epoch, + None => { + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let own_pkh = match self.own_pkh { + Some(own_pkh) => own_pkh, + None => { + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + match (msg.protocol_epoch, msg.protocol_generation) { + (Some(expected_epoch), Some(expected_generation)) + if expected_epoch == current_epoch && expected_generation == protocol_generation => + { + // The caller's snapshot is still the exact actor/runtime context used below. + } + (None, None) => { + // Preserve the source-compatible path for internal callers that do not provide a + // pin. The actor's current epoch and the paired runtime read above remain the + // authority for those callers. + } + (Some(_), Some(_)) => { + return Box::pin(actix::fut::err(ChainManagerError::ChainNotReady.into())); + } + _ => { + return Box::pin(actix::fut::err(anyhow::anyhow!( + "protocol epoch and generation pins must be supplied together" + ))); + } + } + let protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + // A pre-activation dry-run may explicitly carry versioned authorization terms so a + // caller can prepare the exact Wit/2.1 transaction for later submission. Construct that + // preview under Wit/2.1 rules: otherwise the body would retain its terms while fee/weight + // estimation and input signatures still used the legacy identity. Ordinary legacy + // dry-runs, and every broadcast path, remain pinned to the active protocol. + let build_protocol_version = if msg.dry_run + && protocol_version < ProtocolVersion::V2_1 + && msg.stake_output.authorization_terms.is_some() + { + ProtocolVersion::V2_1 + } else { + protocol_version + }; let maximum_stake_block_weight = self .consensus_constants_wit2 - .get_maximum_stake_block_weight(current_epoch); - match transaction_factory::build_st( + .get_maximum_stake_block_weight_for_protocol(build_protocol_version); + match transaction_factory::build_st_for_protocol( msg.stake_output, msg.fee, &mut self.chain_state.own_utxos, - self.own_pkh.unwrap(), + own_pkh, &self.chain_state.unspent_outputs_pool, timestamp, self.tx_pending_timeout, &msg.utxo_strategy, maximum_stake_block_weight, msg.dry_run, + build_protocol_version, ) { Err(e) => { log::error!("Error when building stake transaction: {e}"); Box::pin(actix::fut::err(e.into())) } - Ok(st) => { - let fut = signature_mngr::sign_transaction_hash(st.hash(), st.inputs.len()) + Ok((st, reservation)) => { + // Sign exactly the body identity that the containing protocol validates. Wit/2.1 + // commits the delegated-staking authorization terms to the Stake body hash; + // using the legacy Hashable implementation here would produce signatures that + // the native validator rejects as soon as those terms are present. + let fut = signature_mngr::sign_transaction_hash( + st.versioned_hash(build_protocol_version), + st.inputs.len(), + ) .into_actor(self) .then(move |s, act, _ctx| match s { Ok(signatures) => { @@ -1435,12 +1915,17 @@ impl Handler for ChainManager { }, get_timestamp(), ) - .map_ok(move |_, _, _| st), + .map_ok(move |_, _, _| st) + .map_err(move |error, act, _ctx| { + reservation.rollback(&mut act.chain_state.own_utxos); + error + }), ) } } Err(e) => { log::error!("Failed to sign stake transaction: {e}"); + reservation.rollback(&mut act.chain_state.own_utxos); Either::Right(actix::fut::result(Err(e))) } }); @@ -1451,6 +1936,47 @@ impl Handler for ChainManager { } } +/// Resolve the transaction's net withdrawal output without changing the historical meaning of +/// `BuildUnstake::value`. Gross-debit modes are strict internal variants used only by the +/// `unstakeByDebit` RPC: their fee is included in the selected debit, and `all` is bound to the +/// exact live `(operator, own_pkh)` entry read in the same ChainManager turn. +fn resolve_unstake_withdrawal_value( + msg: &BuildUnstake, + exact_entry_balance: u64, +) -> Result { + match (msg.debit, msg.all) { + (None, false) => Ok(msg.value), + (Some(debit), false) => { + anyhow::ensure!( + msg.value == 0, + "gross-debit unstake cannot also set the legacy net value" + ); + anyhow::ensure!(debit > 0, "unstake debit must be positive"); + debit + .checked_sub(msg.fee) + .filter(|withdrawal| *withdrawal > 0) + .ok_or_else(|| anyhow::anyhow!("unstake debit must exceed its fee")) + } + (None, true) => { + anyhow::ensure!( + msg.value == 0, + "all-stake unstake cannot also set the legacy net value" + ); + exact_entry_balance + .checked_sub(msg.fee) + .filter(|withdrawal| *withdrawal > 0) + .ok_or_else(|| { + anyhow::anyhow!( + "exact stake entry balance must exceed the unstake fee" + ) + }) + } + (Some(_), true) => Err(anyhow::anyhow!( + "unstake debit and all-stake modes are mutually exclusive" + )), + } +} + impl Handler for ChainManager { type Result = ResponseActFuture::Result>; @@ -1464,37 +1990,136 @@ impl Handler for ChainManager { )); } - match self.chain_state.stakes.query_nonce(StakeKey { + let current_epoch = match self.current_epoch { + Some(current_epoch) => current_epoch, + None => { + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let own_pkh = match self.own_pkh { + Some(own_pkh) => own_pkh, + None => { + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + match (msg.protocol_epoch, msg.protocol_generation) { + (Some(expected_epoch), Some(expected_generation)) + if expected_epoch == current_epoch && expected_generation == protocol_generation => + { + // The caller's snapshot is still the exact actor/runtime context used below. + } + (None, None) => { + // Preserve the source-compatible path for internal callers that do not provide a + // pin. + } + (Some(_), Some(_)) => { + return Box::pin(actix::fut::err(ChainManagerError::ChainNotReady.into())); + } + _ => { + return Box::pin(actix::fut::err(anyhow::anyhow!( + "protocol epoch and generation pins must be supplied together" + ))); + } + } + let protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + if protocol_version >= ProtocolVersion::V2_1 + && msg.debit.is_none() + && !msg.all + && msg.value == 0 + { + // Preserve the historical `unstake` error precedence before looking up the stake + // entry. Gross-debit modes resolve their nonzero output from the entry below. + return Box::pin(actix::fut::err(ZeroValueUnstakeOutput.into())); + } + let stake_key = StakeKey { validator: msg.operator, - withdrawer: self.own_pkh.unwrap(), - }) { + withdrawer: own_pkh, + }; + // Preserve the historical nonce-only read for legacy net withdrawals and explicit gross + // debits. Only `all` needs the balance, so only that mode performs a primary-ledger scan + // that also authenticates the stored entry identity against its canonical key. + let nonce_and_balance = if msg.all { + self.chain_state + .stakes + .query_stakes_primary_checked(QueryStakesKey::Key(stake_key.clone())) + .and_then(|entries| { + entries + .into_iter() + .next() + .map(|entry| (entry.value.nonce, entry.value.coins.nanowits())) + .ok_or(StakesError::EntryNotFound { + key: stake_key.clone(), + }) + }) + } else { + self.chain_state + .stakes + .query_nonce(stake_key) + .map(|nonce| (nonce, 0)) + }; + match nonce_and_balance { Err(e) => { log::error!("Error when building unstake transaction: {e}"); Box::pin(actix::fut::err(e.into())) } - Ok(nonce) => { - let current_epoch = self.current_epoch.unwrap(); + Ok((nonce, exact_entry_balance)) => { + let withdrawal_value = match resolve_unstake_withdrawal_value( + &msg, + exact_entry_balance, + ) { + Ok(value) => value, + Err(error) => { + log::error!("Error when building unstake transaction: {error}"); + return Box::pin(actix::fut::err(error)); + } + }; + if protocol_version >= ProtocolVersion::V2_1 && withdrawal_value == 0 { + return Box::pin(actix::fut::err(ZeroValueUnstakeOutput.into())); + } let unstaking_delay = self .consensus_constants_wit2 - .get_unstaking_delay_seconds(current_epoch); + .get_unstaking_delay_seconds_for_protocol(protocol_version); let withdrawal = ValueTransferOutput { time_lock: unstaking_delay, - pkh: self.own_pkh.unwrap(), - value: msg.value, + pkh: own_pkh, + value: withdrawal_value, }; - match transaction_factory::build_ut(msg.operator, withdrawal, msg.fee, nonce) { + match transaction_factory::build_ut( + msg.operator, + withdrawal, + msg.fee, + nonce, + ) { Err(e) => { log::error!("Error when building unstake transaction: {e}"); Box::pin(actix::fut::err(e.into())) } Ok(ut) => { - let fut = signature_mngr::sign_transaction_hash(ut.hash(), 1) + // Keep the builder aligned with protocol-pinned validation. Unstake is + // wire-stable today, but signing through the versioned identity closes + // the same future field-extension seam as admission and replay. + let fut = signature_mngr::sign_transaction_hash( + ut.versioned_hash(protocol_version), + 1, + ) .into_actor(self) .then(move |s, act, _ctx| match s { Ok(signature) => { + let Some(signature) = signature.into_iter().next() else { + let error = anyhow::anyhow!( + "signature manager returned no signature for unstake transaction" + ); + log::error!("Failed to sign unstake transaction: {error}"); + return Either::Right(actix::fut::result(Err(error))); + }; let ut = UnstakeTransaction::new( ut, - signature.first().unwrap().clone(), + signature, ); if msg.dry_run { @@ -1570,141 +2195,837 @@ impl Handler for ChainManager { fn handle(&mut self, msg: QueryStakes, _ctx: &mut Self::Context) -> Self::Result { let filter = msg.filter.unwrap_or_default(); let params = msg.params.unwrap_or_default(); - let since = params.since.unwrap_or_default(); - let order = params.order.unwrap_or_default(); - let order_desc = order.reverse.unwrap_or_default(); - let distinct = params.distinct.unwrap_or_default(); - let offset = params.offset.unwrap_or_default(); - let limit = params.limit.unwrap_or(u16::MAX) as usize; - - // fetch filtered stake entries from current chain state: - let mut stakes = self.chain_state.stakes.query_stakes(filter.clone())?; - - // filter out stake entries having a nonce, last mining or witnessing epochs (depending - // on actual ordering field) older than calculated `since_epoch`: - let since_epoch: u64 = if since >= 0 { - since.try_into().inspect_err(|&e| { - log::warn!("Invalid 'since' limit on QueryStakes: {e}"); - })? - } else { - (i64::from(self.current_epoch.unwrap()) + since) - .try_into() - .unwrap_or_default() - }; - if since_epoch > 0 { - match order.by { - QueryStakesOrderByOptions::Coins | QueryStakesOrderByOptions::Nonce => { - stakes.retain(|stake| stake.value.nonce >= since_epoch); - } - QueryStakesOrderByOptions::Mining => { - stakes.retain(|stake| { - u64::from(stake.value.epochs.mining) >= since_epoch - && u64::from(stake.value.epochs.mining) >= stake.value.nonce - }); - } - QueryStakesOrderByOptions::Witnessing => { - stakes.retain(|stake| { - u64::from(stake.value.epochs.witnessing) >= since_epoch - && u64::from(stake.value.epochs.witnessing) >= stake.value.nonce - }); - } - } - } - // sort stake entries by specified field, worst case costing n * log (n) ... + // Query the canonical primary ledger before applying the historical shaping rules. The + // maintained validator/withdrawer indexes are derived projections; trusting one here could + // silently omit a live delegated entry after a malformed restore or partial reindex. + let stakes = self + .chain_state + .stakes + .query_stakes_primary_checked(filter.clone())?; + + apply_query_stakes_params(stakes, params, self.current_epoch) + } +} + +type QueriedStakeEntry = + StakeEntry; + +/// Apply the historical `queryStakes` recency, ordering, distinct, and pagination contract to an +/// already selected entry set. Legacy `queryStakes` and the primary-backed tip-labelled endpoint +/// share this exact shaping step so hardening selection cannot create a second wire behavior. +fn apply_query_stakes_params( + mut stakes: Vec, + params: QueryStakesParams, + current_epoch: Option, +) -> anyhow::Result> { + let since = params.since.unwrap_or_default(); + let order = params.order.unwrap_or_default(); + let order_desc = order.reverse.unwrap_or_default(); + let distinct = params.distinct.unwrap_or_default(); + let offset = params.offset.unwrap_or_default(); + let limit = params.limit.unwrap_or(u16::MAX) as usize; + + // filter out stake entries having a nonce, last mining or witnessing epochs (depending + // on actual ordering field) older than calculated `since_epoch`: + let since_epoch: u64 = if since >= 0 { + since.try_into().inspect_err(|&e| { + log::warn!("Invalid 'since' limit on QueryStakes: {e}"); + })? + } else { + // Relative filters need a published current epoch. A query arriving during startup + // must return an ordinary RPC error instead of panicking the ChainManager actor, and + // extreme signed offsets must not overflow before conversion to the unsigned epoch. + let current_epoch = current_epoch + .ok_or_else(|| anyhow::anyhow!("current epoch is not available"))?; + i64::from(current_epoch) + .saturating_add(since) + .try_into() + .unwrap_or_default() + }; + if since_epoch > 0 { match order.by { - QueryStakesOrderByOptions::Coins => { - stakes.sort_by(|a, b| { - if order_desc { - b.value.coins.cmp(&a.value.coins) - } else { - a.value.coins.cmp(&b.value.coins) - } - }); - } - QueryStakesOrderByOptions::Nonce => { - stakes.sort_by(|a, b| { - if order_desc { - b.value.nonce.cmp(&a.value.nonce) - } else { - a.value.nonce.cmp(&b.value.nonce) - } - }); + QueryStakesOrderByOptions::Coins | QueryStakesOrderByOptions::Nonce => { + stakes.retain(|stake| stake.value.nonce >= since_epoch); } QueryStakesOrderByOptions::Mining => { - stakes.sort_by(|a, b| { - if order_desc { - b.value.epochs.mining.cmp(&a.value.epochs.mining) - } else { - a.value.epochs.mining.cmp(&b.value.epochs.mining) - } + stakes.retain(|stake| { + u64::from(stake.value.epochs.mining) >= since_epoch + && u64::from(stake.value.epochs.mining) >= stake.value.nonce }); } QueryStakesOrderByOptions::Witnessing => { - stakes.sort_by(|a, b| { - if order_desc { - b.value.epochs.witnessing.cmp(&a.value.epochs.witnessing) - } else { - a.value.epochs.witnessing.cmp(&b.value.epochs.witnessing) - } + stakes.retain(|stake| { + u64::from(stake.value.epochs.witnessing) >= since_epoch + && u64::from(stake.value.epochs.witnessing) >= stake.value.nonce }); } } - - // if `distinct` is required, retain first appearence per validator: - let stakes = if distinct { - stakes - .into_iter() - .unique_by(|stake| stake.key.validator) - .collect() - } else { - stakes - }; - - // applies `offset`, if any, and limits number of returned records to `limit`` - Ok(stakes.into_iter().skip(offset).take(limit).collect()) } -} - -impl Handler for ChainManager { - type Result = ::Result; - fn handle(&mut self, msg: QueryStakingPowers, _ctx: &mut Self::Context) -> Self::Result { - let current_epoch = self.current_epoch.unwrap(); - let limit = msg.limit.unwrap_or(u16::MAX) as usize; - let offset = msg.offset.unwrap_or_default(); + // sort stake entries by specified field, worst case costing n * log (n) ... + match order.by { + QueryStakesOrderByOptions::Coins => { + stakes.sort_by(|a, b| { + let value_order = if order_desc { + b.value.coins.cmp(&a.value.coins) + } else { + a.value.coins.cmp(&b.value.coins) + }; + value_order.then_with(|| a.key.cmp(&b.key)) + }); + } + QueryStakesOrderByOptions::Nonce => { + stakes.sort_by(|a, b| { + let value_order = if order_desc { + b.value.nonce.cmp(&a.value.nonce) + } else { + a.value.nonce.cmp(&b.value.nonce) + }; + value_order.then_with(|| a.key.cmp(&b.key)) + }); + } + QueryStakesOrderByOptions::Mining => { + stakes.sort_by(|a, b| { + let value_order = if order_desc { + b.value.epochs.mining.cmp(&a.value.epochs.mining) + } else { + a.value.epochs.mining.cmp(&b.value.epochs.mining) + }; + value_order.then_with(|| a.key.cmp(&b.key)) + }); + } + QueryStakesOrderByOptions::Witnessing => { + stakes.sort_by(|a, b| { + let value_order = if order_desc { + b.value.epochs.witnessing.cmp(&a.value.epochs.witnessing) + } else { + a.value.epochs.witnessing.cmp(&b.value.epochs.witnessing) + }; + value_order.then_with(|| a.key.cmp(&b.key)) + }); + } + } - // Enumerate power entries order by specified capability, - // mapping into a record containing: the ranking, the stake key and current power - let powers = self - .chain_state - .stakes - .by_rank(msg.order_by, current_epoch) - .enumerate() - .map(|(index, record)| (index + 1, record.0, record.1)); + // if `distinct` is required, retain first appearence per validator: + let stakes = if distinct { + stakes + .into_iter() + .unique_by(|stake| stake.key.validator) + .collect() + } else { + stakes + }; - // Skip first `offset` entries and take next `limit`. - // If distinct is specified, retain just first appearance per validator. - let powers: Vec<(usize, StakeKey, Power)> = if msg.distinct.unwrap_or(false) - { - powers - .unique_by(|(_, key, _)| key.validator) - .skip(offset) - .take(limit) - .collect() - } else { - powers.skip(offset).take(limit).collect() - }; + // applies `offset`, if any, and limits number of returned records to `limit`` + Ok(stakes.into_iter().skip(offset).take(limit).collect()) +} - powers +/// Select one tip-labelled query directly from the canonical primary map. In contrast with the +/// compatibility `queryStakes` endpoint, a missing validator/withdrawer secondary row cannot make +/// a live primary entry look absent here. +fn query_stakes_at_tip_primary( + stakes: &Stakes, + filter: QueryStakesFilter, +) -> anyhow::Result> { + match stakes.query_stakes_primary_checked(filter) { + Ok(entries) => Ok(entries), + Err( + StakesError::EntryNotFound { .. } + | StakesError::ValidatorNotFound { .. } + | StakesError::WithdrawerNotFound { .. }, + ) => Ok(Vec::new()), + Err(error) => Err(error.into()), } } -impl Handler for ChainManager { - type Result = ResponseActFuture>; +impl Handler for ChainManager { + type Result = ::Result; - fn handle(&mut self, msg: BuildDrt, _ctx: &mut Self::Context) -> Self::Result { - if self.sm_state != StateMachine::Synced { + fn handle( + &mut self, + msg: QueryStakesAtTip, + _ctx: &mut Self::Context, + ) -> Self::Result { + let tip = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .highest_block_checkpoint; + let entries = query_stakes_at_tip_primary( + &self.chain_state.stakes, + msg.filter.unwrap_or_default(), + )?; + let entries = apply_query_stakes_params( + entries, + msg.params.unwrap_or_default(), + self.current_epoch, + )?; + + Ok(StakeEntriesAtTip { + tip, + entries: entries + .into_iter() + .map(|entry| ValidatorPolicyStakeEntry { + key: entry.key, + value: ValidatorPolicyStake { + coins: entry.value.coins.nanowits(), + epochs: entry.value.epochs, + nonce: entry.value.nonce, + }, + }) + .collect(), + }) + } +} + +/// Resolve the actual canonical block covered by a finalized superblock, not its index or the +/// wall-clock epoch. Missing finality and sparse block history deliberately fail closed. +fn stake_recovery_finalized_tip( + blocks: &BTreeMap, + tip: CheckpointBeacon, + finalized_superblock: CheckpointBeacon, + superblock_period: u16, +) -> anyhow::Result { + if superblock_period == 0 || finalized_superblock.checkpoint == 0 { + anyhow::bail!("stake recovery requires a consolidated superblock"); + } + if blocks.get(&tip.checkpoint) != Some(&tip.hash_prev_block) { + anyhow::bail!("stake recovery canonical tip is unavailable"); + } + let exclusive_boundary = finalized_superblock.checkpoint + .checked_mul(u32::from(superblock_period)) + .ok_or_else(|| anyhow::anyhow!("stake recovery finality epoch overflow"))?; + let last_epoch = exclusive_boundary - 1; + let (&checkpoint, &hash_prev_block) = blocks + .range(..=last_epoch.min(tip.checkpoint)) + .next_back() + .ok_or_else(|| anyhow::anyhow!("stake recovery has no finalized canonical block"))?; + Ok(CheckpointBeacon { checkpoint, hash_prev_block }) +} + +impl Handler for ChainManager { + type Result = ::Result; + + fn handle(&mut self, msg: QueryStakeRecoveryEvidence, _ctx: &mut Self::Context) -> Self::Result { + msg.validate()?; + if self.sm_state != StateMachine::Synced { + return Err(StakeRecoveryError::ChainNotReady.into()); + } + let queried_epoch = self + .current_epoch + .ok_or(StakeRecoveryError::ChainNotReady)?; + let protocol_generation = get_protocol_runtime().2; + let chain_info = self.chain_state.chain_info.as_ref() + .ok_or(StakeRecoveryError::CanonicalTipUnavailable)?; + let tip = chain_info.highest_block_checkpoint; + if tip.checkpoint > queried_epoch { + return Err(StakeRecoveryError::CanonicalTipUnavailable.into()); + } + let finalized_tip = stake_recovery_finalized_tip( + &self.chain_state.block_chain, + tip, + self.get_superblock_beacon(), + chain_info.consensus_constants.superblock_period, + ) + .map_err(|error| StakeRecoveryError::FinalityUnavailable(error.to_string()))?; + if self.transactions_pool.get(&msg.transaction_hash).is_some() { + return Err(StakeRecoveryError::TransactionRetained.into()); + } + let mut unspent_inputs = Vec::with_capacity(msg.input_pointers.len()); + for output_pointer in msg.input_pointers { + let output = self.chain_state.unspent_outputs_pool.try_get(&output_pointer)? + .ok_or(StakeRecoveryError::InputUnavailable { output: output_pointer.clone() })?; + unspent_inputs.push(StakeRecoveryInput { + output_pointer, + pkh: output.pkh, + value: output.value, + time_lock: output.time_lock, + }); + } + unspent_inputs.sort_by_cached_key(|input| input.output_pointer.to_string()); + // All reads above are synchronous within one actor turn. The global registry can be + // published by another actor, so also guard against a mixed protocol-runtime report. + if get_protocol_runtime().2 != protocol_generation { + return Err(StakeRecoveryError::ProtocolChanged.into()); + } + Ok(StakeRecoveryEvidence { + transaction_hash: msg.transaction_hash, + network: self.get_magic(), + queried_epoch, + tip, + finalized_tip, + transaction_absent: true, + unspent_inputs, + }) + } +} + +impl Handler for ChainManager { + type Result = ::Result; + + /// Return the complete canonical stake ledger together with its owning tip. It validates the authoritative index before cloning and serializing the actor state. + fn handle( + &mut self, + _msg: QueryStakeEntriesAtTip, + _ctx: &mut Self::Context, + ) -> Self::Result { + let tip = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .highest_block_checkpoint; + // The detached complete-ledger response uses a checked clone, which intentionally + // rebuilds secondary indexes from `by_key`. Once delegated staking is authoritative, + // reject malformed source indexes before that read-only projection can hide them. + if self.chain_state.delegation_state.activation_initialized { + self.chain_state.stakes.validate_validator_index_checked()?; + } + let stakes = self.chain_state.stakes.try_clone_checked()?; + let mut entries = stakes.query_stakes_checked(QueryStakesKey::All)?; + + // Live insertion order and restored BTreeMap order are intentionally different internal + // details. Give every equivalent tip exactly one wire representation. + entries.sort_by(|left, right| left.key.cmp(&right.key)); + + Ok(StakeEntriesAtTip { + tip, + entries: entries + .into_iter() + .map(|entry| ValidatorPolicyStakeEntry { + key: entry.key, + value: ValidatorPolicyStake { + coins: entry.value.coins.nanowits(), + epochs: entry.value.epochs, + nonce: entry.value.nonce, + }, + }) + .collect(), + }) + } +} + +/// Query canonical stake entries for a bounded set of withdrawers. It delegates filtering and checked ordering to Stakes::query_stakes_by_withdrawers_checked for the actor-atomic RPC handler. +fn query_stake_entries_at_tip_by_withdrawers( + stakes: &Stakes, + withdrawers: impl IntoIterator, +) -> anyhow::Result< + Vec>, +> { + Ok(stakes.query_stakes_by_withdrawers_checked(withdrawers)?) +} + +impl Handler for ChainManager { + type Result = ::Result; + + /// Return the canonical stake subset for the requested withdrawers together with its owning tip. It preserves the same checked index and ordering guarantees as the complete-ledger handler. + fn handle( + &mut self, + msg: QueryStakeEntriesAtTipByWithdrawers, + _ctx: &mut Self::Context, + ) -> Self::Result { + // The message constructor already canonicalizes this list. Keeping the field private and + // consuming it here ensures internal callers cannot bypass the same bound used by JSON-RPC. + let withdrawers = msg.into_withdrawers(); + let tip = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .highest_block_checkpoint; + // The filtered query proves the requested withdrawer subset against the canonical primary + // map, but once delegated staking is authoritative the complete validator/withdrawer + // projection is also a persisted invariant. Reject a malformed unrelated validator index + // rather than presenting a partial tip snapshot as safe wallet state. Before activation the + // legacy index remains compatibility-owned, so retain the historical filtered behavior. + if self.chain_state.delegation_state.activation_initialized { + self.chain_state.stakes.validate_validator_index_checked()?; + } + let entries = + query_stake_entries_at_tip_by_withdrawers(&self.chain_state.stakes, withdrawers)?; + + Ok(StakeEntriesAtTip { + tip, + entries: entries + .into_iter() + .map(|entry| ValidatorPolicyStakeEntry { + key: entry.key, + value: ValidatorPolicyStake { + coins: entry.value.coins.nanowits(), + epochs: entry.value.epochs, + nonce: entry.value.nonce, + }, + }) + .collect(), + }) + } +} + +impl Handler for ChainManager { + type Result = ::Result; + + fn handle(&mut self, msg: QueryValidatorPolicy, _ctx: &mut Self::Context) -> Self::Result { + let validator = try_do_magic_into_pkh(msg.validator)?; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let current_epoch = self + .current_epoch + .ok_or_else(|| anyhow::anyhow!("current epoch is not available"))?; + let current_protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + let tip = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .highest_block_checkpoint; + // Native Wit/2.1 owner activity is governed by the immutable delegated-staking + // minimum, not by a mutable/legacy consensus-constants field. Keep the legacy + // schedule-derived value only for pre-Wit/2.1 policy projections; otherwise an + // accidentally divergent constants table could make this RPC report a 10,000-WIT + // owner as active even though consensus eligibility requires 10,000 WIT. + let minimum_self_stake = if current_protocol_version >= ProtocolVersion::V2_1 { + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS) + } else { + Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(current_protocol_version), + ) + }; + + // The protocol epoch can select Wit/2.1 before the first Wit/2.1 block has persisted the + // one-time activation marker. In that interval, raw upgrade-history sidecars remain inert, + // but standalone transaction admission reconstructs authoritative legacy collateral from + // the live data-request pool. Expose that same canonical projection here: otherwise a + // safety-sensitive full-exit preflight could observe zero reservations even though the + // transaction validator will reject removal of the reservation-backed entry. + let projected_delegation_state = if current_protocol_version >= ProtocolVersion::V2_1 + && !self.chain_state.delegation_state.activation_initialized + { + let collateral_minimum = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .consensus_constants + .collateral_minimum; + Some( + prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + current_epoch, + collateral_minimum, + minimum_self_stake, + current_protocol_version, + &protocol_info, + )? + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 validator policy activation projection was not produced" + ) + })?, + ) + } else { + None + }; + let delegation_state = projected_delegation_state + .as_ref() + .unwrap_or(&self.chain_state.delegation_state); + + if delegation_state.activation_initialized { + delegation_state.validate_authoritative_owner_policies(&self.chain_state.stakes)?; + delegation_state.validate_pending_commissions_at_epoch(current_epoch)?; + } + let owner_withdrawer = delegation_state + .owner_withdrawer_or_legacy(&self.chain_state.stakes, validator)?; + // Wit/2.0 policy sidecars are inert until the one-time activation transition rebuilds + // them from the live stake ledger. Keep this RPC view consistent with owner recovery so + // upgrade timing cannot expose a cached commission, nonce, or phantom validator policy. + let persisted_policy = delegation_state + .activation_initialized + .then(|| { + delegation_state.validators.get(&validator).copied() + }) + .flatten(); + let policy = persisted_policy.unwrap_or_else(|| ValidatorPolicy::new(owner_withdrawer)); + // Build the externally trusted policy projection from the canonical primary-key ledger, + // not the maintained validator index. A retained policy may legitimately have no live + // entries, while an omitted secondary-index row must never make an owner full exit look + // safe by hiding a live third-party delegation. + let mut entries = match self + .chain_state + .stakes + .query_stakes_by_validator_primary_checked(validator) + { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) if persisted_policy.is_some() => Vec::new(), + Err(error) => return Err(error.into()), + }; + // The stake index is append-ordered during live block processing, while a restored + // tracker is rebuilt from its BTreeMap. Sort the RPC projection explicitly so equivalent + // policy state has one wire order across nodes and farm members. + entries.sort_by(|left, right| left.key.cmp(&right.key)); + let live_aggregate_stake = entries + .iter() + .try_fold(0u64, |total, entry| { + total + .checked_add(entry.value.coins.nanowits()) + .ok_or_else(|| anyhow::anyhow!("live aggregate stake overflow")) + })?; + let (outstanding_reserved_collateral, reservation_debits) = if delegation_state + .activation_initialized + { + let mut outstanding_reserved_collateral = 0u64; + let mut reservation_debits = + BTreeMap::, u64>::new(); + for (reservation_key, reservation) in delegation_state + .collateral_reservations + .iter() + .filter(|(key, _)| key.validator == validator) + { + let reservation_total = reservation.total.nanowits(); + if reservation_total == 0 || reservation.debits.is_empty() { + return Err(anyhow::anyhow!( + "validator policy contains an empty collateral reservation" + )); + } + + let mut debit_total = 0u64; + let mut previous_key: Option<&StakeKey> = None; + for debit in &reservation.debits { + let debit_amount = debit.amount.nanowits(); + if debit.key.validator != reservation_key.validator + || debit_amount == 0 + || previous_key.is_some_and(|key| key >= &debit.key) + { + return Err(anyhow::anyhow!( + "validator policy contains noncanonical collateral debits" + )); + } + debit_total = debit_total + .checked_add(debit_amount) + .ok_or_else(|| anyhow::anyhow!("reserved collateral debit overflow"))?; + let aggregate = reservation_debits.entry(debit.key.clone()).or_default(); + *aggregate = aggregate + .checked_add(debit_amount) + .ok_or_else(|| anyhow::anyhow!("reserved collateral debit overflow"))?; + previous_key = Some(&debit.key); + } + if debit_total != reservation_total { + return Err(anyhow::anyhow!( + "validator policy reservation debit sum does not match its total" + )); + } + outstanding_reserved_collateral = outstanding_reserved_collateral + .checked_add(reservation_total) + .ok_or_else(|| anyhow::anyhow!("reserved collateral overflow"))?; + } + + // Refund and slash settlement add funds back to the exact debit key, so a policy + // report must never make a missing post-debit entry look safe to remove. + for key in reservation_debits.keys() { + if !entries.iter().any(|entry| &entry.key == key) { + return Err(anyhow::anyhow!( + "validator policy reservation debit has no matching live stake entry" + )); + } + } + + // Refund settlement restores the aggregate debit to its exact live entry. Mirror the + // checked balance boundary here so this RPC cannot expose a policy that looks valid to + // callers while the corresponding refund would overflow the persisted u64 balance. + for (key, reserved) in &reservation_debits { + let live_coins = entries + .iter() + .find(|entry| &entry.key == key) + .map(|entry| entry.value.coins.nanowits()) + .ok_or_else(|| { + anyhow::anyhow!( + "validator policy reservation debit has no matching live stake entry" + ) + })?; + live_coins + .checked_add(*reserved) + .ok_or_else(|| { + anyhow::anyhow!( + "validator policy reservation debit cannot be restored to its live stake entry" + ) + })?; + } + + ( + outstanding_reserved_collateral, + reservation_debits + .into_iter() + .map(|(key, amount)| ValidatorPolicyReservationDebit { key, amount }) + .collect(), + ) + } else { + // Pre-activation reservation records are non-authoritative compatibility caches. + // They are validated and rebuilt from the live data-request pool by the one-time + // Wit/2.1 activation transition before they may affect policy or cap reporting. + (0, Vec::new()) + }; + live_aggregate_stake + .checked_add(outstanding_reserved_collateral) + .ok_or_else(|| anyhow::anyhow!("live stake plus reserved collateral overflow"))?; + // Once the activation marker exists, use the same checked commission lookup as native + // admission, reward distribution, and replay. The pre-activation fallback policy is a + // compatibility projection rather than an authoritative sidecar, so it has no pending + // transition to resolve here. + let effective_commission_percent = if persisted_policy.is_some() { + delegation_state.effective_commission_at(validator, current_epoch)? + } else { + policy.commission_percent + }; + // A retained post-activation policy remains queryable after its validator has fully + // exited. In that state the live ledger is intentionally empty, so absence means an + // inactive owner rather than a missing/corrupt policy. For a non-empty ledger, preserve + // the checked helper so a poisoned entry still fails closed. + let exiting_withdrawers: Vec<_> = if delegation_state.activation_initialized { + delegation_state.exit_intents.iter() + .filter(|key| key.validator == validator) + .map(|key| key.withdrawer) + .collect() + } else { + Vec::new() + }; + let pending_reward_withdrawers: Vec<_> = if delegation_state.activation_initialized { + let mut pending = delegation_state.collateral_reservations.iter() + .filter(|(key, _)| key.validator == validator) + .flat_map(|(_, reservation)| reservation.reward_weights.iter()) + .map(|(key, _)| key.withdrawer) + .collect::>(); + let legacy_locks = LegacyRewardLocks::from_pool( + &self.chain_state.data_request_pool, delegation_state, &protocol_info, + )?; + pending.extend(legacy_locks.owner_keys().iter() + .filter(|key| key.validator == validator) + .map(|key| key.withdrawer)); + pending.into_iter().collect() + } else { + Vec::new() + }; + let active_owner = if entries.is_empty() || exiting_withdrawers.contains(&owner_withdrawer) { + false + } else { + self.chain_state.stakes.validator_is_active_checked( + validator, + owner_withdrawer, + minimum_self_stake, + )? + }; + + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while projecting validator policy" + )); + } + + Ok(ValidatorPolicyInfo { + validator, + queried_epoch: Some(current_epoch), + tip: Some(tip), + owner_withdrawer, + active_commission_percent: policy.commission_percent, + effective_commission_percent, + last_accepted_nonce: policy + .pending_commission + .map_or(policy.commission_nonce, |pending| pending.nonce), + pending_commission: policy.pending_commission.map(|pending| PendingCommissionInfo { + commission_percent: pending.commission_percent, + effective_epoch: pending.effective_epoch, + nonce: pending.nonce, + }), + live_aggregate_stake: Wit::from_nanowits(live_aggregate_stake), + outstanding_reserved_collateral: Wit::from_nanowits(outstanding_reserved_collateral), + reservation_debits, + exiting_withdrawers: Some(exiting_withdrawers), + pending_reward_withdrawers: Some(pending_reward_withdrawers), + active_owner, + delegation_entries: entries + .into_iter() + .map(|entry| ValidatorPolicyStakeEntry { + key: entry.key, + value: ValidatorPolicyStake { + coins: entry.value.coins.nanowits(), + epochs: entry.value.epochs, + nonce: entry.value.nonce, + }, + }) + .collect(), + }) + } +} + +impl Handler for ChainManager { + type Result = ::Result; + + fn handle(&mut self, msg: QueryStakingPowers, _ctx: &mut Self::Context) -> Self::Result { + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let current_epoch = self + .current_epoch + .ok_or_else(|| anyhow::anyhow!("current epoch is not available"))?; + let current_protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + // Native Wit/2.1 ranking is governed by the immutable delegated-staking minimum. Keep + // the schedule-derived value only for pre-Wit/2.1 projections so a divergent constants + // table cannot make this RPC disagree with checked consensus eligibility. + let minimum_stake = if current_protocol_version >= ProtocolVersion::V2_1 { + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS) + } else { + Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(current_protocol_version), + ) + }; + + // The protocol schedule can select Wit/2.1 before its first block has persisted the + // activation marker. During that boundary, policy sidecars and reservation caches are + // still inert; reconstruct the canonical live-owner and legacy-collateral projection so + // ranking agrees with mining, tally validation, and validator-policy queries. + let projected_delegation_state = if current_protocol_version >= ProtocolVersion::V2_1 + && !self.chain_state.delegation_state.activation_initialized + { + let collateral_minimum = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .consensus_constants + .collateral_minimum; + Some( + prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + current_epoch, + collateral_minimum, + minimum_stake, + current_protocol_version, + &protocol_info, + )? + .ok_or_else(|| { + anyhow::anyhow!( + "Wit/2.1 staking-power activation projection was not produced" + ) + })?, + ) + } else { + None + }; + let delegation_state = projected_delegation_state + .as_ref() + .unwrap_or(&self.chain_state.delegation_state); + if current_protocol_version >= ProtocolVersion::V2_1 { + // Query responses must not turn a malformed post-activation owner-policy map into a + // plausible but incomplete ranking. Consensus state is authoritative for every query + // shape, including non-distinct per-withdrawer responses. + delegation_state.validate_authoritative_owner_policies(&self.chain_state.stakes)?; + delegation_state.validate_pending_commissions_at_epoch(current_epoch)?; + } + let limit = msg.limit.unwrap_or(u16::MAX) as usize; + let offset = msg.offset.unwrap_or_default(); + + // Wit/2.1 validator ranking is defined over one aggregate power per validator. Keep the + // legacy `distinct` projection unchanged before activation, but once delegated staking is + // active do not report whichever withdrawer happened to be first in the entry ranking. + // Use the persisted (or pre-activation canonical live) owner key for the stable RPC shape; + // per-withdrawer inspection remains available through `queryStakes`. + if current_protocol_version >= ProtocolVersion::V2_1 + && msg.distinct.unwrap_or(false) + { + let powers = delegation_state + .by_active_validator_rank_checked( + &self.chain_state.stakes, + msg.order_by, + current_epoch, + minimum_stake, + )? + .enumerate() + .map(|(index, (validator, power))| { + let withdrawer = delegation_state + .owner_withdrawer_or_legacy(&self.chain_state.stakes, validator)?; + Ok(( + index + 1, + StakeKey { + validator, + withdrawer, + }, + power, + )) + }) + .collect::>>()?; + + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while projecting staking powers" + )); + } + return Ok(powers.into_iter().skip(offset).take(limit).collect()); + } + + // Enumerate power entries ordered by the requested capability. Wit/2.1 must reject + // per-entry coin-age overflow just like its aggregate ranking path; retain the wrapping + // iterator before activation for wire and consensus compatibility. In the delegated view, + // an inactive owner disables the validator's entire power population, including its + // delegators. Keep those balances discoverable through `queryStakes`, but do not expose + // them as usable mining/witnessing power here. + let delegated_active = current_protocol_version >= ProtocolVersion::V2_1; + let ranked = if delegated_active { + // Filter the validator population before calculating checked coin-age power. An + // inactive validator must not make this delegated view fail because its excluded + // entry overflows, and this keeps the per-entry query semantics aligned with the + // pre-filtered aggregate ranking above. + let active_validators = delegation_state + .active_validator_set_checked(&self.chain_state.stakes, minimum_stake)?; + self.chain_state + .stakes + .by_rank_checked_filtered(msg.order_by, current_epoch, |key| { + active_validators.contains(&key.validator) + && (!delegation_state.activation_initialized + || !delegation_state.exit_intents.contains(key)) + })? + .collect::>() + } else { + self.chain_state + .stakes + .by_rank(msg.order_by, current_epoch) + .collect::>() + }; + let powers = ranked + .into_iter() + .enumerate() + .map(|(index, record)| (index + 1, record.0, record.1)); + + // Skip first `offset` entries and take next `limit`. + // If distinct is specified, retain just first appearance per validator. + let powers: Vec<(usize, StakeKey, Power)> = if msg.distinct.unwrap_or(false) + { + powers + .unique_by(|(_, key, _)| key.validator) + .skip(offset) + .take(limit) + .collect() + } else { + powers.skip(offset).take(limit).collect() + }; + + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while projecting staking powers" + )); + } + + Ok(powers) + } +} + +impl Handler for ChainManager { + type Result = ResponseActFuture>; + + fn handle(&mut self, msg: BuildDrt, _ctx: &mut Self::Context) -> Self::Result { + if self.sm_state != StateMachine::Synced { return Box::pin(actix::fut::err( ChainManagerError::NotSynced { current_state: self.sm_state, @@ -1713,22 +3034,48 @@ impl Handler for ChainManager { )); } + let current_epoch = match self.current_epoch { + Some(current_epoch) => current_epoch, + None => { + log::warn!("Cannot build a data request without a current epoch"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; let active_wips = ActiveWips { active_wips: self.chain_state.tapi_engine.wip_activation.clone(), - block_epoch: self.current_epoch.unwrap(), + block_epoch: current_epoch, }; let dr_output = msg.dro; if let Err(e) = validate_rad_request(&dr_output.data_request, &active_wips) { return Box::pin(actix::fut::err(e)); } - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let timestamp = match u64::try_from(get_timestamp()) { + Ok(timestamp) => timestamp, + Err(_) => { + log::warn!("Cannot build a data request before the system timestamp is non-negative"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; + let own_pkh = match self.own_pkh { + Some(own_pkh) => own_pkh, + None => { + log::warn!("Cannot build a data request without the node identity"); + return Box::pin(actix::fut::err( + ChainManagerError::ChainNotReady.into(), + )); + } + }; let max_dr_weight = self.consensus_constants().max_dr_weight; match transaction_factory::build_drt( dr_output, msg.fee, &mut self.chain_state.own_utxos, - self.own_pkh.unwrap(), + own_pkh, &self.chain_state.unspent_outputs_pool, timestamp, self.tx_pending_timeout, @@ -1784,7 +3131,7 @@ impl Handler for ChainManager { } impl Handler for ChainManager { - type Result = ResponseFuture>; + type Result = ResponseActFuture>; fn handle(&mut self, msg: GetDataRequestInfo, _ctx: &mut Self::Context) -> Self::Result { let dr_pointer = msg.dr_pointer; @@ -1797,18 +3144,110 @@ impl Handler for ChainManager { .get(&dr_pointer) .map(|dr_state| dr_state.info.clone()) { - Box::pin(future::ready(Ok(dr_info))) + Box::pin(actix::fut::ready(Ok(dr_info))) } else { let dr_pointer_string = format!("DR-REPORT-{dr_pointer}"); // Otherwise, try to get it from storage let fut = async move { - let dr_info = storage_mngr::get::<_, DataRequestInfo>(&dr_pointer_string).await?; + let view = storage_mngr::get_inventory_view().await?; + // Pin the schedule together with the inventory view. The report and its + // identifying blocks are historical data, so validating them against whatever + // schedule happens to be published after this await could pair one generation's + // bytes with another generation's versioned-hash rules. + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let report = storage_mngr::get_inventory::( + view, + storage_mngr::InventoryDomain::Report, + dr_pointer_string.into_bytes(), + ) + .await?; + let blocks = match report.as_ref().and_then(|report| { + Some((report.block_hash_dr_tx?, report.block_hash_tally_tx?)) + }) { + Some((dr_block_hash, tally_block_hash)) => { + match InventoryManager::get_multiple_blocks_in_view_for_protocol( + view, + [dr_block_hash, tally_block_hash], + &protocol_info, + ) + .await + { + Ok(blocks) => blocks, + Err(InventoryManagerError::ItemNotFound) => Vec::new(), + Err(error) => return Err(error.into()), + } + } + None => Vec::new(), + }; - match dr_info { - Some(x) => Ok(x), - None => Err(DataRequestNotFound { hash: dr_pointer }.into()), + Ok::<_, anyhow::Error>((report, blocks, protocol_info, protocol_generation)) + } + .into_actor(self) + .map(move |result, act, _ctx| { + let (dr_info, blocks, protocol_info, protocol_generation) = result?; + // Do not return a report whose blocks were validated under a superseded schedule + // while the callback derives its request identity under the new one. The actor + // callback is synchronous after this check, so the paired generation remains + // stable for all subsequent canonicality and identity checks. + let (_, _, current_protocol_generation) = get_protocol_runtime(); + if current_protocol_generation != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while loading data-request report" + )); } - }; + let Some(report) = dr_info else { + return Err(DataRequestNotFound { hash: dr_pointer }.into()); + }; + let (Some(tally), Some(dr_block_hash), Some(tally_block_hash)) = ( + report.tally.as_ref(), + report.block_hash_dr_tx, + report.block_hash_tally_tx, + ) else { + log::warn!( + "Ignoring malformed or orphaned persisted data-request report for {dr_pointer}" + ); + return Err(DataRequestNotFound { hash: dr_pointer }.into()); + }; + let [dr_block, tally_block] = blocks.as_slice() else { + log::warn!( + "Ignoring persisted data-request report with missing blocks for {dr_pointer}" + ); + return Err(DataRequestNotFound { hash: dr_pointer }.into()); + }; + let dr_epoch = dr_block.block_header.beacon.checkpoint; + let tally_epoch = tally_block.block_header.beacon.checkpoint; + let dr_is_canonical = act.chain_state.block_chain.get(&dr_epoch) + == Some(&dr_block_hash); + let tally_is_canonical = act.chain_state.block_chain.get(&tally_epoch) + == Some(&tally_block_hash); + let dr_protocol = protocol_info.all_versions.version_for_epoch(dr_epoch); + let request_occurrences = dr_block + .txns + .data_request_txns + .iter() + .filter(|transaction| transaction.versioned_hash(dr_protocol) == dr_pointer) + .count(); + let tally_occurrences = tally_block + .txns + .tally_txns + .iter() + .filter(|transaction| *transaction == tally) + .count(); + if report.current_stage.is_none() + && tally.dr_pointer == dr_pointer + && dr_is_canonical + && tally_is_canonical + && request_occurrences == 1 + && tally_occurrences == 1 + { + Ok(report) + } else { + log::warn!( + "Ignoring malformed or orphaned persisted data-request report for {dr_pointer}" + ); + Err(DataRequestNotFound { hash: dr_pointer }.into()) + } + }); Box::pin(fut) } @@ -1826,90 +3265,115 @@ impl Handler for ChainManager { } .into()); } - let now = get_timestamp() as u64; + let now = u64::try_from(get_timestamp()).map_err(|_| ChainManagerError::ChainNotReady)?; let balance = match msg { GetBalance2::All(limits) => { let mut balances: HashMap = HashMap::new(); - for (_output_pointer, (vto, _)) in self.chain_state.unspent_outputs_pool.iter() { - balances + for entry in self.chain_state.unspent_outputs_pool.try_iter() { + let (_output_pointer, (vto, _)) = entry?; + let balance = balances .entry(vto.pkh) .or_insert(NodeBalance2::One { locked: 0, staked: 0, unlocked: 0, - }) - .add_utxo_value(vto.value, vto.time_lock > now); + }); + try_add_balance2_utxo(balance, vto.value, vto.time_lock > now)?; } - let stakes = self.chain_state.stakes.query_stakes(QueryStakesKey::All); - if let Ok(stakes) = stakes { - stakes.iter().for_each(|entry| { - balances - .entry(entry.key.withdrawer) - .or_insert(NodeBalance2::One { - locked: 0, - staked: 0, - unlocked: 0, - }) - .add_staked(entry.value.coins.into()) - }); + let stakes = self + .chain_state + .stakes + .query_stakes_checked(QueryStakesKey::All)?; + for entry in stakes { + let balance = balances + .entry(entry.key.withdrawer) + .or_insert(NodeBalance2::One { + locked: 0, + staked: 0, + unlocked: 0, + }); + try_add_balance2_stake(balance, entry.value.coins.nanowits())?; + } + let mut filtered = HashMap::with_capacity(balances.len()); + for (pkh, balance) in balances { + let total = try_balance2_total(&balance)?; + if total >= limits.min_balance + && total <= limits.max_balance.unwrap_or(u64::MAX) + { + filtered.insert(pkh, balance); + } } - balances.retain(|_pkh, balance| { - balance.get_total().unwrap_or_default() - >= Wit::from_nanowits(limits.min_balance) - && balance.get_total().unwrap_or_default() - <= Wit::from_nanowits(limits.max_balance.unwrap_or(u64::MAX)) - }); - NodeBalance2::Many(balances) + NodeBalance2::Many(filtered) } GetBalance2::Address(pkh) => { let pkh = try_do_magic_into_pkh(pkh.clone()).map_err(|e| { log::warn!("Failed to convert {pkh:?} into a valid public key hash: {e}"); e })?; - let mut balance = transaction_factory::get_utxos_balance( + let mut balance = transaction_factory::try_get_utxos_balance( &self.chain_state.unspent_outputs_pool, pkh, now, - ); - let stakes = self + )?; + let stakes = match self .chain_state .stakes - .query_stakes(QueryStakesKey::Withdrawer(pkh)); - if let Ok(stakes) = stakes { - balance.add_staked(stakes.iter().fold(0u64, |staked: u64, entry| { - staked.saturating_add(entry.value.coins.nanowits()) - })); - } + // Balance is a safety-sensitive financial projection. Read membership from + // the canonical primary ledger so an omitted withdrawer-index row cannot make + // a live delegated balance disappear from the result. + .query_stakes_primary_checked(QueryStakesKey::Withdrawer(pkh)) + { + Ok(stakes) => stakes, + Err(StakesError::WithdrawerNotFound { .. }) => Vec::new(), + Err(error) => return Err(error.into()), + }; + let staked = stakes.iter().try_fold(0u64, |staked, entry| { + staked + .checked_add(entry.value.coins.nanowits()) + .ok_or_else(|| anyhow::anyhow!("address staked balance overflow")) + })?; + try_add_balance2_stake(&mut balance, staked)?; + try_balance2_total(&balance)?; balance } GetBalance2::Sum(pkhs) => { - let (total_locked, total_staked, total_unlocked) = pkhs.iter().fold( - Default::default(), - |(total_locked, total_staked, total_unlocked), pkh| { - if let Ok(NodeBalance2::One { + let mut total_locked = 0u64; + let mut total_staked = 0u64; + let mut total_unlocked = 0u64; + for pkh in pkhs { + match self.handle(GetBalance2::Address(pkh), _ctx)? { + NodeBalance2::One { locked, staked, unlocked, - }) = self.handle(GetBalance2::Address(pkh.clone()), _ctx) - { - ( - total_locked + locked, - total_staked + staked, - total_unlocked + unlocked, - ) - } else { - (total_locked, total_staked, total_unlocked) + } => { + total_locked = total_locked + .checked_add(locked) + .ok_or_else(|| anyhow::anyhow!("locked balance sum overflow"))?; + total_staked = total_staked + .checked_add(staked) + .ok_or_else(|| anyhow::anyhow!("staked balance sum overflow"))?; + total_unlocked = total_unlocked + .checked_add(unlocked) + .ok_or_else(|| anyhow::anyhow!("unlocked balance sum overflow"))?; } - }, - ); + NodeBalance2::Count(_) | NodeBalance2::Many(_) => { + return Err(anyhow::anyhow!( + "address balance query unexpectedly returned a non-address balance" + )); + } + } + } - NodeBalance2::One { + let balance = NodeBalance2::One { locked: total_locked, staked: total_staked, unlocked: total_unlocked, - } + }; + try_balance2_total(&balance)?; + balance } }; @@ -1917,6 +3381,60 @@ impl Handler for ChainManager { } } +fn try_add_balance2_utxo( + balance: &mut NodeBalance2, + value: u64, + locked_value: bool, +) -> Result<(), anyhow::Error> { + match balance { + NodeBalance2::One { + locked, unlocked, .. + } => { + let target = if locked_value { locked } else { unlocked }; + *target = target + .checked_add(value) + .ok_or_else(|| anyhow::anyhow!("UTXO balance overflow"))?; + Ok(()) + } + NodeBalance2::Count(_) | NodeBalance2::Many(_) => Err(anyhow::anyhow!( + "cannot add a UTXO value to a non-address balance" + )), + } +} + +fn try_add_balance2_stake( + balance: &mut NodeBalance2, + value: u64, +) -> Result<(), anyhow::Error> { + match balance { + NodeBalance2::One { staked, .. } => { + *staked = staked + .checked_add(value) + .ok_or_else(|| anyhow::anyhow!("staked balance overflow"))?; + Ok(()) + } + NodeBalance2::Count(_) | NodeBalance2::Many(_) => Err(anyhow::anyhow!( + "cannot add stake to a non-address balance" + )), + } +} + +fn try_balance2_total(balance: &NodeBalance2) -> Result { + match balance { + NodeBalance2::One { + locked, + staked, + unlocked, + } => staked + .checked_add(*unlocked) + .and_then(|value| value.checked_add(*locked)) + .ok_or_else(|| anyhow::anyhow!("total balance overflow")), + NodeBalance2::Count(_) | NodeBalance2::Many(_) => Err(anyhow::anyhow!( + "cannot total a non-address balance" + )), + } +} + impl Handler for ChainManager { type Result = Result; @@ -1939,7 +3457,8 @@ impl Handler for ChainManager { GetBalanceTarget::All => { let mut balances: HashMap = HashMap::new(); - for (_output_pointer, (vto, _)) in self.chain_state.unspent_outputs_pool.iter() { + for entry in self.chain_state.unspent_outputs_pool.try_iter() { + let (_output_pointer, (vto, _)) = entry?; balances .entry(vto.pkh) .or_insert(NodeBalance::One { @@ -1958,25 +3477,25 @@ impl Handler for ChainManager { let total = self .chain_state .own_utxos - .get_balance(&self.chain_state.unspent_outputs_pool); + .try_get_balance(&self.chain_state.unspent_outputs_pool)?; NodeBalance::One { confirmed: None, total, } } else { - transaction_factory::get_total_balance( + transaction_factory::try_get_total_balance( &self.chain_state.unspent_outputs_pool, own_pkh, simple, - ) + )? } } - GetBalanceTarget::Address(pkh) => transaction_factory::get_total_balance( + GetBalanceTarget::Address(pkh) => transaction_factory::try_get_total_balance( &self.chain_state.unspent_outputs_pool, pkh, simple, - ), + )?, }; Ok(balance) @@ -1994,15 +3513,35 @@ impl Handler for ChainManager { .into()); } - let chain_info = self.chain_state.chain_info.as_ref().unwrap(); - let current_epoch = self.current_epoch.unwrap(); - let current_time = u64::try_from(get_timestamp()).unwrap(); - let current_staked_supply = self.chain_state.stakes.total_staked().nanowits(); + let chain_info = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)?; + let current_epoch = self + .current_epoch + .ok_or(ChainManagerError::ChainNotReady)?; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while calculating supply" + )); + } + let current_protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + let current_time = u64::try_from(get_timestamp()) + .map_err(|_| anyhow::anyhow!("current timestamp cannot be represented as u64"))?; + let current_staked_supply = self + .chain_state + .stakes + .total_staked_checked()? + .nanowits(); let wit1_block_reward = chain_info.consensus_constants.initial_block_reward; - let wit2_activation_epoch = get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - let wit2_block_reward = - ConsensusConstantsWit2::default().get_validator_block_reward(current_epoch); + let wit2_activation_epoch = protocol_info + .all_versions + .get_activation_epoch(ProtocolVersion::V2_0); + let wit2_block_reward = ConsensusConstantsWit2::default() + .get_validator_block_reward_for_protocol(current_protocol_version); let (mut blocks_minted, mut blocks_minted_reward) = (self.last_blocks_minted, self.last_blocks_minted_reward); @@ -2011,27 +3550,47 @@ impl Handler for ChainManager { for epoch in self.last_supply_info_epoch..wit2_activation_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit1_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit1_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } for epoch in wit2_activation_epoch..current_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit2_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit2_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } } else { for epoch in self.last_supply_info_epoch..current_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit2_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit2_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } } + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while calculating supply" + )); + } + + // Publish the memoized counters only after the schedule snapshot has remained valid. A + // stale calculation must not advance the next range and silently lose rewards. self.last_supply_info_epoch = current_epoch; self.last_blocks_minted = blocks_minted; self.last_blocks_minted_reward = blocks_minted_reward; @@ -2058,31 +3617,57 @@ impl Handler for ChainManager { .into()); } - let chain_info = self.chain_state.chain_info.as_ref().unwrap(); - let current_epoch = self.current_epoch.unwrap(); - let current_time = u64::try_from(get_timestamp()).unwrap(); - let current_staked_supply = self.chain_state.stakes.total_staked().nanowits(); + let chain_info = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)?; + let current_epoch = self + .current_epoch + .ok_or(ChainManagerError::ChainNotReady)?; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while calculating supply" + )); + } + let current_protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + let current_time = u64::try_from(get_timestamp()) + .map_err(|_| anyhow::anyhow!("current timestamp cannot be represented as u64"))?; + let current_staked_supply = self + .chain_state + .stakes + .total_staked_checked()? + .nanowits(); let wit1_block_reward = chain_info.consensus_constants.initial_block_reward; - let wit2_activation_epoch = get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - let wit2_block_reward = - ConsensusConstantsWit2::default().get_validator_block_reward(current_epoch); + let wit2_activation_epoch = protocol_info + .all_versions + .get_activation_epoch(ProtocolVersion::V2_0); + let wit2_block_reward = ConsensusConstantsWit2::default() + .get_validator_block_reward_for_protocol(current_protocol_version); let collateral_minimum = chain_info.consensus_constants.collateral_minimum; let requests_in_flight_collateral = self .chain_state .data_request_pool - .locked_wits_by_requests(collateral_minimum); + .locked_wits_by_requests_for_protocol(collateral_minimum, |request_epoch| { + protocol_info.all_versions.version_for_epoch(request_epoch) + }); let mut current_locked_supply = requests_in_flight_collateral; - let mut current_unlocked_supply = 0; - for (_output_pointer, value_transfer_output) in self.chain_state.unspent_outputs_pool.iter() - { + let mut current_unlocked_supply = 0u64; + for entry in self.chain_state.unspent_outputs_pool.try_iter() { + let (_output_pointer, value_transfer_output) = entry?; if value_transfer_output.0.time_lock <= current_time { - current_unlocked_supply += value_transfer_output.0.value; + current_unlocked_supply = current_unlocked_supply + .checked_add(value_transfer_output.0.value) + .ok_or_else(|| anyhow::anyhow!("unlocked supply overflow"))?; } else { - current_locked_supply += value_transfer_output.0.value; + current_locked_supply = current_locked_supply + .checked_add(value_transfer_output.0.value) + .ok_or_else(|| anyhow::anyhow!("locked supply overflow"))?; } } @@ -2093,27 +3678,47 @@ impl Handler for ChainManager { for epoch in self.last_supply_info_epoch..wit2_activation_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit1_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit1_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } for epoch in wit2_activation_epoch..current_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit2_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit2_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } } else { for epoch in self.last_supply_info_epoch..current_epoch { // If the blockchain contains an epoch, a block was minted in that epoch, add the reward to blocks_minted_reward if self.chain_state.block_chain.contains_key(&epoch) { - blocks_minted += 1; - blocks_minted_reward += wit2_block_reward; + blocks_minted = blocks_minted + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("minted block count overflow"))?; + blocks_minted_reward = blocks_minted_reward + .checked_add(wit2_block_reward) + .ok_or_else(|| anyhow::anyhow!("minted reward overflow"))?; } } } + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while calculating supply" + )); + } + + // Keep the cached accounting cursor unchanged when a concurrent publication supersedes + // the schedule used for this read. self.last_supply_info_epoch = current_epoch; self.last_blocks_minted = blocks_minted; self.last_blocks_minted_reward = blocks_minted_reward; @@ -2155,10 +3760,15 @@ impl Handler for ChainManager { .into()); } - let chain_info = self.chain_state.chain_info.as_ref().unwrap(); + let chain_info = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)?; + let current_epoch = self.current_epoch.ok_or(ChainManagerError::ChainNotReady)?; let active_wips = ActiveWips { active_wips: self.chain_state.tapi_engine.wip_activation.clone(), - block_epoch: self.current_epoch.unwrap(), + block_epoch: current_epoch, }; let collateral_age = self .consensus_constants_wit2 @@ -2174,13 +3784,13 @@ impl Handler for ChainManager { Some(pkh) }; - Ok(get_utxo_info( + try_get_utxo_info( pkh, &self.chain_state.own_utxos, &self.chain_state.unspent_outputs_pool, chain_info.consensus_constants.collateral_minimum, block_number_limit, - )) + ) } } @@ -2223,7 +3833,7 @@ impl Handler for ChainManager { .into_iter() .map(|pkh| { let reputation = rep_eng.trs().get(pkh); - let eligibility = rep_eng.get_eligibility(pkh) + 1; + let eligibility = rep_eng.get_eligibility(pkh).saturating_add(1); let is_active = rep_eng.ars().contains(pkh); let rep_stats = ReputationStats { @@ -2277,21 +3887,43 @@ impl Handler for ChainManager { _ctx: &mut Self::Context, ) -> Self::Result { let dr_pointer = commit_transaction.body.dr_pointer; - // Hold reveal transaction under "waiting_for_reveal" field of data requests pool - self.chain_state - .data_request_pool - .insert_reveal(dr_pointer, reveal_transaction); - - // Send AddTransaction message to self - // And broadcast it to all of peers + let commit_transaction = Transaction::Commit(commit_transaction); + let (protocol_info, _, _) = get_protocol_runtime(); + let admission_protocol = protocol_info.current_version; + // Admit the commit before retaining its reveal. Admission can yield to signature + // verification and reject a stale or no-longer-valid commit; inserting first would leave + // an orphaned reveal under `waiting_for_reveal`, where it could shadow a later commit for + // the same data request. The actor-local success callback keeps both pool mutations + // ordered and makes rejection side-effect free. Box::pin( self.add_transaction( AddTransaction { - transaction: Transaction::Commit(commit_transaction), + transaction: commit_transaction.clone(), broadcast_flag: true, }, get_timestamp(), ) + .map_ok(move |_, act, _| { + // `add_transaction` is intentionally idempotent and returns `Ok(())` for a body + // that is already in-flight. Only retain the reveal once the commit is actually + // present in the pool; otherwise a rejected in-flight attempt could still orphan + // this reveal after the admission future completes. + match act + .transactions_pool + .contains_for_protocol(&commit_transaction, admission_protocol) + { + Ok(true) => act + .chain_state + .data_request_pool + .insert_reveal(dr_pointer, reveal_transaction), + Ok(false) => log::debug!( + "Not retaining reveal for data request {dr_pointer}: commit admission did not retain the body" + ), + Err(error) => log::debug!( + "Not retaining reveal for data request {dr_pointer}: cannot confirm commit admission: {error}" + ), + } + }) .map_err(|e, _, _| { log::warn!("Failed to add commit transaction: {e}"); e @@ -2301,10 +3933,12 @@ impl Handler for ChainManager { } impl Handler for ChainManager { - type Result = Result; + type Result = Result<(Transaction, u32), ()>; fn handle(&mut self, msg: GetMemoryTransaction, _ctx: &mut Self::Context) -> Self::Result { - self.transactions_pool.get(&msg.hash).ok_or(()) + self.transactions_pool + .get_with_admission_weight(&msg.hash) + .ok_or(()) } } @@ -2312,10 +3946,14 @@ impl Handler for ChainManager { type Result = Result; fn handle(&mut self, _msg: GetMempool, _ctx: &mut Self::Context) -> Self::Result { + // Stake transactions are keyed in the pool by their protocol-versioned identity. Return + // those stored keys directly: a reorganization can briefly retain a transaction across a + // protocol rollback, in which case recomputing under the current snapshot would no + // longer identify the entry that `getTransaction` and removal must access. let res = GetMempoolResult { value_transfer: self.transactions_pool.vt_iter().map(|t| t.hash()).collect(), data_request: self.transactions_pool.dr_iter().map(|t| t.hash()).collect(), - stake: self.transactions_pool.st_iter().map(|t| t.hash()).collect(), + stake: self.transactions_pool.st_hash_iter().copied().collect(), unstake: self.transactions_pool.ut_iter().map(|t| t.hash()).collect(), }; @@ -2359,8 +3997,11 @@ impl Handler for ChainManager { .superblock_period; let superblock_beacon = self.get_superblock_beacon(); // Superblock 1 confirms blocks 0..=9 - let last_confirmed_block = - (superblock_beacon.checkpoint * u32::from(superblock_period)).saturating_sub(1); + let last_confirmed_block = superblock_beacon + .checkpoint + .checked_mul(u32::from(superblock_period)) + .ok_or(ChainManagerError::ChainNotReady)? + .saturating_sub(1); if msg.block_epoch <= last_confirmed_block { if self.chain_state.block_chain.get(&msg.block_epoch) == Some(&msg.block_hash) { @@ -2378,9 +4019,18 @@ impl Handler for ChainManager { } impl Handler for ChainManager { - type Result = Result; + type Result = AtomicResponse>; + + fn handle(&mut self, msg: Rewind, _ctx: &mut Self::Context) -> Self::Result { + let (completion_tx, completion_rx) = oneshot::channel::>(); + // Replay failures can be reported by the block-loading future while successful completion + // is reported by the final durable ChainState/peer-limit future. The callbacks are + // mutually exclusive, so a single guarded sender gives the AtomicResponse one terminal + // result without allowing a late callback to send twice. + let completion = Arc::new(Mutex::new(Some(completion_tx))); + let completion_success = Arc::clone(&completion); + let completion_failure = Arc::clone(&completion); - fn handle(&mut self, msg: Rewind, ctx: &mut Self::Context) -> Self::Result { // Save list of blocks that are known to be valid let old_block_chain: VecDeque<(Epoch, Hash)> = self .chain_state @@ -2389,46 +4039,106 @@ impl Handler for ChainManager { .map(|(k, v)| (*k, *v)) .collect(); - self.delete_chain_state_and_reinitialize() - .map(|_res, act, ctx| { - // Set outbound limit to 0 - // This will avoid receiving any messages that could interfere with the - // resynchronization. - let sessions_manager = SessionsManager::from_registry(); - sessions_manager - .send(SetPeersLimits { - inbound: 0, - outbound: 0, + let rewind = SessionsManager::from_registry() + .send(SetPeersLimits { + inbound: 0, + outbound: 0, + }) + .into_actor(self) + .map_err(|error, _act, _ctx| { + anyhow::anyhow!("failed to disable peers for rewind: {error}") + }) + .and_then(|(), act, _ctx| { + act.delete_chain_state_and_reinitialize() + .map_err(|(), _act, _ctx| { + anyhow::anyhow!("failed to clear and reinitialize ChainState for rewind") }) - .into_actor(act) - .map(|_res, _act, _ctx| ()) - .spawn(ctx); - act.resync_from_storage(old_block_chain, ctx, |act, ctx| { - // After the resync is done: - // Persist chain state to storage - ctx.wait( - act.persist_chain_state(None) - .map(|_res: Result<(), ()>, _act, _ctx| ()), - ); - // Set outbound limit back to the old value - async { - let config = config_mngr::get().await.expect("failed to read config"); - let sessions_manager = SessionsManager::from_registry(); - sessions_manager - .send(SetPeersLimits { - inbound: config.connections.inbound_limit, - outbound: config.connections.outbound_limit, + }) + .and_then(move |(), act, ctx| -> ResponseActFuture> { + act.resync_from_storage( + old_block_chain, + ctx, + move |act, ctx| { + // Publish the replayed state and restore peer traffic only after that + // durable write succeeds. A failed post-clear commit cannot safely + // continue running. + let completion_success = Arc::clone(&completion_success); + let finalize_rewind = act + .persist_chain_state(None, None) + .map_err(|(), _act, _ctx| { + anyhow::anyhow!( + "failed to persist replayed ChainState after rewind" + ) + }) + .and_then(|(), act, _ctx| { + async { + let config = config_mngr::get().await.map_err(|error| { + anyhow::anyhow!( + "failed to read peer limits after rewind: {error}" + ) + })?; + SessionsManager::from_registry() + .send(SetPeersLimits { + inbound: config.connections.inbound_limit, + outbound: config.connections.outbound_limit, + }) + .await + .map_err(|error| { + anyhow::anyhow!( + "failed to restore peer limits after rewind: {error}" + ) + })?; + Ok::<(), anyhow::Error>(()) + } + .into_actor(act) }) - .await - .expect("failed to set peers limits"); + .then(move |result, _act, _ctx| { + if let Err(error) = &result { + log::error!( + "Cannot safely complete rewind; stopping the node: {error}" + ); + System::current().stop_with_code(1); + } + let mut sender = match completion_success.lock() { + Ok(sender) => sender, + Err(poisoned) => poisoned.into_inner(), + }; + if let Some(sender) = sender.take() { + let _ = sender.send(result); + } + actix::fut::ready(()) + }); + ctx.wait(finalize_rewind); + }, + move |error| { + log::error!("Cannot safely complete rewind; stopping the node: {error}"); + System::current().stop_with_code(1); + let mut sender = match completion_failure.lock() { + Ok(sender) => sender, + Err(poisoned) => poisoned.into_inner(), + }; + if let Some(sender) = sender.take() { + let _ = sender.send(Err(error)); + } + }, + ); + + let rewind_completion = completion_rx.into_actor(act).map(|result, _act, _ctx| { + match result { + Ok(Ok(())) => Ok(true), + Ok(Err(error)) => Err(error), + Err(_) => Err(anyhow::anyhow!("rewind completion channel was dropped")), } - .into_actor(act) - .spawn(ctx); }); + Box::pin(rewind_completion) }) - .wait(ctx); + .map_err(|error, _act, _ctx| { + log::error!("Cannot safely start rewind; stopping the node: {error}"); + System::current().stop_with_code(1); + error + }); - Ok(true) + AtomicResponse::new(Box::pin(rewind)) } } @@ -2458,11 +4168,19 @@ impl Handler for ChainManager { // Prevent estimating priority if chain is not synchronized match self.sm_state { StateMachine::Synced => { + let (protocol_info, published_epoch_constants, _) = get_protocol_runtime(); + let epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => constants, + (None, Some(constants)) if protocol_info.all_versions.is_empty() => constants, + (None, _) => return Err(ChainManagerError::ChainNotReady.into()), + }; + let current_epoch = self + .current_epoch + .ok_or(ChainManagerError::ChainNotReady)?; + // Use the period of the epoch being served. Wit/2 may shorten checkpoint periods, + // while the legacy field remains the pre-activation period for compatibility. let seconds_per_epoch = Duration::from_secs( - self.epoch_constants - .ok_or(ChainManagerError::ChainNotReady)? - .checkpoints_period - .into(), + epoch_constants.get_epoch_period(current_epoch)?.into(), ); Ok(self.priority_engine.estimate_priority(seconds_per_epoch)?) @@ -2480,22 +4198,158 @@ impl Handler for ChainManager { } } +impl Handler for ChainManager { + type Result = ::Result; + + fn handle( + &mut self, + _msg: GetDelegatedStakingActivationPreflight, + _ctx: &mut Self::Context, + ) -> Self::Result { + if self.sm_state != StateMachine::Synced { + return Err(ChainManagerError::NotSynced { + current_state: self.sm_state, + } + .into()); + } + let inspected_beacon = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)? + .highest_block_checkpoint; + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let _current_epoch = self.current_epoch.ok_or(ChainManagerError::ChainNotReady)?; + // This report evaluates a future Wit/2.1 transition, not the current protocol epoch. An + // early-chain node must therefore report the immutable delegated-staking minimum rather + // than the pre-staking zero returned by the epoch-gated legacy constants accessor. + let minimum_stake = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + // Capture the protocol registry at the same actor boundary as the stake/request reads. The + // explicit helper keeps request classification stable if an activation schedule is + // published concurrently with this read-only report. + let report = delegated_staking_activation_preflight_for_protocol( + &self.chain_state.data_request_pool, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + inspected_beacon, + minimum_stake, + &protocol_info, + ); + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while running delegated-staking activation preflight" + )); + } + Ok(report) + } +} + +/// Bind the diagnostic classifier to the actor's canonical tip and network timing/round rules. +/// Do not call full delegated persistence accounting here: below-floor balances, missing current +/// recipients, wide nominal totals, and divergent advisory sidecars are precisely what this +/// diagnostic must distinguish without treating its output as migration or activation approval. +fn delegated_staking_activation_classification_at_tip( + chain_state: &ChainState, + current_epoch: Epoch, + protocol_info: &ProtocolInfo, +) -> Result { + let chain_info = chain_state.chain_info.as_ref() + .ok_or(ChainInfoError::ChainInfoNotFound)?; + protocol_info.validate_timing_topology(&chain_info.consensus_constants)?; + anyhow::ensure!( + chain_state.data_request_pool.extra_rounds == chain_info.consensus_constants.extra_rounds, + "activation classification request-pool round allowance disagrees with network consensus constants" + ); + let tip = chain_info.highest_block_checkpoint; + anyhow::ensure!( + tip.checkpoint <= current_epoch, + "activation classification current epoch is behind the canonical tip" + ); + if let Some((&first_epoch, &first_hash)) = chain_state.block_chain.iter().next() { + anyhow::ensure!( + first_epoch == 0 && first_hash == chain_info.consensus_constants.genesis_hash, + "activation classification canonical block index does not begin at configured genesis" + ); + } + match chain_state.block_chain.iter().next_back() { + Some((&epoch, &hash)) => anyhow::ensure!( + tip.checkpoint == epoch && tip.hash_prev_block == hash, + "activation classification tip beacon disagrees with canonical block index" + ), + None => anyhow::ensure!( + tip.checkpoint == 0 + && tip.hash_prev_block == chain_info.consensus_constants.bootstrap_hash, + "activation classification empty block index disagrees with bootstrap beacon" + ), + } + // The core classifier checks preactivation marker/protocol consistency and canonical primary + // stake/request structure. It never reads optional policy/reservation caches or changes state. + Ok(chain_state.delegation_state.activation_boundary_classification( + &chain_state.stakes, + &chain_state.data_request_pool, + tip, + protocol_info, + )?) +} + +impl Handler for ChainManager { + type Result = ::Result; + + fn handle( + &mut self, + _msg: GetDelegatedStakingActivationClassification, + _ctx: &mut Self::Context, + ) -> Self::Result { + if self.sm_state != StateMachine::Synced { + return Err(ChainManagerError::NotSynced { + current_state: self.sm_state, + }.into()); + } + let current_epoch = self.current_epoch.ok_or(ChainManagerError::ChainNotReady)?; + let (protocol_info, _epoch_constants, protocol_generation) = get_protocol_runtime(); + // All ledger/request/tip reads are synchronous in this actor turn. A different actor can + // publish the process-global schedule, so reject the complete report if its pinned + // generation changed before return. There is deliberately no readiness/safe decision. + let report = delegated_staking_activation_classification_at_tip( + &self.chain_state, + current_epoch, + &protocol_info, + ); + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while classifying delegated-staking activation boundary" + )); + } + report + } +} + impl Handler for ChainManager { - type Result = ResponseActFuture::Result>; + type Result = AtomicResponse::Result>; fn handle( &mut self, SnapshotExport { path }: SnapshotExport, _ctx: &mut Self::Context, ) -> Self::Result { - let fut = Self::static_snapshot_export(self.sm_state, self.chain_state.clone(), path); + let sm_state = self.sm_state; + if self.chain_state.delegation_state.activation_initialized { + if let Err(error) = self.chain_state.stakes.validate_validator_index_checked() { + return AtomicResponse::new(Box::pin(actix::fut::err(error.into()))); + } + } + let chain_state = self.chain_state.try_clone_checked(); + let fut = async move { + let chain_state = chain_state.map_err(anyhow::Error::from)?; + Self::static_snapshot_export(sm_state, chain_state, path).await + }; - Box::pin(actix::fut::wrap_future(fut)) + AtomicResponse::new(Box::pin(actix::fut::wrap_future(fut))) } } impl Handler for ChainManager { - type Result = ResponseActFuture::Result>; + type Result = AtomicResponse::Result>; fn handle( &mut self, @@ -2506,7 +4360,7 @@ impl Handler for ChainManager { self.put_import_from_path(path); let fut = self.snapshot_import(); - Box::pin(fut) + AtomicResponse::new(fut) } } @@ -2529,23 +4383,37 @@ where { use BlockBatches::*; + if superblock_period == 0 { + log::error!( + "Cannot split synchronized blocks: superblock period is zero" + ); + return Err(ChainManagerError::ChainNotReady); + } + let current_superblock_index = current_epoch / superblock_period; - assert!( - current_superblock_index >= sync_target.superblock.checkpoint, - "Provided a sync target (epoch: {}, superblock index: {}) that is in the future (epoch: {}, superblock index: {})", - current_epoch, - current_superblock_index, - sync_target.block.checkpoint, - sync_target.superblock.checkpoint, - ); + let superblock_delta = current_superblock_index + .checked_sub(sync_target.superblock.checkpoint) + .ok_or_else(|| ChainManagerError::WrongBlocksForSuperblock { + wrong_index: sync_target.block.checkpoint, + consolidated_superblock_index: sync_target.superblock.checkpoint, + current_superblock_index, + })?; // If the chain reverted, this function cannot receive blocks from between the reverted epochs - let first_valid_block = (current_superblock_index - - ((current_superblock_index - sync_target.superblock.checkpoint) % 2)) - * superblock_period; + let first_valid_superblock_index = current_superblock_index + .checked_sub(superblock_delta % 2) + .ok_or(ChainManagerError::ChainNotReady)?; + let first_valid_block = first_valid_superblock_index + .checked_mul(superblock_period) + .ok_or(ChainManagerError::ChainNotReady)?; + let consolidated_blocks_target = sync_target + .superblock + .checkpoint + .checked_mul(superblock_period) + .ok_or(ChainManagerError::ChainNotReady)?; let wrong_index = blocks.iter().position(|block| { - key(block) >= sync_target.superblock.checkpoint * superblock_period + key(block) >= consolidated_blocks_target && key(block) < first_valid_block }); @@ -2566,14 +4434,13 @@ where // The case where blocks is an empty array let last_epoch = blocks.last().map(key).unwrap_or(0); - if last_epoch < ((sync_target.superblock.checkpoint * superblock_period).saturating_sub(1)) + if last_epoch < consolidated_blocks_target.saturating_sub(1) && last_epoch < sync_target.block.checkpoint { return Ok(TargetNotReached(blocks)); } - if (current_superblock_index - sync_target.superblock.checkpoint).is_multiple_of(2) { - let consolidated_blocks_target = sync_target.superblock.checkpoint * superblock_period; + if superblock_delta.is_multiple_of(2) { let mut consolidated_blocks = blocks; let mut remaining_blocks = vec![]; let split_position = consolidated_blocks @@ -2587,8 +4454,10 @@ where } let (consolidated_blocks_target, candidate_blocks_target) = ( - sync_target.superblock.checkpoint * superblock_period, - current_superblock_index * superblock_period, + consolidated_blocks_target, + current_superblock_index + .checked_mul(superblock_period) + .ok_or(ChainManagerError::ChainNotReady)?, ); let mut consolidated_blocks = blocks; let candidate_split_position = consolidated_blocks @@ -2620,8 +4489,449 @@ where mod tests { use std::str::FromStr; + use crate::actors::messages::MagicEither; + use super::*; + #[test] + fn activation_classification_binds_tip_rounds_and_timing_without_reading_advisory_policy() { + use witnet_data_structures::chain::ChainInfo; + + let mut state = ChainState::default(); + let mut chain_info = ChainInfo::default(); + chain_info.consensus_constants.extra_rounds = 0; + chain_info.consensus_constants.checkpoints_period = 45; + chain_info.consensus_constants.genesis_hash = Hash::SHA256([1; 32]); + chain_info.consensus_constants.bootstrap_hash = Hash::SHA256([2; 32]); + chain_info.highest_block_checkpoint = CheckpointBeacon { + checkpoint: 0, + hash_prev_block: chain_info.consensus_constants.bootstrap_hash, + }; + state.chain_info = Some(chain_info); + state.data_request_pool.extra_rounds = 0; + let protocols = ProtocolInfo::default(); + let bootstrap = delegated_staking_activation_classification_at_tip(&state, 0, &protocols).unwrap(); + assert!(bootstrap.diagnostic_only); + assert!(bootstrap.validators.is_empty()); + + state.data_request_pool.extra_rounds = 1; + assert!(delegated_staking_activation_classification_at_tip(&state, 0, &protocols).is_err()); + state.data_request_pool.extra_rounds = 0; + let genesis_hash = state.chain_info.as_ref().unwrap().consensus_constants.genesis_hash; + state.block_chain.insert(0, genesis_hash); + assert!(delegated_staking_activation_classification_at_tip(&state, 0, &protocols).is_err()); + state.chain_info.as_mut().unwrap().highest_block_checkpoint.hash_prev_block = genesis_hash; + let genesis = delegated_staking_activation_classification_at_tip(&state, 0, &protocols).unwrap(); + assert_eq!(genesis.inspected_beacon.hash_prev_block, genesis_hash); + + let mut advisory = ValidatorPolicy::new(PublicKeyHash::default()); + advisory.commission_percent = u8::MAX; + state.delegation_state.validators.insert(PublicKeyHash::default(), advisory); + assert_eq!(delegated_staking_activation_classification_at_tip(&state, 0, &protocols).unwrap(), genesis); + state.delegation_state.activation_initialized = true; + assert!(delegated_staking_activation_classification_at_tip(&state, 0, &protocols).is_err()); + state.delegation_state.activation_initialized = false; + + let later_hash = Hash::SHA256([4; 32]); + state.block_chain.insert(4, later_hash); + state.chain_info.as_mut().unwrap().highest_block_checkpoint = CheckpointBeacon { + checkpoint: 4, hash_prev_block: later_hash, + }; + assert!(delegated_staking_activation_classification_at_tip(&state, 3, &protocols).is_err()); + assert!(delegated_staking_activation_classification_at_tip(&state, 4, &protocols).is_ok()); + state.block_chain.remove(&0); + assert!(delegated_staking_activation_classification_at_tip(&state, 4, &protocols).is_err()); + state.block_chain.insert(0, genesis_hash); + let mut wrong_timing = ProtocolInfo::default(); + wrong_timing.try_register(1, ProtocolVersion::V1_8, 46).unwrap(); + assert!(delegated_staking_activation_classification_at_tip(&state, 4, &wrong_timing).is_err()); + } + + #[test] + fn recovery_finality_uses_actual_canonical_block_not_superblock_index_or_clock() { + let first = Hash::SHA256([1; 32]); + let last_final = Hash::SHA256([2; 32]); + let unfinalized = Hash::SHA256([3; 32]); + let blocks = BTreeMap::from([(1, first), (8, last_final), (12, unfinalized)]); + let tip = CheckpointBeacon { checkpoint: 12, hash_prev_block: unfinalized }; + let superblock = CheckpointBeacon { checkpoint: 1, hash_prev_block: first }; + // Superblock 1 covers epochs 0..=9, but epoch 9 contains no canonical block. + assert_eq!( + stake_recovery_finalized_tip(&blocks, tip, superblock, 10).unwrap(), + CheckpointBeacon { checkpoint: 8, hash_prev_block: last_final }, + ); + assert!(stake_recovery_finalized_tip(&blocks, tip, superblock, 0).is_err()); + assert!(stake_recovery_finalized_tip( + &blocks, tip, CheckpointBeacon { checkpoint: 0, ..superblock }, 10, + ).is_err()); + assert!(stake_recovery_finalized_tip( + &blocks, tip, CheckpointBeacon { checkpoint: u32::MAX, ..superblock }, 10, + ).is_err()); + assert!(stake_recovery_finalized_tip( + &blocks, CheckpointBeacon { hash_prev_block: first, ..tip }, superblock, 10, + ).is_err()); + assert!(stake_recovery_finalized_tip(&BTreeMap::new(), tip, superblock, 10).is_err()); + } + + #[test] + fn recovery_requests_are_bounded_unique_and_lossless() { + use crate::actors::messages::MAX_STAKE_RECOVERY_INPUTS; + use witnet_data_structures::chain::OutputPointer; + + let transaction_hash = Hash::SHA256([4; 32]); + let pointer = OutputPointer { transaction_id: transaction_hash, output_index: 0 }; + let mut query = QueryStakeRecoveryEvidence { transaction_hash, input_pointers: vec![] }; + assert!(query.validate().is_err()); + query.input_pointers.push(pointer); + assert!(query.validate().is_ok()); + query.input_pointers.push(pointer); + assert!(query.validate().is_err()); + query.input_pointers = (0..=MAX_STAKE_RECOVERY_INPUTS) + .map(|index| OutputPointer { output_index: index as u32, ..pointer }) + .collect(); + assert!(query.validate().is_err()); + query.input_pointers.pop(); + assert!(query.validate().is_ok()); + let input = StakeRecoveryInput { + output_pointer: pointer, + pkh: PublicKeyHash::default(), + value: u64::MAX, + time_lock: u64::MAX, + }; + let wire = serde_json::to_value(&input).unwrap(); + assert_eq!(wire["value"], u64::MAX.to_string()); + assert_eq!(wire["time_lock"], u64::MAX.to_string()); + assert_eq!(serde_json::from_value::(wire).unwrap(), input); + } + + #[test] + fn unstake_gross_debit_resolution_preserves_legacy_net_semantics() { + let legacy = BuildUnstake { + value: 11, + fee: 3, + ..BuildUnstake::default() + }; + assert_eq!(resolve_unstake_withdrawal_value(&legacy, 99).unwrap(), 11); + + let explicit_debit = BuildUnstake { + debit: Some(11), + fee: 3, + ..BuildUnstake::default() + }; + assert_eq!( + resolve_unstake_withdrawal_value(&explicit_debit, 99).unwrap(), + 8 + ); + let maximum_debit = BuildUnstake { + debit: Some(u64::MAX), + fee: 7, + ..BuildUnstake::default() + }; + assert_eq!( + resolve_unstake_withdrawal_value(&maximum_debit, 0).unwrap(), + u64::MAX - 7 + ); + + let all = BuildUnstake { + all: true, + fee: 3, + ..BuildUnstake::default() + }; + assert_eq!(resolve_unstake_withdrawal_value(&all, 11).unwrap(), 8); + } + + #[test] + fn unstake_gross_debit_resolution_rejects_ambiguous_or_empty_outputs() { + for invalid in [ + BuildUnstake { + debit: Some(3), + fee: 3, + ..BuildUnstake::default() + }, + BuildUnstake { + debit: Some(2), + fee: 3, + ..BuildUnstake::default() + }, + BuildUnstake { + debit: Some(4), + all: true, + ..BuildUnstake::default() + }, + BuildUnstake { + value: 1, + all: true, + ..BuildUnstake::default() + }, + BuildUnstake { + all: true, + fee: 3, + ..BuildUnstake::default() + }, + ] { + assert!(resolve_unstake_withdrawal_value(&invalid, 3).is_err()); + } + } + + #[test] + fn tip_bound_stake_query_proves_presence_and_absence_from_the_primary_ledger() { + const ONE_WIT_NANOWITS: u64 = 1_000_000_000; + let validator = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let key = StakeKey { + validator, + withdrawer, + }; + let mut stakes = + Stakes::::default(); + stakes + .add_stake( + key.clone(), + Wit::from_nanowits(11 * ONE_WIT_NANOWITS), + 1, + true, + Wit::from_nanowits(ONE_WIT_NANOWITS), + ) + .unwrap(); + + let present = query_stakes_at_tip_primary( + &stakes, + QueryStakesFilter { + validator: Some(MagicEither::Right(validator)), + withdrawer: None, + }, + ) + .unwrap(); + assert_eq!(present.len(), 1); + assert_eq!(present[0].key, key); + + let absent = query_stakes_at_tip_primary( + &stakes, + QueryStakesFilter { + validator: Some(MagicEither::Right( + PublicKeyHash::from_bytes(&[3; 20]).unwrap(), + )), + withdrawer: None, + }, + ) + .unwrap(); + assert!(absent.is_empty()); + } + + #[test] + fn shared_stake_query_shaping_preserves_default_order_offset_and_limit() { + const ONE_WIT_NANOWITS: u64 = 1_000_000_000; + let mut stakes = + Stakes::::default(); + for (seed, coins) in [(1u8, 11u64), (2, 33), (3, 22)] { + stakes + .add_stake( + StakeKey { + validator: PublicKeyHash::from_bytes(&[seed; 20]).unwrap(), + withdrawer: PublicKeyHash::from_bytes(&[seed + 10; 20]).unwrap(), + }, + Wit::from_nanowits(coins * ONE_WIT_NANOWITS), + 1, + true, + Wit::from_nanowits(ONE_WIT_NANOWITS), + ) + .unwrap(); + } + + let selected = stakes + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap(); + let shaped = apply_query_stakes_params( + selected, + QueryStakesParams { + offset: Some(1), + limit: Some(1), + ..Default::default() + }, + None, + ) + .unwrap(); + assert_eq!(shaped.len(), 1); + assert_eq!( + shaped[0].value.coins, + Wit::from_nanowits(22 * ONE_WIT_NANOWITS) + ); + } + + #[test] + fn filtered_tip_stake_projection_is_exact_and_canonical() { + const ONE_WIT_NANOWITS: u64 = 1_000_000_000; + let withdrawer = PublicKeyHash::from_bytes(&[9; 20]).unwrap(); + let foreign = PublicKeyHash::from_bytes(&[8; 20]).unwrap(); + let first = StakeKey { + validator: PublicKeyHash::from_bytes(&[1; 20]).unwrap(), + withdrawer, + }; + let second = StakeKey { + validator: PublicKeyHash::from_bytes(&[2; 20]).unwrap(), + withdrawer, + }; + let excluded = StakeKey { + validator: PublicKeyHash::from_bytes(&[3; 20]).unwrap(), + withdrawer: foreign, + }; + let mut stakes = + Stakes::::default(); + stakes + .add_stake( + second.clone(), + Wit::from_nanowits(22 * ONE_WIT_NANOWITS), + 1, + true, + Wit::from_nanowits(ONE_WIT_NANOWITS), + ) + .unwrap(); + stakes + .add_stake( + excluded, + Wit::from_nanowits(33 * ONE_WIT_NANOWITS), + 1, + true, + Wit::from_nanowits(ONE_WIT_NANOWITS), + ) + .unwrap(); + stakes + .add_stake( + first.clone(), + Wit::from_nanowits(11 * ONE_WIT_NANOWITS), + 1, + true, + Wit::from_nanowits(ONE_WIT_NANOWITS), + ) + .unwrap(); + + let selected = query_stake_entries_at_tip_by_withdrawers(&stakes, [withdrawer]).unwrap(); + + assert_eq!( + selected + .into_iter() + .map(|entry| (entry.key, entry.value.coins.nanowits() / ONE_WIT_NANOWITS)) + .collect::>(), + vec![(first, 11), (second, 22)] + ); + assert!( + query_stake_entries_at_tip_by_withdrawers( + &stakes, + std::iter::empty::() + ) + .unwrap() + .is_empty() + ); + assert!( + query_stake_entries_at_tip_by_withdrawers( + &stakes, + [PublicKeyHash::from_bytes(&[7; 20]).unwrap()] + ) + .unwrap() + .is_empty() + ); + } + + #[test] + fn validator_policy_reservation_debit_json_is_lossless() { + let debit = ValidatorPolicyReservationDebit { + key: StakeKey { + validator: PublicKeyHash::from_bytes(&[1; 20]).unwrap(), + withdrawer: PublicKeyHash::from_bytes(&[2; 20]).unwrap(), + }, + amount: u64::MAX, + }; + + let encoded = serde_json::to_value(&debit).unwrap(); + assert_eq!( + encoded["amount"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + debit + ); + } + + #[test] + fn validator_policy_missing_reservation_debits_deserializes_as_legacy_empty_projection() { + let validator = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let policy = ValidatorPolicyInfo { + validator, + queried_epoch: None, + tip: None, + owner_withdrawer: validator, + active_commission_percent: 0, + effective_commission_percent: 0, + last_accepted_nonce: 0, + pending_commission: None, + live_aggregate_stake: Wit::from_nanowits(0), + outstanding_reserved_collateral: Wit::from_nanowits(0), + reservation_debits: Vec::new(), + exiting_withdrawers: Some(Vec::new()), + pending_reward_withdrawers: Some(Vec::new()), + active_owner: false, + delegation_entries: Vec::new(), + }; + + let mut encoded = serde_json::to_value(&policy).unwrap(); + assert_eq!(encoded["reservation_debits"], serde_json::json!([])); + encoded + .as_object_mut() + .unwrap() + .remove("reservation_debits"); + + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + policy + ); + } + + #[test] + fn validator_policy_tip_label_round_trips_and_remains_additive() { + let validator = PublicKeyHash::from_bytes(&[3; 20]).unwrap(); + let policy = ValidatorPolicyInfo { + validator, + queried_epoch: Some(42), + tip: Some(CheckpointBeacon { + checkpoint: 41, + hash_prev_block: Hash::default(), + }), + owner_withdrawer: validator, + active_commission_percent: 0, + effective_commission_percent: 0, + last_accepted_nonce: 0, + pending_commission: None, + live_aggregate_stake: Wit::from_nanowits(0), + outstanding_reserved_collateral: Wit::from_nanowits(0), + reservation_debits: Vec::new(), + exiting_withdrawers: Some(Vec::new()), + pending_reward_withdrawers: Some(Vec::new()), + active_owner: false, + delegation_entries: Vec::new(), + }; + + let mut encoded = serde_json::to_value(&policy).unwrap(); + assert_eq!(encoded["queried_epoch"], serde_json::json!(42)); + assert_eq!(encoded["tip"]["checkpoint"], serde_json::json!(41)); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + policy + ); + + encoded.as_object_mut().unwrap().remove("queried_epoch"); + encoded.as_object_mut().unwrap().remove("tip"); + let legacy = serde_json::from_value::(encoded).unwrap(); + assert_eq!(legacy.queried_epoch, None); + assert_eq!(legacy.tip, None); + let mut old_wire = serde_json::to_value(&policy).unwrap(); + old_wire.as_object_mut().unwrap().remove("exiting_withdrawers"); + old_wire.as_object_mut().unwrap().remove("pending_reward_withdrawers"); + let old_policy = serde_json::from_value::(old_wire).unwrap(); + assert_eq!(old_policy.exiting_withdrawers, None); + assert_eq!(old_policy.pending_reward_withdrawers, None); + } + #[test] fn peers_beacons_consensus_less_peers_than_outbound() { let beacon1 = LastBeacon { diff --git a/node/src/actors/chain_manager/mining.rs b/node/src/actors/chain_manager/mining.rs index 48d0b3ff72..640dafb2c8 100644 --- a/node/src/actors/chain_manager/mining.rs +++ b/node/src/actors/chain_manager/mining.rs @@ -3,9 +3,10 @@ use std::{ convert::TryFrom, future, future::Future, + pin::Pin, sync::{ Arc, - atomic::{self, AtomicU16}, + atomic::{self, AtomicU16, AtomicU64}, }, }; @@ -14,27 +15,36 @@ use actix::{ WrapFuture, }; use ansi_term::Color::{White, Yellow}; -use futures::future::{FutureExt, try_join_all}; +use futures::future::{Either, FutureExt, try_join_all}; use witnet_data_structures::{ DEFAULT_VALIDATOR_COUNT_FOR_TESTS, chain::{ Block, BlockHeader, BlockMerkleRoots, BlockTransactions, Bn256PublicKey, CheckpointBeacon, - CheckpointVRF, ConsensusConstantsWit2, DataRequestOutput, EpochConstants, Hash, Hashable, - Input, PublicKeyHash, RADTally, TransactionsPool, ValueTransferOutput, + CheckpointVRF, ConsensusConstantsWit2, DataRequestOutput, Epoch, EpochConstants, Hash, Hashable, + Input, PublicKeyHash, RADTally, StakeOutput, TransactionsPool, ValueTransferOutput, + STAKE_AUTHORIZATION_ACTION_DELEGATE, + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + merkle_tree_root as versioned_merkle_tree_root, tapi::{ActiveWips, after_second_hard_fork}, }, data_request::{ - DataRequestPool, calculate_witness_reward, - calculate_witness_reward_before_second_hard_fork, create_tally, - data_request_has_too_many_witnesses, + DataRequestPool, calculate_tally_change_checked, calculate_witness_reward, + calculate_witness_reward_before_second_hard_fork_checked, + calculate_witness_reward_before_second_hard_fork_legacy_checked, + calculate_witness_reward_checked, classify_too_many_witnesses_tally_for_protocols, + create_tally_checked, data_request_has_too_many_witnesses_for_protocol, + TooManyWitnessesTallyMode, }, error::TransactionError, - get_environment, get_protocol_version, - proto::versioning::{ProtocolVersion, ProtocolVersion::*, VersionedHashable}, + get_environment, get_protocol_info, get_protocol_runtime, get_protocol_version, + proto::versioning::{ProtocolInfo, ProtocolVersion, ProtocolVersion::*, VersionedHashable}, radon_error::RadonError, radon_report::{RadonReport, ReportContext, TypeLike}, staking::{ + delegation::{CollateralReservationKey, DelegationResult, DelegationState}, + errors::StakesError, + prelude::StakeKey, stake::totalize_stakes, stakes::{QueryStakesKey, StakesTracker}, }, @@ -45,7 +55,7 @@ use witnet_data_structures::{ }, transaction_factory::{build_commit_collateral, check_commit_collateral}, utxo_pool::{UnspentOutputsPool, UtxoDiff}, - vrf::{BlockEligibilityClaim, DataRequestEligibilityClaim, VrfMessage}, + vrf::{BlockEligibilityClaim, DataRequestEligibilityClaim, VrfCtx, VrfMessage}, wit::Wit, }; use witnet_futures_utils::TryFutureExt2; @@ -61,10 +71,20 @@ use witnet_validations::{ legacy::*, }, validations::{ - block_reward, calculate_liars_and_errors_count_from_tally, dr_transaction_fee, - merkle_tree_root, run_tally, st_transaction_fee, tally_bytes_on_encode_error, - update_utxo_diff, validate_stake_transaction, validate_unstake_transaction, - vt_transaction_fee, + apply_wit2_age_resets_prevalidated_for_protocol, try_block_reward, + calculate_liars_and_errors_count_from_tally, dr_transaction_fee, + prevalidate_delegation_snapshot, LegacyRewardLocks, + prepare_standalone_delegation_state_for_protocol, + resolve_commit_stake_eligibility, run_tally, st_transaction_fee_for_protocol, + tally_bytes_on_encode_error, + project_tally_transition_for_protocol, project_wit2_inclusion_rewards_for_protocol, + project_wit2_transaction_rewards, + track_pre_activation_collateral_with_transition_check_for_protocol, + try_update_utxo_diff_for_protocol, validate_stake_transaction_for_protocol, + validate_stake_transaction_prevalidated_for_protocol, + validate_unstake_transaction_for_protocol, + validate_unstake_transaction_prevalidated_for_protocol, verify_dr_vrf_signature, + validate_request_protocol_for_block, vt_transaction_fee, }, }; @@ -77,6 +97,21 @@ use crate::{ signature_mngr, }; +/// Return the collateral amount that mining must reserve for a data request whose protocol is +/// determined by its inclusion epoch. Wit/2.0 serialized zero is a legacy no-op, whereas native +/// Wit/2.1 (and the pre-Wit/2.0 UTXO path) interpret zero as the network minimum. +fn collateral_for_mining( + protocol: ProtocolVersion, + collateral: u64, + collateral_minimum: u64, +) -> u64 { + if collateral == 0 && protocol != ProtocolVersion::V2_0 { + collateral_minimum + } else { + collateral + } +} + impl ChainManager { /// Try to mine a block pub fn try_mine_block(&mut self, ctx: &mut Context) -> Result<(), ChainManagerError> { @@ -93,9 +128,6 @@ impl ChainManager { let current_epoch = self.current_epoch.ok_or(ChainManagerError::ChainNotReady)?; let own_pkh = self.own_pkh.ok_or(ChainManagerError::ChainNotReady)?; - let epoch_constants = self - .epoch_constants - .ok_or(ChainManagerError::ChainNotReady)?; let chain_info = self .chain_state .chain_info @@ -110,26 +142,113 @@ impl ChainManager { let initial_block_reward = chain_info.consensus_constants.initial_block_reward; let checkpoint_zero_timestamp = chain_info.consensus_constants.checkpoint_zero_timestamp; let halving_period = chain_info.consensus_constants.halving_period; + let max_rounds = chain_info + .consensus_constants + .extra_rounds + .checked_add(1) + .ok_or(ChainManagerError::ChainNotReady)?; let previous_block_epoch = chain_info.highest_block_checkpoint.checkpoint; let mut beacon = chain_info.highest_block_checkpoint; let mut vrf_input = chain_info.highest_vrf_output; - let protocol_version = get_protocol_version(self.current_epoch); + // Keep the schedule generation paired with the protocol snapshot. VRF proving and tally + // discovery yield to other actor work; a replacement schedule during that window must + // invalidate this proposal rather than letting the later builder reinterpret its proof. + let (protocol_info, published_epoch_constants, protocol_generation) = + get_protocol_runtime(); + let epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => constants, + (None, Some(constants)) if protocol_info.all_versions.is_empty() => constants, + (None, _) => return Err(ChainManagerError::ChainNotReady), + }; + let protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); // The highest checkpoint beacon should contain the current epoch beacon.checkpoint = current_epoch; vrf_input.checkpoint = current_epoch; - let target_hash = if protocol_version == V2_0 { - let replication_factor = self.consensus_constants_wit2.get_replication_factor( - current_epoch, - chain_info.highest_block_checkpoint.checkpoint, - ); - let eligibility = self + // Resolve the delegated owner-active population once for this mining attempt. The same + // immutable set drives eligibility and the validator count; recomputing policy coverage + // from inside every ranking callback would make a large validator set unnecessarily + // quadratic. Use the same one-time activation projection as candidate construction so a + // first Wit/2.1 block cannot rank against inert upgrade-history sidecars. + let minimum_self_stake = Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), + ); + let delegated_projection = if protocol_version >= V2_1 { + // The checked clone rebuilds secondary indexes from `by_key`; validate the source + // ledger first so mining cannot canonicalize malformed index state before the + // activation-aware policy projection observes it. + self.chain_state + .stakes + .validate_validator_index_checked() + .map_err(ChainManagerError::Staking)?; + let projected_stakes = self .chain_state .stakes - .mining_eligibility(own_pkh, current_epoch, replication_factor) + .try_clone_checked() .map_err(ChainManagerError::Staking)?; + let projected_delegation_state = prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &projected_stakes, + &self.chain_state.delegation_state, + current_epoch, + collateral_minimum, + minimum_self_stake, + protocol_version, + &protocol_info, + ) + .map_err(ChainManagerError::Staking)? + .unwrap_or_else(|| self.chain_state.delegation_state.clone()); + Some((projected_stakes, projected_delegation_state)) + } else { + None + }; + let delegated_stakes = delegated_projection + .as_ref() + .map_or(&self.chain_state.stakes, |projection| &projection.0); + let active_validators = if let Some((projected_stakes, projected_delegation_state)) = + delegated_projection.as_ref() + { + projected_delegation_state + .validate_pending_commissions_at_epoch(current_epoch) + .map_err(ChainManagerError::Staking)?; + Some( + projected_delegation_state + .active_validator_set_checked(projected_stakes, minimum_self_stake) + .map_err(ChainManagerError::Staking)?, + ) + } else { + None + }; + + let target_hash = if protocol_version >= V2_0 { + let replication_factor = ConsensusConstantsWit2::get_replication_factor_for_protocol( + current_epoch, + chain_info.highest_block_checkpoint.checkpoint, + protocol_version, + ); + let eligibility = if protocol_version >= V2_1 { + delegated_projection.as_ref() + .ok_or(ChainManagerError::ChainNotReady)?.1 + .participating_stakes_checked(delegated_stakes) + .map_err(ChainManagerError::Staking)?.mining_eligibility_filtered( + own_pkh, + current_epoch, + replication_factor, + |validator| { + active_validators + .as_ref() + .is_some_and(|active| active.contains(validator)) + }, + ) + } else { + self.chain_state + .stakes + .mining_eligibility(own_pkh, current_epoch, replication_factor) + } + .map_err(ChainManagerError::Staking)?; match eligibility { Eligible::Yes => { @@ -143,9 +262,14 @@ impl ChainManager { Hash::max() } else { - let rep_engine = self.chain_state.reputation_engine.as_ref().unwrap().clone(); - let total_identities = - u32::try_from(rep_engine.ars().active_identities_number()).unwrap(); + let rep_engine = self + .chain_state + .reputation_engine + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)? + .clone(); + let total_identities = u32::try_from(rep_engine.ars().active_identities_number()) + .map_err(|_| ChainManagerError::ChainNotReady)?; let epochs_with_minimum_difficulty = chain_info .consensus_constants .epochs_with_minimum_difficulty; @@ -168,8 +292,13 @@ impl ChainManager { target_hash }; - let validator_count = self.chain_state.stakes.validator_count(); + let validator_count = if protocol_version >= V2_1 { + active_validators.as_ref().map_or(0, |active| active.len()) + } else { + self.chain_state.stakes.validator_count() + }; + let tally_protocol_info = protocol_info.clone(); signature_mngr::vrf_prove(VrfMessage::block_mining(vrf_input)) .map(move |res| { res.map_err(|e| log::error!("Failed to create block eligibility proof: {e}")) @@ -199,6 +328,12 @@ impl ChainManager { .into_actor(act) }) .and_then(move |(vrf_proof, tally_transactions), act, _ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding mined block eligibility from superseded protocol generation {protocol_generation}" + ); + return Either::Left(actix::fut::err(())); + } let eligibility_claim = BlockEligibilityClaim { proof: vrf_proof }; // TODO: assess if bn256 keys are redundant @@ -213,9 +348,15 @@ impl ChainManager { // Build the block using the supplied beacon and eligibility proof let block_number = act.chain_state.block_number(); - let (block_header, txns) = build_block( + let network_magic = act.get_magic(); + // Candidate construction performs legacy Commit expiry and invalid-action cleanup. + // Keep those mutations private through signing and full validation; this actor + // future is installed with `wait`, so no admission callback can interleave and be + // overwritten when the validated candidate is finally published below. + let mut candidate_transactions_pool = act.transactions_pool.clone(); + let (block_header, txns) = match build_block_with_protocol_info( ( - &mut act.transactions_pool, + &mut candidate_transactions_pool, &act.chain_state.unspent_outputs_pool, &mut act.chain_state.data_request_pool, ), @@ -223,6 +364,7 @@ impl ChainManager { max_dr_weight, beacon, eligibility_claim, + vrf_input, &tally_transactions, own_pkh, epoch_constants, @@ -238,44 +380,98 @@ impl ChainManager { &active_wips, Some(validator_count), &act.chain_state.stakes, + &act.chain_state.delegation_state, + network_magic, &act.consensus_constants_wit2, - ); + max_rounds, + &tally_protocol_info, + ) { + Ok(block) => block, + Err(error) => { + log::error!( + "Cannot build block candidate because delegated staking state preparation failed: {error}" + ); + return Either::Left(actix::fut::err(())); + } + }; // Sign the block hash let block_header_data = block_header.versioned_hash(protocol_version).data(); - signature_mngr::sign_data(block_header_data) - .map(|res| { - res.map_err(|e| log::error!("Couldn't sign beacon: {e}")) - .map(|block_sig| Block::new(block_header, block_sig, txns)) - }) - .into_actor(act) - }) - .and_then(move |block, act, _ctx| { - act.future_process_validations( - block.clone(), - previous_block_epoch, - current_epoch, - vrf_input, - beacon, - epoch_constants, + Either::Right( + signature_mngr::sign_data(block_header_data) + .map(move |res| { + res.map_err(|e| log::error!("Couldn't sign beacon: {e}")) + .map(move |block_sig| { + ( + Block::new(block_header, block_sig, txns), + candidate_transactions_pool, + ) + }) + }) + .into_actor(act), ) - .map_ok(move |_diff, act, _ctx| { - // Send AddCandidates message to self - // This will run all the validations again - - let block_hash = block.versioned_hash(protocol_version); - // FIXME(#1773): Currently last_block_proposed is not used, but removing it is a breaking change - act.chain_state.node_stats.last_block_proposed = block_hash; - act.chain_state.node_stats.block_proposed_count += 1; - log::info!( - "Proposed block candidate {}", - Yellow.bold().paint(block_hash.to_string()) - ); + }) + .and_then(move |(block, candidate_transactions_pool), act, _ctx| { + // The builder and signer used the generation captured before the asynchronous + // VRF/tally stages. Do not hand a superseded candidate to validation or candidate + // ranking; `future_process_validations` performs its own generation check during + // the later signature phase as well. + let validation = if get_protocol_runtime().2 != protocol_generation { + Either::Left(actix::fut::err(anyhow::anyhow!( + "protocol schedule changed before mined block validation" + ))) + } else { + Either::Right(act.future_process_validations_with_protocol( + block.clone(), + previous_block_epoch, + current_epoch, + vrf_input, + beacon, + epoch_constants, + protocol_info.clone(), + protocol_generation, + )) + }; + validation + .map_ok(move |_diff, act, _ctx| { + // Signing and the mining-specific full validation succeeded. Ordinary + // candidate processing repeats validation and can still reject this block when + // a better candidate is already known. Keep the pool image private until the + // exact block becomes the actor's best candidate, so an ignored proposal does + // not consume retryable local transactions. + let block_hash = block.versioned_hash(protocol_version); + let was_already_best = act + .best_candidate + .as_ref() + .is_some_and(|candidate| candidate.block == block); + act.process_candidate_with_protocol( + block.clone(), + protocol_info.clone(), + protocol_generation, + ); + let became_best = !was_already_best + && act + .best_candidate + .as_ref() + .is_some_and(|candidate| candidate.block == block); + if !became_best { + log::debug!( + "Keeping the authoritative transaction pool unchanged because mined block candidate {block_hash} did not newly become best" + ); + return; + } - act.process_candidate(block); - }) - .map_err(|e, _, _| log::error!("Error trying to mine a block: {e}")) + act.transactions_pool = candidate_transactions_pool; + // FIXME(#1773): Currently last_block_proposed is not used, but removing it is a breaking change + act.chain_state.node_stats.last_block_proposed = block_hash; + act.chain_state.node_stats.block_proposed_count += 1; + log::info!( + "Proposed block candidate {}", + Yellow.bold().paint(block_hash.to_string()) + ); + }) + .map_err(|e, _, _| log::error!("Error trying to mine a block: {e}")) }) .map(|_res: Result<(), ()>, _act, _ctx| ()) .wait(ctx); @@ -306,22 +502,134 @@ impl ChainManager { let own_pkh = self.own_pkh.ok_or(ChainManagerError::ChainNotReady)?; let current_epoch = self.current_epoch.ok_or(ChainManagerError::ChainNotReady)?; let data_request_timeout = self.data_request_timeout; - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let timestamp = u64::try_from(get_timestamp()).map_err(|_| { + log::warn!("Cannot mine data requests before the Unix epoch"); + ChainManagerError::ChainNotReady + })?; let consensus_constants = self.consensus_constants(); let minimum_reppoe_difficulty = consensus_constants.minimum_difficulty; + let (protocol_info, published_epoch_constants, protocol_generation) = + get_protocol_runtime(); + let epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => constants, + (None, Some(constants)) if protocol_info.all_versions.is_empty() => constants, + (None, _) => return Err(ChainManagerError::ChainNotReady), + }; + let current_protocol = protocol_info.all_versions.version_for_epoch(current_epoch); let minimum_stake = Wit::from( self.consensus_constants_wit2 - .get_validator_min_stake_nanowits(current_epoch), + .get_validator_min_stake_nanowits_for_protocol(current_protocol), ); + // Keep an immutable, activation-aware Wit/2.1 baseline for this actor turn. A node can + // observe a Wit/2.1 tip while its persisted one-time activation marker is still unset + // (for example after a restart at the fork boundary). Mining must reconstruct legacy + // owner policies and outstanding Wit/2.0 reservations before speculative collateral + // planning, just like candidate construction and standalone admission do. Otherwise a + // crossing Wit/2.0 request could be charged against stale sidecars or fail to reserve + // capacity that the authoritative block projection would accept. + let delegated_projection = if current_protocol >= V2_1 { + // Keep the source-ledger fail-closed boundary before cloning. The clone is a + // projection convenience and intentionally reindexes from `by_key`, so validating + // only its result could hide malformed secondary indexes in the actor's live state. + self.chain_state + .stakes + .validate_validator_index_checked() + .map_err(ChainManagerError::Staking)?; + let baseline_stakes = self + .chain_state + .stakes + .try_clone_checked() + .map_err(ChainManagerError::Staking)?; + let baseline_delegation_state = prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &baseline_stakes, + &self.chain_state.delegation_state, + current_epoch, + consensus_constants.collateral_minimum, + minimum_stake, + current_protocol, + &protocol_info, + ) + .map_err(ChainManagerError::Staking)? + .unwrap_or_else(|| self.chain_state.delegation_state.clone()); + Some((baseline_stakes, baseline_delegation_state)) + } else { + None + }; + let delegated_stakes = delegated_projection + .as_ref() + .map_or(&self.chain_state.stakes, |projection| &projection.0); + let delegated_state = delegated_projection + .as_ref() + .map_or(&self.chain_state.delegation_state, |projection| &projection.1); + + // Resolve the delegated owner-active population once for this actor turn. Every data + // request below uses the same immutable activation-aware snapshot, so repeating full + // policy and self-stake validation per request only adds avoidable work. + let active_validators = if let Some((baseline_stakes, baseline_delegation_state)) = + delegated_projection.as_ref() + { + baseline_delegation_state + .validate_pending_commissions_at_epoch(current_epoch) + .map_err(ChainManagerError::Staking)?; + Some( + baseline_delegation_state + .active_validator_set_checked(baseline_stakes, minimum_stake) + .map_err(ChainManagerError::Staking)?, + ) + } else { + None + }; // Retrieve all stake entries in which we are the validator - let stake_entries = self - .chain_state - .stakes - .query_stakes(QueryStakesKey::Validator(own_pkh)) - .unwrap_or_default(); + let stake_entries_result = if current_protocol >= V2_1 { + delegated_stakes.query_stakes_primary_checked(QueryStakesKey::Validator(own_pkh)) + } else { + self.chain_state + .stakes + .query_stakes(QueryStakesKey::Validator(own_pkh)) + }; + let stake_entries = match stake_entries_result { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(ChainManagerError::Staking(error)), + }; // Retrieve the withdrawer address of the first stake entry in which we are the validator let first_withdrawer_address = stake_entries.first().map(|stake| stake.key.withdrawer); - let mut available_stake = totalize_stakes(stake_entries).unwrap_or_default(); + // Several retrieval jobs are spawned below in the same actor turn. Each job reserves + // against its own clone, so a request that later loses its VRF/RAD race cannot consume + // capacity needed by another job. The authoritative block builder and validation paths + // remain the final cumulative admission gate. + let available_stake = { + let available_stake = if current_protocol >= V2_1 && stake_entries.is_empty() { + // A node identity that has never registered as a validator legitimately has neither + // live stake nor a retained validator policy. Treat it as having no collateralizable + // stake. Missing policy remains a hard error below whenever live stake exists, because + // post-activation policy ownership is then a persisted consensus invariant. + Wit::default() + } else if current_protocol >= V2_1 { + match delegated_state.owner_withdrawer_or_legacy(delegated_stakes, own_pkh) { + Ok(owner) => match delegated_stakes.query_stakes_checked( + QueryStakesKey::Key(StakeKey { + validator: own_pkh, + withdrawer: owner, + }), + ) { + Ok(entries) => totalize_stakes(entries).map_err(ChainManagerError::Staking)?, + Err(StakesError::EntryNotFound { .. }) => Wit::default(), + Err(error) => return Err(ChainManagerError::Staking(error)), + }, + Err(StakesError::ValidatorNotFound { .. }) => Wit::default(), + Err(error) => return Err(ChainManagerError::Staking(error)), + } + } else { + totalize_stakes(stake_entries).map_err(ChainManagerError::Staking)? + }; + // Legacy Wit/2.0 witnessing jobs are spawned concurrently. Keep their shared + // collateral budget in an atomic nanowit counter so each successful job claims its + // reservation exactly once; mutating a `Wit` captured by a `move` closure would only + // update that closure's private copy and could over-dispatch commitments. + Arc::new(AtomicU64::new(available_stake.nanowits())) + }; // Data Request mining let dr_pointers = self @@ -329,14 +637,18 @@ impl ChainManager { .data_request_pool .get_dr_output_pointers_by_epoch(current_epoch); - let rep_eng = self.chain_state.reputation_engine.as_ref().unwrap(); + let rep_eng = self + .chain_state + .reputation_engine + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)?; let is_ars_member = rep_eng.is_ars_member(&own_pkh); - let my_reputation = rep_eng.trs().get(&own_pkh).0 + 1; - let my_eligibility = rep_eng.get_eligibility(&own_pkh) + 1; + let my_reputation = rep_eng.trs().get(&own_pkh).0.saturating_add(1); + let my_eligibility = rep_eng.get_eligibility(&own_pkh).saturating_add(1); let total_active_reputation = rep_eng.total_active_reputation(); - let num_active_identities = - u32::try_from(rep_eng.ars().active_identities_number()).unwrap(); + let num_active_identities = u32::try_from(rep_eng.ars().active_identities_number()) + .map_err(|_| ChainManagerError::ChainNotReady)?; log::debug!("{} data requests for this epoch", dr_pointers.len()); log::debug!( "Reputation: {my_reputation}, eligibility: {my_eligibility}, total: {total_active_reputation}, active identities: {num_active_identities}", @@ -356,34 +668,40 @@ impl ChainManager { }) { // The protocol version under which a data request needs to be resolved is decided // based on the epoch of the block in which the data request was included. - let protocol_version = get_protocol_version(Some(dr_state.epoch)); + let protocol_version = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + let legacy_owner_stakes = if current_protocol >= V2_1 && protocol_version < V2_1 { + Some( + delegated_state + .legacy_owner_view_checked(delegated_stakes) + .map_err(ChainManagerError::Staking)?, + ) + } else { + None + }; // It's possible a data request requesting too many witnesses are in our local data // request pool (e.g., when we failed to validate a proposed block). Do not attempt // to solve this data request. - let validator_count = self.chain_state.stakes.validator_count(); - if data_request_has_too_many_witnesses( + let validator_count = if protocol_version >= V2_1 { + active_validators.as_ref().map_or(0, |active| active.len()) + } else { + delegated_stakes.validator_count() + }; + if data_request_has_too_many_witnesses_for_protocol( &dr_state.data_request, validator_count, - Some(dr_state.epoch), + protocol_version, ) { continue; } - let (checkpoint_period, max_rounds) = match &self.chain_state.chain_info { - Some(x) => ( - // Unwraps should be safe if we have a chain_info object - self.epoch_constants - .unwrap() - .get_epoch_period(current_epoch) - .unwrap(), - x.consensus_constants.extra_rounds + 1, - ), - None => { - log::error!("ChainInfo is None"); - return Err(ChainManagerError::ChainNotReady); - } - }; + let checkpoint_period = epoch_constants + .get_epoch_period(current_epoch) + .map_err(|_| ChainManagerError::ChainNotReady)?; + let max_rounds = consensus_constants + .extra_rounds + .checked_add(1) + .ok_or(ChainManagerError::ChainNotReady)?; let num_witnesses = dr_state.data_request.witnesses; let round = dr_state.info.current_commit_round; @@ -401,17 +719,36 @@ impl ChainManager { .consensus_constants_wit2 .get_collateral_age(&active_wips); let (target_hash, probability) = if protocol_version >= V2_0 { - let (eligibility, target_hash, probability) = self - .chain_state - .stakes - .witnessing_eligibility( + let eligibility_result = if protocol_version >= V2_1 { + delegated_projection.as_ref() + .ok_or(ChainManagerError::ChainNotReady)?.1 + .participating_stakes_checked(delegated_stakes) + .map_err(ChainManagerError::Staking)?.witnessing_eligibility_filtered( own_pkh, current_epoch, num_witnesses, round, max_rounds, + |validator| { + active_validators + .as_ref() + .is_some_and(|active| active.contains(validator)) + }, ) - .map_err(ChainManagerError::Staking)?; + } else { + legacy_owner_stakes + .as_ref() + .unwrap_or(&self.chain_state.stakes) + .witnessing_eligibility( + own_pkh, + current_epoch, + num_witnesses, + round, + max_rounds, + ) + }; + let (eligibility, target_hash, probability) = + eligibility_result.map_err(ChainManagerError::Staking)?; match eligibility { Eligible::Yes => { @@ -428,7 +765,7 @@ impl ChainManager { calculate_reppoe_threshold( rep_eng, &own_pkh, - num_witnesses + num_backup_witnesses, + num_witnesses.wrapping_add(num_backup_witnesses), minimum_reppoe_difficulty, &active_wips, ) @@ -440,27 +777,75 @@ impl ChainManager { let added_retrieval_count = u16::try_from(dr_state.data_request.data_request.retrieve.len()) .unwrap_or(u16::MAX); - - let collateral_amount = Wit::from_nanowits(if dr_state.data_request.collateral == 0 { - self.chain_state - .chain_info - .as_ref() - .unwrap() - .consensus_constants - .collateral_minimum - } else { - dr_state.data_request.collateral - }); + let available_stake_for_request = Arc::clone(&available_stake); + + // A serialized zero collateral is protocol-version specific. Wit/2.0 keeps the + // historical zero stake debit, while native Wit/2.1 normalizes zero to the network + // minimum. Pre-Wit/2.0 requests retain their legacy UTXO minimum behavior. Using the + // containing block's protocol here would incorrectly turn a pending Wit/2.0 request + // into a delegated minimum-collateral reservation after activation. + let collateral_amount = Wit::from_nanowits(collateral_for_mining( + protocol_version, + dr_state.data_request.collateral, + consensus_constants.collateral_minimum, + )); let block_number_limit = self .chain_state .block_number() .saturating_sub(collateral_age); - if protocol_version >= V2_0 { + if current_protocol >= V2_1 && protocol_version >= V2_0 { + let Some((baseline_stakes, baseline_delegation_state)) = + delegated_projection.as_ref() + else { + log::error!("Mining data request: missing delegated collateral baseline"); + continue; + }; + let mut candidate_stakes = baseline_stakes + .try_clone_checked() + .map_err(ChainManagerError::Staking)?; + let mut candidate_delegation_state = baseline_delegation_state.clone(); + let reservation_key = CollateralReservationKey { + data_request: dr_pointer, + validator: own_pkh, + }; + let result = if protocol_version >= V2_1 { + candidate_delegation_state.reserve_collateral( + &mut candidate_stakes, + reservation_key, + collateral_amount, + current_epoch, + minimum_stake, + ) + } else { + // A Wit/2.0 request crossing activation retains its owner-only legacy + // collateral semantics, including a serialized zero no-op. + candidate_delegation_state.reserve_legacy_collateral( + &mut candidate_stakes, + reservation_key, + collateral_amount, + current_epoch, + minimum_stake, + ) + }; + if let Err(error) = result { + log::debug!( + "Mining data request: delegated collateral cannot be reserved: {error}" + ); + continue; + } + } else if protocol_version >= V2_0 { // In 2_x protocol, check if we have enough stake before attempting retrieval - if collateral_amount > available_stake - minimum_stake { + let available_stake_now = Wit::from_nanowits( + available_stake.load(atomic::Ordering::Acquire), + ); + if collateral_amount.nanowits() + > available_stake_now + .nanowits() + .saturating_sub(minimum_stake.nanowits()) + { log::debug!( - "Mining data request: Insufficient stake, the data request needs {collateral_amount} Wit but we have {available_stake} left" + "Mining data request: Insufficient stake, the data request needs {collateral_amount} Wit but we have {available_stake_now} left" ); continue; } @@ -487,8 +872,21 @@ impl ChainManager { // In V2_X, the message includes the withdrawer address in order to eventually // distinguish eligibility for stake entries belonging to multiple withdrawers but // operated by a single validator acting as a delegatee. - let vrf_message = if protocol_version >= V2_0 { - VrfMessage::data_request_v2(dr_vrf_input, dr_pointer, first_withdrawer_address) + let vrf_message = if protocol_version >= V2_1 { + let owner = delegated_state + .owner_withdrawer_or_legacy(delegated_stakes, own_pkh) + .map_err(ChainManagerError::Staking)?; + VrfMessage::data_request_v2(dr_vrf_input, dr_pointer, Some(owner)) + } else if protocol_version >= V2_0 { + let withdrawer = if current_protocol >= V2_1 { + let owner = delegated_state + .owner_withdrawer_or_legacy(delegated_stakes, own_pkh) + .map_err(ChainManagerError::Staking)?; + Some(owner) + } else { + first_withdrawer_address + }; + VrfMessage::data_request_v2(dr_vrf_input, dr_pointer, withdrawer) } else { VrfMessage::data_request_v1(dr_vrf_input, dr_pointer) }; @@ -597,20 +995,53 @@ impl ChainManager { // V2_X: // Check that we have enough stake. .and_then(move |vrf_proof, act, _| { - if protocol_version >= V2_0 { - let after_balance = available_stake - collateral_amount; - if after_balance > minimum_stake { - // `available_stake` is mutated here so the next cycle of this iterator - // takes into account can acknowledge depletion of the available stake. - available_stake = after_balance; - actix::fut::ok((vrf_proof, Default::default())) - } else { - log::warn!("Not enough available stake for witnessing data request {}: Available balance: {}, Required stake: {}", - Yellow.bold().paint(dr_pointer.to_string()), - available_stake, - collateral_amount, - ); - actix::fut::err(()) + if current_protocol >= V2_1 && protocol_version >= V2_0 { + // A request-version-specific reservation was checked against a private + // baseline clone before retrieval. Its clone is intentionally discarded + // here; block construction and validation perform the cumulative check + // over the commitments that actually complete asynchronously. + actix::fut::ok((vrf_proof, Default::default())) + } else if protocol_version >= V2_0 { + // Consensus permits a commitment whose collateral debit leaves the + // validator at exactly the minimum stake. Claim that capacity with a + // compare/exchange loop so concurrent retrieval jobs cannot all observe + // and spend the same legacy Wit/2.0 collateral budget. + let mut available_nanowits = + available_stake_for_request.load(atomic::Ordering::Acquire); + loop { + let available = Wit::from_nanowits(available_nanowits); + let Some(after_nanowits) = + available.nanowits().checked_sub(collateral_amount.nanowits()) + else { + log::warn!( + "Not enough available stake for witnessing data request {}: Available balance: {}, Required stake: {}", + Yellow.bold().paint(dr_pointer.to_string()), + available, + collateral_amount, + ); + break actix::fut::err(()); + }; + let after_balance = Wit::from_nanowits(after_nanowits); + if after_balance < minimum_stake { + log::warn!( + "Not enough available stake for witnessing data request {}: Available balance: {}, Required stake: {}", + Yellow.bold().paint(dr_pointer.to_string()), + available, + collateral_amount, + ); + break actix::fut::err(()); + } + match available_stake_for_request.compare_exchange( + available_nanowits, + after_nanowits, + atomic::Ordering::AcqRel, + atomic::Ordering::Acquire, + ) { + Ok(_) => break actix::fut::ok((vrf_proof, Default::default())), + Err(next_available) => { + available_nanowits = next_available; + } + } } } else { match build_commit_collateral( @@ -705,7 +1136,24 @@ impl ChainManager { Err(e) => { if active_wips.wip0026() { log::warn!("Couldn't encode reveal value to bytes, will commit RadError::EncodeReveal instead: {e}"); - let reveal_bytes: Vec = RadonTypes::RadonError(RadonError::try_from(RadError::EncodeReveal).unwrap()).encode().unwrap(); + let reveal_error = match RadonError::try_from(RadError::EncodeReveal) { + Ok(reveal_error) => reveal_error, + Err(error) => { + log::error!( + "Couldn't encode reveal fallback error for data request {dr_pointer}: {error}" + ); + return actix::fut::err(()); + } + }; + let reveal_bytes = match RadonTypes::RadonError(reveal_error).encode() { + Ok(reveal_bytes) => reveal_bytes, + Err(error) => { + log::error!( + "Couldn't encode reveal fallback value for data request {dr_pointer}: {error}" + ); + return actix::fut::err(()); + } + }; actix::fut::ok((reveal_bytes, vrf_proof_dr, collateral)) } else { log::error!("Couldn't encode reveal value to bytes: {e}"); @@ -731,7 +1179,15 @@ impl ChainManager { // Commitment is the hash of the RevealTransaction signature // that will be published later - let commitment = reveal_signatures[0].signature.hash(); + let commitment = match reveal_signatures.first() { + Some(signature) => signature.signature.hash(), + None => { + log::error!( + "Signature manager returned no reveal signature for data request {dr_pointer}" + ); + return Err(()); + } + }; let (inputs, outputs) = collateral; let commit_body = CommitTransactionBody::new(dr_pointer, commitment, vrf_proof_dr, inputs, outputs, bn256_public_key); @@ -751,6 +1207,18 @@ impl ChainManager { .into_actor(act) }) .and_then(move |(commit_transaction, reveal_transaction), act, ctx| { + // The VRF/RAD/signing pipeline above yields to several actors. If the + // process-wide protocol schedule was replaced while it was in flight, the + // request-era protocol classification and containing-epoch context captured + // by this attempt are stale. Do not enqueue a commit/reveal pair that was + // assembled against that superseded view; the next epoch turn can retry it + // under one coherent schedule generation. + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding mined data-request candidate from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } ctx.notify(AddCommitReveal { commit_transaction, reveal_transaction, @@ -769,42 +1237,193 @@ impl ChainManager { #[allow(clippy::needless_collect)] fn create_tally_transactions( - &mut self, - ) -> impl Future, ()>> + use<> { - let block_epoch = self.current_epoch.unwrap(); + &self, + ) -> Pin, ()>> + 'static>> { + let block_epoch = match self.current_epoch { + Some(epoch) => epoch, + None => { + log::error!("Cannot construct tally transactions before the current epoch is known"); + return Box::pin(future::ready(Err::, ()>(()))); + } + }; let data_request_pool = &self.chain_state.data_request_pool; - let collateral_minimum = self - .chain_state - .chain_info - .as_ref() - .unwrap() - .consensus_constants - .collateral_minimum; + let collateral_minimum = match self.chain_state.chain_info.as_ref() { + Some(chain_info) => chain_info.consensus_constants.collateral_minimum, + None => { + log::error!("Cannot construct tally transactions without ChainInfo"); + return Box::pin(future::ready(Err::, ()>(()))); + } + }; let active_wips = ActiveWips { active_wips: self.chain_state.tapi_engine.wip_activation.clone(), block_epoch, }; - let validator_count = self.chain_state.stakes.validator_count(); + let protocol_info = get_protocol_info(); + let block_protocol = protocol_info.all_versions.version_for_epoch(block_epoch); + let block_is_wit2_1 = block_protocol >= V2_1; + let wit2_activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); + // Tally construction runs before the block builder persists the one-time activation + // marker. Use the canonical activation projection here as well; otherwise the first + // Wit/2.1 block could derive a validator count from a raw pre-activation cache while its + // final candidate uses rebuilt legacy policies and reservations. + let delegated_projection = if block_is_wit2_1 { + let minimum_self_stake = Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(block_protocol), + ); + // Validate the live index projection before cloning. `try_clone_checked` rebuilds + // secondary indexes and would otherwise make a malformed source ledger appear + // canonical to tally-construction policy and validator-count queries. + if let Err(error) = self + .chain_state + .stakes + .validate_validator_index_checked() + { + log::error!( + "Cannot construct Wit/2.1 tally transactions with invalid stake indexes: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))); + } + let projected_stakes = match self.chain_state.stakes.try_clone_checked() { + Ok(stakes) => stakes, + Err(error) => { + log::error!( + "Cannot construct Wit/2.1 tally transactions with invalid stake indexes: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))); + } + }; + let projected_delegation_state = + match prepare_standalone_delegation_state_for_protocol( + data_request_pool, + &projected_stakes, + &self.chain_state.delegation_state, + block_epoch, + collateral_minimum, + minimum_self_stake, + block_protocol, + &protocol_info, + ) { + Ok(Some(state)) => state, + Ok(None) => self.chain_state.delegation_state.clone(), + Err(error) => { + log::error!( + "Cannot construct Wit/2.1 tally transactions with invalid activation state: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))); + } + }; + Some((projected_stakes, projected_delegation_state, minimum_self_stake)) + } else { + None + }; + let validator_count = if let Some((projected_stakes, projected_delegation_state, minimum)) = + delegated_projection.as_ref() + { + if let Err(error) = projected_delegation_state + .validate_pending_commissions_at_epoch(block_epoch) + { + log::error!( + "Cannot construct Wit/2.1 tally transactions with stale pending commission state: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))); + } + match projected_delegation_state + .active_validator_count_checked(projected_stakes, *minimum) + { + Ok(count) => count, + Err(error) => { + log::error!( + "Cannot construct Wit/2.1 tally transactions with invalid validator policy state: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))); + } + } + } else { + self.chain_state.stakes.validator_count() + }; + let legacy_validator_count = delegated_projection.as_ref().map_or( + self.chain_state.stakes.validator_count(), + |projection| projection.0.validator_count(), + ); - let dr_reveals = data_request_pool - .get_all_reveals(&active_wips, Some(validator_count), Some(block_epoch)) - .into_iter() - .map(|(dr_pointer, reveals)| { - ( - dr_pointer, - reveals, - // "get_all_reveals" returns a HashMap with valid data request output pointer - data_request_pool.data_request_pool[&dr_pointer].clone(), - ) - }) - .collect::>(); + let mut dr_reveals = Vec::new(); + for (dr_pointer, reveals) in data_request_pool + .get_all_reveals_for_protocol_with_legacy_validator_count( + &active_wips, + Some(validator_count), + Some(legacy_validator_count), + block_protocol, + wit2_activation_epoch, + ) + { + // The reveal index is expected to contain every pointer returned by the pool query. + // Treat a broken local index as a construction failure instead of panicking while + // indexing the map; the caller must not sign a candidate built from partial state. + let Some(dr_state) = data_request_pool.data_request_pool.get(&dr_pointer).cloned() + else { + log::error!( + "Cannot construct tally for data request {dr_pointer}: data-request state is missing from the local index" + ); + return Box::pin(future::ready(Err::, ()>(()))); + }; + let request_protocol = protocol_info + .all_versions + .version_for_epoch(dr_state.epoch); + let request_validator_count = if block_is_wit2_1 + && request_protocol < V2_1 + { + legacy_validator_count + } else { + validator_count + }; + let tally_mode = match classify_too_many_witnesses_tally_for_protocols( + &dr_state, + request_validator_count, + data_request_pool.extra_rounds, + request_protocol, + block_protocol, + wit2_activation_epoch, + false, + ) { + Ok(TooManyWitnessesTallyMode::UnsupportedLegacyCrossing) => { + log::error!( + "Cannot construct a pre-Wit/2.1 successor for data request {dr_pointer} while its witness threshold remains exceeded" + ); + return Box::pin(future::ready(Err::, ()>(()))) + } + Ok(TooManyWitnessesTallyMode::InvalidEarlyTallyPopulation) => { + log::error!( + "Cannot construct data request {dr_pointer}: its early tally state is inconsistent with the block-opening validator population" + ); + return Box::pin(future::ready(Err::, ()>(()))) + } + Ok(mode) => mode, + Err(error) => { + log::error!( + "Cannot classify the tally result for data request {dr_pointer}: {error}" + ); + return Box::pin(future::ready(Err::, ()>(()))) + } + }; + dr_reveals.push(( + dr_pointer, + reveals, + dr_state, + request_protocol, + tally_mode, + )); + } let future_tally_transactions = dr_reveals .into_iter() - .map(move |(dr_pointer, reveals, dr_state)| { + .map(move |(dr_pointer, reveals, dr_state, request_protocol, tally_mode)| { let active_wips_inside_move = active_wips.clone(); async move { @@ -843,11 +1462,8 @@ impl ChainManager { let rad_manager_addr = RadManager::from_registry(); - let too_many_witnesses = data_request_has_too_many_witnesses( - &dr_state.data_request, - validator_count, - Some(dr_state.epoch), - ); + let too_many_witnesses = + tally_mode == TooManyWitnessesTallyMode::Sentinel; // The result of `RunTally` will be published as tally let tally_result = rad_manager_addr @@ -873,7 +1489,7 @@ impl ChainManager { } }); - let tally = create_tally( + let tally = match create_tally_checked( dr_pointer, &dr_state.data_request, dr_state.pkh, @@ -883,8 +1499,16 @@ impl ChainManager { collateral_minimum, tally_bytes_on_encode_error(), &active_wips_inside_move, - get_protocol_version(Some(dr_state.epoch)), - ); + request_protocol, + ) { + Ok(tally) => tally, + Err(error) => { + log::error!( + "Cannot construct tally for data request {dr_pointer}: {error}" + ); + return Err(()); + } + }; log::info!( "{} Created Tally for Data Request {} with result: {}\n{}", @@ -905,59 +1529,557 @@ impl ChainManager { Result::<_, ()>::Ok(tally) } - // This future should always return Ok because join_all short-circuits on the - // first Err, and we want to keep creating tallies after the first error - // Map Result to Result, ()> - .then(|x| future::ready(Ok(x.ok()))) + // Preserve construction errors so the outer mining future fails closed rather + // than silently omitting a required tally from the candidate block. + // Successful results remain optional to retain the existing collection shape. + .then(|x| future::ready(x.map(Some))) }); - async { + Box::pin(async { let res = try_join_all(future_tally_transactions).await; // Map Option> to Vec, this returns all the non-error results res.map(|x| x.into_iter().flatten().collect()) + }) + } +} + +/// Add a candidate block fee with the same versioned arithmetic used by validation. +fn add_candidate_fee( + total: u64, + fee: u64, + protocol_version: ProtocolVersion, +) -> DelegationResult { + if protocol_version >= V2_1 { + total + .checked_add(fee) + .ok_or(StakesError::ArithmeticOverflow) + } else { + Ok(total.wrapping_add(fee)) + } +} + +/// Check the dynamic existence of every legacy Commit collateral input in the progressive block +/// view. Fixed ownership, value, age, and signature constraints were checked at pool admission; +/// this read-only pass closes the race with an earlier transaction selected into the same block. +fn commit_inputs_are_available( + utxo_diff: &UtxoDiff<'_>, + commit_tx: &CommitTransaction, +) -> Result { + for input in &commit_tx.body.collateral { + if utxo_diff.try_get(input.output_pointer())?.is_none() { + return Ok(false); } } + + Ok(true) } -/// Build a new Block using the supplied leadership proof and by filling transactions from the -/// `transaction_pool` -/// Returns an unsigned block! -/// TODO: simplify function signature, e.g. through merging multiple related fields into new data structures. -#[allow(clippy::too_many_arguments)] -pub fn build_block( - pools_ref: ( - &mut TransactionsPool, - &UnspentOutputsPool, - &mut DataRequestPool, - ), - max_vt_weight: u32, - max_dr_weight: u32, - beacon: CheckpointBeacon, - proof: BlockEligibilityClaim, - tally_transactions: &[TallyTransaction], - own_pkh: PublicKeyHash, - epoch_constants: EpochConstants, - block_number: u32, - collateral_minimum: u64, - bn256_public_key: Option, - external_address: Option, - external_percentage: u8, - initial_block_reward: u64, - checkpoint_zero_timestamp: i64, - halving_period: u32, - tapi_signals: u32, - active_wips: &ActiveWips, - validator_count: Option, - stakes: &StakesTracker, - consensus_constants_wit2: &ConsensusConstantsWit2, -) -> (BlockHeader, BlockTransactions) { - let validator_count = validator_count.unwrap_or(DEFAULT_VALIDATOR_COUNT_FOR_TESTS); - let (transactions_pool, unspent_outputs_pool, dr_pool) = pools_ref; - let epoch = beacon.checkpoint; - let mut utxo_diff = UtxoDiff::new(unspent_outputs_pool, block_number); +/// Maximum proposal work spent beyond the original linear Commit scan across one whole block. +/// +/// The transaction pool already bounds and hash-sorts every per-request candidate slice. Keep the +/// first depth-first path identical to the historical greedy scan, then allow a fixed amount of +/// deterministic backtracking so one accepted dead-end cannot unnecessarily suppress a feasible +/// group. Candidate scans, checked base-projection clones, and every replayed/projected candidate +/// consume this one block-wide allowance. The limit is local proposal policy only: it cannot make +/// an invalid block valid and it does not change consensus validation. +const COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK: usize = 256; + +#[derive(Debug, Eq, PartialEq)] +enum CommitGroupSearch { + Complete { + commits: Vec, + spent_inputs: HashSet, + projection: Projection, + }, + Incomplete { + search_limit_reached: bool, + }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum DelegatedActionKind { + Stake, + Unstake, +} + +#[derive(Debug)] +enum DelegatedActionReplayFailure { + Fatal(Error), + Candidate { + kind: DelegatedActionKind, + index: usize, + error: Error, + }, +} + +fn last_delegated_action( + stake_count: usize, + unstake_count: usize, +) -> Option<(DelegatedActionKind, usize)> { + unstake_count + .checked_sub(1) + .map(|index| (DelegatedActionKind::Unstake, index)) + .or_else(|| { + stake_count + .checked_sub(1) + .map(|index| (DelegatedActionKind::Stake, index)) + }) +} + +/// Replay one ordered delegated-action subset until it is internally consistent. +/// +/// The callback must always rebuild its projection from the immutable pre-action state. A +/// candidate-relative failure removes only that exact action and restarts the complete +/// Stake-then-Unstake replay; a failure that is not attributable to an action aborts the build. +/// Every retry strictly shortens one vector, so this loop is bounded by the number of initially +/// selected actions plus the final successful attempt. The transaction pool is deliberately not +/// passed to this helper and cannot be mutated by pruning. +fn prune_delegated_actions_until_replay( + stake_txns: &mut Vec, + unstake_txns: &mut Vec, + mut replay: impl FnMut( + &[Stake], + &[Unstake], + ) -> Result>, + mut on_pruned: impl FnMut(DelegatedActionKind, usize, &Error), +) -> Result { + loop { + match replay(stake_txns, unstake_txns) { + Ok(projection) => return Ok(projection), + Err(DelegatedActionReplayFailure::Fatal(error)) => return Err(error), + Err(DelegatedActionReplayFailure::Candidate { kind, index, error }) => { + let valid_index = match kind { + DelegatedActionKind::Stake => index < stake_txns.len(), + DelegatedActionKind::Unstake => index < unstake_txns.len(), + }; + if !valid_index { + // A replay callback that points outside its own candidate slice violates the + // pruning contract. Return its represented error instead of looping or + // panicking on an invalid removal. + return Err(error); + } + on_pruned(kind, index, &error); + match kind { + DelegatedActionKind::Stake => { + stake_txns.remove(index); + } + DelegatedActionKind::Unstake => { + unstake_txns.remove(index); + } + } + } + } + } +} + +/// Select the first complete Commit group in deterministic candidate order. +/// +/// `candidates` must use the hash order returned by `TransactionsPool::select_commit_candidates`. +/// `project_candidate` consumes and returns one branch projection and must leave it unchanged when +/// rejecting a candidate. `new_projection` recreates the immutable group-entry view for a bounded +/// replay, so every abandoned branch is failure-atomic without retaining one full stake-ledger clone +/// per depth. The first path is exactly the former greedy choice sequence. If it dead-ends, later +/// branches replace earlier accepted candidates until a complete group is found or the shared +/// proposal-work allowance is exhausted. +fn select_complete_commit_group( + candidates: &[&CommitTransaction], + required_commits: usize, + previously_spent_inputs: &HashSet, + projection: Projection, + backtrack_work_remaining: &mut usize, + mut new_projection: impl FnMut() -> Result, + mut project_candidate: ProjectCandidate, +) -> Result, Error> +where + ProjectCandidate: + FnMut(Projection, &CommitTransaction) -> Result<(Projection, bool), Error>, +{ + if required_commits == 0 { + return Ok(CommitGroupSearch::Complete { + commits: Vec::new(), + spent_inputs: HashSet::new(), + projection, + }); + } + if candidates.len() < required_commits { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + } + + // Preserve the historical greedy result, projection count, and memory cost before spending + // any additional proposal work. `new_projection` is not invoked unless this path dead-ends. + let mut greedy_projection = projection; + let mut selected_commits = Vec::with_capacity(required_commits); + let mut selected_inputs = Vec::>::with_capacity(required_commits); + let mut group_spent_inputs = HashSet::::new(); + for commit_tx in candidates { + let commit_tx = *commit_tx; + let mut transaction_inputs = HashSet::::new(); + let inputs_are_available = commit_tx.body.collateral.iter().all(|input| { + !previously_spent_inputs.contains(input) + && !group_spent_inputs.contains(input) + && transaction_inputs.insert(*input) + }); + if !inputs_are_available { + continue; + } + + let (next_projection, accepted) = project_candidate(greedy_projection, commit_tx)?; + greedy_projection = next_projection; + if !accepted { + continue; + } + + let transaction_inputs = transaction_inputs.into_iter().collect::>(); + group_spent_inputs.extend(transaction_inputs.iter().copied()); + selected_inputs.push(transaction_inputs); + selected_commits.push(commit_tx.clone()); + if selected_commits.len() == required_commits { + return Ok(CommitGroupSearch::Complete { + commits: selected_commits, + spent_inputs: group_spent_inputs, + projection: greedy_projection, + }); + } + } + + // The greedy projection cannot be reused after replacing one of its accepted candidates. + // Retain only candidate indexes/input ownership and replay each bounded alternative from one + // checked clone of the immutable base. This holds O(1) full ledger projections instead of one + // full clone per search depth. + drop(greedy_projection); + selected_commits.clear(); + selected_inputs.clear(); + group_spent_inputs.clear(); + let mut selected_indexes = Vec::::with_capacity(required_commits); + let mut next_candidate_indexes = Vec::with_capacity(required_commits.saturating_add(1)); + next_candidate_indexes.push(0usize); + + let consume_work = |remaining: &mut usize| { + if *remaining == 0 { + false + } else { + *remaining -= 1; + true + } + }; + + loop { + let depth = selected_indexes.len(); + let Some(next_index) = next_candidate_indexes.get(depth).copied() else { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + }; + + let still_required = required_commits.saturating_sub(depth); + if candidates.len().saturating_sub(next_index) < still_required { + if depth == 0 { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + } + + // This branch cannot supply enough remaining candidates. Restore its exact input set; + // the parent cursor already points past the abandoned selection. + next_candidate_indexes.pop(); + selected_indexes.pop(); + selected_commits.pop(); + let Some(inputs) = selected_inputs.pop() else { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + }; + for input in inputs { + group_spent_inputs.remove(&input); + } + continue; + } + + if !consume_work(backtrack_work_remaining) { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: true, + }); + } + let Some(cursor) = next_candidate_indexes.get_mut(depth) else { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + }; + *cursor = next_index.saturating_add(1); + + let commit_tx = candidates[next_index]; + let mut transaction_inputs = HashSet::::new(); + let inputs_are_available = commit_tx.body.collateral.iter().all(|input| { + !previously_spent_inputs.contains(input) + && !group_spent_inputs.contains(input) + && transaction_inputs.insert(*input) + }); + if !inputs_are_available { + continue; + } + + if !consume_work(backtrack_work_remaining) { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: true, + }); + } + let mut branch_projection = new_projection()?; + for selected_index in &selected_indexes { + if !consume_work(backtrack_work_remaining) { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: true, + }); + } + let (next_projection, accepted) = + project_candidate(branch_projection, candidates[*selected_index])?; + branch_projection = next_projection; + if !accepted { + // A selected prefix must replay identically from the immutable base. Fail closed + // if a projector violates that deterministic contract. + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: false, + }); + } + } + + if !consume_work(backtrack_work_remaining) { + return Ok(CommitGroupSearch::Incomplete { + search_limit_reached: true, + }); + } + let (next_projection, accepted) = project_candidate(branch_projection, commit_tx)?; + if !accepted { + continue; + } + + let transaction_inputs = transaction_inputs.into_iter().collect::>(); + group_spent_inputs.extend(transaction_inputs.iter().copied()); + selected_inputs.push(transaction_inputs); + selected_commits.push(commit_tx.clone()); + selected_indexes.push(next_index); + if selected_commits.len() == required_commits { + return Ok(CommitGroupSearch::Complete { + commits: selected_commits, + spent_inputs: group_spent_inputs, + projection: next_projection, + }); + } + next_candidate_indexes.push(next_index.saturating_add(1)); + } +} + +/// Build a new Block using the supplied leadership proof and by filling transactions from the +/// `transaction_pool` +/// Returns an unsigned block, or an error if the delegated-staking state required to construct a +/// valid Wit/2.1 candidate cannot be prepared. +/// +/// `vrf_input` and `max_rounds` must be the same containing-epoch beacon and consensus bound that +/// subsequent block validation will use. Native and cross-fork Wit/2 Commit groups are filtered +/// against that exact context before they are returned in the unsigned candidate. +/// TODO: simplify function signature, e.g. through merging multiple related fields into new data structures. +#[allow(clippy::too_many_arguments)] +pub fn build_block( + pools_ref: ( + &mut TransactionsPool, + &UnspentOutputsPool, + &mut DataRequestPool, + ), + max_vt_weight: u32, + max_dr_weight: u32, + beacon: CheckpointBeacon, + proof: BlockEligibilityClaim, + vrf_input: CheckpointVRF, + tally_transactions: &[TallyTransaction], + own_pkh: PublicKeyHash, + epoch_constants: EpochConstants, + block_number: u32, + collateral_minimum: u64, + bn256_public_key: Option, + external_address: Option, + external_percentage: u8, + initial_block_reward: u64, + checkpoint_zero_timestamp: i64, + halving_period: u32, + tapi_signals: u32, + active_wips: &ActiveWips, + validator_count: Option, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + consensus_constants_wit2: &ConsensusConstantsWit2, + max_rounds: u16, +) -> DelegationResult<(BlockHeader, BlockTransactions)> { + // Preserve the historical public helper for callers that do not already own an operation + // snapshot. Consensus-sensitive actor paths should call `build_block_with_protocol_info` + // instead so a schedule publication cannot split one candidate across two protocol views. + let protocol_info = get_protocol_info(); + build_block_with_protocol_info( + pools_ref, + max_vt_weight, + max_dr_weight, + beacon, + proof, + vrf_input, + tally_transactions, + own_pkh, + epoch_constants, + block_number, + collateral_minimum, + bn256_public_key, + external_address, + external_percentage, + initial_block_reward, + checkpoint_zero_timestamp, + halving_period, + tapi_signals, + active_wips, + validator_count, + stakes, + delegation_state, + network_magic, + consensus_constants_wit2, + max_rounds, + &protocol_info, + ) +} + +/// Build a block using a caller-owned, immutable protocol schedule snapshot. +/// +/// This explicit variant is used by actor operations that may yield before candidate construction. +/// The caller must validate that the snapshot generation is still current before publishing the +/// resulting candidate. Keeping the schedule as an argument prevents a concurrent global +/// publication between that check and this synchronous projection from changing request-era +/// protocol classification or transaction identities. +#[allow(clippy::too_many_arguments)] +pub fn build_block_with_protocol_info( + pools_ref: ( + &mut TransactionsPool, + &UnspentOutputsPool, + &mut DataRequestPool, + ), + max_vt_weight: u32, + max_dr_weight: u32, + beacon: CheckpointBeacon, + proof: BlockEligibilityClaim, + vrf_input: CheckpointVRF, + tally_transactions: &[TallyTransaction], + own_pkh: PublicKeyHash, + epoch_constants: EpochConstants, + block_number: u32, + collateral_minimum: u64, + bn256_public_key: Option, + external_address: Option, + external_percentage: u8, + initial_block_reward: u64, + checkpoint_zero_timestamp: i64, + halving_period: u32, + tapi_signals: u32, + active_wips: &ActiveWips, + validator_count: Option, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + consensus_constants_wit2: &ConsensusConstantsWit2, + max_rounds: u16, + protocol_info: &ProtocolInfo, +) -> DelegationResult<(BlockHeader, BlockTransactions)> { + let supplied_validator_count = validator_count.unwrap_or(DEFAULT_VALIDATOR_COUNT_FOR_TESTS); + let (transactions_pool, unspent_outputs_pool, live_dr_pool) = pools_ref; + let epoch = beacon.checkpoint; + // Snapshot the schedule once for the complete candidate build. Every request-specific + // transition below must use the same historical protocol view as the containing block. + let protocol_version = protocol_info.all_versions.version_for_epoch(epoch); + // Legacy Commit selection and pre-Wit/2.1 invalid-action cleanup mutate this supplied pool. + // The mining actor therefore passes a private clone and publishes it only after signing and + // full candidate validation. Keeping the publication gate outside this synchronous builder is + // essential: returning a structurally complete block is not yet proof that either later step + // will succeed. Direct callers that need retry-safe authoritative state must use the same + // staging contract. + // Candidate construction is fallible in every protocol version. In particular, immediate + // too-many-witness tallies temporarily insert their request into the oracle pool. Keep those + // preparations on a clone so selection, projection, signing, or validation failure cannot + // mutate live chain state before the candidate is consolidated. + let mut candidate_dr_pool = live_dr_pool.clone(); + let dr_pool = &mut candidate_dr_pool; + let mut utxo_diff = UtxoDiff::new(unspent_outputs_pool, block_number); + let minimum_stake = Wit::from_nanowits( + consensus_constants_wit2.get_validator_min_stake_nanowits_for_protocol(protocol_version), + ); + // Prove the authoritative live indexes before cloning rebuilds secondary projections from the + // primary stake map. Candidate selection and its final replay then reuse the opaque invariant + // proof and the separate pre-action active set under their distinct mutation contracts. + let (delegation_prevalidation, frozen_active_validators) = if protocol_version >= V2_1 { + let (prevalidation, active_validators) = + prevalidate_delegation_snapshot(stakes, delegation_state, minimum_stake)?; + (Some(prevalidation), Some(active_validators)) + } else { + (None, None) + }; + // Keep a block-local staking view in lockstep with the transactions selected below. A + // candidate containing several stake/unstake/commit/tally transitions must be checked against + // the state produced by its earlier sections, just like block validation and consolidation. + let mut projected_stakes = if protocol_version >= V2_1 { + stakes.try_clone_checked()? + } else { + // Preserve the historical clone boundary for pre-delegation candidate construction. + stakes.clone() + }; + let mut projected_delegation_state = delegation_state.clone(); + if protocol_version >= V2_1 { + let activation_transition = !projected_delegation_state.activation_initialized; + if activation_transition { + projected_delegation_state.initialize_legacy_validators_for_activation( + &projected_stakes, + minimum_stake, + )?; + } else { + projected_delegation_state.initialize_legacy_validators(&projected_stakes)?; + } + projected_delegation_state.activate_pending_commissions(epoch)?; + // Full validation and consolidation perform this same strict reconstruction. If it cannot + // be completed, no subset of transactions can make a Wit/2.1 candidate valid, so abort + // before transaction selection mutates either pool. + track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool, + &projected_stakes, + &mut projected_delegation_state, + collateral_minimum, + false, + activation_transition, + &protocol_info, + )?; + if activation_transition { + projected_delegation_state.validate_stake_and_reservation_accounting( + &projected_stakes, + minimum_stake, + )?; + } + } + let mut legacy_reward_locks = if protocol_version >= V2_1 { + LegacyRewardLocks::from_pool(live_dr_pool, &projected_delegation_state, protocol_info)? + } else { + LegacyRewardLocks::for_legacy_protocol(protocol_version)? + }; + // Native requests use the source-frozen population that canonical activation projection + // preserves. The supplied count is retained only for legacy protocols; Wit/2.1 fixtures must + // provide their stake ledger. + let validator_count = if protocol_version >= V2_1 { + frozen_active_validators + .as_ref() + .ok_or(StakesError::InvalidDelegationState { + reason: "candidate construction lost its prevalidated active-validator snapshot", + })? + .len() + } else { + supplied_validator_count + }; // Get all the unspent transactions and calculate the sum of their fees let mut transaction_fees: u64 = 0; + let mut wit2_transaction_fees: u64 = 0; + let mut delegated_action_fees: u64 = 0; let mut vt_weight: u32 = 0; let mut dr_weight: u32 = 0; let mut st_weight: u32 = 0; @@ -965,6 +2087,10 @@ pub fn build_block( let mut value_transfer_txns = Vec::new(); let mut data_request_txns = Vec::new(); let mut tally_txns = Vec::new(); + // A newly selected request that immediately resolves with TooManyWitnesses is represented by + // a synthetic tally in the candidate-local pool. Keep its transaction weight in the data- + // request budget. The unresolved-output scan below runs before new requests are inserted, so + // this synthetic entry is not counted as an additional output weight. let mut stake_txns = Vec::new(); let mut unstake_txns = Vec::new(); @@ -973,8 +2099,24 @@ pub fn build_block( let min_vt_weight = VTTransactionBody::new(vec![Input::default()], vec![ValueTransferOutput::default()]) .weight(); - let min_st_weight = - StakeTransactionBody::new(vec![Input::default()], Default::default(), None).weight(); + // Every valid Wit/2.1 stake action carries authorization terms, including a zero-value + // commission update. Build the sentinel with a placeholder terms object so the early-stop + // optimization uses the true minimum valid post-activation weight; pre-activation stake + // transactions retain the legacy, terms-free minimum. + let min_stake_output = if protocol_version >= ProtocolVersion::V2_1 { + StakeOutput { + authorization_terms: Some(Default::default()), + ..Default::default() + } + } else { + StakeOutput::default() + }; + let min_st_weight = StakeTransactionBody::new( + vec![Input::default()], + min_stake_output, + None, + ) + .weight_for_protocol(protocol_version); let min_ut_weight = UnstakeTransactionBody::new(PublicKeyHash::default(), Default::default(), 0, 0).weight(); @@ -988,19 +2130,57 @@ pub fn build_block( } }; + let new_wit2_transaction_fees = if protocol_version >= V2_1 { + match wit2_transaction_fees.checked_add(transaction_fee) { + Some(fees) => fees, + None => { + log::warn!( + "Cannot account value-transfer fee for transaction {}; skipping it", + vt_tx.hash() + ); + continue; + } + } + } else { + wit2_transaction_fees + }; + let new_transaction_fees = match add_candidate_fee( + transaction_fees, + transaction_fee, + protocol_version, + ) { + Ok(fees) => fees, + Err(error) => { + log::warn!( + "Cannot account value-transfer fee for transaction {}; skipping it: {}", + vt_tx.hash(), + error + ); + continue; + } + }; let new_vt_weight = vt_weight.saturating_add(transaction_weight); if new_vt_weight <= max_vt_weight { - update_utxo_diff( + if let Err(error) = try_update_utxo_diff_for_protocol( &mut utxo_diff, vt_tx.body.inputs.iter(), vt_tx.body.outputs.iter(), - vt_tx.hash(), + vt_tx.versioned_hash(protocol_version), epoch, epoch_constants, checkpoint_zero_timestamp, - ); + protocol_version, + ) { + log::warn!( + "Cannot include value-transfer transaction {} because its UTXO update failed: {}", + vt_tx.hash(), + error + ); + continue; + } value_transfer_txns.push(vt_tx.clone()); - transaction_fees = transaction_fees.saturating_add(transaction_fee); + transaction_fees = new_transaction_fees; + wit2_transaction_fees = new_wit2_transaction_fees; vt_weight = new_vt_weight; } @@ -1014,52 +2194,450 @@ pub fn build_block( for ta_tx in tally_transactions { if let Some(dr_state) = dr_pool.data_request_state(&ta_tx.dr_pointer) { tally_txns.push(ta_tx.clone()); - let commits_count = dr_state.info.commits.len(); - let reveals_count = dr_state.info.reveals.len(); + } else { + log::warn!( + "Data Request pointed by tally transaction doesn't exist in DataRequestPool" + ); + } + } + + let pre_oracle_projection = if protocol_version >= V2_1 { + Some(( + projected_stakes.try_clone_checked()?, + projected_delegation_state.clone(), + utxo_diff.clone(), + transaction_fees, + )) + } else { + None + }; + + let (mut commit_txns, commits_fees, mut solved_dr_pointers) = if protocol_version >= V2_1 { + // Wit/2.1 candidate preparation remains read-only with respect to the live transaction + // pool. For each request, search its bounded hash-sorted candidate set in deterministic + // depth-first order. The first path preserves the former greedy choices; bounded + // backtracking can replace an accepted candidate when that path cannot complete the group. + let commit_candidates = transactions_pool.select_commit_candidates(dr_pool); + let candidate_capacity = commit_candidates.iter().try_fold( + 0usize, + |capacity, (_, required_commits, _)| { + capacity + .checked_add(*required_commits) + .ok_or(StakesError::ArithmeticOverflow) + }, + )?; + let mut accepted_commit_txns = Vec::with_capacity(candidate_capacity); + let mut accepted_solved_dr_pointers = Vec::new(); + let mut accepted_commits_fees = 0u64; + let mut spent_commit_inputs = HashSet::new(); + let mut commit_group_backtrack_work_remaining = + COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK; + // Full validation checks every Commit proof against the progressively debited stake view. + // Reuse its synchronous verifier here so an exact target/message mismatch rejects the + // candidate before it enters a complete request group. Context creation is fallible; if + // the local crypto backend is unavailable, omitting optional Commit groups still produces + // a valid retryable candidate. + let mut commit_vrf_context = match VrfCtx::secp256k1() { + Ok(context) => Some(context), + Err(error) => { + log::warn!( + "Cannot initialize Commit VRF verification for candidate construction: {}", + error + ); + None + } + }; - let (liars_count, errors_count) = calculate_liars_and_errors_count_from_tally(ta_tx); + for (dr_pointer, required_commits, candidates) in commit_candidates { + let Some(dr_state) = dr_pool.data_request_state(&dr_pointer) else { + log::warn!( + "Cannot project commits for missing data request {}; omitting the group", + dr_pointer + ); + continue; + }; - let collateral = if dr_state.data_request.collateral == 0 { + let dr_protocol = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + let collateral = if dr_protocol >= V2_1 && dr_state.data_request.collateral == 0 { collateral_minimum } else { dr_state.data_request.collateral }; + if dr_protocol >= V2_0 && commit_vrf_context.is_none() { + // The initialization error was logged once above. No candidate for a Wit/2 + // request can pass without this shared verifier, so avoid a pointless full-pool + // scan while retaining the request for a later retry. + continue; + } + let Some(group_fee) = dr_state + .data_request + .commit_and_reveal_fee + .checked_mul(u64::from(dr_state.data_request.witnesses)) + else { + log::warn!( + "Cannot account commit fees for data request {}; omitting the group", + dr_pointer + ); + continue; + }; + let Some(new_commits_fees) = accepted_commits_fees.checked_add(group_fee) else { + log::warn!( + "Cannot account cumulative commit fees for data request {}; omitting the group", + dr_pointer + ); + continue; + }; - // Remainder collateral goes to the miner - let (_, extra_tally_fee) = if after_second_hard_fork(epoch, get_environment()) { - calculate_witness_reward( - commits_count, - liars_count, - errors_count, - dr_state.data_request.witness_reward, - collateral, - active_wips.wip0023(), - ) - } else { - calculate_witness_reward_before_second_hard_fork( - commits_count, - reveals_count, - liars_count, - errors_count, - dr_state.data_request.witness_reward, - collateral, - ) + let mut last_candidate_error = None; + let selected_group = select_complete_commit_group( + &candidates, + required_commits, + &spent_commit_inputs, + ( + projected_stakes.try_clone_checked()?, + projected_delegation_state.clone(), + utxo_diff.clone(), + ), + &mut commit_group_backtrack_work_remaining, + || -> DelegationResult<_> { + Ok(( + projected_stakes.try_clone_checked()?, + projected_delegation_state.clone(), + utxo_diff.clone(), + )) + }, + |(mut candidate_stakes, mut candidate_delegation_state, mut candidate_utxo_diff), + commit_tx| + -> DelegationResult<_> { + // Eligibility, proof, and input checks are read-only on the owned branch and + // run before its first collateral/UTXO mutation. Rejected branches return the + // unchanged projection to the greedy scan; backtracking discards it and + // reconstructs the selected prefix from the immutable checked-clone base. + let mut target_context = None; + if dr_protocol >= V2_0 { + let validator = commit_tx.body.proof.proof.pkh(); + let (target_hash, withdrawer) = + match resolve_commit_stake_eligibility( + validator, + protocol_version, + dr_protocol, + epoch, + dr_state.data_request.witnesses, + dr_state.info.current_commit_round, + max_rounds, + &candidate_stakes, + &candidate_delegation_state, + minimum_stake.nanowits(), + frozen_active_validators.as_ref(), + ) { + Ok(context) => context, + Err(error) => { + last_candidate_error = Some(format!( + "validator {} is not eligible in the progressive candidate view: {}", + validator, error + )); + return Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + false, + )); + } + }; + let Some(vrf_context) = commit_vrf_context.as_mut() else { + last_candidate_error = Some( + "the Commit VRF verification context is unavailable".to_string(), + ); + return Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + false, + )); + }; + if let Err(error) = verify_dr_vrf_signature( + &commit_tx.body.proof, + vrf_context, + vrf_input, + dr_pointer, + target_hash, + withdrawer, + dr_protocol, + ) { + last_candidate_error = Some(format!( + "validator {} has an invalid or above-target Commit proof: {}", + validator, error + )); + return Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + false, + )); + } + target_context = Some(validator); + } + + match commit_inputs_are_available(&candidate_utxo_diff, commit_tx) { + Ok(true) => {} + Ok(false) => { + last_candidate_error = Some(format!( + "Commit {} spends collateral that is no longer available in the progressive candidate view", + commit_tx.hash() + )); + return Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + false, + )); + } + Err(error) => { + log::error!( + "Cannot read progressive collateral inputs for commit transaction {}: {}", + commit_tx.hash(), + error + ); + return Err(StakesError::ArithmeticOverflow); + } + } + + if let Some(validator) = target_context { + let reservation_key = CollateralReservationKey { + data_request: dr_pointer, + validator, + }; + // Both reservation methods stage their stake debit and publish delegation + // metadata last. Rely on that explicit failure-atomic contract rather than + // cloning the complete DelegationState for every scanned candidate. + let result = if dr_protocol >= V2_1 { + candidate_delegation_state.reserve_collateral( + &mut candidate_stakes, + reservation_key, + Wit::from_nanowits(collateral), + epoch, + minimum_stake, + ) + } else { + candidate_delegation_state.reserve_legacy_collateral( + &mut candidate_stakes, + reservation_key, + Wit::from_nanowits(collateral), + epoch, + minimum_stake, + ) + }; + if let Err(error) = result { + last_candidate_error = Some(error.to_string()); + return Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + false, + )); + } + } + + // Apply the UTXO transition only after every candidate-level rejection that + // can be retried. `try_update_utxo_diff` stages its own clone and publishes + // only on success, so it needs no outer copy. A UTXO storage/representation + // failure retains the historical fail-fast behavior: abort the private group + // projection (and therefore the candidate build) instead of returning a + // partially reserved state for another fallback attempt. + try_update_utxo_diff_for_protocol( + &mut candidate_utxo_diff, + commit_tx.body.collateral.iter(), + commit_tx.body.outputs.iter(), + commit_tx.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for commit transaction {}: {}", + commit_tx.hash(), + error + ); + StakesError::ArithmeticOverflow + })?; + + Ok(( + ( + candidate_stakes, + candidate_delegation_state, + candidate_utxo_diff, + ), + true, + )) + }, + )?; + + let (group, group_spent_inputs, group_projection) = match selected_group { + CommitGroupSearch::Complete { + commits, + spent_inputs, + projection, + } => (commits, spent_inputs, projection), + CommitGroupSearch::Incomplete { + search_limit_reached, + } => { + let error = last_candidate_error.unwrap_or_else(|| { + "the deterministic candidate set was exhausted by input conflicts" + .to_string() + }); + let limit = if search_limit_reached { + " after reaching the bounded backtracking limit" + } else { + "" + }; + log::warn!( + "Cannot include commits for data request {} because no complete progressive candidate group was found{}: {}; omitting the group", + dr_pointer, + limit, + error + ); + continue; + } }; - transaction_fees += extra_tally_fee; - } else { + + let (candidate_stakes, candidate_delegation_state, candidate_utxo_diff) = + group_projection; + projected_stakes = candidate_stakes; + projected_delegation_state = candidate_delegation_state; + utxo_diff = candidate_utxo_diff; + spent_commit_inputs.extend(group_spent_inputs); + accepted_commit_txns.extend(group); + accepted_solved_dr_pointers.push(dr_pointer); + accepted_commits_fees = new_commits_fees; + } + + ( + accepted_commit_txns, + accepted_commits_fees, + accepted_solved_dr_pointers, + ) + } else { + transactions_pool.remove_commits(dr_pool) + }; + transaction_fees = add_candidate_fee(transaction_fees, commits_fees, protocol_version)?; + if protocol_version < V2_1 { + for commit_tx in &commit_txns { + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + commit_tx.body.collateral.iter(), + commit_tx.body.outputs.iter(), + commit_tx.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for commit transaction {}: {}", + commit_tx.hash(), + error + ); + StakesError::ArithmeticOverflow + })?; + } + } + + let (all_reveal_txns, all_reveals_fees) = transactions_pool + .get_reveals_checked(dr_pool) + .map_err(|error| { + log::error!( + "Cannot construct candidate because the transaction-pool reveal index is inconsistent: {error}" + ); + StakesError::InvalidDelegationState { + reason: "transaction-pool reveal index is inconsistent", + } + })?; + // Reveals belong to requests that reached REVEAL in an earlier block; they do not depend on + // the commit groups selected above for this block. Keep every independently valid reveal. + let mut reveal_txns = all_reveal_txns.into_iter().cloned().collect::>(); + let reveals_fees = if protocol_version >= V2_1 { + reveal_txns.iter().try_fold(0u64, |fees, reveal_tx| { + let fee = dr_pool + .get_dr_output(&reveal_tx.body.dr_pointer) + .map(|dr_output| dr_output.commit_and_reveal_fee) + .ok_or(StakesError::ArithmeticOverflow)?; + fees + .checked_add(fee) + .ok_or(StakesError::ArithmeticOverflow) + })? + } else { + all_reveals_fees + }; + transaction_fees = add_candidate_fee(transaction_fees, reveals_fees, protocol_version)?; + if protocol_version >= V2_1 { + let mut candidate_stakes = projected_stakes.try_clone_checked()?; + let mut candidate_delegation_state = projected_delegation_state.clone(); + // The shared inclusion projector is also used by accepted-block replay, where the + // surrounding transition has already checked request chronology. Candidate construction + // calls it as an optional projection, so establish the same invariant before allowing a + // malformed or future pinned schedule to select native fee economics. Missing request + // state is left to the projector's existing error path, which omits optional oracle + // groups as before. + let chronology_error = commit_txns + .iter() + .map(|transaction| transaction.body.dr_pointer) + .chain(reveal_txns.iter().map(|transaction| transaction.body.dr_pointer)) + .find_map(|dr_pointer| { + let dr_state = dr_pool.data_request_state(&dr_pointer)?; + let request_protocol = + protocol_info.all_versions.version_for_epoch(dr_state.epoch); + validate_request_protocol_for_block(protocol_version, request_protocol).err() + }); + let inclusion_projection: Result<(), anyhow::Error> = chronology_error + .map(Err) + .unwrap_or_else(|| { + project_wit2_inclusion_rewards_for_protocol( + &mut candidate_stakes, + &mut candidate_delegation_state, + own_pkh, + epoch, + dr_pool, + &commit_txns, + &reveal_txns, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) + }); + if let Err(error) = inclusion_projection { log::warn!( - "Data Request pointed by tally transaction doesn't exist in DataRequestPool" + "Cannot project Wit/2.1 commit/reveal inclusion rewards for miner {}; omitting the optional oracle transaction groups: {}", + own_pkh, + error ); + if let Some((stakes_before, delegation_before, utxo_before, fees_before)) = + &pre_oracle_projection + { + projected_stakes = stakes_before.try_clone_checked()?; + projected_delegation_state = delegation_before.clone(); + utxo_diff = utxo_before.clone(); + transaction_fees = *fees_before; + } + commit_txns.clear(); + solved_dr_pointers.clear(); + reveal_txns.clear(); + } else { + projected_stakes = candidate_stakes; + projected_delegation_state = candidate_delegation_state; } } - let (commit_txns, commits_fees, solved_dr_pointers) = transactions_pool.remove_commits(dr_pool); - transaction_fees += commits_fees; - - let (reveal_txns, reveals_fees) = transactions_pool.get_reveals(dr_pool); - let reveal_txns: Vec = reveal_txns.into_iter().cloned().collect(); - transaction_fees += reveals_fees; - // Calculate data request not solved weight let mut dr_pointers: HashSet = dr_pool .get_dr_output_pointers_by_epoch(epoch) @@ -1068,7 +2646,6 @@ pub fn build_block( for dr in solved_dr_pointers { dr_pointers.remove(&dr); } - for dr in dr_pointers { let unsolved_dro = dr_pool.get_dr_output(&dr); if let Some(dro) = unsolved_dro { @@ -1093,34 +2670,69 @@ pub fn build_block( } }; + let too_many_witnesses = data_request_has_too_many_witnesses_for_protocol( + &dr_tx.body.dr_output, + validator_count, + protocol_version, + ); + // Validation charges the newly included transaction itself. The synthetic + // TooManyWitnesses pool entry is inserted after the unresolved-output scan above, so it + // does not add a second output weight. let new_dr_weight = dr_weight.saturating_add(transaction_weight); + let new_wit2_transaction_fees = if protocol_version >= V2_1 { + match wit2_transaction_fees.checked_add(transaction_fee) { + Some(fees) => fees, + None => { + log::warn!( + "Cannot account data-request fee for transaction {}; skipping it", + dr_tx.hash() + ); + continue; + } + } + } else { + wit2_transaction_fees + }; + let new_transaction_fees = match add_candidate_fee( + transaction_fees, + transaction_fee, + protocol_version, + ) { + Ok(fees) => fees, + Err(error) => { + log::warn!( + "Cannot account data-request fee for transaction {}; skipping it: {}", + dr_tx.hash(), + error + ); + continue; + } + }; if new_dr_weight <= max_dr_weight { - update_utxo_diff( - &mut utxo_diff, - dr_tx.body.inputs.iter(), - dr_tx.body.outputs.iter(), - dr_tx.hash(), - epoch, - epoch_constants, - checkpoint_zero_timestamp, - ); - // Number of data request witnesses is limited by a fraction of the number of validators // If the requested witnesses exceed this fraction, resolve the data request with an error - if data_request_has_too_many_witnesses( - &dr_tx.body.dr_output, - validator_count, - Some(epoch), - ) { - log::debug!("Data request {} has too many witnesses", dr_tx.hash()); + if too_many_witnesses { + log::debug!( + "Data request {} has too many witnesses", + dr_tx.versioned_hash(protocol_version) + ); // Temporarily insert the data request into the dr_pool - if let Err(e) = dr_pool.process_data_request(dr_tx, epoch, None) { + if let Err(e) = dr_pool.process_data_request_for_protocol( + dr_tx, + epoch, + None, + protocol_version, + ) { log::error!("Error adding data request to the data request pool: {e}"); continue; } - if let Some(dr_state) = dr_pool.data_request_state_mutable(&dr_tx.hash()) { - dr_state.update_stage(0, true); + if let Some(dr_state) = + dr_pool.data_request_state_mutable(&dr_tx.versioned_hash(protocol_version)) + { + dr_state + .try_update_stage(0, true) + .map_err(|_| StakesError::ArithmeticOverflow)?; } else { log::error!("Could not find data request state"); continue; @@ -1140,9 +2752,20 @@ pub fn build_block( true, // too many witnesses ); - let pkh = dr_tx.signatures[0].public_key.pkh(); - tally_txns.push(create_tally( - dr_tx.hash(), + let Some(signature) = dr_tx.signatures.first() else { + // A transaction pool entry can be malformed after restart or a custom RPC + // injection. Do not let the miner panic while constructing the synthetic + // tally; the ordinary block validator will likewise reject the body. + log::warn!( + "Cannot construct immediate TooManyWitnesses tally for data request {} without a signature", + dr_tx.versioned_hash(protocol_version) + ); + continue; + }; + let pkh = signature.public_key.pkh(); + let request_protocol = protocol_info.all_versions.version_for_epoch(epoch); + let tally = match create_tally_checked( + dr_tx.versioned_hash(protocol_version), &dr_tx.body.dr_output, pkh, &tally_result, @@ -1151,12 +2774,52 @@ pub fn build_block( collateral_minimum, tally_bytes_on_encode_error(), active_wips, - get_protocol_version(Some(epoch)), - )); + // The request may have been created under Wit/2.0 and only reach an + // immediate tally after activation. Tally construction must use the request + // protocol, just like validation and consolidation, rather than the current + // block epoch's protocol. + request_protocol, + ) { + Ok(tally) => tally, + Err(error) => { + log::error!( + "Cannot construct immediate TooManyWitnesses tally for data request {}: {error}", + dr_tx.versioned_hash(protocol_version) + ); + return Err(StakesError::InvalidDelegationState { + reason: "failed to construct immediate tally", + }); + } + }; + tally_txns.push(tally); } + // Do not update the candidate UTXO view until every fallible preparation for this + // request has succeeded. In particular, a malformed immediate-tally request that + // cannot be inserted into the candidate-local oracle pool must not leave phantom + // inputs consumed or outputs available to transactions selected later in the block. + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + dr_tx.body.inputs.iter(), + dr_tx.body.outputs.iter(), + dr_tx.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for data-request transaction {}: {}", + dr_tx.hash(), + error + ); + StakesError::ArithmeticOverflow + })?; + data_request_txns.push(dr_tx.clone()); - transaction_fees = transaction_fees.saturating_add(transaction_fee); + transaction_fees = new_transaction_fees; + wit2_transaction_fees = new_wit2_transaction_fees; dr_weight = new_dr_weight; } @@ -1167,47 +2830,311 @@ pub fn build_block( } } - let protocol_version = ProtocolVersion::from_epoch(epoch); + let mut accepted_tally_txns = Vec::with_capacity(tally_txns.len()); + for ta_tx in tally_txns { + let Some(dr_state) = dr_pool.data_request_state(&ta_tx.dr_pointer) else { + continue; + }; + let data_request_protocol = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + // Candidate construction is advisory, but its projected delegation state must never + // interpret a request under a protocol newer than the block being assembled. Canonical + // validation and accepted-block consolidation enforce the same chronology; applying it + // here avoids constructing a locally inconsistent native tally projection if a malformed + // or future protocol schedule is supplied to the miner. + if validate_request_protocol_for_block(protocol_version, data_request_protocol).is_err() { + log::warn!( + "Cannot build candidate tally for data request {} because its request protocol {} is newer than block protocol {}", + ta_tx.dr_pointer, + data_request_protocol, + protocol_version, + ); + return Err(StakesError::InvalidDelegationState { + reason: "request protocol is newer than its containing block protocol", + }); + } + + if protocol_version >= V2_1 { + // The shared tally projector stages and publishes the complete transition atomically. + // Reuse the candidate view directly instead of cloning the full ledger twice per tally. + if let Err(error) = project_tally_transition_for_protocol( + &ta_tx, + dr_pool, + &mut projected_stakes, + &mut projected_delegation_state, + epoch, + collateral_minimum, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) { + log::warn!( + "Cannot build a valid candidate because mandatory tally settlement for data request {} failed: {}", + ta_tx.dr_pointer, + error + ); + return Err( + error + .downcast::>() + .unwrap_or(StakesError::ArithmeticOverflow), + ); + } + } + + let commits_count = dr_state.info.commits.len(); + let reveals_count = dr_state.info.reveals.len(); + let (liars_count, errors_count) = calculate_liars_and_errors_count_from_tally(&ta_tx); + // Tally reward arithmetic must use the same request-protocol collateral semantics as + // commit reservation and validation. In particular, a Wit/2.0 serialized zero is a + // legacy owner-only no-op; normalizing it here would make a post-activation candidate + // charge/reward a minimum collateral that consolidation deliberately does not settle. + let collateral = collateral_for_mining( + data_request_protocol, + dr_state.data_request.collateral, + collateral_minimum, + ); + if data_request_protocol >= V2_1 { + let honests_count = commits_count + .checked_sub(ta_tx.out_of_consensus.len()) + .ok_or(StakesError::ArithmeticOverflow)?; + calculate_tally_change_checked( + commits_count, + reveals_count, + honests_count, + &dr_state.data_request, + ) + .ok_or(StakesError::ArithmeticOverflow)?; + } + let (_, extra_tally_fee) = if after_second_hard_fork(epoch, get_environment()) { + if data_request_protocol >= V2_1 { + calculate_witness_reward_checked( + commits_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + active_wips.wip0023(), + ) + .ok_or(StakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward( + commits_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + active_wips.wip0023(), + ) + } + } else { + if data_request_protocol >= V2_1 { + calculate_witness_reward_before_second_hard_fork_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + ) + .ok_or(StakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + ) + .ok_or(StakesError::ArithmeticOverflow)? + } + }; + transaction_fees = + add_candidate_fee(transaction_fees, extra_tally_fee, protocol_version)?; + try_update_utxo_diff_for_protocol( + &mut utxo_diff, + vec![], + ta_tx.outputs.iter(), + ta_tx.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for tally transaction {}: {}", + ta_tx.hash(), + error + ); + StakesError::ArithmeticOverflow + })?; + accepted_tally_txns.push(ta_tx); + } + tally_txns = accepted_tally_txns; + + let pre_action_projection = if protocol_version >= V2_1 { + let frozen_active_validators = frozen_active_validators.as_ref().ok_or( + StakesError::InvalidDelegationState { + reason: "candidate projection lost its prevalidated active-validator snapshot", + }, + )?; + let pre_action_projection = ( + projected_stakes.try_clone_checked()?, + projected_delegation_state.clone(), + utxo_diff.clone(), + transaction_fees, + wit2_transaction_fees, + delegated_action_fees, + ); + let mut reward_stakes = projected_stakes.try_clone_checked()?; + let mut reward_delegation_state = projected_delegation_state.clone(); + project_wit2_transaction_rewards( + &mut reward_stakes, + &mut reward_delegation_state, + own_pkh, + epoch, + wit2_transaction_fees, + consensus_constants_wit2 + .get_validator_block_reward_for_protocol(protocol_version), + )?; + apply_wit2_age_resets_prevalidated_for_protocol( + &mut reward_stakes, + &reward_delegation_state, + own_pkh, + epoch, + Wit::from_nanowits( + consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), + ), + protocol_version, + dr_pool, + &commit_txns, + &tally_txns, + |pointer| { + dr_pool + .data_request_state(&pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + }, + frozen_active_validators, + )?; + projected_stakes = reward_stakes; + projected_delegation_state = reward_delegation_state; + Some(pre_action_projection) + } else { + None + }; + let mut included_deposit_entries = HashSet::<(PublicKeyHash, PublicKeyHash)>::new(); if protocol_version > V1_7 { - let max_st_weight = consensus_constants_wit2.get_maximum_stake_block_weight(epoch); + let max_st_weight = consensus_constants_wit2 + .get_maximum_stake_block_weight_for_protocol(protocol_version); let mut invalid_stake_transactions = Vec::::new(); - let mut included_validators = HashSet::::new(); + let mut included_stake_entries = HashSet::<(PublicKeyHash, PublicKeyHash)>::new(); for st_tx in transactions_pool.st_iter() { + // Keep candidate diagnostics and legacy eviction keyed to the same protocol-pinned + // identity used by UTXO projection and pool admission. A terms-bearing Wit/2.1 Stake + // has a different pre-activation hash, so the process-global `hash()` helper could + // misidentify the retained body during a fork transition. + let stake_hash = st_tx.versioned_hash(protocol_version); let validator_pkh = st_tx.body.output.authorization.public_key.pkh(); - if included_validators.contains(&validator_pkh) { + let stake_identity = if protocol_version >= V2_1 { + (st_tx.body.output.key.validator, st_tx.body.output.key.withdrawer) + } else { + // Under the legacy 1:1 invariant, validator identity alone is the duplicate key. + (validator_pkh, validator_pkh) + }; + // Only deposits and owner registrations consume a live stake-key identity slot. + // Commission updates and exit intents are policy actions with independent nonce/ + // exact-key sequencing, so they may legitimately share the owner's output key with + // a deposit in the same candidate block. + let is_delegated_deposit = protocol_version >= V2_1 + && st_tx + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| { + matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE + | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + ) + }); + if (protocol_version < V2_1 || is_delegated_deposit) + && included_stake_entries.contains(&stake_identity) + { log::debug!( - "Cannot include more than one stake transaction for {validator_pkh} in a single block" + "Cannot include more than one stake transaction for {:?} in a single block", + stake_identity ); continue; } // Revalidate stake transaction since concurrent stake transactions could have invalidated the transaction // we want to include here which would result in producing an invalid block. - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(epoch); - let max_stake = consensus_constants_wit2.get_validator_max_stake_nanowits(epoch); - if let Err(e) = validate_stake_transaction( - st_tx, - &utxo_diff, - epoch, - epoch_constants, - &mut vec![], - stakes, - min_stake, - max_stake, - ) { + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version); + let max_stake = consensus_constants_wit2 + .get_validator_max_stake_nanowits_for_protocol(protocol_version); + let (validation_stakes, validation_delegation_state) = + if protocol_version >= V2_1 { + (&projected_stakes, &projected_delegation_state) + } else { + (stakes, delegation_state) + }; + let validation = if let Some(prevalidated) = delegation_prevalidation.as_ref() { + validate_stake_transaction_prevalidated_for_protocol( + st_tx, + &utxo_diff, + epoch, + epoch_constants, + &mut vec![], + validation_stakes, + validation_delegation_state, + network_magic, + min_stake, + max_stake, + protocol_version, + prevalidated, + ) + } else { + validate_stake_transaction_for_protocol( + st_tx, + &utxo_diff, + epoch, + epoch_constants, + &mut vec![], + validation_stakes, + validation_delegation_state, + network_magic, + min_stake, + max_stake, + protocol_version, + ) + }; + if let Err(e) = validation { log::warn!( "Cannot build block with stake transaction {}: {}", - st_tx.hash(), + stake_hash, e ); - invalid_stake_transactions.push(st_tx.hash()); + // A V2.1 failure here is relative to this miner's candidate projection, which + // already includes its chosen oracle actions, rewards, and earlier stake actions. + // The same transaction may remain valid at the authoritative tip and in another + // miner's candidate, so leave eviction to periodic standalone revalidation. + if protocol_version < V2_1 { + invalid_stake_transactions.push(stake_hash); + } continue; } - let transaction_weight = st_tx.weight(); + let transaction_weight = st_tx.weight_for_protocol(protocol_version); let transaction_fee = - match st_transaction_fee(st_tx, &utxo_diff, epoch, epoch_constants) { + match st_transaction_fee_for_protocol( + st_tx, + &utxo_diff, + epoch, + epoch_constants, + protocol_version, + ) { Ok(x) => x, Err(e) => { log::warn!("Error when calculating transaction fee for transaction: {e}"); @@ -1217,18 +3144,104 @@ pub fn build_block( let new_st_weight = st_weight.saturating_add(transaction_weight); if new_st_weight <= max_st_weight { - update_utxo_diff( + let new_transaction_fees = match add_candidate_fee( + transaction_fees, + transaction_fee, + protocol_version, + ) { + Ok(fees) => fees, + Err(error) => { + log::warn!( + "Cannot account stake fee for transaction {}; skipping it: {}", + stake_hash, + error + ); + continue; + } + }; + let (new_wit2_transaction_fees, new_delegated_action_fees) = + if protocol_version >= V2_1 { + let Some(new_wit2_transaction_fees) = + wit2_transaction_fees.checked_add(transaction_fee) + else { + log::warn!( + "Cannot account stake fee for transaction {}; skipping it", + stake_hash + ); + continue; + }; + let Some(new_delegated_action_fees) = + delegated_action_fees.checked_add(transaction_fee) + else { + log::warn!( + "Cannot account delegated stake fee for transaction {}; skipping it", + stake_hash + ); + continue; + }; + (new_wit2_transaction_fees, new_delegated_action_fees) + } else { + (wit2_transaction_fees, delegated_action_fees) + }; + let delegated_projection = if protocol_version >= V2_1 { + let mut candidate_stakes = projected_stakes.try_clone_checked()?; + let mut candidate_delegation_state = projected_delegation_state.clone(); + if let Err(error) = super::process_delegated_stake_transactions( + &mut candidate_stakes, + &mut candidate_delegation_state, + std::iter::once(st_tx), + epoch, + min_stake, + epoch_constants, + network_magic, + protocol_version, + ) { + log::warn!( + "Cannot include stake transaction {} because cumulative state projection failed: {}", + stake_hash, + error + ); + continue; + } + Some((candidate_stakes, candidate_delegation_state)) + } else { + None + }; + + try_update_utxo_diff_for_protocol( &mut utxo_diff, st_tx.body.inputs.iter(), st_tx.body.change.iter(), - st_tx.hash(), + st_tx.versioned_hash(protocol_version), epoch, epoch_constants, checkpoint_zero_timestamp, - ); + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for stake transaction {}: {}", + stake_hash, + error + ); + StakesError::ArithmeticOverflow + })?; stake_txns.push(st_tx.clone()); - transaction_fees = transaction_fees.saturating_add(transaction_fee); + transaction_fees = new_transaction_fees; + wit2_transaction_fees = new_wit2_transaction_fees; + delegated_action_fees = new_delegated_action_fees; st_weight = new_st_weight; + + if let Some((candidate_stakes, candidate_delegation_state)) = delegated_projection { + projected_stakes = candidate_stakes; + projected_delegation_state = candidate_delegation_state; + } + if protocol_version < V2_1 || is_delegated_deposit { + included_stake_entries.insert(stake_identity); + } + if is_delegated_deposit { + included_deposit_entries.insert(stake_identity); + } } // The condition to stop is if the free space in the block for VTTransactions @@ -1237,7 +3250,6 @@ pub fn build_block( break; } - included_validators.insert(validator_pkh); } transactions_pool.remove_invalid_stake_transactions(invalid_stake_transactions); @@ -1246,49 +3258,209 @@ pub fn build_block( } if protocol_version > V1_8 { - let mut included_validators = HashSet::::new(); + if protocol_version >= V2_1 { + legacy_reward_locks.include_accepted_commits(&commit_txns, &projected_delegation_state)?; + } + let mut included_entries = HashSet::<(PublicKeyHash, PublicKeyHash)>::new(); let mut invalid_unstake_transactions = Vec::::new(); - let max_ut_weight = consensus_constants_wit2.get_maximum_unstake_block_weight(epoch); + let max_ut_weight = consensus_constants_wit2 + .get_maximum_unstake_block_weight_for_protocol(protocol_version); for ut_tx in transactions_pool.ut_iter() { + // Keep candidate diagnostics and legacy eviction keyed to the same protocol-pinned + // identity used by transaction validation and pool revalidation. Unstake currently + // has no fork-specific body fields, but retaining this explicit binding prevents a + // future versioned field from reintroducing a process-global hash seam here. + let unstake_hash = ut_tx.versioned_hash(protocol_version); let validator_pkh = ut_tx.body.operator; - if included_validators.contains(&validator_pkh) { + let stake_key = if protocol_version >= V2_1 { + // Wit/2.1 permits independent delegated withdrawers to unstake in one block. + (validator_pkh, ut_tx.signature.public_key.pkh()) + } else { + // Preserve the historical Wit/2.0 operator-scoped duplicate rule even if a + // malformed legacy ledger contains more than one withdrawer for a validator. + (validator_pkh, validator_pkh) + }; + if protocol_version >= V2_1 && included_deposit_entries.contains(&stake_key) { log::debug!( - "Cannot include more than one unstake transaction for {validator_pkh} in a single block" + "Cannot include an unstake transaction for {:?} after crediting that delegated entry in the same candidate", + stake_key ); continue; } - - // Revalidate unstake transaction since concurrent unstake transactions could have invalidated the transaction - // we want to include here which would result in producing an invalid block. - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(epoch); - let unstake_delay = consensus_constants_wit2.get_unstaking_delay_seconds(epoch); - if let Err(e) = - validate_unstake_transaction(ut_tx, epoch, stakes, min_stake, unstake_delay) - { - log::warn!( - "Cannot build block with unstake transaction {}: {}", - ut_tx.hash(), - e + if included_entries.contains(&stake_key) { + log::debug!( + "Cannot include more than one unstake transaction for the same legacy operator or delegated validator/withdrawer entry in a single block" ); - invalid_unstake_transactions.push(ut_tx.hash()); continue; } + // Revalidate unstake transaction since concurrent unstake transactions could have invalidated the transaction + // we want to include here which would result in producing an invalid block. + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version); + let unstake_delay = consensus_constants_wit2 + .get_unstaking_delay_seconds_for_protocol(protocol_version); + let (validation_stakes, validation_delegation_state) = + if protocol_version >= V2_1 { + (&projected_stakes, &projected_delegation_state) + } else { + (stakes, delegation_state) + }; + let validation = if let Some(prevalidated) = delegation_prevalidation.as_ref() { + validate_unstake_transaction_prevalidated_for_protocol( + ut_tx, + epoch, + epoch_constants, + validation_stakes, + validation_delegation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + protocol_version, + prevalidated, + ) + } else { + validate_unstake_transaction_for_protocol( + ut_tx, + epoch, + epoch_constants, + validation_stakes, + validation_delegation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + protocol_version, + ) + }; + let outputs = match validation { + Ok((_, _, outputs)) => outputs, + Err(e) => { + log::warn!( + "Cannot build block with unstake transaction {}: {}", + unstake_hash, + e + ); + // As above, candidate rewards or earlier selected actions can make a tip-valid + // V2.1 unstake temporarily invalid only for this proposed block. + if protocol_version < V2_1 { + invalid_unstake_transactions.push(unstake_hash); + } + continue; + } + }; + let transaction_weight = ut_tx.weight(); let new_ut_weight = ut_weight.saturating_add(transaction_weight); if new_ut_weight <= max_ut_weight { - update_utxo_diff( + let new_transaction_fees = match add_candidate_fee( + transaction_fees, + ut_tx.body.fee, + protocol_version, + ) { + Ok(fees) => fees, + Err(error) => { + log::warn!( + "Cannot account unstake fee for transaction {}; skipping it: {}", + unstake_hash, + error + ); + continue; + } + }; + let (new_wit2_transaction_fees, new_delegated_action_fees) = + if protocol_version >= V2_1 { + let Some(new_wit2_transaction_fees) = + wit2_transaction_fees.checked_add(ut_tx.body.fee) + else { + log::warn!( + "Cannot account unstake fee for transaction {}; skipping it", + unstake_hash + ); + continue; + }; + let Some(new_delegated_action_fees) = + delegated_action_fees.checked_add(ut_tx.body.fee) + else { + log::warn!( + "Cannot account delegated unstake fee for transaction {}; skipping it", + unstake_hash + ); + continue; + }; + (new_wit2_transaction_fees, new_delegated_action_fees) + } else { + (wit2_transaction_fees, delegated_action_fees) + }; + let delegated_projection = if protocol_version >= V2_1 { + let stake_key = StakeKey { + validator: ut_tx.body.operator, + withdrawer: ut_tx.signature.public_key.pkh(), + }; + let requested = match ut_tx.body.value().checked_add(ut_tx.body.fee) { + Some(requested) => requested, + None => { + log::warn!( + "Cannot include unstake transaction {} because its amount overflows", + unstake_hash + ); + continue; + } + }; + let amount = Wit::from_nanowits(requested); + let mut candidate_stakes = projected_stakes.try_clone_checked()?; + if let Err(error) = candidate_stakes.remove_stake( + stake_key, + amount, + epoch, + true, + Wit::from_nanowits(min_stake), + ) { + log::warn!( + "Cannot include unstake transaction {} because cumulative state projection failed: {}", + unstake_hash, + error + ); + continue; + } + Some(candidate_stakes) + } else { + None + }; + + try_update_utxo_diff_for_protocol( &mut utxo_diff, vec![], - vec![&ut_tx.body.withdrawal], - ut_tx.hash(), + outputs, + ut_tx.versioned_hash(protocol_version), epoch, epoch_constants, checkpoint_zero_timestamp, - ); + protocol_version, + ) + .map_err(|error| { + log::error!( + "Cannot update candidate UTXO view for unstake transaction {}: {}", + unstake_hash, + error + ); + StakesError::ArithmeticOverflow + })?; unstake_txns.push(ut_tx.clone()); - transaction_fees = transaction_fees.saturating_add(ut_tx.body.fee); + transaction_fees = new_transaction_fees; + wit2_transaction_fees = new_wit2_transaction_fees; + delegated_action_fees = new_delegated_action_fees; ut_weight = new_ut_weight; + + if let Some(candidate_stakes) = delegated_projection { + projected_stakes = candidate_stakes; + projected_delegation_state.complete_exit_if_removed( + &projected_stakes, &StakeKey { + validator: ut_tx.body.operator, + withdrawer: ut_tx.signature.public_key.pkh(), + }, + )?; + } + included_entries.insert(stake_key); } // The condition to stop is if the free space in the block for VTTransactions @@ -1296,8 +3468,6 @@ pub fn build_block( if ut_weight > max_ut_weight.saturating_sub(min_ut_weight) { break; } - - included_validators.insert(validator_pkh); } transactions_pool.remove_invalid_unstake_transactions(invalid_unstake_transactions); @@ -1309,14 +3479,427 @@ pub fn build_block( transactions_pool.clear_unstake_transactions(); } + if let Some(( + pre_action_stakes, + pre_action_delegation_state, + pre_action_utxo_diff, + pre_action_transaction_fees, + pre_action_wit2_transaction_fees, + pre_action_delegated_action_fees, + )) = pre_action_projection + { + let prevalidated = delegation_prevalidation.as_ref().ok_or( + StakesError::InvalidDelegationState { + reason: "candidate replay lost its delegated invariant proof", + }, + )?; + let frozen_active_validators = frozen_active_validators.as_ref().ok_or( + StakesError::InvalidDelegationState { + reason: "candidate replay lost its frozen active-validator snapshot", + }, + )?; + + let replay_result = prune_delegated_actions_until_replay( + &mut stake_txns, + &mut unstake_txns, + |candidate_stake_txns, candidate_unstake_txns| { + let aggregate_action_failure = |error: anyhow::Error| { + if let Some((kind, index)) = last_delegated_action( + candidate_stake_txns.len(), + candidate_unstake_txns.len(), + ) { + DelegatedActionReplayFailure::Candidate { + kind, + index, + error, + } + } else { + DelegatedActionReplayFailure::Fatal(error) + } + }; + + // Action fees affect the miner reward, while a Stake fee can depend on change + // created by an earlier Stake. Rebuild both totals and that progressive fee view + // before projecting the reward. This pass is thrown away on every retry. + let mut fee_utxo_diff = pre_action_utxo_diff.clone(); + let mut replay_transaction_fees = pre_action_transaction_fees; + let mut replay_wit2_transaction_fees = pre_action_wit2_transaction_fees; + let mut replay_delegated_action_fees = pre_action_delegated_action_fees; + for (index, transaction) in candidate_stake_txns.iter().enumerate() { + let transaction_hash = transaction.versioned_hash(protocol_version); + let transaction_fee = st_transaction_fee_for_protocol( + transaction, + &fee_utxo_diff, + epoch, + epoch_constants, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "cannot recompute fee for candidate stake transaction {}: {}", + transaction_hash, + error + ), + })?; + let new_transaction_fees = add_candidate_fee( + replay_transaction_fees, + transaction_fee, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "cannot account candidate stake transaction {} fee: {}", + transaction_hash, + error + ), + })?; + let new_wit2_transaction_fees = replay_wit2_transaction_fees + .checked_add(transaction_fee) + .ok_or_else(|| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "Wit/2 fee total overflows at candidate stake transaction {}", + transaction_hash + ), + })?; + let new_delegated_action_fees = replay_delegated_action_fees + .checked_add(transaction_fee) + .ok_or_else(|| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "delegated-action fee total overflows at candidate stake transaction {}", + transaction_hash + ), + })?; + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.change.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "cannot rebuild fee UTXO view for candidate stake transaction {}: {}", + transaction_hash, + error + ), + })?; + replay_transaction_fees = new_transaction_fees; + replay_wit2_transaction_fees = new_wit2_transaction_fees; + replay_delegated_action_fees = new_delegated_action_fees; + } + for (index, transaction) in candidate_unstake_txns.iter().enumerate() { + let transaction_hash = transaction.versioned_hash(protocol_version); + let new_transaction_fees = add_candidate_fee( + replay_transaction_fees, + transaction.body.fee, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "cannot account candidate unstake transaction {} fee: {}", + transaction_hash, + error + ), + })?; + let new_wit2_transaction_fees = replay_wit2_transaction_fees + .checked_add(transaction.body.fee) + .ok_or_else(|| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "Wit/2 fee total overflows at candidate unstake transaction {}", + transaction_hash + ), + })?; + let new_delegated_action_fees = replay_delegated_action_fees + .checked_add(transaction.body.fee) + .ok_or_else(|| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "delegated-action fee total overflows at candidate unstake transaction {}", + transaction_hash + ), + })?; + replay_transaction_fees = new_transaction_fees; + replay_wit2_transaction_fees = new_wit2_transaction_fees; + replay_delegated_action_fees = new_delegated_action_fees; + } + + // Reward, age resets, action state, and action UTXOs all restart from the same + // immutable pre-action snapshot. If the aggregate reward itself fails, prune the + // canonical suffix (last Unstake, otherwise last Stake) so the longest known-good + // prefix survives; the no-action projection already succeeded. + let mut replay_stakes = pre_action_stakes.try_clone_checked().map_err(|error| { + DelegatedActionReplayFailure::Fatal(anyhow::anyhow!( + "cannot clone pre-action stakes for candidate replay: {}", + error + )) + })?; + let mut replay_delegation_state = pre_action_delegation_state.clone(); + let mut replay_utxo_diff = pre_action_utxo_diff.clone(); + project_wit2_transaction_rewards( + &mut replay_stakes, + &mut replay_delegation_state, + own_pkh, + epoch, + replay_wit2_transaction_fees, + consensus_constants_wit2 + .get_validator_block_reward_for_protocol(protocol_version), + ) + .map_err(|error| { + aggregate_action_failure(anyhow::anyhow!( + "candidate transaction reward projection failed: {}", + error + )) + })?; + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version); + apply_wit2_age_resets_prevalidated_for_protocol( + &mut replay_stakes, + &replay_delegation_state, + own_pkh, + epoch, + Wit::from_nanowits(min_stake), + protocol_version, + dr_pool, + &commit_txns, + &tally_txns, + |pointer| { + dr_pool + .data_request_state(&pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + }, + frozen_active_validators, + ) + .map_err(|error| { + aggregate_action_failure(anyhow::anyhow!( + "candidate post-reward age reset failed: {}", + error + )) + })?; + + let max_stake = consensus_constants_wit2 + .get_validator_max_stake_nanowits_for_protocol(protocol_version); + let mut replay_signatures = Vec::new(); + for (index, transaction) in candidate_stake_txns.iter().enumerate() { + let transaction_hash = transaction.versioned_hash(protocol_version); + validate_stake_transaction_prevalidated_for_protocol( + transaction, + &replay_utxo_diff, + epoch, + epoch_constants, + &mut replay_signatures, + &replay_stakes, + &replay_delegation_state, + network_magic, + min_stake, + max_stake, + protocol_version, + prevalidated, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "candidate stake transaction {} failed reward-aware validation: {}", + transaction_hash, + error + ), + })?; + super::process_delegated_stake_transactions( + &mut replay_stakes, + &mut replay_delegation_state, + std::iter::once(transaction), + epoch, + min_stake, + epoch_constants, + network_magic, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "candidate stake transaction {} failed state replay: {}", + transaction_hash, + error + ), + })?; + try_update_utxo_diff_for_protocol( + &mut replay_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.change.iter(), + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index, + error: anyhow::anyhow!( + "cannot replay candidate UTXO view for stake transaction {}: {}", + transaction_hash, + error + ), + })?; + } + + let unstake_delay = consensus_constants_wit2 + .get_unstaking_delay_seconds_for_protocol(protocol_version); + for (index, transaction) in candidate_unstake_txns.iter().enumerate() { + let transaction_hash = transaction.versioned_hash(protocol_version); + let (_, _, outputs) = validate_unstake_transaction_prevalidated_for_protocol( + transaction, + epoch, + epoch_constants, + &replay_stakes, + &replay_delegation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + protocol_version, + prevalidated, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "candidate unstake transaction {} failed reward-aware validation: {}", + transaction_hash, + error + ), + })?; + let stake_key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.signature.public_key.pkh(), + }; + let requested = transaction + .body + .value() + .checked_add(transaction.body.fee) + .ok_or_else(|| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "candidate unstake transaction {} amount overflows during replay", + transaction_hash + ), + })?; + let amount = Wit::from_nanowits(requested); + replay_stakes + .remove_stake( + stake_key.clone(), + amount, + epoch, + true, + Wit::from_nanowits(min_stake), + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "candidate unstake transaction {} failed state replay: {}", + transaction_hash, + error + ), + })?; + replay_delegation_state.complete_exit_if_removed(&replay_stakes, &stake_key) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!("cannot finish stake exit: {error}"), + })?; + try_update_utxo_diff_for_protocol( + &mut replay_utxo_diff, + vec![], + outputs, + transaction.versioned_hash(protocol_version), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol_version, + ) + .map_err(|error| DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index, + error: anyhow::anyhow!( + "cannot replay candidate UTXO view for unstake transaction {}: {}", + transaction_hash, + error + ), + })?; + } + + Ok(( + replay_stakes, + replay_delegation_state, + replay_utxo_diff, + replay_transaction_fees, + replay_wit2_transaction_fees, + replay_delegated_action_fees, + )) + }, + |kind, index, error| { + log::warn!( + "Pruning reward-incompatible Wit/2.1 {:?} candidate at index {} and replaying the remaining delegated actions: {}", + kind, + index, + error + ); + }, + ); + + let ( + replay_stakes, + replay_delegation_state, + replay_utxo_diff, + replay_transaction_fees, + replay_wit2_transaction_fees, + _replay_delegated_action_fees, + ) = replay_result.map_err(|error| { + log::error!( + "Cannot rebuild the Wit/2.1 delegated-action projection from its pre-action snapshot: {}", + error + ); + StakesError::InvalidDelegationState { + reason: "candidate delegated-action replay failed without an attributable transaction", + } + })?; + projected_stakes = replay_stakes; + projected_delegation_state = replay_delegation_state; + utxo_diff = replay_utxo_diff; + transaction_fees = replay_transaction_fees; + wit2_transaction_fees = replay_wit2_transaction_fees; + } + // Include Mint Transaction by miner - let mint = if protocol_version == V2_0 { + let mint = if protocol_version >= V2_0 { let mut mint = MintTransaction::default(); mint.epoch = epoch; mint } else { - let reward = block_reward(epoch, initial_block_reward, halving_period) + transaction_fees; + let reward = try_block_reward(epoch, initial_block_reward, halving_period) + .map_err(|_| StakesError::ArithmeticOverflow)? + .wrapping_add(transaction_fees); MintTransaction::with_external_address( epoch, reward, @@ -1327,28 +3910,33 @@ pub fn build_block( }; // Compute `hash_merkle_root` and build block header - let vt_hash_merkle_root = merkle_tree_root(&value_transfer_txns); - let dr_hash_merkle_root = merkle_tree_root(&data_request_txns); - let commit_hash_merkle_root = merkle_tree_root(&commit_txns); - let reveal_hash_merkle_root = merkle_tree_root(&reveal_txns); - let tally_hash_merkle_root = merkle_tree_root(&tally_txns); - - let stake_hash_merkle_root = if protocol_version == V1_7 { + // Use the same protocol-aware transaction identity that block validation uses. This is + // especially important for Wit/2.1 stake transactions: authorization terms are part of the + // hash and weight only after activation, while pre-activation blocks must retain the legacy + // identity. Using the legacy `Hashable` helper here would make mined headers disagree with + // validation and with the block merkle root exposed by RPC. + let vt_hash_merkle_root = versioned_merkle_tree_root(&value_transfer_txns, protocol_version); + let dr_hash_merkle_root = versioned_merkle_tree_root(&data_request_txns, protocol_version); + let commit_hash_merkle_root = versioned_merkle_tree_root(&commit_txns, protocol_version); + let reveal_hash_merkle_root = versioned_merkle_tree_root(&reveal_txns, protocol_version); + let tally_hash_merkle_root = versioned_merkle_tree_root(&tally_txns, protocol_version); + + let stake_hash_merkle_root = if protocol_version < V1_8 { log::debug!("Legacy protocol: the default stake hash merkle root will be used"); Hash::default() } else { - log::debug!("Pseudo-2.0 protocol: a merkle tree will be built for the stake transactions"); - merkle_tree_root(&stake_txns) + log::debug!("A merkle tree will be built for the stake transactions"); + versioned_merkle_tree_root(&stake_txns, protocol_version) }; let unstake_hash_merkle_root = if protocol_version < V2_0 { Hash::default() } else { - merkle_tree_root(&unstake_txns) + versioned_merkle_tree_root(&unstake_txns, protocol_version) }; let merkle_roots = BlockMerkleRoots { - mint_hash: mint.hash(), + mint_hash: mint.versioned_hash(protocol_version), vt_hash_merkle_root, dr_hash_merkle_root, commit_hash_merkle_root, @@ -1377,18 +3965,27 @@ pub fn build_block( unstake_txns, }; - (block_header, txns) + Ok((block_header, txns)) } #[cfg(test)] mod tests { - use std::{collections::HashMap, convert::TryInto}; + use std::{ + collections::{HashMap, HashSet}, + convert::TryInto, + sync::Arc, + }; use witnet_crypto::secp256k1::{ PublicKey as Secp256k1_PublicKey, SecretKey as Secp256k1_SecretKey, }; use witnet_crypto::signature::{sign, verify}; - use witnet_data_structures::{chain::*, transaction::*, vrf::VrfCtx}; + use witnet_data_structures::{ + chain::*, + transaction::*, + utxo_pool::{UtxoDb, UtxoWriteBatch, utxo_db::UtxoStorageIterator}, + vrf::VrfCtx, + }; use witnet_protected::Protected; use witnet_validations::validations::validate_block_signature; @@ -1400,6 +3997,484 @@ mod tests { const INITIAL_BLOCK_REWARD: u64 = 250 * 1_000_000_000; const HALVING_PERIOD: u32 = 3_500_000; + fn fallback_commit(marker: u8) -> CommitTransaction { + CommitTransaction { + body: CommitTransactionBody::without_collateral( + Hash::SHA256([0x44; 32]), + Hash::SHA256([marker; 32]), + Default::default(), + ), + signatures: vec![KeyedSignature::default()], + } + } + + #[test] + fn candidate_fee_arithmetic_wraps_before_wit2_1_and_rejects_after_activation() { + assert_eq!( + add_candidate_fee(u64::MAX, 1, ProtocolVersion::V2_0), + Ok(0) + ); + assert_eq!( + add_candidate_fee(u64::MAX, 1, ProtocolVersion::V2_1), + Err(StakesError::ArithmeticOverflow) + ); + } + + #[test] + fn delegated_action_replay_prunes_only_the_reported_candidate() { + let mut stake_candidates = vec![10u8, 11]; + let mut unstake_candidates = vec![20u8, 21]; + let mut attempts = Vec::new(); + let mut pruned = Vec::new(); + + let projection = prune_delegated_actions_until_replay( + &mut stake_candidates, + &mut unstake_candidates, + |stakes, unstakes| { + attempts.push((stakes.to_vec(), unstakes.to_vec())); + if stakes.first() == Some(&10) { + Err(DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index: 0, + error: "isolated stake failure", + }) + } else { + Ok((stakes.to_vec(), unstakes.to_vec())) + } + }, + |kind, index, error| pruned.push((kind, index, *error)), + ) + .unwrap(); + + assert_eq!(stake_candidates, vec![11]); + assert_eq!(unstake_candidates, vec![20, 21]); + assert_eq!(projection, (vec![11], vec![20, 21])); + assert_eq!( + attempts, + vec![ + (vec![10, 11], vec![20, 21]), + (vec![11], vec![20, 21]) + ] + ); + assert_eq!( + pruned, + vec![( + DelegatedActionKind::Stake, + 0, + "isolated stake failure" + )] + ); + } + + #[test] + fn delegated_action_replay_is_bounded_and_preserves_stake_then_unstake_order() { + let mut stake_candidates = vec![10u8, 11]; + let mut unstake_candidates = vec![20u8, 21]; + let initial_action_count = stake_candidates.len() + unstake_candidates.len(); + let mut attempts = 0usize; + let mut pruned_kinds = Vec::new(); + + prune_delegated_actions_until_replay( + &mut stake_candidates, + &mut unstake_candidates, + |stakes, unstakes| { + attempts += 1; + if !stakes.is_empty() { + Err(DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Stake, + index: 0, + error: "stake failure", + }) + } else if !unstakes.is_empty() { + Err(DelegatedActionReplayFailure::Candidate { + kind: DelegatedActionKind::Unstake, + index: 0, + error: "unstake failure", + }) + } else { + Ok(()) + } + }, + |kind, _, _| pruned_kinds.push(kind), + ) + .unwrap(); + + assert!(stake_candidates.is_empty()); + assert!(unstake_candidates.is_empty()); + assert_eq!(attempts, initial_action_count + 1); + assert_eq!( + pruned_kinds, + vec![ + DelegatedActionKind::Stake, + DelegatedActionKind::Stake, + DelegatedActionKind::Unstake, + DelegatedActionKind::Unstake, + ] + ); + } + + struct ReadFailureUtxoDb; + + impl UtxoDb for ReadFailureUtxoDb { + fn get_utxo( + &self, + _out_ptr: &OutputPointer, + ) -> Result, anyhow::Error> { + Err(anyhow::anyhow!("injected candidate UTXO read failure")) + } + + fn utxo_iterator(&self) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn utxo_iterator_by_pkh( + &self, + _pkh: PublicKeyHash, + ) -> Result, anyhow::Error> { + Ok(Box::new(std::iter::empty())) + } + + fn write(&self, _batch: UtxoWriteBatch) -> Result<(), anyhow::Error> { + Err(anyhow::anyhow!("fixture backend is read-only")) + } + } + + #[test] + fn failed_legacy_commit_projection_retains_the_authoritative_transaction_pool() { + let beacon = CheckpointBeacon::default(); + assert!( + ProtocolVersion::from_epoch(beacon.checkpoint) < ProtocolVersion::V2_1, + "fixture requires the mutating pre-Wit/2.1 Commit-selection path" + ); + let dr_transaction = DRTransaction::new( + DRTransactionBody::new( + vec![], + DataRequestOutput { + witnesses: 1, + ..Default::default() + }, + vec![], + ), + vec![KeyedSignature::default()], + ); + let dr_pointer = dr_transaction.hash(); + let mut dr_pool = DataRequestPool::default(); + dr_pool + .add_data_request(0, dr_transaction, None) + .expect("fixture request must enter Commit stage"); + + // `remove_commits` historically removed this transaction before the collateral lookup + // reached the injected backend failure. The candidate-local pool must absorb that + // destructive selection until a complete block is ready for publication. + let missing_collateral = OutputPointer { + transaction_id: Hash::SHA256([0x91; 32]), + output_index: 0, + }; + let commit = CommitTransaction::new( + CommitTransactionBody::new( + dr_pointer, + Hash::SHA256([0x92; 32]), + Default::default(), + vec![Input::new(missing_collateral)], + vec![], + None, + ), + vec![KeyedSignature::default()], + ); + let commit_hash = commit.hash(); + let mut transaction_pool = TransactionsPool::default(); + assert!(transaction_pool + .insert(Transaction::Commit(commit), 0) + .is_empty()); + assert_eq!( + transaction_pool + .select_commits(&dr_pool) + .0 + .iter() + .map(|transaction| transaction.hash()) + .collect::>(), + vec![commit_hash] + ); + let mut candidate_transactions_pool = transaction_pool.clone(); + let unspent_outputs_pool = UnspentOutputsPool { + diff: Default::default(), + db: Some(Arc::new(ReadFailureUtxoDb)), + }; + let expected_dr_pool = dr_pool.clone(); + + let result = build_block( + ( + &mut candidate_transactions_pool, + &unspent_outputs_pool, + &mut dr_pool, + ), + 0, + 0, + beacon, + BlockEligibilityClaim::default(), + CheckpointVRF::default(), + &[], + PublicKeyHash::default(), + EpochConstants::default(), + 1, + 1_000_000_000, + None, + None, + 0, + INITIAL_BLOCK_REWARD, + CHECKPOINT_ZERO_TIMESTAMP, + HALVING_PERIOD, + 0, + &ActiveWips::default(), + None, + &StakesTracker::default(), + &DelegationState::default(), + 0, + &ConsensusConstantsWit2::default(), + 1, + ); + assert!(matches!(result, Err(StakesError::ArithmeticOverflow))); + assert_eq!(dr_pool, expected_dr_pool); + assert!( + candidate_transactions_pool + .select_commits(&dr_pool) + .0 + .is_empty(), + "the injected failure must occur after destructive candidate-local Commit selection" + ); + assert_eq!( + transaction_pool + .select_commits(&dr_pool) + .0 + .iter() + .map(|transaction| transaction.hash()) + .collect::>(), + vec![commit_hash] + ); + } + + #[test] + fn commit_input_availability_reads_the_progressive_utxo_view() { + let unspent_outputs_pool = UnspentOutputsPool::default(); + let mut utxo_diff = UtxoDiff::new(&unspent_outputs_pool, 0); + let output_pointer = OutputPointer { + transaction_id: Hash::SHA256([0x33; 32]), + output_index: 7, + }; + let mut commit = fallback_commit(1); + commit.body.collateral = vec![Input::new(output_pointer)]; + + assert!(!commit_inputs_are_available(&utxo_diff, &commit).unwrap()); + utxo_diff.insert_utxo(output_pointer, ValueTransferOutput::default(), None); + assert!(commit_inputs_are_available(&utxo_diff, &commit).unwrap()); + utxo_diff.remove_utxo(output_pointer); + assert!(!commit_inputs_are_available(&utxo_diff, &commit).unwrap()); + } + + #[test] + fn commit_group_selection_replaces_a_rejected_first_hash() { + let mut candidates = vec![fallback_commit(1), fallback_commit(2), fallback_commit(3)]; + candidates.sort_unstable_by_key(|candidate| candidate.hash()); + let rejected_hash = candidates[0].hash(); + let candidate_refs = candidates.iter().collect::>(); + let mut backtrack_work = COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK; + + // The predicate stands for the exact eligibility/VRF projection in `build_block`: the + // first hash is invalid or above target, while the later hashes are valid replacements. + let selected_group = select_complete_commit_group( + &candidate_refs, + 2, + &HashSet::new(), + 0usize, + &mut backtrack_work, + || Ok::<_, ()>(0usize), + |projected_count, candidate| { + let accepted = candidate.hash() != rejected_hash; + Ok::<_, ()>(( + projected_count + if accepted { 1 } else { 0 }, + accepted, + )) + }, + ) + .unwrap(); + let CommitGroupSearch::Complete { + commits: selected, + spent_inputs, + projection: projected_count, + } = selected_group + else { + panic!("later valid Commit candidates should complete the group") + }; + + assert_eq!(selected.as_slice(), &candidates[1..]); + assert!(spent_inputs.is_empty()); + assert_eq!(projected_count, 2); + assert_eq!( + backtrack_work, + COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK, + "a successful greedy path must not spend the shared backtracking allowance" + ); + } + + #[test] + fn commit_group_selection_backtracks_an_accepted_dead_end() { + let mut candidates = vec![fallback_commit(1), fallback_commit(2), fallback_commit(3)]; + candidates.sort_unstable_by_key(|candidate| candidate.hash()); + let dead_end_hash = candidates[0].hash(); + let candidate_refs = candidates.iter().collect::>(); + let mut backtrack_work = COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK; + + // Model a progressive collateral/eligibility interaction: the lowest-hash candidate is + // valid alone but leaves no later candidate admissible. Excluding it permits the next two + // candidates to form the complete group. The old greedy scan returned no group here. + let selected_group = select_complete_commit_group( + &candidate_refs, + 2, + &HashSet::new(), + Vec::::new(), + &mut backtrack_work, + || Ok::<_, ()>(Vec::::new()), + |selected_hashes, candidate| { + if selected_hashes.first() == Some(&dead_end_hash) { + return Ok::<_, ()>((selected_hashes, false)); + } + let mut next = selected_hashes; + next.push(candidate.hash()); + Ok((next, true)) + }, + ) + .unwrap(); + let CommitGroupSearch::Complete { + commits: selected, + spent_inputs, + projection: selected_hashes, + } = selected_group + else { + panic!("bounded backtracking should recover the complete later combination") + }; + + assert_eq!(selected.as_slice(), &candidates[1..]); + assert!(spent_inputs.is_empty()); + assert_eq!( + selected_hashes, + candidates[1..] + .iter() + .map(|candidate| candidate.hash()) + .collect::>() + ); + assert!(backtrack_work < COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK); + } + + #[test] + fn commit_group_backtracking_stops_at_the_shared_work_budget() { + let mut candidates = vec![fallback_commit(1), fallback_commit(2), fallback_commit(3)]; + candidates.sort_unstable_by_key(|candidate| candidate.hash()); + let dead_end_hash = candidates[0].hash(); + let candidate_refs = candidates.iter().collect::>(); + let mut limited_backtrack_work = 1usize; + let mut reconstruction_attempts = 0usize; + + // The historical greedy path accepts the first candidate and then dead-ends. With + // only one shared block work unit left, the helper may examine the first alternative but + // must stop before cloning it, report the limit, and publish no partial projection. + let selected_group = select_complete_commit_group( + &candidate_refs, + 2, + &HashSet::new(), + Vec::::new(), + &mut limited_backtrack_work, + || { + reconstruction_attempts += 1; + Ok::<_, ()>(Vec::::new()) + }, + |selected_hashes, candidate| { + if selected_hashes.first() == Some(&dead_end_hash) { + Ok::<_, ()>((selected_hashes, false)) + } else { + let mut next = selected_hashes; + next.push(candidate.hash()); + Ok((next, true)) + } + }, + ) + .unwrap(); + + assert_eq!( + selected_group, + CommitGroupSearch::Incomplete { + search_limit_reached: true + } + ); + assert_eq!(limited_backtrack_work, 0); + assert_eq!(reconstruction_attempts, 0); + } + + #[test] + fn incomplete_commit_group_discards_every_private_projection_and_marker() { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct Projection { + stakes: u64, + delegation: u64, + utxo: u64, + } + + let mut candidates = vec![fallback_commit(1), fallback_commit(2), fallback_commit(3)]; + candidates.sort_unstable_by_key(|candidate| candidate.hash()); + let accepted_hash = candidates[0].hash(); + let candidate_refs = candidates.iter().collect::>(); + let initial_projection = Projection { + stakes: 10, + delegation: 20, + utxo: 30, + }; + let mut published_projection = initial_projection; + let mut published_fee = 40u64; + let mut solved_requests = vec![Hash::SHA256([0x55; 32])]; + let mut backtrack_work = COMMIT_GROUP_BACKTRACK_WORK_PER_BLOCK; + + let selected = select_complete_commit_group( + &candidate_refs, + 2, + &HashSet::new(), + initial_projection, + &mut backtrack_work, + || Ok::<_, ()>(initial_projection), + |projection, candidate| { + if candidate.hash() == accepted_hash { + Ok::<_, ()>(( + Projection { + stakes: projection.stakes + 1, + delegation: projection.delegation + 1, + utxo: projection.utxo + 1, + }, + true, + )) + } else { + Ok((projection, false)) + } + }, + ) + .unwrap(); + + // Mirror the only publication gate used by `build_block`. The partial scan accepted one + // candidate privately, but no complete group means no projection, fee, or solved marker is + // observable by the caller. + assert_eq!( + selected, + CommitGroupSearch::Incomplete { + search_limit_reached: false + } + ); + if let CommitGroupSearch::Complete { projection, .. } = selected { + published_projection = projection; + published_fee += 1; + solved_requests.push(Hash::SHA256([0x66; 32])); + } + assert_eq!(published_projection, initial_projection); + assert_eq!(published_fee, 40); + assert_eq!(solved_requests, vec![Hash::SHA256([0x55; 32])]); + } + #[test] fn build_empty_block() { // Initialize transaction_pool with 1 transaction @@ -1409,6 +4484,10 @@ mod tests { vec![], )); transaction_pool.insert(transaction.clone(), 0); + let pending_reservation = transaction_pool + .reserve_pending_transaction_admission(Hash::SHA256([0xA5; 32])) + .expect("fixture must reserve an independent asynchronous admission"); + let mut candidate_transactions_pool = transaction_pool.clone(); let unspent_outputs_pool = UnspentOutputsPool::default(); let mut dr_pool = DataRequestPool::default(); @@ -1426,11 +4505,16 @@ mod tests { // Build empty block (because max weight is zero) let (block_header, txns) = build_block( - (&mut transaction_pool, &unspent_outputs_pool, &mut dr_pool), + ( + &mut candidate_transactions_pool, + &unspent_outputs_pool, + &mut dr_pool, + ), max_vt_weight, max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1446,8 +4530,13 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); + transaction_pool = candidate_transactions_pool; let block = Block::new(block_header, KeyedSignature::default(), txns); // Check if block only contains the Mint Transaction @@ -1459,6 +4548,10 @@ mod tests { // Check that transaction in block is not the transaction in `transactions_pool` assert_ne!(Transaction::Mint(block.txns.mint), transaction); + // Successful clone publication must preserve ownership generations held by verifier jobs + // that cannot mutate the actor-owned pool while this synchronous build is running. + assert!(transaction_pool.owns_pending_transaction_admission(&pending_reservation)); + assert!(transaction_pool.rollback_pending_transaction_admission(pending_reservation)); } static LAST_VRF_INPUT: &str = @@ -1511,6 +4604,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1526,8 +4620,12 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); // Create a KeyedSignature let Hash::SHA256(data) = block_header.versioned_hash(get_protocol_version(Some(0))); @@ -1644,6 +4742,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1659,8 +4758,12 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); let block = Block::new(block_header, KeyedSignature::default(), txns); // Check if block contains only 2 transactions (Mint Transaction + 1 included transaction) @@ -1749,6 +4852,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1764,8 +4868,12 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); let block = Block::new(block_header, KeyedSignature::default(), txns); // Check if block contains only 2 transactions (Mint Transaction + 1 included transaction) @@ -1868,6 +4976,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1883,8 +4992,12 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); let block = Block::new(block_header, KeyedSignature::default(), txns); // Check if block contains only 2 transactions (Mint Transaction + 1 included transaction) @@ -1970,6 +5083,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -1985,8 +5099,12 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); let block = Block::new(block_header, KeyedSignature::default(), txns); // Check if block contains only 2 transactions (Mint Transaction + 1 included transaction) diff --git a/node/src/actors/chain_manager/mod.rs b/node/src/actors/chain_manager/mod.rs index 09f0bdcef5..d55ff32344 100644 --- a/node/src/actors/chain_manager/mod.rs +++ b/node/src/actors/chain_manager/mod.rs @@ -25,7 +25,8 @@ //! * Updating the UTXO set with valid transactions that have already been anchored into a valid block. This includes: //! - Removing the UTXOs that the transaction spends as inputs. //! - Adding a new UTXO for every output in the transaction. -use std::path::PathBuf; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; use std::{ cmp::{Ordering, max, min}, collections::{HashMap, HashSet, VecDeque}, @@ -42,22 +43,25 @@ use actix::{ }; use ansi_term::Color::{Purple, White, Yellow}; use derive_more::Display; -use futures::future::{FutureExt, try_join_all}; -use glob::glob; +use futures::future::{BoxFuture, FutureExt, try_join_all}; use itertools::Itertools; use rand::Rng; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ actors::{ chain_manager::handlers::SYNCED_BANNER, + epoch_manager::EpochManager, inventory_manager::InventoryManager, json_rpc::JsonRpcServer, messages::{ - AddItem, AddItems, AddTransaction, Anycast, BlockNotify, Broadcast, DropOutboundPeers, + AddItems, AddSnapshotItems, AddTransaction, Anycast, BlockNotify, Broadcast, + DropOutboundPeers, GetBlocksEpochRange, GetItemBlock, NodeStatusNotify, RemoveAddressesFromTried, SendInventoryItem, SendInventoryRequest, SendLastBeacon, SendSuperBlockVote, - SetLastBeacon, SetSuperBlockTargetBeacon, StoreInventoryItem, SuperBlockNotify, + SetEpochConstants, SetLastBeacon, SetSuperBlockTargetBeacon, StoreInventoryItem, + SuperBlockNotify, }, node::{NodeOps, PutNodeOps}, peers_manager::PeersManager, @@ -65,30 +69,37 @@ use crate::{ storage_keys, }, signature_mngr, storage_mngr, - utils::{Force, deserialize_from_file, file_name_compose, stop_system_if_panicking}, + utils::{Force, stop_system_if_panicking}, }; use witnet_config::{ config::Tapi, defaults::PSEUDO_CONSENSUS_CONSTANTS_WIP0022_REWARD_COLLATERAL_RATIO, }; -use witnet_crypto::hash::calculate_sha256; +use witnet_crypto::hash::{calculate_sha256, calculate_sha256_reader}; use witnet_data_structures::{ + bincode_compatibility, chain::{ Alpha, AltKeys, Block, BlockHeader, Bn256PublicKey, ChainImport, ChainInfo, ChainState, CheckpointBeacon, CheckpointVRF, ConsensusConstants, ConsensusConstantsWit2, DataRequestInfo, DataRequestOutput, DataRequestStage, Epoch, EpochConstants, Hash, - Hashable, InventoryEntry, InventoryItem, NodeStats, PublicKeyHash, Reputation, - ReputationEngine, SignaturesToVerify, StateMachine, SuperBlock, SuperBlockVote, - TransactionsPool, penalize_factor, + Hashable, InventoryEntry, InventoryItem, NodeStats, OutputPointer, PublicKey, Signature, + PendingDelegatedActionReservation, PendingTransactionAdmissionOutcome, + PendingTransactionAdmissionReservation, PublicKeyHash, Reputation, ReputationEngine, + SignaturesToVerify, StateMachine, SuperBlock, SuperBlockVote, TransactionsPool, + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, STAKE_AUTHORIZATION_ACTION_DELEGATE, + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, + penalize_factor, priority::{Priorities, PriorityEngine, PriorityVisitor}, reputation_issuance, tapi::{ActiveWips, after_second_hard_fork, current_active_wips, in_emergency_period}, }, data_request::DataRequestPool, - get_environment, get_protocol_version, get_protocol_version_activation_epoch, - proto::versioning::{ProtocolVersion, VersionedHashable}, + error::TransactionError, + get_environment, get_protocol_info, get_protocol_runtime, get_protocol_version, + load_protocol_runtime, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, radon_error::RadonError, radon_report::{RadonReport, ReportContext}, - register_protocol_version, + try_register_protocol_version_with_epoch_constants_and_generation, staking::prelude::*, superblock::{ARSIdentities, AddSuperBlockVote, SuperBlockConsensus}, transaction::{RevealTransaction, TallyTransaction, Transaction}, @@ -101,12 +112,22 @@ use witnet_data_structures::{ wit::Wit, }; use witnet_rad::{error::RadError::TooManyWitnesses, types::RadonTypes}; -use witnet_util::timestamp::seconds_to_human_string; +use witnet_util::timestamp::{get_timestamp, seconds_to_human_string}; +use secp256k1::{Message as SecpMessage, PublicKey as Secp256k1PublicKey}; use witnet_validations::{ eligibility::legacy::VrfSlots, validations::{ - compare_block_candidates, dr_transaction_fee, st_transaction_fee, validate_block, - validate_block_transactions, validate_new_transaction, validate_rad_request, + apply_wit2_age_resets_for_protocol, compare_block_candidates, dr_transaction_fee, LegacyRewardLocks, + project_tally_transition_for_protocol, project_wit2_inclusion_rewards_for_protocol, + project_wit2_transaction_rewards, st_transaction_fee_for_protocol, + prepare_standalone_delegation_state_for_protocol, + validate_block, + track_pre_activation_collateral_for_protocol, + track_pre_activation_collateral_with_transition_check_for_protocol, + validate_block_transactions_for_protocol, + validate_request_protocol_for_block, + validate_new_transaction_for_protocol, validate_rad_request, + try_update_utxo_diff_for_protocol, verify_signatures, vt_transaction_fee, }, }; @@ -119,6 +140,257 @@ pub mod mining; /// Maximum blocks number to be sent during synchronization process pub const MAX_BLOCKS_SYNC: usize = 500; +/// Portable snapshot set format. The manifest is the only public commit point: every component is +/// written under this generation before the manifest replaces the previous one. +pub(crate) const SNAPSHOT_MANIFEST_VERSION: u32 = 3; +pub(crate) const SNAPSHOT_MANIFEST_VERSION_V2: u32 = 2; +pub(crate) const SNAPSHOT_MANIFEST_VERSION_V1: u32 = 1; +pub(crate) const SNAPSHOT_BLOCK_BATCH_SIZE: usize = 50_000; +pub(crate) const SNAPSHOT_MANIFEST_MAGIC: [u8; 16] = *b"WITNET-SNAPSHOT!"; +pub(crate) const SNAPSHOT_TRUST_ANCHOR_MAGIC: [u8; 16] = *b"WITNET-ANCHOR!!!"; +pub(crate) const SNAPSHOT_TRUST_ANCHOR_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotGeneration { + pub(crate) unix_nanos: u128, + pub(crate) process_id: u32, + pub(crate) attempt: u32, +} + +impl SnapshotGeneration { + /// Return the manifest token identifying one snapshot component file. It is used to bind paths and digests during snapshot export and import. + fn file_token(self) -> String { + format!( + "{:032x}_{:08x}_{:08x}", + self.unix_nanos, self.process_id, self.attempt + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotComponent { + pub(crate) byte_len: u64, + pub(crate) sha256: [u8; 32], +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotBlockBatch { + pub(crate) index: u64, + pub(crate) block_count: u64, + pub(crate) component: SnapshotComponent, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotManifest { + pub(crate) magic: [u8; 16], + pub(crate) format_version: u32, + pub(crate) chain_state_version: u32, + pub(crate) generation: SnapshotGeneration, + pub(crate) highest_block_checkpoint: CheckpointBeacon, + pub(crate) highest_superblock_checkpoint: CheckpointBeacon, + pub(crate) block_count: u64, + pub(crate) superblock_count: u64, + pub(crate) chain_state: SnapshotComponent, + pub(crate) superblocks: SnapshotComponent, + pub(crate) block_batches: Vec, + /// Portable historical data-request report component. `None` represents a version-1 manifest, + /// whose pre-report format remains importable but cannot carry reports. + pub(crate) reports: Option, + pub(crate) report_count: u64, + /// Complete protocol schedule used to authenticate every historical block in this snapshot. + /// New manifests carry this value so import does not depend on the importing process having + /// the same schedule already loaded in its global runtime. + pub(crate) protocol_info: Option, +} + +/// Canonical content signed by an operator-approved snapshot checkpoint signer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotTrustAnchorPayload { + pub(crate) network_magic: u16, + pub(crate) genesis_hash: Hash, + pub(crate) finalized_superblock_checkpoint: CheckpointBeacon, + pub(crate) chain_state_sha256: [u8; 32], + pub(crate) protocol_schedule_sha256: [u8; 32], + pub(crate) producer_release: String, +} + +/// External checkpoint authorization for a non-genesis delegated-staking snapshot. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SnapshotTrustAnchor { + pub(crate) magic: [u8; 16], + pub(crate) format_version: u32, + pub(crate) payload: SnapshotTrustAnchorPayload, + pub(crate) signer: PublicKey, + pub(crate) signature: Signature, +} + +impl SnapshotTrustAnchor { + /// Compute the canonical digest covered by a snapshot trust-anchor signature. It serializes the manifest-bound fields through the same representation used by validation. + fn signing_digest(&self) -> Result<[u8; 32], anyhow::Error> { + let bytes = bincode::serialize(&self.payload)?; + Ok(calculate_sha256(&bytes).0) + } + + /// Validate the structural fields of a snapshot trust-anchor envelope. It checks version, chain identity, manifest binding, and signature metadata before cryptographic verification. + fn validate_envelope(&self) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.magic == SNAPSHOT_TRUST_ANCHOR_MAGIC, + "snapshot trust anchor magic does not match" + ); + anyhow::ensure!( + self.format_version == SNAPSHOT_TRUST_ANCHOR_VERSION, + "unsupported snapshot trust anchor version {}", + self.format_version + ); + anyhow::ensure!( + !self.payload.producer_release.trim().is_empty(), + "snapshot trust anchor has no producer release identity" + ); + Ok(()) + } + + /// Verify the cryptographic signature carried by a snapshot trust anchor. It uses signing_digest and the configured secp256k1 trust root before import is authorized. + fn verify_signature(&self) -> Result<(), anyhow::Error> { + self.validate_envelope()?; + let digest = self.signing_digest()?; + let message = SecpMessage::from_digest(digest); + let public_key: Secp256k1PublicKey = self.signer.clone().try_into()?; + self.signature.verify(&message, &public_key) + } +} + +/// Exact version-2 manifest layout. Keep this frozen: bincode is positional and the live +/// manifest has since gained the self-contained protocol schedule field. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct SnapshotManifestV2 { + magic: [u8; 16], + format_version: u32, + chain_state_version: u32, + generation: SnapshotGeneration, + highest_block_checkpoint: CheckpointBeacon, + highest_superblock_checkpoint: CheckpointBeacon, + block_count: u64, + superblock_count: u64, + chain_state: SnapshotComponent, + superblocks: SnapshotComponent, + block_batches: Vec, + reports: Option, + report_count: u64, +} + +impl From for SnapshotManifest { + /// Convert a version-two snapshot manifest into the current import manifest. It preserves component paths, digests, protocol schedule, and optional trust-anchor metadata. + fn from(manifest: SnapshotManifestV2) -> Self { + Self { + magic: manifest.magic, + format_version: manifest.format_version, + chain_state_version: manifest.chain_state_version, + generation: manifest.generation, + highest_block_checkpoint: manifest.highest_block_checkpoint, + highest_superblock_checkpoint: manifest.highest_superblock_checkpoint, + block_count: manifest.block_count, + superblock_count: manifest.superblock_count, + chain_state: manifest.chain_state, + superblocks: manifest.superblocks, + block_batches: manifest.block_batches, + reports: manifest.reports, + report_count: manifest.report_count, + protocol_info: None, + } + } +} + +/// Exact version-1 manifest layout. Keep this frozen: bincode is positional and adding fields to +/// the live manifest type must not make historical manifests decode ambiguously. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct SnapshotManifestV1 { + magic: [u8; 16], + format_version: u32, + chain_state_version: u32, + generation: SnapshotGeneration, + highest_block_checkpoint: CheckpointBeacon, + highest_superblock_checkpoint: CheckpointBeacon, + block_count: u64, + superblock_count: u64, + chain_state: SnapshotComponent, + superblocks: SnapshotComponent, + block_batches: Vec, +} + +impl From for SnapshotManifest { + /// Convert a legacy snapshot manifest into the current import manifest. It supplies compatibility defaults while leaving newer trust-anchor fields absent. + fn from(manifest: SnapshotManifestV1) -> Self { + Self { + magic: manifest.magic, + format_version: manifest.format_version, + chain_state_version: manifest.chain_state_version, + generation: manifest.generation, + highest_block_checkpoint: manifest.highest_block_checkpoint, + highest_superblock_checkpoint: manifest.highest_superblock_checkpoint, + block_count: manifest.block_count, + superblock_count: manifest.superblock_count, + chain_state: manifest.chain_state, + superblocks: manifest.superblocks, + block_batches: manifest.block_batches, + reports: None, + report_count: 0, + protocol_info: None, + } + } +} + +/// Derive the generation directory for a snapshot export. It is shared by component writers, manifest publication, and cleanup ownership. +pub(crate) fn snapshot_generation_dir( + manifest_path: &Path, + generation: SnapshotGeneration, +) -> Result { + let file_name = manifest_path.file_name().ok_or_else(|| { + anyhow::anyhow!( + "snapshot manifest path {} has no file name", + manifest_path.display() + ) + })?; + let mut generation_name = file_name.to_os_string(); + generation_name.push(format!(".generation-{}", generation.file_token())); + Ok(manifest_path.with_file_name(generation_name)) +} + +/// Derive the chain-state component path for a snapshot generation. It is consumed by export and verified import readers. +pub(crate) fn snapshot_chain_state_path(generation_dir: &Path) -> PathBuf { + generation_dir.join("chain_state.bin") +} + +/// Derive the superblocks component path for a snapshot generation. It is consumed by manifest construction and component verification. +pub(crate) fn snapshot_superblocks_path(generation_dir: &Path) -> PathBuf { + generation_dir.join("superblocks.bin") +} + +/// Derive the reports component path for a snapshot generation. It is consumed by report export and exact import validation. +pub(crate) fn snapshot_reports_path(generation_dir: &Path) -> PathBuf { + generation_dir.join("reports.bin") +} + +/// Derive one block-batch component path for a snapshot generation. It is used by the manifest and by ordered batch import. +pub(crate) fn snapshot_blocks_batch_path(generation_dir: &Path, index: u64) -> PathBuf { + generation_dir.join(format!("blocks_batch_{index:020}.bin")) +} + +type ConsensusStakesError = StakesError; + +/// Add two consensus values using the arithmetic rules of a selected protocol. It preserves legacy wrapping behavior while rejecting Wit/2.1 overflow at the validation boundary. +fn add_value_for_protocol( + left: u64, + right: u64, + protocol_version: ProtocolVersion, +) -> Result { + if protocol_version >= ProtocolVersion::V2_1 { + left.checked_add(right) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + } else { + Ok(left.wrapping_add(right)) + } +} + /// Possible errors when interacting with ChainManager #[derive(Debug, PartialEq, Eq, Error)] pub enum ChainManagerError { @@ -139,6 +411,24 @@ pub enum ChainManagerError { /// not possible at this time. current_state: StateMachine, }, + /// A transaction passed consensus validation but the current local mempool policy would not + /// retain it (for example because of a delegated-action fee floor or shared weight pressure). + #[error("Transaction {transaction_hash} would not be retained by the current mempool policy")] + MempoolPolicyRejected { + /// Hash of the transaction rejected by the local mempool policy. + transaction_hash: Hash, + }, + /// A Stake preview selected an own UTXO that another local operation has since reserved or + /// removed. The read-only admission check never acquires the reservation itself. + #[error("Dry-run preview input {output} is not currently available for local reservation")] + PreviewInputUnavailable { + /// UTXO selected by the preview but no longer available for reservation. + output: OutputPointer, + }, + /// The opt-in admission assertion received Wit/2.1 authorization terms while the live + /// protocol snapshot was still preactivation. + #[error("A Wit/2.1 Stake preview is not admissible before protocol activation")] + DelegatedStakingNotActive, /// The node is trying to mine a block so commits are not allowed #[error("Commit received while node is trying to mine a block")] TooLateToCommit, @@ -165,6 +455,36 @@ pub enum ChainManagerError { NotEligible, } +/// Stable failure identities for the read-only stake-recovery RPC. +/// +/// These errors deliberately describe only the expected, retryable/diagnostic boundaries. Storage +/// failures and other unexpected actor failures remain ordinary internal errors instead of being +/// misclassified as safe recovery evidence. +#[derive(Debug, PartialEq, Eq, Error)] +pub enum StakeRecoveryError { + /// The chain is not synchronized enough to provide a coherent recovery view. + #[error("Stake recovery requires a synchronized chain")] + ChainNotReady, + /// The actor has no canonical tip that can be labelled with the evidence. + #[error("Stake recovery canonical tip is unavailable")] + CanonicalTipUnavailable, + /// No consolidated superblock boundary can establish the requested finalized view. + #[error("Stake recovery finalized boundary is unavailable: {0}")] + FinalityUnavailable(String), + /// The signed transaction is still retained and may still be relayed. + #[error("Stake recovery transaction is still retained in the mempool")] + TransactionRetained, + /// One of the caller-supplied original inputs is no longer an unspent output. + #[error("Stake recovery input is missing or spent: {output}")] + InputUnavailable { + /// Original input that is no longer unspent. + output: OutputPointer, + }, + /// The protocol runtime changed during the actor-owned read. + #[error("Protocol schedule changed while querying stake recovery evidence")] + ProtocolChanged, +} + /// Synchronization target determined by the beacons received from outbound peers #[derive(Clone, Copy, Debug)] pub struct SyncTarget { @@ -240,8 +560,9 @@ pub struct ChainManager { temp_superblock_votes: HashSet, /// Commits and reveals to process later temp_reveals: Vec, - /// Value transfers and data requests to process later - temp_vts_and_drs: VecDeque, + /// Transactions recovered from a rewind to process later (excluding reveals, which use + /// `temp_reveals` so they can be regenerated and broadcast). + temp_reinserted_transactions: VecDeque, /// Maximum number of recovered transactions to include by epoch max_reinserted_transactions: usize, /// Last received Beacons @@ -254,6 +575,12 @@ pub struct ChainManager { priority_engine: PriorityEngine, /// Chain snapshot to be imported import: Force>, + /// Optional operator-supplied authorization for the snapshot currently being imported. + snapshot_trust_anchor: Option, + /// Manifest component whose digest is bound by the current snapshot trust anchor. + snapshot_chain_state_component: Option, + /// Configured secp256k1 public keys trusted to sign delegated snapshot anchors. + snapshot_trust_public_keys: Vec, /// Signals that a chain snapshot export is due. export: Force, /// Consensus constants for wit/2 @@ -268,12 +595,29 @@ pub struct ChainManager { last_blocks_minted: u32, /// Counf of minted rewards the last time getSupplyInfo2 was resolved. last_blocks_minted_reward: u64, + /// Process-global protocol schedule to restore if the block that first scheduled Wit/2 fails + /// to become finalized and canonical. The marker is cleared only when a finalized-superblock + /// ChainState write containing the exact triggering block and its activation certificate has + /// been acknowledged by storage. + pending_protocol_activation_rollback: Option, +} + +#[derive(Debug)] +struct PendingProtocolActivationRollback { + protocol_info: ProtocolInfo, + epoch_constants: EpochConstants, + trigger_epoch: Epoch, + trigger_hash: Hash, + activation_epoch: Epoch, + checkpoint_period: u16, } impl ChainManager { /// Drop the value of the `import` field. fn drop_import(&mut self) { self.import = Force::None; + self.snapshot_trust_anchor = None; + self.snapshot_chain_state_component = None; } /// Order a chain snapshot export upon starting. @@ -286,9 +630,8 @@ impl ChainManager { self.import = import; } - /// Try to read and load a chain snapshot from the filesystem into a ChainManager. - /// - /// This method is intentionally best-effort. + /// Try to read and load a manifest-committed chain snapshot from the filesystem into a + /// ChainManager. pub fn put_import_from_path(&mut self, path: Force) { // Deconstruct path and force degree, and abort if there's no path let (path, force) = match path { @@ -299,215 +642,1263 @@ impl ChainManager { } }; - // A future for reading and deserializing the chain state from a single file - let chain_state_path = path.clone(); - let chain_state = Box::pin(async move { - let path_display = chain_state_path.display().to_string(); - log::debug!("Trying to read chain state from file {path_display}"); - - // Open file, create reader, and decode using bincode - let chain_state = - deserialize_from_file(&chain_state_path).map_err(|e: ImportError| match e { - ImportError::Bincode(_) => ImportError::Deserialize { - path: path_display.clone(), - }, - ImportError::Io(_) => ImportError::FileRead { - path: path_display.clone(), - }, - e => e, - })?; + // Metadata belongs to this import attempt, not to the ChainManager lifetime. Clear it + // before decoding the new path so a failed or legacy import can never inherit an anchor + // from a previous manifest. + self.snapshot_trust_anchor = None; + self.snapshot_chain_state_component = None; + + let import = match load_snapshot_manifest(&path) { + Ok(manifest) => { + self.snapshot_chain_state_component = Some(manifest.chain_state.clone()); + self.snapshot_trust_anchor = match load_snapshot_trust_anchor(&path) { + Ok(anchor) => anchor, + Err(error) => { + self.put_import(Force::new(failed_chain_import(error), force)); + return; + } + }; + let generation_dir = match snapshot_generation_dir(&path, manifest.generation) { + Ok(path) => path, + Err(error) => { + self.put_import(Force::new(failed_chain_import(ImportError::SnapshotContents { + message: error.to_string(), + }), force)); + return; + } + }; + // Manifest validation accepts the frozen v1/v2 layouts and the current v3 + // schedule-carrying layout. Keep the compatibility choice explicit: v1 has no + // Report component, while v2/v3 may never fall back to the report-less path when + // their required component is missing or malformed. + let reports_required = manifest.format_version != SNAPSHOT_MANIFEST_VERSION_V1; + + let chain_state_path = snapshot_chain_state_path(&generation_dir); + let chain_state_component = manifest.chain_state.clone(); + let manifest_chain_state_version = manifest.chain_state_version; + let manifest_protocol_info = manifest.protocol_info.clone(); + let manifest_block_tip = manifest.highest_block_checkpoint; + let manifest_superblock_tip = manifest.highest_superblock_checkpoint; + let manifest_block_count = manifest.block_count; + let chain_state = Box::pin(async move { + let path_display = chain_state_path.display().to_string(); + log::debug!("Trying to read snapshot ChainState from {path_display}"); + let chain_state = deserialize_chain_state_snapshot_file( + &chain_state_path, + &chain_state_component, + &path_display, + Some(manifest_chain_state_version), + manifest_protocol_info.as_ref(), + )?; + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + ImportError::SnapshotContents { + message: "manifest ChainState has no ChainInfo".to_string(), + } + })?; + let decoded_block_count = u64::try_from(chain_state.block_chain.len()).map_err(|_| { + ImportError::SnapshotContents { + message: "decoded ChainState block count does not fit u64".to_string(), + } + })?; + if chain_info.highest_block_checkpoint != manifest_block_tip + || chain_info.highest_superblock_checkpoint != manifest_superblock_tip + || decoded_block_count != manifest_block_count + { + return Err(ImportError::SnapshotContents { + message: "snapshot manifest metadata does not match its ChainState" + .to_string(), + }); + } - Ok(chain_state) - }); + Ok(chain_state) + }); - // A future for reading and deserializing superblocks from a single file - let superblocks_path = file_name_compose(path.clone(), Some("superblocks".into())); - let superblocks = Box::pin(async move { - // Derive superblocks file path from the base path - let path_display = superblocks_path.display().to_string(); - log::debug!("Trying to read superblocks file at {path_display}"); - - // Open file, create reader, and decode using bincode - let superblocks: Vec<_> = - deserialize_from_file(&superblocks_path).map_err(|e: ImportError| match e { - ImportError::Bincode(_) => ImportError::Deserialize { - path: path_display.clone(), - }, - ImportError::Io(_) => ImportError::FileRead { - path: path_display.clone(), - }, - e => e, - })?; + let superblocks_path = snapshot_superblocks_path(&generation_dir); + let superblocks_component = manifest.superblocks.clone(); + let manifest_superblock_count = manifest.superblock_count; + let superblocks = Box::pin(async move { + let path_display = superblocks_path.display().to_string(); + log::debug!("Trying to read snapshot superblocks from {path_display}"); + let superblocks: Vec = + deserialize_verified_snapshot_file_exact( + &superblocks_path, + &superblocks_component, + ) + .map_err(|error| match error { + ImportError::Bincode(_) => ImportError::Deserialize { + path: path_display.clone(), + }, + error => error, + })?; + let decoded_count = u64::try_from(superblocks.len()).map_err(|_| { + ImportError::SnapshotContents { + message: "decoded superblock count does not fit u64".to_string(), + } + })?; + if decoded_count != manifest_superblock_count { + return Err(ImportError::SnapshotContents { + message: format!( + "manifest advertises {manifest_superblock_count} superblocks but component contains {decoded_count}" + ), + }); + } - log::info!( - "Read {} superblocks from file {}", - superblocks.len(), - path_display - ); + log::info!("Read {decoded_count} superblocks from {path_display}"); + Ok(superblocks) + }); - Ok(superblocks) - }); + let reports: BoxFuture<'static, Result, Vec)>, ImportError>> = + if let Some(reports_component) = manifest.reports.clone() { + let reports_path = snapshot_reports_path(&generation_dir); + let manifest_report_count = manifest.report_count; + Box::pin(async move { + let path_display = reports_path.display().to_string(); + log::debug!("Trying to read snapshot reports from {path_display}"); + let reports: Vec<(Vec, Vec)> = + deserialize_verified_snapshot_file_exact( + &reports_path, + &reports_component, + ) + .map_err(|error| match error { + ImportError::Bincode(_) => ImportError::Deserialize { + path: path_display.clone(), + }, + error => error, + })?; + let decoded_count = u64::try_from(reports.len()).map_err(|_| { + ImportError::SnapshotContents { + message: "decoded report count does not fit u64".to_string(), + } + })?; + if decoded_count != manifest_report_count { + return Err(ImportError::SnapshotContents { + message: format!( + "manifest advertises {manifest_report_count} reports but component contains {decoded_count}" + ), + }); + } + log::info!("Read {decoded_count} reports from {path_display}"); + Ok(reports) + }) + } else { + Box::pin(async { Ok(Vec::new()) }) + }; - // A vector of futures for reading and deserializing blocks from multiple files - let blocks_path = file_name_compose(path, Some("blocks_batch_*".into())); - let path_display = blocks_path.display().to_string(); - let blocks = match glob(&path_display) { - Ok(entries) => { - let mut blocks = Vec::new(); - for batch_path in entries.flatten() { - let fut = futurize_batch_read(batch_path); - blocks.push(fut); + let blocks = Ok(manifest + .block_batches + .into_iter() + .map(|batch| { + let batch_path = snapshot_blocks_batch_path(&generation_dir, batch.index); + futurize_manifest_batch_read(batch_path, batch) + }) + .collect()); + + ChainImport { + blocks, + chain_state, + superblocks, + reports, + reports_required, + protocol_info: manifest.protocol_info, } - Ok(blocks) } - Err(_) => Err(ImportError::FileRead { path: path_display }), + Err(manifest_error) => match snapshot_file_has_manifest_magic(&path) { + Ok(true) => failed_chain_import(manifest_error), + Ok(false) => match legacy_chain_import(&path) { + Ok(import) => import, + Err(error) => failed_chain_import(error), + }, + Err(error) => failed_chain_import(error), + }, }; - let import = Force::new( - ChainImport { - blocks, - chain_state, - superblocks, - }, - force, - ); + let import = Force::new(import, force); self.put_import(import); } } -fn futurize_batch_read( +/// Convert an import error into a failed chain-import state. It keeps the actor's import state machine explicit after snapshot or chain validation failure. +fn failed_chain_import(error: ImportError) -> ChainImport { + ChainImport { + blocks: Ok(Vec::new()), + chain_state: Box::pin(async move { Err(error) }), + superblocks: Box::pin(async { Ok(Vec::new()) }), + reports: Box::pin(async { Ok(Vec::new()) }), + reports_required: false, + protocol_info: None, + } +} + +/// Detect whether a snapshot file begins with the current manifest magic. It separates modern manifest imports from legacy component formats. +fn snapshot_file_has_manifest_magic(path: &Path) -> Result { + let path_display = path.display().to_string(); + let mut file = std::fs::File::open(path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + let mut magic = [0_u8; SNAPSHOT_MANIFEST_MAGIC.len()]; + match std::io::Read::read_exact(&mut file, &mut magic) { + Ok(()) => Ok(magic == SNAPSHOT_MANIFEST_MAGIC), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(_) => Err(ImportError::FileRead { path: path_display }), + } +} + +/// Resolve the legacy path for one snapshot component. It supports historical exports before generation manifests existed. +fn legacy_snapshot_component_path( + base_path: &Path, + suffix: &str, +) -> Result { + let stem = base_path.file_stem().ok_or_else(|| ImportError::SnapshotContents { + message: format!("legacy snapshot path {} has no file stem", base_path.display()), + })?; + let extension = base_path.extension().ok_or_else(|| ImportError::SnapshotContents { + message: format!("legacy snapshot path {} has no extension", base_path.display()), + })?; + let mut file_name = stem.to_os_string(); + file_name.push("-"); + file_name.push(suffix); + file_name.push("."); + file_name.push(extension); + Ok(base_path.with_file_name(file_name)) +} + +/// Enumerate legacy snapshot block-batch files in canonical order. It supplies compatibility import for pre-manifest snapshots. +fn legacy_snapshot_batch_paths(base_path: &Path) -> Result, ImportError> { + let parent = base_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent_display = parent.display().to_string(); + let stem = base_path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| ImportError::SnapshotContents { + message: "legacy snapshot file stem is not valid UTF-8".to_string(), + })?; + let extension = base_path + .extension() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| ImportError::SnapshotContents { + message: "legacy snapshot extension is not valid UTF-8".to_string(), + })?; + let prefix = format!("{stem}-blocks_batch_"); + let suffix = format!(".{extension}"); + let entries = std::fs::read_dir(parent).map_err(|_| ImportError::FileRead { + path: parent_display, + })?; + let mut indexed_paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|_| ImportError::FileRead { + path: parent.display().to_string(), + })?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let Some(remainder) = file_name.strip_prefix(&prefix) else { + continue; + }; + let digits = remainder.strip_suffix(&suffix).ok_or_else(|| { + ImportError::SnapshotContents { + message: format!( + "legacy snapshot batch-like file {file_name} has an invalid suffix" + ), + } + })?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ImportError::SnapshotContents { + message: format!( + "legacy snapshot batch-like file {file_name} has a non-numeric identity" + ), + }); + } + let index = digits.parse::().map_err(|_| ImportError::SnapshotContents { + message: format!("legacy snapshot batch identity in {file_name} overflows u64"), + })?; + indexed_paths.push((index, entry.path())); + } + indexed_paths.sort_unstable_by_key(|(index, _)| *index); + for (position, (index, _)) in indexed_paths.iter().enumerate() { + let expected = u64::try_from(position).map_err(|_| ImportError::SnapshotContents { + message: "legacy snapshot batch index does not fit u64".to_string(), + })?; + if *index != expected { + return Err(ImportError::SnapshotContents { + message: format!( + "legacy snapshot batches must have exact consecutive numeric identities: expected {expected}, found {index}" + ), + }); + } + } + + Ok(indexed_paths.into_iter().map(|(_, path)| path).collect()) +} + +/// Construct a chain import from a legacy snapshot path. It decodes historical component layouts before current manifest validation is applied. +fn legacy_chain_import(path: &Path) -> Result, ImportError> { + log::warn!( + "Importing legacy pre-manifest snapshot layout at {}; exact numeric batches will be preflighted, but the legacy files do not provide generation-level hashes", + path.display() + ); + let chain_state_path = path.to_path_buf(); + let chain_state = Box::pin(async move { + let path_display = chain_state_path.display().to_string(); + let bytes = std::fs::read(&chain_state_path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + deserialize_chain_state_snapshot_bytes(&bytes, &path_display, None, None) + }); + + let superblocks_path = legacy_snapshot_component_path(path, "superblocks")?; + let superblocks = Box::pin(async move { + let path_display = superblocks_path.display().to_string(); + let bytes = std::fs::read(&superblocks_path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + deserialize_snapshot_bytes_exact(&bytes).map_err(|_| ImportError::Deserialize { + path: path_display, + }) + }); + + let blocks = legacy_snapshot_batch_paths(path)? + .into_iter() + .map(futurize_legacy_batch_read) + .collect(); + Ok(ChainImport { + blocks: Ok(blocks), + chain_state, + superblocks, + reports: Box::pin(async { Ok(Vec::new()) }), + reports_required: false, + protocol_info: None, + }) +} + +/// Convert a legacy batch read into the actor's asynchronous import future. It preserves ordered error propagation while compatibility deserialization runs off the actor loop. +fn futurize_legacy_batch_read( batch_path: PathBuf, ) -> Pin< Box< - dyn futures_util::Future< - Output = Result, ImportError>, - > + std::marker::Send + dyn futures_util::Future, ImportError>> + + std::marker::Send + 'static, >, > { Box::pin(async move { let path_display = batch_path.display().to_string(); - let batch: Vec<_> = - deserialize_from_file(&batch_path).map_err(|e: ImportError| match e { - ImportError::Bincode(_) => ImportError::Deserialize { - path: path_display.clone(), - }, - ImportError::Io(_) => ImportError::FileRead { - path: path_display.clone(), - }, - e => e, - })?; - - log::info!("Read {} blocks from file {}", batch.len(), path_display); - + let bytes = std::fs::read(&batch_path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + let batch = deserialize_blocks_snapshot_bytes(&bytes).map_err(|_| { + ImportError::Deserialize { + path: path_display.clone(), + } + })?; + log::info!("Read {} legacy blocks from {path_display}", batch.len()); Ok(batch) }) } -impl Drop for ChainManager { - fn drop(&mut self) { - log::trace!("Dropping ChainManager"); - stop_system_if_panicking("ChainManager"); - } -} - -impl PutNodeOps for ChainManager { - fn put_node_ops(&mut self, ops: NodeOps) { - self.put_import_from_path(ops.snapshot_import()); - self.put_export(ops.snapshot_export()); - } +/// Load and decode a snapshot manifest from disk. It validates the manifest envelope before component paths or trust anchors are consumed. +fn load_snapshot_manifest(path: &Path) -> Result { + let path_display = path.display().to_string(); + let bytes = std::fs::read(path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + let manifest = match deserialize_snapshot_bytes_exact::(&bytes) { + Ok(manifest) => manifest, + Err(ImportError::Bincode(_)) => { + match deserialize_snapshot_bytes_exact::(&bytes) { + Ok(legacy) => legacy.into(), + Err(ImportError::Bincode(_)) => { + let legacy = deserialize_snapshot_bytes_exact::(&bytes) + .map_err(|_| ImportError::Deserialize { + path: path_display.clone(), + })?; + legacy.into() + } + Err(error) => return Err(error), + } + } + Err(error) => return Err(error), + }; + validate_snapshot_manifest(&manifest)?; + Ok(manifest) } -/// Wrapper around a block candidate that contains additional metadata regarding -/// needed chain state mutations in case the candidate gets consolidated. -#[derive(Debug)] -pub struct BlockCandidate { - /// Block - pub block: Block, - /// Utxo diff - pub utxo_diff: Diff, - /// Reputation - pub reputation: Reputation, - /// Vrf proof - pub vrf_proof: Hash, - /// Prority info - pub priorities: Priorities, +/// Load the optional trust anchor associated with a snapshot manifest. It returns absence for legacy snapshots and typed errors for malformed anchor files. +fn load_snapshot_trust_anchor(path: &Path) -> Result, ImportError> { + let anchor_path = path.with_extension("anchor"); + let path_display = anchor_path.display().to_string(); + let bytes = match std::fs::read(&anchor_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(ImportError::FileRead { path: path_display }), + }; + let anchor = deserialize_snapshot_bytes_exact::(&bytes) + .map_err(|_| ImportError::Deserialize { + path: path_display.clone(), + })?; + anchor + .validate_envelope() + .map_err(|error| ImportError::SnapshotContents { + message: format!("invalid snapshot trust anchor: {error}"), + })?; + Ok(Some(anchor)) } -/// Required trait for being able to retrieve ChainManager address from registry -impl Supervised for ChainManager {} - -/// Required trait for being able to retrieve ChainManager address from registry -impl SystemService for ChainManager {} - -/// Auxiliary methods for ChainManager actor -impl ChainManager { - /// Persist previous chain state into storage - /// None case: persist current chain state into storage (during synchronization) - fn persist_chain_state( - &mut self, - superblock_index: Option, - ) -> ResponseActFuture> { - let previous_chain_state = if let Some(superblock_index) = superblock_index { - let chain_state_snapshot = self.chain_state_snapshot.restore(superblock_index); +/// Validate the external authorization required before importing a non-genesis snapshot carrying delegated-staking state. It checks the manifest component, canonical schedule, signature, configured trust roots, and chain identity before import proceeds. +pub(crate) fn validate_snapshot_trust_anchor( + anchor: Option<&SnapshotTrustAnchor>, + imported_chain_info: &ChainInfo, + chain_state: &ChainState, + manifest_chain_state_component: Option<&SnapshotComponent>, + protocol_info: Option<&ProtocolInfo>, + trusted_public_keys: &[String], +) -> Result<(), ImportError> { + let non_genesis = imported_chain_info.highest_block_checkpoint.checkpoint > 0 + || imported_chain_info.highest_superblock_checkpoint.checkpoint > 0; + let delegation_state = &chain_state.delegation_state; + let carries_delegated_history = delegation_state.activation_initialized + || !delegation_state.validators.is_empty() + || !delegation_state.collateral_reservations.is_empty() + || !delegation_state.legacy_activation_obligations.is_empty(); + let delegated = carries_delegated_history + || protocol_info.is_some_and(|info| { + info.all_versions + .try_get_activation_epoch(ProtocolVersion::V2_1) + .is_some() + }); - if chain_state_snapshot.is_none() { - return Box::pin(actix::fut::ok(())); - } + // Genesis-only and pre-delegated historical imports remain compatible with the existing + // Wit/2.0 snapshot formats. The closed production release gate still prevents this branch + // from activating Wit/2.1 in a production runtime. + if !(non_genesis && delegated) { + return Ok(()); + } - chain_state_snapshot.unwrap() - } else { - // None case is used to persist chain_state during synchronization - self.chain_state.clone() - }; + let anchor = anchor.ok_or_else(|| ImportError::SnapshotContents { + message: "non-genesis delegated snapshot requires an external trust anchor".to_string(), + })?; + let component = manifest_chain_state_component.ok_or_else(|| ImportError::SnapshotContents { + message: + "non-genesis delegated snapshot has no manifest ChainState component to authenticate" + .to_string(), + })?; + let protocol_info = protocol_info.ok_or_else(|| ImportError::SnapshotContents { + message: "non-genesis delegated snapshot has no protocol schedule to authenticate" + .to_string(), + })?; + if trusted_public_keys.is_empty() { + return Err(ImportError::SnapshotContents { + message: "non-genesis delegated snapshot requires at least one configured snapshot trust root" + .to_string(), + }); + } - // When updating the chain state, we need to update the highest superblock checkpoint. - // This is the highest superblock that obtained a majority of votes and we do not want to - // lose it when restoring the state. - let mut state = ChainState { - chain_info: Some(ChainInfo { - highest_superblock_checkpoint: self.get_superblock_beacon(), - ..previous_chain_state.chain_info.as_ref().unwrap().clone() - }), - superblock_state: self.chain_state.superblock_state.clone(), - ..previous_chain_state + anchor + .verify_signature() + .map_err(|error| ImportError::SnapshotContents { + message: format!("snapshot trust anchor signature is invalid: {error}"), + })?; + + let signer_is_trusted = trusted_public_keys.iter().any(|configured| { + let normalized = configured + .strip_prefix("0x") + .or_else(|| configured.strip_prefix("0X")) + .unwrap_or(configured); + let Ok(bytes) = hex::decode(normalized) else { + return false; }; + PublicKey::try_from_slice(&bytes) + .map(|trusted| trusted == anchor.signer) + .unwrap_or(false) + }); + if !signer_is_trusted { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor signer is not in the configured trust roots" + .to_string(), + }); + } - let chain_beacon = state.get_chain_beacon(); - let superblock_beacon = state.get_superblock_beacon(); - - if let Some(superblock_index) = superblock_index { - log::debug!( - "Persisting chain state for superblock #{superblock_index} with chain beacon {chain_beacon:?} and super beacon {superblock_beacon:?}" - ); - - assert_eq!(superblock_beacon.checkpoint, superblock_index); - } else { - log::debug!( - "Persisting chain state during synchronization, chain beacon: {chain_beacon:?}" - ); + if anchor.payload.network_magic != imported_chain_info.consensus_constants.get_magic() { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor network magic does not match the imported chain" + .to_string(), + }); + } + if anchor.payload.genesis_hash != imported_chain_info.consensus_constants.genesis_hash { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor genesis hash does not match the imported chain" + .to_string(), + }); + } + if anchor.payload.finalized_superblock_checkpoint + != imported_chain_info.highest_superblock_checkpoint + { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor finalized superblock does not match the imported chain" + .to_string(), + }); + } + if anchor.payload.chain_state_sha256 != component.sha256 { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor ChainState digest does not match the manifest" + .to_string(), + }); + } + let schedule_bytes = protocol_info.canonical_schedule_bytes().map_err(|error| { + ImportError::SnapshotContents { + message: format!("could not serialize snapshot protocol schedule: {error}"), } + })?; + if anchor.payload.protocol_schedule_sha256 != calculate_sha256(&schedule_bytes).0 { + return Err(ImportError::SnapshotContents { + message: "snapshot trust anchor protocol schedule digest does not match the manifest" + .to_string(), + }); + } - // Update UTXO set: - // * Remove from memory the UTXOs that will be persisted - // * Persist the consolidated UTXOs to the database - self.chain_state - .unspent_outputs_pool - .remove_persisted_from_memory(&state.unspent_outputs_pool.diff); - let mut batch = UtxoWriteBatch::default(); - state.unspent_outputs_pool.persist_add_to_batch(&mut batch); + Ok(()) +} - let fut = storage_mngr::put_chain_state_in_batch( - &storage_keys::chain_state_key(self.get_magic()), - &state, - batch, - ) - .into_actor(self) - .and_then(|_, _, _| { - log::debug!("Successfully persisted previous_chain_state into storage"); - fut::ok(()) - }) +/// Validate snapshot manifest structure and component references. It checks digest metadata and trust-anchor bindings before any component is imported. +fn validate_snapshot_manifest(manifest: &SnapshotManifest) -> Result<(), ImportError> { + if manifest.magic != SNAPSHOT_MANIFEST_MAGIC { + return Err(ImportError::SnapshotContents { + message: "snapshot manifest magic does not match".to_string(), + }); + } + if !matches!( + manifest.format_version, + SNAPSHOT_MANIFEST_VERSION_V1 | SNAPSHOT_MANIFEST_VERSION_V2 | SNAPSHOT_MANIFEST_VERSION + ) { + return Err(ImportError::SnapshotContents { + message: format!( + "unsupported snapshot manifest version {}, expected {}, {}, or {}", + manifest.format_version, + SNAPSHOT_MANIFEST_VERSION_V1, + SNAPSHOT_MANIFEST_VERSION_V2, + SNAPSHOT_MANIFEST_VERSION + ), + }); + } + if manifest.format_version == SNAPSHOT_MANIFEST_VERSION_V1 { + if manifest.reports.is_some() || manifest.report_count != 0 { + return Err(ImportError::SnapshotContents { + message: "version-1 snapshot manifest unexpectedly contains reports".to_string(), + }); + } + } else if manifest.reports.is_none() { + return Err(ImportError::SnapshotContents { + message: format!( + "version-{} snapshot manifest is missing its report component", + manifest.format_version + ), + }); + } + if manifest.format_version == SNAPSHOT_MANIFEST_VERSION { + let Some(protocol_info) = manifest.protocol_info.as_ref() else { + return Err(ImportError::SnapshotContents { + message: "version-3 snapshot manifest is missing its protocol schedule" + .to_string(), + }); + }; + protocol_info.validate().map_err(|error| ImportError::SnapshotContents { + message: format!("version-3 snapshot manifest has an invalid protocol schedule: {error}"), + })?; + } else if manifest.protocol_info.is_some() { + return Err(ImportError::SnapshotContents { + message: format!( + "version-{} snapshot manifest unexpectedly contains a protocol schedule", + manifest.format_version + ), + }); + } + if !matches!( + manifest.chain_state_version, + storage_mngr::DELEGATION_CHAIN_STATE_VERSION + | storage_mngr::PRE_EXIT_CHAIN_STATE_VERSION + | storage_mngr::PRE_RECOVERY_CHAIN_STATE_VERSION + | storage_mngr::CURRENT_CHAIN_STATE_VERSION + ) { + return Err(ImportError::SnapshotContents { + message: format!( + "unsupported snapshot ChainState version {}; expected {}, {}, {}, or {}", + manifest.chain_state_version, + storage_mngr::DELEGATION_CHAIN_STATE_VERSION, + storage_mngr::PRE_EXIT_CHAIN_STATE_VERSION, + storage_mngr::PRE_RECOVERY_CHAIN_STATE_VERSION, + storage_mngr::CURRENT_CHAIN_STATE_VERSION + ), + }); + } + if manifest.chain_state.byte_len == 0 || manifest.superblocks.byte_len == 0 { + return Err(ImportError::SnapshotContents { + message: "snapshot manifest contains an empty required component".to_string(), + }); + } + + let mut advertised_blocks = 0_u64; + for (position, batch) in manifest.block_batches.iter().enumerate() { + let expected_index = u64::try_from(position).map_err(|_| ImportError::SnapshotContents { + message: "snapshot batch index does not fit u64".to_string(), + })?; + if batch.index != expected_index { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block batches must have exact consecutive numeric identities: expected {expected_index}, found {}", + batch.index + ), + }); + } + if batch.block_count == 0 || batch.block_count > SNAPSHOT_BLOCK_BATCH_SIZE as u64 { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot block batch {} has invalid record count {}", + batch.index, batch.block_count + ), + }); + } + if batch.component.byte_len == 0 { + return Err(ImportError::SnapshotContents { + message: format!("snapshot block batch {} is empty", batch.index), + }); + } + if position + 1 != manifest.block_batches.len() + && batch.block_count != SNAPSHOT_BLOCK_BATCH_SIZE as u64 + { + return Err(ImportError::SnapshotContents { + message: format!( + "non-final snapshot block batch {} is not full", + batch.index + ), + }); + } + advertised_blocks = advertised_blocks.checked_add(batch.block_count).ok_or_else(|| { + ImportError::SnapshotContents { + message: "snapshot manifest block count overflow".to_string(), + } + })?; + } + if advertised_blocks != manifest.block_count { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot batches advertise {advertised_blocks} blocks but manifest expects {}", + manifest.block_count + ), + }); + } + + Ok(()) +} + +/// Open one snapshot component after checking its manifest digest and size. It returns a reader that downstream deserializers can trust as the named component. +fn open_verified_snapshot_component( + path: &Path, + component: &SnapshotComponent, +) -> Result, ImportError> { + let path_display = path.display().to_string(); + let file = std::fs::File::open(path).map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + let mut reader = BufReader::new(file); + let (actual_len, actual_hash) = calculate_sha256_reader(&mut reader).map_err(|_| { + ImportError::FileRead { + path: path_display.clone(), + } + })?; + if actual_len != component.byte_len || actual_hash.0 != component.sha256 { + return Err(ImportError::SnapshotContents { + message: format!( + "snapshot component {path_display} does not match its manifest length/hash" + ), + }); + } + + reader + .seek(SeekFrom::Start(0)) + .map_err(|_| ImportError::FileRead { path: path_display })?; + Ok(reader) +} + +/// Deserialize one reader's exact bincode payload. Exact EOF checking preserves the prior +/// rejection of trailing bytes without retaining the raw component alongside its decoded value. +fn deserialize_snapshot_reader_exact(reader: &mut R) -> Result +where + D: serde::de::DeserializeOwned, + R: Read, +{ + let value = bincode::deserialize_from(&mut *reader)?; + let mut trailing = [0_u8; 1]; + if reader.read(&mut trailing)? != 0 { + return Err(ImportError::Bincode(Box::new( + bincode::ErrorKind::Custom("snapshot payload contains trailing bytes".to_string()), + ))); + } + + Ok(value) +} + +/// Deserialize one snapshot component directly from disk after its streaming digest has +/// matched. Hashing and decoding share one file descriptor to avoid a time-of-check/time-of-use +/// gap if a component is modified while an import is in progress. +fn deserialize_verified_snapshot_file_exact( + path: &Path, + component: &SnapshotComponent, +) -> Result +where + D: serde::de::DeserializeOwned, +{ + let mut reader = open_verified_snapshot_component(path, component)?; + deserialize_snapshot_reader_exact(&mut reader) +} + +/// Convert a verified manifest batch reader into an asynchronous chain-import future. It preserves batch ordering and propagates exact component errors to the actor. +fn futurize_manifest_batch_read( + batch_path: PathBuf, + manifest_batch: SnapshotBlockBatch, +) -> Pin< + Box< + dyn futures_util::Future< + Output = Result, ImportError>, + > + std::marker::Send + + 'static, + >, +> { + Box::pin(async move { + let path_display = batch_path.display().to_string(); + let batch = deserialize_blocks_snapshot_file(&batch_path, &manifest_batch.component) + .map_err(|error| match error { + ImportError::Bincode(_) => ImportError::Deserialize { + path: path_display.clone(), + }, + error => error, + })?; + let decoded_count = u64::try_from(batch.len()).map_err(|_| { + ImportError::SnapshotContents { + message: format!("block count in {path_display} does not fit u64"), + } + })?; + if decoded_count != manifest_batch.block_count { + return Err(ImportError::SnapshotContents { + message: format!( + "manifest batch {} advertises {} blocks but component contains {decoded_count}", + manifest_batch.index, manifest_batch.block_count + ), + }); + } + + log::info!("Read {} blocks from file {}", batch.len(), path_display); + + Ok(batch) + }) +} + +/// Verify and decode one manifest-bound block batch directly from disk. Current-layout batches +/// avoid retaining their serialized bytes; only the historical compatibility fallback performs a +/// raw read because its deserializers require the complete byte slice. +fn deserialize_blocks_snapshot_file( + path: &Path, + component: &SnapshotComponent, +) -> Result, ImportError> { + let mut reader = open_verified_snapshot_component(path, component)?; + match deserialize_snapshot_reader_exact::, _>(&mut reader) { + Ok(blocks) => Ok(blocks), + Err(ImportError::Bincode(_)) => { + let path_display = path.display().to_string(); + reader + .seek(SeekFrom::Start(0)) + .map_err(|_| ImportError::FileRead { + path: path_display.clone(), + })?; + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| ImportError::FileRead { + path: path_display, + })?; + deserialize_blocks_snapshot_bytes(&bytes) + } + Err(error) => Err(error), + } +} + +/// Decode the exact bytes committed by the manifest, with a one-time exact fallback for batches +/// serialized before `StakeOutput::authorization_terms` was added. +fn deserialize_blocks_snapshot_bytes(bytes: &[u8]) -> Result, ImportError> { + match deserialize_snapshot_bytes_exact(bytes) { + Ok(blocks) => Ok(blocks), + Err(ImportError::Bincode(_)) => bincode_compatibility::deserialize_blocks(bytes) + .map_err(ImportError::Bincode), + Err(error) => Err(error), + } +} + +fn deserialize_snapshot_bytes_exact(bytes: &[u8]) -> Result +where + D: serde::de::DeserializeOwned, +{ + let mut reader = std::io::Cursor::new(bytes); + let value = bincode::deserialize_from(&mut reader)?; + if reader.position() != bytes.len() as u64 { + return Err(ImportError::Bincode(Box::new( + bincode::ErrorKind::Custom( + "snapshot payload contains trailing bytes".to_string(), + ), + ))); + } + + Ok(value) +} + +/// Decode the manifest-verified versioned ChainState bytes, while retaining exact raw-current and +/// raw-v7/raw-v5 component compatibility for explicitly repackaged legacy data. +fn deserialize_chain_state_snapshot_bytes( + bytes: &[u8], + path_display: &str, + expected_version: Option, + protocol_info: Option<&ProtocolInfo>, +) -> Result { + let first_byte = *bytes.first().ok_or_else(|| ImportError::Deserialize { + path: path_display.to_string(), + })?; + + if matches!(first_byte, 0 | 1) { + if expected_version.is_some() { + return Err(ImportError::Deserialize { + path: path_display.to_string(), + }); + } + return match deserialize_snapshot_bytes_exact(bytes) { + Ok(chain_state) => { + validate_imported_chain_state(chain_state, path_display, protocol_info) + } + Err(ImportError::Bincode(_)) => { + let chain_state = storage_mngr::deserialize_legacy_chain_state_snapshot(bytes) + .map_err(ImportError::Bincode)?; + validate_imported_chain_state(chain_state, path_display, protocol_info) + } + Err(error) => Err(error), + }; + } + + let (version, chain_state) = storage_mngr::deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(bytes), + ) + .map_err(ImportError::Bincode)?; + if expected_version.is_some_and(|expected| version != expected) { + return Err(ImportError::Deserialize { + path: path_display.to_string(), + }); + } + + validate_imported_chain_state(chain_state, path_display, protocol_info) +} + +/// Deserialize a manifest-bound, versioned ChainState directly from its hash-verified file. This +/// avoids retaining the raw component next to the decoded (and potentially very large portable +/// UTXO) image. Raw legacy snapshots still use the byte-slice compatibility path above because +/// they require historical layout discrimination. +fn deserialize_chain_state_snapshot_file( + path: &Path, + component: &SnapshotComponent, + path_display: &str, + expected_version: Option, + protocol_info: Option<&ProtocolInfo>, +) -> Result { + let mut reader = open_verified_snapshot_component(path, component)?; + let (version, chain_state) = storage_mngr::deserialize_versioned_chain_state_snapshot_reader( + &mut reader, + ) + .map_err(ImportError::Bincode)?; + if expected_version.is_some_and(|expected| version != expected) { + return Err(ImportError::Deserialize { + path: path_display.to_string(), + }); + } + + validate_imported_chain_state(chain_state, path_display, protocol_info) +} + +/// Validate the chain state reconstructed from imported snapshot components. It compares protocol, beacon, UTXO, and delegated-staking state before activation can continue. +fn validate_imported_chain_state( + chain_state: ChainState, + path_display: &str, + protocol_info: Option<&ProtocolInfo>, +) -> Result { + let validation = match protocol_info { + Some(protocol_info) => { + storage_mngr::validate_delegated_staking_persistence_with_protocol_info( + &chain_state, + protocol_info, + ) + } + None => storage_mngr::validate_delegated_staking_persistence(&chain_state), + }; + validation.map_err(|error| { + log::error!( + "Cannot import ChainState from {path_display}: delegated-staking persistence invariant failed: {error}" + ); + ImportError::Deserialize { + path: path_display.to_string(), + } + })?; + Ok(chain_state) +} + +impl Drop for ChainManager { + fn drop(&mut self) { + log::trace!("Dropping ChainManager"); + stop_system_if_panicking("ChainManager"); + } +} + +impl PutNodeOps for ChainManager { + fn put_node_ops(&mut self, ops: NodeOps) { + self.put_import_from_path(ops.snapshot_import()); + self.put_export(ops.snapshot_export()); + } +} + +/// Wrapper around a block candidate that contains additional metadata regarding +/// needed chain state mutations in case the candidate gets consolidated. +#[derive(Debug)] +pub struct BlockCandidate { + /// Block + pub block: Block, + /// Utxo diff + pub utxo_diff: Diff, + /// Chain height used to validate the block and construct its UTXO diff. + pub block_number: u32, + /// Reputation + pub reputation: Reputation, + /// Vrf proof + pub vrf_proof: Hash, + /// Prority info + pub priorities: Priorities, + /// Protocol schedule generation used for candidate validation and ranking. + /// + /// A candidate may remain in memory until the next epoch notification. Keep the generation + /// alongside it so an activation/schedule publication cannot make the epoch handler consolidate + /// a block validated under an obsolete fork view. + pub protocol_generation: u64, +} + +/// Consolidation effects held until the matching ChainState commit is acknowledged. +#[derive(Debug)] +pub(crate) struct PendingSuperblockConsolidation { + beacon: CheckpointBeacon, + notify: Option, +} + +/// Required trait for being able to retrieve ChainManager address from registry +impl Supervised for ChainManager {} + +/// Required trait for being able to retrieve ChainManager address from registry +impl SystemService for ChainManager {} + +/// Auxiliary methods for ChainManager actor +impl ChainManager { + /// Persist previous chain state into storage + /// None case: persist the current state as a replay/synchronization checkpoint without + /// finalizing an ordinary superblock. + fn persist_chain_state( + &mut self, + superblock_index: Option, + superblock_notification: Option, + ) -> ResponseActFuture> { + if superblock_index.is_none() && superblock_notification.is_some() { + log::error!( + "Refusing to persist an ordinary superblock notification without a finalized checkpoint" + ); + return Box::pin(actix::fut::err(())); + } + if superblock_index.is_some_and(|index| index != 0) + && superblock_notification.is_none() + { + log::error!( + "Refusing to persist a non-bootstrap finalized checkpoint without its superblock notification" + ); + return Box::pin(actix::fut::err(())); + } + + let previous_chain_state = if let Some(superblock_index) = superblock_index { + let chain_state_snapshot = match self.chain_state_snapshot.try_restore(superblock_index) { + Ok(snapshot) => snapshot, + Err(error) => { + log::error!( + "Refusing to persist superblock #{superblock_index}: invalid ChainState snapshot: {error}" + ); + return Box::pin(actix::fut::err(())); + } + }; + + if chain_state_snapshot.is_none() { + // The matching ChainState checkpoint is already durable. This is the only safe + // inventory-only path: it repairs an absent legacy record or exact-replays a lost + // acknowledgement after the snapshot journal proves durability and the storage + // worker binds the notification to the reread durable state. + return match superblock_notification { + Some(notification) => { + if notification.superblock.index != superblock_index { + log::error!( + "Refusing to repair superblock #{} inventory with notification #{}", + superblock_index, + notification.superblock.index + ); + Box::pin(actix::fut::err(())) + } else { + let chain_state_key = + storage_keys::chain_state_key(self.get_magic()); + Box::pin( + storage_mngr::repair_ordinary_superblock_inventory( + chain_state_key, + notification, + ) + .into_actor(self) + .map_err(move |error, _act, _ctx| { + log::error!( + "Failed to repair already-persisted superblock #{superblock_index} inventory: {error}" + ); + }), + ) + } + } + None => Box::pin(actix::fut::ok(())), + }; + } + + match chain_state_snapshot { + Some(chain_state) => chain_state, + None => { + // The no-snapshot branch above is expected to return immediately. Keep this + // boundary fallible as well so a future change cannot turn a missing finalized + // ChainState into an actor panic. + log::error!( + "Refusing to persist finalized superblock #{superblock_index}: ChainState snapshot disappeared before publication" + ); + return Box::pin(actix::fut::err(())); + } + } + } else { + // None is a non-finalizing replay/synchronization checkpoint. + // ChainState cloning rebuilds stake secondary indexes from the canonical map. Once + // delegated staking is authoritative, reject malformed source indexes before that + // normalization can be persisted as an apparently valid snapshot. + if self.chain_state.delegation_state.activation_initialized { + if let Err(error) = self.chain_state.stakes.validate_validator_index_checked() { + log::error!( + "Refusing to persist ChainState with invalid delegated stake indexes: {error}" + ); + return Box::pin(actix::fut::err(())); + } + } + match self.chain_state.try_clone_checked() { + Ok(chain_state) => chain_state, + Err(error) => { + log::error!( + "Refusing to persist ChainState because the stake ledger cannot be cloned: {error}" + ); + return Box::pin(actix::fut::err(())); + } + } + }; + + // Validate before reading required fields or mutating either the live in-memory UTXO diff + // or the snapshot copy that will be drained into the write batch. This also turns a missing + // ChainInfo into an ordinary persistence failure instead of reaching the unwrap below. The + // storage-layer guard remains as a second line of defense over the final state. + if let Err(error) = + storage_mngr::validate_delegated_staking_persistence(&previous_chain_state) + { + log::error!( + "Refusing to persist ChainState: delegated-staking persistence invariant failed: {error}" + ); + return Box::pin(actix::fut::err(())); + } + let Some(previous_chain_info) = previous_chain_state.chain_info.as_ref() else { + log::error!("Refusing to persist an initialized ChainState without ChainInfo"); + return Box::pin(actix::fut::err(())); + }; + let superblock_period = previous_chain_info.consensus_constants.superblock_period; + + // When updating the chain state, we need to update the highest superblock checkpoint. + // This is the highest superblock that obtained a majority of votes and we do not want to + // lose it when restoring the state. + let mut state = ChainState { + chain_info: Some(ChainInfo { + highest_superblock_checkpoint: self.get_superblock_beacon(), + ..previous_chain_info.clone() + }), + superblock_state: self.chain_state.superblock_state.clone(), + ..previous_chain_state + }; + + let chain_beacon = state.get_chain_beacon(); + let superblock_beacon = state.get_superblock_beacon(); + + // Clear the process-local rollback journal only after a finalized-superblock ChainState + // containing the activation trigger at its exact indexed epoch is acknowledged by the same + // atomic write that persists its UTXO diff and restart certificate. A + // synchronization/replay checkpoint (`None`) does not carry proof of finality and must + // never discharge this journal, even if a future caller happens to persist a state + // containing the trigger. + // Every durable ChainState that already contains a newly discovered trigger must carry + // the matching restart certificate in the same batch. Otherwise a synchronization/replay + // checkpoint (`None`) could survive a process crash while the schedule remained only in + // this process-local journal. Finality is still required to *discharge* the journal; the + // provisional certificate merely makes the exact durable checkpoint self-describing. + let restored_protocol_activation_rollback = + match storage_mngr::restored_wit2_activation_rollback() { + Ok(rollback) => rollback, + Err(error) => { + log::error!( + "Refusing to persist ChainState because the restored protocol-activation journal is unreadable: {error}" + ); + return Box::pin(actix::fut::err(())); + } + }; + let persisted_protocol_activation_certificate = self + .pending_protocol_activation_rollback + .as_ref() + .filter(|pending| { + state.block_chain.get(&pending.trigger_epoch) == Some(&pending.trigger_hash) + }) + .map(|pending| { + storage_mngr::Wit2ActivationCertificate::new( + pending.trigger_epoch, + pending.trigger_hash, + pending.activation_epoch, + pending.checkpoint_period, + ) + }) + .or_else(|| { + restored_protocol_activation_rollback + .as_ref() + .filter(|pending| { + state.block_chain.get(&pending.trigger_epoch) + == Some(&pending.trigger_hash) + }) + .map(|pending| { + storage_mngr::Wit2ActivationCertificate::new( + pending.trigger_epoch, + pending.trigger_hash, + pending.activation_epoch, + pending.checkpoint_period, + ) + }) + }); + let persisted_protocol_activation_trigger = match ( + superblock_index, + persisted_protocol_activation_certificate.as_ref(), + ) { + (Some(superblock_index), Some(certificate)) => { + match storage_mngr::finalized_wit2_activation_trigger( + certificate, + superblock_index, + superblock_period, + ) { + Ok(trigger) => trigger, + Err(error) => { + log::error!( + "Refusing to persist superblock #{superblock_index}: cannot derive the finalized Wit/2 activation boundary: {error}" + ); + return Box::pin(actix::fut::err(())); + } + } + } + _ => None, + }; + + if let Some(superblock_index) = superblock_index { + log::debug!( + "Persisting chain state for superblock #{superblock_index} with chain beacon {chain_beacon:?} and super beacon {superblock_beacon:?}" + ); + + if superblock_beacon.checkpoint != superblock_index { + log::error!( + "Refusing to persist superblock #{superblock_index}: snapshot beacon is for superblock #{}", + superblock_beacon.checkpoint + ); + return Box::pin(actix::fut::err(())); + } + } else { + log::debug!( + "Persisting chain state during synchronization, chain beacon: {chain_beacon:?}" + ); + } + + // Stage the captured UTXO diff without removing it from live memory yet. Only a + // successful atomic database write proves that these changes are persisted; keeping the + // live copy intact makes storage/backend errors retryable. + let persisted_utxo_diff = state.unspent_outputs_pool.diff.clone(); + let mut batch = UtxoWriteBatch::default(); + if let Err(error) = state + .unspent_outputs_pool + .try_persist_add_to_batch(&mut batch) + { + log::error!( + "Refusing to persist superblock {:?}: invalid UTXO diff: {error}", + superblock_index + ); + return Box::pin(actix::fut::err(())); + } + + let fut = storage_mngr::put_chain_state_with_wit2_activation_in_batch( + &storage_keys::chain_state_key(self.get_magic()), + &state, + batch, + persisted_protocol_activation_certificate, + None, + superblock_notification, + ) + .into_actor(self) + .and_then(move |_, act, _| { + if persisted_protocol_activation_trigger.is_some_and(|(epoch, hash)| { + act.pending_protocol_activation_rollback + .as_ref() + .is_some_and(|pending| { + pending.trigger_epoch == epoch && pending.trigger_hash == hash + }) + }) { + act.pending_protocol_activation_rollback = None; + } + if let Some((epoch, hash)) = persisted_protocol_activation_trigger { + if let Err(error) = storage_mngr::acknowledge_restored_wit2_activation(epoch, hash) { + log::error!( + "Persisted ChainState but could not acknowledge the restored protocol-activation journal: {error}" + ); + System::current().stop_with_code(1); + return actix::fut::err(anyhow::anyhow!( + "failed to acknowledge the restored Wit/2 activation journal" + )); + } + } + if let Some(superblock_index) = superblock_index { + if let Err(error) = act + .chain_state_snapshot + .try_mark_persisted(superblock_index) + { + log::error!( + "Persisted ChainState but failed to acknowledge snapshot for superblock #{superblock_index}: {error}" + ); + // The backend write succeeded, but the in-memory journal + // cannot prove that fact consistently. Stop before any + // further consolidation can build on ambiguous state. + System::current().stop_with_code(1); + return actix::fut::err(anyhow::anyhow!( + "failed to acknowledge the persisted snapshot generation" + )); + } + } + act.chain_state + .unspent_outputs_pool + .remove_persisted_from_memory(&persisted_utxo_diff); + log::debug!("Successfully persisted previous_chain_state into storage"); + fut::ok(()) + }) .map_err(|err, _, _| { log::error!("Failed to persist previous_chain_state into storage: {err}") }); @@ -520,11 +1911,31 @@ impl ChainManager { fn delete_chain_state_and_reinitialize(&mut self) -> ResponseActFuture> { // Delete all the UTXOs from the database let mut batch = UtxoWriteBatch::default(); - self.chain_state + if let Err(error) = self + .chain_state .unspent_outputs_pool - .delete_all_from_db_batch(&mut batch); + .try_delete_all_from_db_batch(&mut batch) + { + log::error!("Failed to enumerate UTXOs before ChainState reset: {error}"); + return Box::pin(fut::err(())); + } let empty_state = ChainState::default(); + let protocol_schedule_key = + storage_keys::protocol_schedule_key(self.get_magic()); + let protocol_schedule_key = match bincode::serialize(&protocol_schedule_key) { + Ok(key) => key, + Err(error) => { + log::error!( + "Failed to serialize protocol schedule key before ChainState reset: {error}" + ); + return Box::pin(fut::err(())); + } + }; + // A reset intentionally leaves the historical Wit/2 activation certificate available for + // replay diagnostics, but the snapshot-authenticated schedule belongs to the replaced + // ChainState and must not survive as an orphan companion. + batch.delete_raw(protocol_schedule_key); let fut = storage_mngr::put_chain_state_in_batch( &storage_keys::chain_state_key(self.get_magic()), &empty_state, @@ -538,7 +1949,7 @@ impl ChainManager { log::info!("Successfully persisted empty chain state into storage"); act.update_state_machine(StateMachine::WaitingConsensus, ctx); - act.initialize_from_storage_fut(true) + act.initialize_from_storage_fut(true, false) }); Box::pin(fut) @@ -548,13 +1959,15 @@ impl ChainManager { /// /// The blocks are assumed to be valid, so validations are skipped, and block metadata is not /// persisted to the storage because it is assumed to already be there. - fn resync_from_storage( + fn resync_from_storage( &mut self, mut block_list: VecDeque<(Epoch, Hash)>, ctx: &mut Context, done: F, + failed: E, ) where F: FnOnce(&mut Self, &mut Context) + 'static, + E: FnOnce(anyhow::Error) + 'static, { if block_list.is_empty() { // Done, all the blocks have been processed @@ -563,49 +1976,117 @@ impl ChainManager { return; } - let last_epoch = block_list.back().unwrap().0; - let (epoch, hash) = block_list.pop_front().unwrap(); + // The empty-list branch above is the normal proof that both accesses are available, but + // keep this asynchronous boundary panic-free if a future caller changes the queue between + // the check and extraction. A missing item means the rewind is already complete. + let Some(last_epoch) = block_list.back().map(|(epoch, _)| *epoch) else { + done(self, ctx); + return; + }; + let Some((epoch, hash)) = block_list.pop_front() else { + done(self, ctx); + return; + }; + let protocol_info = get_protocol_info(); let inventory_manager_addr = InventoryManager::from_registry(); inventory_manager_addr - .send(GetItemBlock { hash }) + .send(GetItemBlock { + hash, + protocol_info: Some(protocol_info), + }) .into_actor(self) .map(move |res, act, ctx| { match res { Ok(Ok(block)) => { log::info!("REWIND [{epoch}/{last_epoch}] Got block {hash} from storage"); - act.process_requested_block(ctx, block, true) - .expect("resync from storage fail"); + if let Err(error) = act.process_requested_block(ctx, block, true) { + // Rewind starts from a freshly persisted empty ChainState. A replay + // failure therefore cannot be repaired by continuing with later + // blocks; stop the node after recording the represented error so the + // durable empty state can be recovered explicitly on restart. + log::error!( + "Cannot resync block {hash} from storage during rewind: {error}" + ); + failed(error); + return; + } // We need to persist the chain state periodically, otherwise the entire // UTXO set will be in memory, consuming a huge amount of memory. if block_list.len().is_multiple_of(1_000) { - act.persist_chain_state(None) - .map(|_res: Result<(), ()>, _act, _ctx| ()) + // Do not schedule the next replay batch until this checkpoint is + // durable. `Context::wait` queues the future but returns immediately; + // recursing here would otherwise keep mutating the in-memory state + // (and potentially hit more storage failures) before the persistence + // result is known. + act.persist_chain_state(None, None) + .then(move |result: Result<(), ()>, act, ctx| { + if result.is_err() { + let error = anyhow::anyhow!( + "cannot persist replayed ChainState during rewind" + ); + log::error!( + "Cannot persist replayed ChainState during rewind; stopping the node: {error}" + ); + failed(error); + } else { + act.resync_from_storage(block_list, ctx, done, failed); + } + actix::fut::ready(()) + }) .wait(ctx); + return; } // Recursion - act.resync_from_storage(block_list, ctx, done); + act.resync_from_storage(block_list, ctx, done, failed); } Ok(Err(e)) => { - panic!("{e:?}"); + let error = anyhow::anyhow!( + "cannot load block {hash} from storage during rewind: {e}" + ); + log::error!("{error}"); + failed(error); } Err(e) => { - panic!("{e:?}"); + let error = anyhow::anyhow!( + "inventory manager mailbox failed for block {hash} during rewind: {e}" + ); + log::error!("{error}"); + failed(error); } } }) .spawn(ctx); } - /// Replace `previous_chain_state` with current `chain_state` - fn move_chain_state_forward(&mut self, superblock_index: u32) { - self.chain_state_snapshot - .take(superblock_index, &self.chain_state); + /// Replace `previous_chain_state` with current `chain_state`. + /// + /// Snapshot failure is returned to the actor future as well as triggering the existing + /// fail-stop path. Callers must not continue building, replaying, or persisting a block after + /// the snapshot journal has rejected the state. + fn move_chain_state_forward(&mut self, superblock_index: u32) -> Result<(), anyhow::Error> { + match self + .chain_state_snapshot + .try_take(superblock_index, &self.chain_state) + { + Ok(_) => Ok(()), + Err(error) => { + log::error!( + "Cannot retain ChainState snapshot for superblock #{superblock_index}: {error}" + ); + // A snapshot mismatch means the next persistence operation cannot + // be trusted. Stop the node rather than continuing with divergent + // in-memory and durable state. + System::current().stop_with_code(1); + Err(error) + } + } } /// Method to Send items to Inventory Manager fn persist_items( &self, items: Vec, + protocol_info: Option, ) -> ResponseActFuture> { // Get InventoryManager address let inventory_manager_addr = InventoryManager::from_registry(); @@ -613,45 +2094,58 @@ impl ChainManager { // Persist block into storage through InventoryManager. Box::pin( inventory_manager_addr - .send(AddItems { items }) - .into_actor(self) - .map_ok(|_, _, _| { - // Upon success, ignore any response and simply let the future resolve + .send(AddItems { + items, + protocol_info, }) + .into_actor(self) .map_err(|err, _, _| { - // Error when sending message log::error!("Unsuccessful communication with InventoryManager: {err}"); - - err.into() + anyhow::Error::from(err) + }) + .and_then(|result, _, _| match result { + Ok(()) => actix::fut::ok(()), + Err(error) => { + log::error!("InventoryManager failed to persist items: {error}"); + actix::fut::err(anyhow::Error::from(error)) + } }), ) } - /// Method to persist a Data Request into the Storage - fn persist_data_requests(&self, ctx: &mut Context, dr_infos: Vec) { - let kvs: Vec<_> = dr_infos - .into_iter() - .map(|dr_info| { - let dr_pointer = &dr_info.tally.as_ref().unwrap().dr_pointer; - let dr_pointer_string = format!("DR-REPORT-{dr_pointer}"); + /// Persist inventory into one explicit inactive snapshot generation. + fn persist_snapshot_items( + &self, + token: storage_mngr::SnapshotInventoryToken, + items: Vec, + protocol_info: Option, + ) -> ResponseActFuture> { + let inventory_manager_addr = InventoryManager::from_registry(); - (dr_pointer_string, dr_info) - }) - .collect(); - let kvs_len = kvs.len(); - storage_mngr::put_batch(&kvs) - .into_actor(self) - .map_err(|e, _, _| { - log::error!("Failed to persist data request report into storage: {e}") - }) - .and_then(move |_, _, _| { - log::trace!( - "Successfully persisted reports for {kvs_len} data requests into storage" - ); - fut::ok(()) - }) - .map(|_res: Result<(), ()>, _act, _ctx| ()) - .wait(ctx); + Box::pin( + inventory_manager_addr + .send(AddSnapshotItems { + token, + items, + protocol_info, + }) + .into_actor(self) + .map_err(|error, _, _| { + log::error!( + "Unsuccessful snapshot communication with InventoryManager: {error}" + ); + anyhow::Error::from(error) + }) + .and_then(|result, _, _| match result { + Ok(()) => actix::fut::ok(()), + Err(error) => { + log::error!( + "InventoryManager failed to persist snapshot items: {error}" + ); + actix::fut::err(anyhow::Error::from(error)) + } + }), + ) } fn broadcast_item(&self, item: InventoryItem) { @@ -670,17 +2164,38 @@ impl ChainManager { block: Block, resynchronizing: bool, ) -> Result<(), anyhow::Error> { - if let (Some(epoch_constants), Some(chain_info), Some(rep_engine), Some(vrf_ctx)) = ( - self.epoch_constants, + let (protocol_info, published_epoch_constants, protocol_generation) = + get_protocol_runtime(); + let epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => Some(constants), + (None, Some(constants)) => { + if protocol_info.all_versions.is_empty() { + Some(constants) + } else { + return Err(anyhow::anyhow!( + "cannot process a block with an unpaired protocol schedule" + )); + } + } + (None, None) => None, + }; + if let ( + Some(epoch_constants), + Some(chain_info), + Some(rep_engine), + Some(vrf_ctx), + Some(current_epoch), + ) = ( + epoch_constants, self.chain_state.chain_info.as_ref(), self.chain_state.reputation_engine.as_ref(), self.vrf_ctx.as_mut(), + self.current_epoch, ) { - if self.current_epoch.is_none() { - log::trace!("Called process_requested_block when current_epoch is None"); - } if self.chain_state.unspent_outputs_pool.db.is_none() { - panic!("NO UTXO DB"); + return Err(anyhow::anyhow!( + "cannot process block without an initialized UTXO database" + )); } let block_number = self.chain_state.block_number(); let mut vrf_input = chain_info.highest_vrf_output; @@ -692,10 +2207,12 @@ impl ChainManager { let mut transaction_visitor = PriorityVisitor::default(); - let protocol_version = get_protocol_version(Some(block.block_header.beacon.checkpoint)); - let utxo_diff = process_validations( + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + let utxo_diff = process_validations_for_protocol( &block, - self.current_epoch.unwrap_or_default(), + current_epoch, vrf_input, chain_info.highest_block_checkpoint, rep_engine, @@ -710,14 +2227,26 @@ impl ChainManager { &active_wips, Some(&mut transaction_visitor), &self.chain_state.stakes, + &self.chain_state.delegation_state, protocol_version, + &protocol_info, )?; // Extract the collected priorities from the internal state of the visitor let priorities = transaction_visitor.take_state(); // Persist block and update ChainState - self.consolidate_block(ctx, block, utxo_diff, priorities, resynchronizing); + self.consolidate_block( + ctx, + block, + utxo_diff, + priorities, + block_number, + protocol_version, + &protocol_info, + protocol_generation, + resynchronizing, + )?; Ok(()) } else { @@ -727,15 +2256,97 @@ impl ChainManager { #[allow(clippy::map_entry)] fn process_candidate(&mut self, block: Block) { - if let (Some(current_epoch), Some(chain_info), Some(rep_engine), Some(vrf_ctx)) = ( + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + self.process_candidate_with_protocol(block, protocol_info, protocol_generation); + } + + /// Return whether the protocol generation captured for a candidate has been superseded. + /// Keeping this helper independent of `self` lets callers mutate only the candidate set while + /// reputation and VRF fields remain borrowed for the current processing pass. + fn candidate_generation_is_stale(protocol_generation: u64) -> bool { + get_protocol_runtime().2 != protocol_generation + } + + /// Process a candidate using one immutable protocol schedule snapshot. + /// + /// Mining already captured the schedule before its asynchronous proof/tally pipeline. Keep + /// that same view through candidate hashing, comparison, and full replay; otherwise a schedule + /// publication between validation and ranking could reinterpret the candidate or cache it + /// under a different versioned identity. The generation is checked at the mutation boundaries + /// so superseded candidates are discarded before entering candidate state. + #[allow(clippy::map_entry)] + fn process_candidate_with_protocol( + &mut self, + block: Block, + protocol_info: ProtocolInfo, + protocol_generation: u64, + ) { + // Keep the epoch clock paired with the supplied schedule snapshot. The actor-local clock + // can lag a runtime publication while its notification is still queued; using that stale + // value here would validate the candidate under a different epoch/timing regime even + // though the generation guard below still passes. An empty compatibility registry may + // continue using the actor's legacy clock because no process-wide pair exists yet. + let (_, published_epoch_constants, _) = get_protocol_runtime(); + let pinned_epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => Some(constants), + (None, Some(constants)) if protocol_info.all_versions.is_empty() => Some(constants), + (None, _) => None, + }; + if pinned_epoch_constants.is_none() { + // Do not leave a candidate permanently hidden behind a retry marker while a + // configured schedule is waiting for its paired clock. The marker can be reacquired + // when the runtime pair is restored; retaining it here would make the later retry + // look like a duplicate and suppress validation indefinitely. + self.seen_candidates.remove(&block); + log::debug!( + "Ignoring block candidate while the protocol schedule has no paired epoch clock" + ); + return; + } + if let ( + Some(current_epoch), + Some(chain_info), + Some(rep_engine), + Some(vrf_ctx), + Some(epoch_constants), + ) = ( self.current_epoch, self.chain_state.chain_info.as_ref(), self.chain_state.reputation_engine.as_ref(), self.vrf_ctx.as_mut(), + pinned_epoch_constants, ) { - // To continue processing, received block epoch should equal to `current_epoch` or `current_epoch + 1` + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Ignoring block candidate from superseded protocol generation {protocol_generation}" + ); + return; + } + // A previously selected winner can remain in memory across a protocol schedule + // publication. Do not use that obsolete ranking, hash, or protocol view to reject a + // candidate validated under the current generation. Release its duplicate-suppression + // marker so the old block can be admitted again if it is still valid under the new + // schedule. + if self + .best_candidate + .as_ref() + .is_some_and(|candidate| candidate.protocol_generation != protocol_generation) + { + if let Some(stale_candidate) = self.best_candidate.take() { + self.seen_candidates.remove(&stale_candidate.block); + log::debug!( + "Discarding retained best block candidate from superseded protocol generation {} (current {})", + stale_candidate.protocol_generation, + protocol_generation, + ); + } + } + // To continue processing, received block epoch should equal `current_epoch` or the + // next representable epoch. Do not let the `u32` addition wrap at the epoch limit: + // a wrapped value could otherwise make a stale candidate appear current. + let next_epoch = current_epoch.checked_add(1); if !(block.block_header.beacon.checkpoint == current_epoch - || block.block_header.beacon.checkpoint == current_epoch + 1) + || next_epoch == Some(block.block_header.beacon.checkpoint)) { log::debug!( "Ignoring received block candidate because its beacon shows an old epoch ({}). The current epoch is {}.", @@ -746,13 +2357,32 @@ impl ChainManager { return; } - let hash_block = block.hash(); + // Candidate identity must use the protocol selected for the candidate's containing + // epoch. `Block::hash()` follows the process-global guessed protocol and can therefore + // key the candidate cache on a different fork while an activation is being published. + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + let hash_block = block.versioned_hash(protocol_version); // If this candidate has not been seen before, validate it if !self.seen_candidates.contains(&block) { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Ignoring block candidate from superseded protocol generation {protocol_generation}" + ); + return; + } self.seen_candidates.insert(block.clone()); if self.sm_state == StateMachine::WaitingConsensus || self.sm_state == StateMachine::Synchronizing { + if get_protocol_runtime().2 != protocol_generation { + self.seen_candidates.remove(&block); + log::debug!( + "Ignoring block candidate from superseded protocol generation {protocol_generation}" + ); + return; + } self.candidates .entry(hash_block) .or_default() @@ -769,8 +2399,22 @@ impl ChainManager { active_wips: self.chain_state.tapi_engine.wip_activation.clone(), block_epoch: block.block_header.beacon.checkpoint, }; + let total_identities = match + u32::try_from(rep_engine.ars().active_identities_number()) + { + Ok(total_identities) => total_identities, + Err(_) => { + log::warn!( + "Ignoring block candidate {hash_block}: active reputation population exceeds the VRF count range" + ); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + return; + } + }; let target_vrf_slots = VrfSlots::from_rf( - u32::try_from(rep_engine.ars().active_identities_number()).unwrap(), + total_identities, chain_info.consensus_constants.mining_replication_factor, chain_info.consensus_constants.mining_backup_factor, block.block_header.beacon.checkpoint, @@ -788,6 +2432,10 @@ impl ChainManager { Err(e) => { log::warn!("Block candidate has an invalid mining eligibility proof: {e}"); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + // In order to do not block possible validate candidates in AlmostSynced // state, we would broadcast the errors too if self.sm_state == StateMachine::AlmostSynced { @@ -797,12 +2445,67 @@ impl ChainManager { return; } }; - let protocol_version = - ProtocolVersion::from_epoch(block.block_header.beacon.checkpoint); if let Some(best_candidate) = &self.best_candidate { - let best_hash = best_candidate.block.hash(); let best_pkh = best_candidate.block.block_sig.public_key.pkh(); + let best_candidate_protocol = protocol_info + .all_versions + .version_for_epoch(best_candidate.block.block_header.beacon.checkpoint); + let best_hash = best_candidate.block.versioned_hash(best_candidate_protocol); + // The first block whose epoch selects Wit/2.1 can arrive while the persisted + // activation marker is still unset. Candidate construction already rebuilds + // the canonical owner/pending-collateral projection for that boundary; use + // the same view when comparing candidate power, otherwise a raw pre-activation + // sidecar/owner view can rank the candidate differently from the block that + // will ultimately be validated. The projected stake ledger is unchanged here: + // legacy collateral was debited by its Wit/2.0 commit transition, while this + // projection supplies the authoritative policy and reservation metadata. + let comparison_delegation_state = if (protocol_version >= ProtocolVersion::V2_1 + || best_candidate_protocol >= ProtocolVersion::V2_1) + && !self.chain_state.delegation_state.activation_initialized + { + // A current-epoch Wit/2.0 candidate can be compared with a next-epoch + // Wit/2.1 candidate during the same actor turn. Select a post-fork protocol + // for the projection in that mixed view; owner activity itself is epoch + // independent, so the new candidate's checkpoint is a stable reconstruction + // point for both power lookups. + let projection_protocol = if protocol_version >= ProtocolVersion::V2_1 { + protocol_version + } else { + best_candidate_protocol + }; + let minimum_self_stake = Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(projection_protocol), + ); + match prepare_standalone_delegation_state_for_protocol( + &self.chain_state.data_request_pool, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + block.block_header.beacon.checkpoint, + chain_info.consensus_constants.collateral_minimum, + minimum_self_stake, + projection_protocol, + &protocol_info, + ) { + Ok(Some(projected)) => Some(projected), + Ok(None) => None, + Err(error) => { + log::warn!( + "Cannot compare block candidate {hash_block}: Wit/2.1 activation projection is invalid: {error}" + ); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + return; + } + } + } else { + None + }; + let comparison_delegation_state = comparison_delegation_state + .as_ref() + .unwrap_or(&self.chain_state.delegation_state); let best_candidate_is_active = if after_second_hard_fork(current_epoch, get_environment()) { rep_engine.ars().contains(&best_pkh) @@ -811,24 +2514,78 @@ impl ChainManager { // than the other to avoid the "activeness" comparison is_active }; - let power = self - .chain_state - .stakes - .query_power( + let power = if protocol_version >= ProtocolVersion::V2_1 { + let minimum_self_stake = Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), + ); + match comparison_delegation_state.active_validator_power( + &self.chain_state.stakes, *block_pkh, Capability::Mining, block.block_header.beacon.checkpoint, - ) - .unwrap_or(0); - let best_candidate_power = self - .chain_state - .stakes - .query_power( + minimum_self_stake, + ) { + Ok(power) => power, + Err(error) => { + log::warn!( + "Cannot compare block candidate {hash_block}: delegated mining power for {} is invalid: {}", + block_pkh, + error + ); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + return; + } + } + } else { + self.chain_state + .stakes + .query_power_legacy( + *block_pkh, + Capability::Mining, + block.block_header.beacon.checkpoint, + ) + .unwrap_or(0) + }; + let best_candidate_power = if best_candidate_protocol >= ProtocolVersion::V2_1 { + let minimum_self_stake = Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol( + best_candidate_protocol, + ), + ); + match comparison_delegation_state.active_validator_power( + &self.chain_state.stakes, best_pkh, Capability::Mining, best_candidate.block.block_header.beacon.checkpoint, - ) - .unwrap_or(0); + minimum_self_stake, + ) { + Ok(power) => power, + Err(error) => { + log::warn!( + "Cannot compare block candidate {hash_block} against {best_hash}: delegated mining power for {} is invalid: {}", + best_pkh, + error + ); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + return; + } + } + } else { + self.chain_state + .stakes + .query_power_legacy( + best_pkh, + Capability::Mining, + best_candidate.block.block_header.beacon.checkpoint, + ) + .unwrap_or(0) + }; if compare_block_candidates( hash_block, @@ -849,6 +2606,10 @@ impl ChainManager { "Ignoring new block candidate ({hash_block}) because a better one ({best_hash}) has been already validated" ); + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + } + return; } } @@ -858,18 +2619,16 @@ impl ChainManager { let mut transaction_visitor = PriorityVisitor::default(); let block_number = self.chain_state.block_number(); - match process_validations( + match process_validations_for_protocol( &block, current_epoch, vrf_input, chain_info.highest_block_checkpoint, rep_engine, - self.epoch_constants.unwrap(), + epoch_constants, &self.chain_state.unspent_outputs_pool, &mut self.chain_state.data_request_pool, - // The unwrap is safe because if there is no VRF context, - // the actor should have stopped execution - self.vrf_ctx.as_mut().expect("No initialized VRF context"), + vrf_ctx, block_number, &chain_info.consensus_constants, &self.consensus_constants_wit2, @@ -877,17 +2636,28 @@ impl ChainManager { &active_wips, Some(&mut transaction_visitor), &self.chain_state.stakes, + &self.chain_state.delegation_state, protocol_version, + &protocol_info, ) { Ok(utxo_diff) => { + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + log::debug!( + "Discarding block candidate after protocol generation {protocol_generation} was superseded" + ); + return; + } let priorities = transaction_visitor.take_state(); self.best_candidate = Some(BlockCandidate { block: block.clone(), utxo_diff, + block_number, reputation, vrf_proof, priorities, + protocol_generation, }); self.broadcast_item(InventoryItem::Block(block)); @@ -895,6 +2665,18 @@ impl ChainManager { Err(e) => { log::warn!("Error when processing a block candidate {hash_block}: {e}"); + // A candidate may fail after the schedule generation was replaced. Its + // validation result then says nothing about the new fork rules; remove the + // retry marker so the same block can be admitted again under the current + // snapshot. Keep ordinary invalid candidates marked as seen, preserving + // the existing duplicate-suppression behavior. + if Self::candidate_generation_is_stale(protocol_generation) { + self.seen_candidates.remove(&block); + log::debug!( + "Removing stale candidate marker after protocol generation {protocol_generation} was superseded" + ); + } + // In order to do not block possible validate candidates in AlmostSynced // state, we would broadcast the errors too if self.sm_state == StateMachine::AlmostSynced { @@ -906,17 +2688,261 @@ impl ChainManager { log::trace!("Block candidate already seen: {hash_block}"); } } else { - log::warn!("ChainManager doesn't have current epoch"); + log::warn!("ChainManager is missing state required to process a block candidate"); } } - fn persist_blocks_batch(&self, ctx: &mut Context, blocks: Vec) { - let mut to_persist = Vec::with_capacity(blocks.len()); + /// Persist one processed block batch, all of its transaction pointers, and the reports whose + /// exact tallies it contains through one generation-pinned backend batch. This prevents an + /// online inventory-generation switch from publishing the blocks and reports into different + /// active namespaces. + fn persist_blocks_and_data_requests( + &self, + blocks: Vec, + dr_infos: Vec, + ) -> ResponseActFuture> { + // Keep the historical convenience entry point for synchronization and local callers that + // do not already own a schedule snapshot. Consolidation paths must use the explicit variant + // below so inventory keys cannot be recomputed under a different schedule after validation. + let protocol_info = get_protocol_info(); + self.persist_blocks_and_data_requests_with_protocol(blocks, dr_infos, protocol_info) + } + + /// Persist processed blocks and reports with the exact protocol schedule used to validate them. + /// + /// Block inventory keys are versioned by the containing epoch. Re-reading the mutable global + /// schedule here would allow an activation/publication between validation and persistence to + /// place an otherwise valid block under a different key (or to send a mismatched schedule to + /// InventoryManager). The compatibility wrapper above remains for callers that have no pinned + /// context; ChainManager consolidation passes its validation snapshot explicitly. + fn persist_blocks_and_data_requests_with_protocol( + &self, + blocks: Vec, + dr_infos: Vec, + protocol_info: ProtocolInfo, + ) -> ResponseActFuture> { + let persisted_blocks: Vec<(Epoch, Hash)> = blocks + .iter() + .map(|block| { + let epoch = block.block_header.beacon.checkpoint; + ( + epoch, + block.versioned_hash(protocol_info.all_versions.version_for_epoch(epoch)), + ) + }) + .collect(); + + // A report batch is authoritative only when it is a one-to-one projection of the exact + // tally transactions in these blocks. Validate before moving any value into the actor + // message so malformed in-memory state cannot become a partial durable inventory update. + let report_consistency = (|| -> Result<(), anyhow::Error> { + let mut tallies = HashMap::new(); + for (block_index, block) in blocks.iter().enumerate() { + let (_, block_hash) = persisted_blocks[block_index]; + for tally in &block.txns.tally_txns { + anyhow::ensure!( + tallies + .insert(tally.dr_pointer, (block_hash, tally)) + .is_none(), + "processed inventory batch contains duplicate tally pointer {}", + tally.dr_pointer + ); + } + } + + let mut report_pointers = HashSet::with_capacity(dr_infos.len()); + for report in &dr_infos { + let tally = report.tally.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "cannot persist an incomplete data-request report without a tally" + ) + })?; + anyhow::ensure!( + report.current_stage.is_none() + && report.block_hash_dr_tx.is_some() + && report.block_hash_tally_tx.is_some(), + "cannot persist data-request report {} without a finished stage and both containing block hashes", + tally.dr_pointer + ); + anyhow::ensure!( + report_pointers.insert(tally.dr_pointer), + "processed inventory batch contains duplicate report {}", + tally.dr_pointer + ); + let (block_hash, block_tally) = tallies.remove(&tally.dr_pointer).ok_or_else(|| { + anyhow::anyhow!( + "data-request report {} has no tally in the processed block batch", + tally.dr_pointer + ) + })?; + anyhow::ensure!( + report.block_hash_tally_tx == Some(block_hash) && block_tally == tally, + "data-request report {} disagrees with its exact containing block tally", + tally.dr_pointer + ); + } + anyhow::ensure!( + tallies.is_empty(), + "processed block batch contains {} tally transaction(s) without reports", + tallies.len() + ); + Ok(()) + })(); + if let Err(error) = report_consistency { + return Box::pin(actix::fut::err(error)); + } + + let mut to_persist = Vec::with_capacity(blocks.len() + dr_infos.len()); for block in blocks { to_persist.push(StoreInventoryItem::Block(Box::new(block))); } + for report in dr_infos { + to_persist.push(StoreInventoryItem::DataRequestReport(Box::new(report))); + } + + // An inventory record does not make its block part of the durable canonical ChainState. + // The block may still sit above the last consolidated superblock and later be discarded by + // consensus recovery. Keep any protocol-schedule rollback journal until an atomic + // ChainState write has durably committed the exact trigger block as canonical. + Box::pin( + self.persist_items(to_persist, Some(protocol_info)) + .map_ok(|(), _act, _| ()), + ) + } + + /// Fail closed after a local commit/persistence error. Reload the last complete ChainState + /// only when no protocol-activation acknowledgement is ambiguous. A local failure is not + /// evidence that the supplying peer was malicious, so peers are disconnected but the sender + /// is not iced. + fn recover_after_chain_commit_failure( + &mut self, + ctx: &mut Context, + error: &anyhow::Error, + ) { + log::error!("Cannot continue after a local chain commit failed: {error}"); + self.drop_all_outbounds(); + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + self.sync_waiting_for_add_blocks_since = None; + + // The storage future deliberately exposes one error type for both backend failures and + // mailbox/acknowledgement failures. Once a protocol-activation trigger is pending, an + // error therefore cannot prove that the atomic ChainState write did not commit. Reloading + // through `initialize_from_storage_fut` would roll the process-global schedule back before + // reading that durable state and could leave a committed trigger paired with the old + // schedule. Stop conservatively instead; ordinary failures without a pending activation + // journal can still restore the last acknowledged ChainState in process. + if let Some(pending) = self.pending_protocol_activation_rollback.as_ref() { + log::error!( + "ChainState persistence acknowledgement is ambiguous for protocol-activation trigger at epoch {}; stopping instead of rolling back the process-global protocol schedule", + pending.trigger_epoch + ); + ctx.stop(); + System::current().stop_with_code(1); + return; + } + + self.initialize_from_storage(ctx); + } + + fn rollback_unpersisted_protocol_activation(&mut self) -> Result<(), ()> { + if let Some(mut rollback) = self.pending_protocol_activation_rollback.take() { + log::warn!( + "Rolling back protocol activation scheduled by an unfinalized block at epoch {}", + rollback.trigger_epoch + ); + let rollback_epoch = match rollback.epoch_constants.epoch_at(get_timestamp()) { + Ok(epoch) => epoch, + Err(error) => { + log::error!( + "Cannot restore the preceding protocol runtime during activation rollback: {error}" + ); + // Do not consume the process journal when the rollback clock cannot be + // reconstructed. The enclosing storage reload must fail rather than continue + // against a schedule whose rollback was only requested. + self.pending_protocol_activation_rollback = Some(rollback); + System::current().stop_with_code(1); + return Err(()); + } + }; + // Discharge any restart-reconstructed copy only after the old clock is known to be + // publishable. A failed conversion must retain both the actor journal above and the + // process-level recovery witness for the ensuing fail-stop/restart path. + if let Err(error) = storage_mngr::acknowledge_restored_wit2_activation( + rollback.trigger_epoch, + rollback.trigger_hash, + ) { + log::error!( + "Cannot acknowledge the restored protocol-activation rollback journal: {error}" + ); + self.pending_protocol_activation_rollback = Some(rollback); + System::current().stop_with_code(1); + return Err(()); + } + rollback.protocol_info.current_version = rollback + .protocol_info + .all_versions + .version_for_epoch(rollback_epoch); + load_protocol_runtime(rollback.protocol_info, rollback.epoch_constants); + self.epoch_constants = Some(rollback.epoch_constants); + self.current_epoch = Some(rollback_epoch); + SessionsManager::from_registry().do_send(SetEpochConstants { + epoch_constants: rollback.epoch_constants, + }); + EpochManager::from_registry().do_send(SetEpochConstants { + epoch_constants: rollback.epoch_constants, + }); + } else if let Some(mut rollback) = (match storage_mngr::restored_wit2_activation_rollback() { + Ok(rollback) => rollback, + Err(error) => { + log::error!( + "Cannot read the restored protocol-activation rollback journal: {error}" + ); + System::current().stop_with_code(1); + return Err(()); + } + }) { + log::warn!( + "Rolling back protocol activation restored from an unfinalized durable trigger at epoch {}", + rollback.trigger_epoch + ); + let rollback_epoch = match rollback.epoch_constants.epoch_at(get_timestamp()) { + Ok(epoch) => epoch, + Err(error) => { + log::error!( + "Cannot restore the preceding durable protocol runtime during activation rollback: {error}" + ); + // `restored_wit2_activation_rollback` cloned rather than consumed the journal, + // so a failed enclosing reload and restart can retry this exact decision. + System::current().stop_with_code(1); + return Err(()); + } + }; + if let Err(error) = storage_mngr::acknowledge_restored_wit2_activation( + rollback.trigger_epoch, + rollback.trigger_hash, + ) { + log::error!( + "Cannot acknowledge the restored protocol-activation rollback journal: {error}" + ); + System::current().stop_with_code(1); + return Err(()); + } + rollback.protocol_info.current_version = rollback + .protocol_info + .all_versions + .version_for_epoch(rollback_epoch); + load_protocol_runtime(rollback.protocol_info, rollback.epoch_constants); + self.epoch_constants = Some(rollback.epoch_constants); + self.current_epoch = Some(rollback_epoch); + SessionsManager::from_registry().do_send(SetEpochConstants { + epoch_constants: rollback.epoch_constants, + }); + EpochManager::from_registry().do_send(SetEpochConstants { + epoch_constants: rollback.epoch_constants, + }); + } - ctx.wait(self.persist_items(to_persist).map(|_, _, _| ())); + Ok(()) } fn consolidate_block( @@ -925,29 +2951,61 @@ impl ChainManager { block: Block, utxo_diff: Diff, priorities: Priorities, + block_number: u32, + protocol_version: ProtocolVersion, + protocol_info: &ProtocolInfo, + protocol_generation: u64, resynchronizing: bool, - ) { + ) -> Result<(), anyhow::Error> { + // Validation and consolidation must consume the same immutable schedule generation. A + // publication between those phases would otherwise let this method mutate ChainState + // using an obsolete fork view. The activation branch below intentionally advances the + // generation itself; the registration helper returns that exact new generation before the + // final state publication check. + let (runtime_protocol_info, published_epoch_constants, runtime_generation) = + get_protocol_runtime(); + if runtime_generation != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed before block consolidation" + )); + } + if !runtime_protocol_info.all_versions.is_empty() && published_epoch_constants.is_none() { + return Err(anyhow::anyhow!( + "cannot consolidate a block with an unpaired protocol schedule" + )); + } + let mut expected_protocol_generation = protocol_generation; // Update chain_info and reputation_engine let own_pkh = match self.own_pkh { Some(x) => x, None => { - log::error!("No OwnPkh loaded in ChainManager"); - return; + return Err(anyhow::anyhow!("No OwnPkh loaded in ChainManager")); } }; + let epoch_constants = published_epoch_constants + .or(self.epoch_constants) + .ok_or_else(|| anyhow::anyhow!("No epoch constants available"))?; + + // Reject an unrepresentable terminal data-request round before any live chain field is + // mutated below. Startup and persistence validate the same invariant, but this local gate + // also protects direct/in-memory callers of consolidation. + self.chain_state + .data_request_pool + .validate_stage_round_configuration()?; + match self.chain_state { ChainState { chain_info: Some(ref mut chain_info), reputation_engine: Some(ref mut reputation_engine), + ref mut alt_keys, ref mut stakes, + ref mut delegation_state, .. } => { let block_epoch = block.block_header.beacon.checkpoint; - let protocol_version = get_protocol_version(Some(block_epoch)); let block_hash = block.versioned_hash(protocol_version); let block_signals = block.block_header.signals; - let validator_count = stakes.validator_count(); // Update `highest_block_checkpoint` let beacon = CheckpointBeacon { @@ -959,8 +3017,7 @@ impl ChainManager { let vrf_ctx = match self.vrf_ctx.as_mut() { Some(x) => x, None => { - log::error!("No VRF context available"); - return; + return Err(anyhow::anyhow!("No VRF context available")); } }; @@ -973,7 +3030,15 @@ impl ChainManager { hash_prev_vrf: block_hash, }, _ => { - let proof_hash = block.block_header.proof.proof_to_hash(vrf_ctx).unwrap(); + let proof_hash = block + .block_header + .proof + .proof_to_hash(vrf_ctx) + .map_err(|error| { + anyhow::anyhow!( + "Cannot derive previous VRF hash while consolidating block {block_hash}: {error}" + ) + })?; CheckpointVRF { checkpoint: block_epoch, hash_prev_vrf: proof_hash, @@ -989,16 +3054,19 @@ impl ChainManager { log::Level::Trace }; - // Update beacon and vrf output - chain_info.highest_block_checkpoint = beacon; - chain_info.highest_vrf_output = vrf_input; - let miner_pkh = block.block_header.proof.proof.pkh(); - // Check total amount staked to make sure we can activate wit/2 + // Check total amount staked to make sure we can activate wit/2. Keep the global + // protocol registry and the live epoch constants untouched until every fallible + // staking transition for this block has succeeded below. let superblock_period = chain_info.consensus_constants.superblock_period; - if protocol_version == ProtocolVersion::V1_8 - && get_protocol_version_activation_epoch(ProtocolVersion::V2_0) == Epoch::MAX + let pending_wit2_activation = if protocol_version == ProtocolVersion::V1_8 + // Keep activation discovery on the same immutable protocol snapshot used + // to validate and replay this block. Re-reading the process-global schedule + // here could race a concurrent publication and make the trigger decision + // disagree with the pinned `protocol_info` passed through consolidation. + && protocol_info.all_versions.get_activation_epoch(ProtocolVersion::V2_0) + == Epoch::MAX && block_epoch.is_multiple_of(u32::from(superblock_period)) { let min_total_stake = self @@ -1009,82 +3077,131 @@ impl ChainManager { .get_wit2_activation_delay_epochs(); let checkpoint_period = self.consensus_constants_wit2.get_checkpoints_period_wit2(); - if stakes.total_staked() >= Wit::from(min_total_stake) { - let scheduled_epoch = block_epoch + activation_delay; - log::info!( - "The stake threshold for activating protocol V2_0 has been met. Protocol V2_0 will be scheduled for epoch {scheduled_epoch}" - ); - // Register the 2_0 protocol into global state - register_protocol_version( - ProtocolVersion::V2_0, - scheduled_epoch, - checkpoint_period, - ); - - if let Some(epoch_constants) = &mut self.epoch_constants { - match epoch_constants.set_values_for_wit2( - checkpoint_period, - block_epoch + activation_delay, - ) { - Ok(_) => (), - Err(_) => panic!("Could not set wit/2 checkpoint variables"), - }; - } else { - panic!("Could not set wit/2 checkpoint variables"); - } + let total_staked = stakes.total_staked_checked().map_err(|error| { + anyhow::anyhow!( + "Cannot evaluate Wit/2 activation threshold at block {block_hash}: {error}" + ) + })?; + if total_staked >= Wit::from(min_total_stake) { + let scheduled_epoch = block_epoch.checked_add(activation_delay).ok_or_else( + || { + anyhow::anyhow!( + "Cannot schedule protocol V2_0 from block {block_hash}: activation epoch overflow" + ) + }, + )?; + let mut next_epoch_constants = epoch_constants; + next_epoch_constants + .set_values_for_wit2(checkpoint_period, scheduled_epoch) + .map_err(|error| { + anyhow::anyhow!( + "Cannot set Wit/2 checkpoint variables for block {block_hash}: {error}" + ) + })?; + Some((scheduled_epoch, checkpoint_period, next_epoch_constants)) + } else { + None } - } + } else { + None + }; - process_wit2_stakes_changes( + let mut consolidated_stakes = if protocol_version >= ProtocolVersion::V2_1 { + // `try_clone_checked` intentionally rebuilds the maintained secondary + // indexes from the canonical map. Prove the source projection first so + // accepted-block consolidation cannot turn malformed omitted, duplicate, + // detached, or mis-owned rows into a silently repaired state image. + stakes.validate_validator_index_checked()?; + stakes.try_clone_checked()? + } else { + // Keep the legacy clone boundary unchanged before delegated staking activates. + stakes.clone() + }; + let mut consolidated_delegation_state = delegation_state.clone(); + // Validation keeps same-block `TooManyWitnesses` requests in a private pool + // clone while checking their immediate tally. Build the same view for staking + // settlement: otherwise `process_wit2_stakes_changes` would inspect the live + // pre-block pool, fail to find that request, and silently skip its collateral / + // reward transition. The accepted-pool projection is built separately below and + // remains private across its fallible replay and protocol-registration boundary. + let mut staking_data_request_pool = self.chain_state.data_request_pool.clone(); + for tally_transaction in &block.txns.tally_txns { + materialize_immediate_too_many_witnesses_tally( + &block, + tally_transaction, + &mut staking_data_request_pool, + protocol_version, + )?; + } + process_wit2_stakes_changes_for_protocol( &block, &self.consensus_constants_wit2, - &self.chain_state.data_request_pool, - stakes, - self.epoch_constants.unwrap(), - &UtxoDiff::new(&self.chain_state.unspent_outputs_pool, block_epoch), - ); - - let rep_info = update_pools( - &block, - &mut self.chain_state.unspent_outputs_pool, - &mut self.chain_state.data_request_pool, - &mut self.transactions_pool, - utxo_diff, - own_pkh, - &mut self.chain_state.own_utxos, - &mut self.chain_state.node_stats, - self.sm_state, - ); + &staking_data_request_pool, + &mut consolidated_stakes, + &mut consolidated_delegation_state, + epoch_constants, + chain_info.consensus_constants.collateral_minimum, + chain_info.consensus_constants.checkpoint_zero_timestamp, + // Rebuild the same block-local fee view with the exact height supplied to + // validation. Epoch and block height diverge after an empty checkpoint. + &UtxoDiff::new(&self.chain_state.unspent_outputs_pool, block_number), + protocol_version, + protocol_info, + ) + .map_err(|error| { + anyhow::anyhow!( + "Cannot consolidate Wit/2 staking transitions for block {block_hash}: {error}" + ) + })?; // Do not update reputation or stakes when consolidating genesis block if block_hash != chain_info.consensus_constants.genesis_hash { - if ProtocolVersion::from_epoch(block_epoch) < ProtocolVersion::V2_0 { - update_reputation( - reputation_engine, - &mut self.chain_state.alt_keys, - &chain_info.consensus_constants, - miner_pkh, - rep_info, - log_level, - block_epoch, - self.own_pkh.unwrap_or_default(), - ); - } - let stake_txns_count = block.txns.stake_txns.len(); if stake_txns_count > 0 { log::debug!("Processing {stake_txns_count} stake transactions"); let minimum_stakeable = self .consensus_constants_wit2 - .get_validator_min_stake_nanowits(block_epoch); - - let _ = process_stake_transactions( - stakes, - block.txns.stake_txns.iter(), - block_epoch, - minimum_stakeable, - ); + .get_validator_min_stake_nanowits_for_protocol(protocol_version); + + if protocol_version >= ProtocolVersion::V2_1 { + process_delegated_stake_transactions( + &mut consolidated_stakes, + &mut consolidated_delegation_state, + block.txns.stake_txns.iter(), + block_epoch, + minimum_stakeable, + epoch_constants, + self.magic, + protocol_version, + ) + .map_err(|error| { + anyhow::anyhow!( + "Cannot consolidate delegated stake transitions for block {block_hash}: {error}" + ) + })?; + } else { + // Preserve Wit/2.0 consolidation semantics: validation assessed each + // transaction against the pre-block ledger and the historical caller + // retained any prefix mutations while ignoring a later cumulative + // failure. Wit/2.1 uses the strict projected path above. + let result = process_stake_transactions_for_protocol( + &mut consolidated_stakes, + block.txns.stake_txns.iter(), + block_epoch, + minimum_stakeable, + protocol_version, + ); + if matches!(result, Err(StakesError::ArithmeticOverflow)) { + // Legacy release builds trapped when wrapped age averaging reached + // a zero denominator. Reject that block deterministically instead of + // accepting the helper's partial mutation; continue ignoring the + // ordinary inner failures that historical consolidation ignored. + return Err(anyhow::anyhow!( + "Cannot consolidate legacy stake transitions for block {block_hash}: arithmetic trap" + )); + } + } } let unstake_txns_count = block.txns.unstake_txns.len(); @@ -1093,19 +3210,340 @@ impl ChainManager { let minimum_stakeable = self .consensus_constants_wit2 - .get_validator_min_stake_nanowits(block_epoch); + .get_validator_min_stake_nanowits_for_protocol(protocol_version); + + if protocol_version >= ProtocolVersion::V2_1 { + // Preserve pre-block legacy reward destinations even when this block + // settles their zero-collateral request before the Unstake section. + let mut legacy_reward_locks = LegacyRewardLocks::from_pool( + &staking_data_request_pool, &consolidated_delegation_state, protocol_info, + )?; + legacy_reward_locks.include_accepted_commits( + &block.txns.commit_txns, &consolidated_delegation_state, + )?; + for transaction in &block.txns.unstake_txns { + if transaction.signature.public_key.pkh() + != transaction.body.withdrawal.pkh + { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake signer does not match its withdrawal output", + } + .into()); + } + if transaction.body.withdrawal.value == 0 { + return Err(TransactionError::ZeroValueUnstakeOutput.into()); + } + let key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.body.withdrawal.pkh, + }; + let requested = transaction.body.value().checked_add(transaction.body.fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let entries = consolidated_stakes.query_stakes_checked(QueryStakesKey::Key(key.clone()))?; + let entry = entries.first().ok_or_else(|| ConsensusStakesError::EntryNotFound { key: key.clone() })?; + if entry.value.nonce != transaction.body.nonce { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake nonce does not match the live stake entry", + } + .into()); + } + if requested == entry.value.coins.nanowits() { + // The validation pass checks both native collateral locks and + // cross-fork legacy reward locks before accepting a full + // removal. Re-prove the native reservation lock at this + // consolidation boundary as well: direct replay callers must + // not let the generic stake-ledger helper remove an entry whose + // exact committed collateral still awaits tally settlement. + consolidated_delegation_state.ensure_entry_can_be_removed(&key)?; + legacy_reward_locks.ensure_entry_can_be_removed(&key)?; + } + } + // The policy and cross-fork lock proofs above are part of the native + // transition contract. Do not hand the transaction back to the + // generic data-structure helper, which has no DelegationState and + // therefore cannot prove those locks itself. Apply the already-checked + // exact debit directly to the staged ledger; the checked `remove_stake` + // mutation still enforces the fixed minimum/remainder rule and keeps + // index updates failure-atomic. + for transaction in &block.txns.unstake_txns { + if transaction.signature.public_key.pkh() + != transaction.body.withdrawal.pkh + { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake signer does not match its withdrawal output", + } + .into()); + } + if transaction.body.withdrawal.value == 0 { + return Err(TransactionError::ZeroValueUnstakeOutput.into()); + } + let key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.body.withdrawal.pkh, + }; + let requested = transaction + .body + .value() + .checked_add(transaction.body.fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let entries = consolidated_stakes + .query_stakes_checked(QueryStakesKey::Key(key.clone()))?; + let entry = entries.first().ok_or_else(|| { + ConsensusStakesError::EntryNotFound { key: key.clone() } + })?; + if entry.value.nonce != transaction.body.nonce { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake nonce does not match the live stake entry", + } + .into()); + } + consolidated_stakes + .remove_stake( + key, + Wit::from_nanowits(requested), + block_epoch, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .map_err(|error| { + anyhow::anyhow!( + "Cannot consolidate unstake transitions for block {block_hash}: {error}" + ) + })?; + } + for transaction in &block.txns.unstake_txns { + consolidated_delegation_state.complete_exit_if_removed( + &consolidated_stakes, + &StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.signature.public_key.pkh(), + }, + )?; + } + } else { + // See the Wit/2.0 stake compatibility note above. A cumulative + // unstake failure was historically ignored after retaining any + // successful prefix mutations. + let _ = process_unstake_transactions_for_protocol( + &mut consolidated_stakes, + block.txns.unstake_txns.iter(), + block_epoch, + minimum_stakeable, + protocol_version, + ); + } + } + } - let _ = process_unstake_transactions( - stakes, - block.txns.unstake_txns.iter(), + // Derive the post-block validator population from the staged staking state before + // any live ChainState or protocol-schedule mutation. Missing post-activation owner + // policy must therefore abort atomically rather than after the block is visible. + let validator_count = if protocol_version >= ProtocolVersion::V2_1 { + consolidated_delegation_state.active_validator_count_checked( + &consolidated_stakes, + Wit::from_nanowits( + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), + ), + )? + } else { + consolidated_stakes.validator_count() + }; + // A native block still advances pre-V2.1 requests with the raw post-block validator + // population. Capture that compatibility view before the staged ledger is moved into + // live ChainState below. + let legacy_validator_count = (protocol_version >= ProtocolVersion::V2_1) + .then(|| consolidated_stakes.validator_count()); + + // Replay every accepted oracle/mempool/UTXO transition on private copies before + // publishing a protocol activation, chain beacon, or staking state. Wit/2.1 turns + // any disagreement with the already validated sequence into an error; legacy + // protocols retain their historical per-item log-and-continue result inside this + // private projection. + let mut pending_pool_updates = stage_pool_updates( + &block, + &self.chain_state.unspent_outputs_pool, + &self.chain_state.data_request_pool, + &self.transactions_pool, + utxo_diff, + own_pkh, + &self.chain_state.own_utxos, + &self.chain_state.node_stats, + self.sm_state, + protocol_version, + )?; + + // Preserve the historical branch-specific persistence contract while moving the + // last checked data-request transition before protocol, pool, stake, or beacon + // publication. Synchronizing batches retain finished reports for their enclosing + // AddBlocks persistence path; every other state drains the same accepted reports + // that was previously taken from the live pool below. + let PendingDataRequestEffects { + to_be_stored, + reveals, + } = stage_data_request_progression_for_protocol( + &mut pending_pool_updates.data_request_pool, + self.sm_state, + validator_count, + legacy_validator_count, + block_epoch, + protocol_info, + )?; + + // The legacy reputation helper preserves historical arithmetic and contains + // unwraps for invariants normally established by live validation. Run it against + // private clones before publishing any pool, stake, beacon, or activation state, + // and turn a represented invariant panic into a deterministic consolidation + // error. A failed block must not leave the accepted-block projection visible while + // the reputation transition is only partially applied (or has taken down the + // actor). + let staged_legacy_reputation = if block_hash + != chain_info.consensus_constants.genesis_hash + && protocol_version < ProtocolVersion::V2_0 + { + let mut staged_reputation_engine = reputation_engine.clone(); + let mut staged_alt_keys = alt_keys.clone(); + let reputation_info = pending_pool_updates.reputation_info.clone(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + update_reputation( + &mut staged_reputation_engine, + &mut staged_alt_keys, + &chain_info.consensus_constants, + miner_pkh, + reputation_info, + log_level, block_epoch, - minimum_stakeable, + own_pkh, ); + })) + .map_err(|_| { + anyhow::anyhow!( + "Cannot consolidate legacy reputation transition for block {block_hash}: production transition panicked" + ) + })?; + Some((staged_reputation_engine, staged_alt_keys)) + } else { + None + }; + + // Every represented staking, delegation, pool, UTXO, and data-request stage + // transition has succeeded on staged clones. + // Only now may an activation discovered from this accepted block become globally + // visible. A rejected/resync-corrupt block therefore cannot advance the process-wide + // protocol schedule while leaving the chain tip unchanged. + if let Some((scheduled_epoch, checkpoint_period, next_epoch_constants)) = + pending_wit2_activation + { + // Capture rollback ownership before publishing the schedule, including while + // resynchronizing. A dynamically rediscovered trigger is no safer merely + // because its block arrived in a synchronization batch: until an exact + // finalized ChainState acknowledges it, recovery must still be able to restore + // the previous process-global schedule. + let protocol_activation_rollback = if self + .pending_protocol_activation_rollback + .is_none() + { + let (protocol_info, published_epoch_constants, _) = + get_protocol_runtime(); + let rollback_epoch_constants = match published_epoch_constants { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => epoch_constants, + None => { + return Err(anyhow::anyhow!( + "Cannot capture protocol activation rollback from an unpaired protocol schedule" + )); + } + }; + if rollback_epoch_constants.checkpoint_zero_timestamp + != epoch_constants.checkpoint_zero_timestamp + || rollback_epoch_constants.checkpoints_period + != epoch_constants.checkpoints_period + || rollback_epoch_constants.checkpoint_zero_timestamp_wit2 + != epoch_constants.checkpoint_zero_timestamp_wit2 + || rollback_epoch_constants.checkpoints_period_wit2 + != epoch_constants.checkpoints_period_wit2 + { + return Err(anyhow::anyhow!( + "Cannot capture protocol activation rollback from a stale ChainManager epoch clock" + )); + } + Some(PendingProtocolActivationRollback { + protocol_info, + epoch_constants: rollback_epoch_constants, + trigger_epoch: block_epoch, + trigger_hash: block_hash, + activation_epoch: scheduled_epoch, + checkpoint_period, + }) + } else { + None + }; + if get_protocol_runtime().2 != protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed before activation registration" + )); + } + expected_protocol_generation = + try_register_protocol_version_with_epoch_constants_and_generation( + ProtocolVersion::V2_0, + scheduled_epoch, + checkpoint_period, + next_epoch_constants, + next_epoch_constants.epoch_at(get_timestamp()).map_err(|error| { + anyhow::anyhow!( + "Cannot derive the current epoch while scheduling protocol V2_0 from block {block_hash}: {error}" + ) + })?, + ) + .map_err(|error| { + anyhow::anyhow!( + "Cannot schedule protocol V2_0 from block {block_hash}: {error}" + ) + })?; + if let Some(rollback) = protocol_activation_rollback { + self.pending_protocol_activation_rollback = Some(rollback); } + log::info!( + "The stake threshold for activating protocol V2_0 has been met. Protocol V2_0 will be scheduled for epoch {scheduled_epoch}" + ); + self.epoch_constants = Some(next_epoch_constants); + } + + // All staged transitions above were derived from `protocol_info`. If the global + // schedule advanced concurrently, discard the private projection before any + // accepted-block field is replaced. A schedule publication performed by the + // activation branch is legitimate, so the registration helper supplies its + // exact published generation as the new expected value. + if get_protocol_runtime().2 != expected_protocol_generation { + return Err(anyhow::anyhow!( + "protocol schedule changed while consolidating block" + )); + } + + // The pool projection above, protocol registration, and every staking transition + // have succeeded. Publish the staged accepted-block state, including the private + // projection of the already validated UTXO diff, through field replacement and then + // advance the matching beacon. + pending_pool_updates.publish( + &mut self.chain_state.unspent_outputs_pool, + &mut self.chain_state.data_request_pool, + &mut self.transactions_pool, + &mut self.chain_state.own_utxos, + &mut self.chain_state.node_stats, + ); + + if let Some((staged_reputation_engine, staged_alt_keys)) = staged_legacy_reputation { + *reputation_engine = staged_reputation_engine; + *alt_keys = staged_alt_keys; } + *stakes = consolidated_stakes; + *delegation_state = consolidated_delegation_state; + chain_info.highest_block_checkpoint = beacon; + chain_info.highest_vrf_output = vrf_input; + // Update bn256 public keys with block information - self.chain_state.alt_keys.insert_keys_from_block(&block); + alt_keys.insert_keys_from_block(&block); // Insert candidate block into `block_chain` state self.chain_state.block_chain.insert(block_epoch, block_hash); @@ -1132,19 +3570,6 @@ impl ChainManager { match self.sm_state { StateMachine::WaitingConsensus => { - // Persist finished data requests into storage - let to_be_stored = - self.chain_state.data_request_pool.finished_data_requests(); - - if !resynchronizing { - self.persist_data_requests(ctx, to_be_stored); - } - - let reveals = self - .chain_state - .data_request_pool - .update_data_request_stages(Some(validator_count), Some(block_epoch)); - for reveal in reveals { // Send AddTransaction message to self // And broadcast it to all of peers @@ -1155,22 +3580,23 @@ impl ChainManager { } if !resynchronizing { - ctx.wait( - self.persist_items(vec![StoreInventoryItem::Block(Box::new( - block, - ))]) - .map(|_, _, _| ()), - ); + let persistence = self + .persist_blocks_and_data_requests_with_protocol( + vec![block], + to_be_stored, + protocol_info.clone(), + ) + .map(|result, act, ctx| { + if let Err(error) = result { + act.recover_after_chain_commit_failure(ctx, &error); + } + }); + ctx.wait(persistence); } } StateMachine::Synchronizing => { // In Synchronizing stage, blocks and data requests are persisted // trough batches in AddBlocks handler - let reveals = self - .chain_state - .data_request_pool - .update_data_request_stages(Some(validator_count), Some(block_epoch)); - for reveal in reveals { // Send AddTransaction message to self // And broadcast it to all of peers @@ -1181,23 +3607,26 @@ impl ChainManager { } } StateMachine::AlmostSynced | StateMachine::Synced => { - // Persist finished data requests into storage - let to_be_stored = - self.chain_state.data_request_pool.finished_data_requests(); for dr_info in &to_be_stored { - show_tally_info(dr_info.tally.as_ref().unwrap(), block_epoch); - } - - if !resynchronizing { - self.persist_data_requests(ctx, to_be_stored); + if let Some(tally) = dr_info.tally.as_ref() { + show_tally_info(tally, block_epoch); + } else { + // A finished record without a tally is malformed persisted + // state. Keep synchronization alive so the record can still be + // handed to persistence/recovery instead of unwinding the actor + // while trying to log it. + log::warn!( + "Finished data request record has no tally at epoch #{}", + block_epoch + ); + } } - let reveals = self - .chain_state - .data_request_pool - .update_data_request_stages(Some(validator_count), Some(block_epoch)); - - show_info_dr(&self.chain_state.data_request_pool, &block); + show_info_dr( + &self.chain_state.data_request_pool, + &block, + protocol_version, + ); for reveal in reveals { // Send AddTransaction message to self @@ -1215,16 +3644,26 @@ impl ChainManager { // is not on the main chain. To fix this we could remove forked blocks when // a reorganization is detected. if !resynchronizing { - ctx.wait( - self.persist_items(vec![StoreInventoryItem::Block(Box::new( - block.clone(), - ))]) - .map(|_, _, _| ()), - ); + let persistence = self + .persist_blocks_and_data_requests_with_protocol( + vec![block.clone()], + to_be_stored, + protocol_info.clone(), + ) + .map(move |result, act, ctx| match result { + Ok(()) => { + JsonRpcServer::from_registry() + .do_send(BlockNotify { block }); + } + Err(error) => { + act.recover_after_chain_commit_failure(ctx, &error); + } + }); + ctx.wait(persistence); + } else { + // Inventory is already durable while replaying from local storage. + JsonRpcServer::from_registry().do_send(BlockNotify { block }); } - - // Send notification to JsonRpcServer - JsonRpcServer::from_registry().do_send(BlockNotify { block }) } } @@ -1244,14 +3683,38 @@ impl ChainManager { } } _ => { - log::error!("No ChainInfo loaded in ChainManager"); + return Err(anyhow::anyhow!("No ChainInfo loaded in ChainManager")); } } + + Ok(()) } /// Create a superblock, sign a superblock vote and broadcast it fn create_and_broadcast_superblock(&mut self, ctx: &mut Context, current_epoch: u32) { + let (runtime_protocol_info, published_epoch_constants, protocol_generation) = + get_protocol_runtime(); + let vote_epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => constants, + (None, Some(constants)) if runtime_protocol_info.all_versions.is_empty() => constants, + (None, _) => { + log::error!( + "Cannot construct and schedule a superblock vote without a paired protocol clock" + ); + return; + } + }; self.construct_superblock(current_epoch, None) + .and_then(|(superblock, pending_consolidation), _, _| { + if pending_consolidation.is_some() { + log::error!( + "Refusing to broadcast a superblock vote before consolidation is durable" + ); + actix::fut::err(()) + } else { + actix::fut::ok(superblock) + } + }) .and_then(move |superblock, act, _ctx| { let superblock_hash = superblock.hash(); log::debug!( @@ -1296,29 +3759,109 @@ impl ChainManager { }) .into_actor(act) }) - .map_ok(move |res, act, ctx| { + .and_then(move |res, act, ctx| { + let (current_protocol_info, current_published_epoch_constants, current_generation) = + get_protocol_runtime(); + if current_generation != protocol_generation { + log::debug!( + "Refusing to schedule a superblock vote from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } + // Keep the vote timer paired with the same runtime schedule used to + // construct the superblock. The actor-local clock may lag a published + // runtime notification, so it is only a compatibility fallback for an + // empty protocol registry. + let current_epoch_constants = match ( + current_published_epoch_constants, + act.epoch_constants, + ) { + (Some(constants), _) => constants, + (None, Some(constants)) + if current_protocol_info.all_versions.is_empty() => constants, + (None, _) => { + log::error!( + "Refusing to schedule a signed superblock vote without a paired epoch clock" + ); + return actix::fut::err(()); + } + }; // Broadcast vote between one and ("superblock_period" - 5) epoch checkpoints later. // This is used to prevent the race condition described in issue #1573 // It is also used to spread the CPU load by checking superblock votes along // the superblock period with a safe margin - let mut rng = rand::thread_rng(); - // Should be safe here to just call unwraps - let checkpoints_period = act - .epoch_constants - .unwrap() + if current_epoch_constants.checkpoint_zero_timestamp + != vote_epoch_constants.checkpoint_zero_timestamp + || current_epoch_constants.checkpoints_period + != vote_epoch_constants.checkpoints_period + || current_epoch_constants.checkpoint_zero_timestamp_wit2 + != vote_epoch_constants.checkpoint_zero_timestamp_wit2 + || current_epoch_constants.checkpoints_period_wit2 + != vote_epoch_constants.checkpoints_period_wit2 + { + log::error!( + "Refusing to schedule a signed superblock vote after the epoch clock changed" + ); + return actix::fut::err(()); + } + let checkpoints_period = match vote_epoch_constants .get_epoch_period(current_epoch) - .unwrap(); - let superblock_period = act.consensus_constants().superblock_period; + { + Ok(period) if period != 0 => period, + Ok(_) => { + log::error!( + "Refusing to schedule a superblock vote with a zero checkpoint period" + ); + return actix::fut::err(()); + } + Err(error) => { + log::error!( + "Cannot derive the checkpoint period for a signed superblock vote: {error}" + ); + return actix::fut::err(()); + } + }; + let Some(chain_info) = act.chain_state.chain_info.as_ref() else { + log::error!( + "Refusing to schedule a signed superblock vote without ChainInfo" + ); + return actix::fut::err(()); + }; + let superblock_period = + chain_info.consensus_constants.superblock_period; let end_range = if superblock_period > 5 { - (superblock_period - 5) * checkpoints_period + match (superblock_period - 5).checked_mul(checkpoints_period) { + Some(end_range) => end_range, + None => { + log::error!( + "Superblock-vote scheduling range overflow" + ); + return actix::fut::err(()); + } + } } else { checkpoints_period }; - let random_waiting = rng.gen_range(checkpoints_period, end_range + 1); + let Some(exclusive_end) = end_range.checked_add(1) else { + log::error!("Superblock-vote scheduling upper bound overflow"); + return actix::fut::err(()); + }; + let mut rng = rand::thread_rng(); + let random_waiting = + rng.gen_range(checkpoints_period, exclusive_end); ctx.run_later( Duration::from_secs(u64::from(random_waiting)), - |act, ctx| act.add_superblock_vote(res, ctx), + move |act, ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding delayed superblock vote from superseded protocol generation {protocol_generation}" + ); + return; + } + act.add_superblock_vote(res, ctx); + }, ); + actix::fut::ok(()) }) }) .map(|_res: Result<(), ()>, _act, _ctx| ()) @@ -1338,6 +3881,27 @@ impl ChainManager { self.chain_state.get_consensus_constants() } + /// Resolve the BN256 key committed for the same secp256k1 identity as a superblock vote. + /// Votes do not carry their BN256 public key on the wire, so the node must bind this key from + /// its authoritative alternate-key mapping before admitting the vote to consensus state. + fn resolve_superblock_vote_bn256_public_key( + &self, + superblock_vote: &SuperBlockVote, + ) -> Result { + let identity = superblock_vote.secp256k1_signature.public_key.pkh(); + let public_key = self + .chain_state + .alt_keys + .get_bn256(&identity) + .ok_or_else(|| { + anyhow::anyhow!( + "missing BN256 alternate key for superblock vote identity {identity}" + ) + })?; + + Ok(public_key.clone()) + } + fn add_temp_superblock_votes(&mut self, ctx: &mut Context) { let consensus_constants = self.consensus_constants(); @@ -1350,9 +3914,21 @@ impl ChainManager { continue; } - // Validate secp256k1 signature + let bn256_public_key = match self + .resolve_superblock_vote_bn256_public_key(&superblock_vote) + { + Ok(public_key) => public_key, + Err(error) => { + log::error!("Resolve superblock vote BN256 key: {error}"); + continue; + } + }; + + // Validate the secp256k1 envelope after the BN256 signature has been bound to the + // alternate key for the same identity. signature_mngr::verify_signatures(vec![SignaturesToVerify::SuperBlockVote { superblock_vote: superblock_vote.clone(), + bn256_public_key, }]) .map(|res| { res.map_err(|e| { @@ -1387,18 +3963,39 @@ impl ChainManager { let superblock_period = u32::from(consensus_constants.superblock_period); - if self.sm_state != StateMachine::Synced { - self.temp_superblock_votes.insert(superblock_vote.clone()); + // Check if we already received this vote before resolving its alternate key. A duplicate + // vote is already authenticated state and should remain idempotent even if an old key + // mapping was compacted during a later synchronization step. + if self.chain_state.superblock_state.contains(&superblock_vote) { + return; } - // Check if we already received this vote - if self.chain_state.superblock_state.contains(&superblock_vote) { + // A vote received while block synchronization is in progress may arrive before the block + // that commits its BN256 alternate key. Keep it in the non-authoritative temporary set so + // the vote can be revalidated after synchronization; the temporary set is never consulted + // by consensus until the full BN256 and secp256k1 verification below succeeds. Votes in + // WaitingConsensus and AlmostSynced must continue through the normal path immediately: + // the temporary set is drained only on entry to AlmostSynced. + if self.sm_state == StateMachine::Synchronizing { + self.temp_superblock_votes.insert(superblock_vote.clone()); return; } - // Validate secp256k1 signature + let bn256_public_key = match self + .resolve_superblock_vote_bn256_public_key(&superblock_vote) + { + Ok(public_key) => public_key, + Err(error) => { + log::error!("Resolve superblock vote BN256 key: {error}"); + return; + } + }; + + // Validate the secp256k1 envelope after the BN256 signature has been bound to the + // alternate key for the same identity. signature_mngr::verify_signatures(vec![SignaturesToVerify::SuperBlockVote { superblock_vote: superblock_vote.clone(), + bn256_public_key, }]) .into_actor(self) .map_err(|e, _act, _ctx| { @@ -1473,6 +4070,208 @@ impl ChainManager { .spawn(ctx); } + fn rollback_pending_transaction_admission( + &mut self, + transaction: &Transaction, + transaction_admission_reservation: PendingTransactionAdmissionReservation, + delegated_action_reservation: Option, + ) { + if let Some(reservation) = delegated_action_reservation { + self + .transactions_pool + .release_pending_delegated_action(reservation); + } + if self + .transactions_pool + .rollback_pending_transaction_admission(transaction_admission_reservation) + { + self.seen_transactions.remove(transaction); + } + } + + /// Validate a signed delegated-action preview against one atomic, read-only view of the live + /// chain and mempool. + /// + /// Unlike `add_transaction`, this path never registers `seen`/unconfirmed state, reserves a + /// body or delegated-action slot, broadcasts inventory, or mutates the live pool. Signature + /// verification is performed synchronously with a fresh local secp256k1 context so the actor + /// cannot advance to a different epoch, tip, protocol, UTXO set, stake ledger, or mempool + /// between standalone validation and the cloned-pool retention simulation. + fn validate_transaction_read_only( + &self, + transaction: Transaction, + ) -> Result<(), anyhow::Error> { + // A broadcast created through the Stake/Unstake builder is gated on full synchronization. + // Keep construction-only dry runs available through their legacy path, but make the + // explicit admission assertion answer the question that a broadcast request would ask. + if self.sm_state != StateMachine::Synced { + return Err(ChainManagerError::NotSynced { + current_state: self.sm_state, + } + .into()); + } + + let (protocol_info, published_epoch_constants, protocol_generation) = + get_protocol_runtime(); + let admission_protocol = protocol_info.current_version; + let transaction = match transaction { + Transaction::Stake(stake) => { + // Construction-only previews intentionally permit preparation before activation, + // but an admission assertion must not erase the V2 terms and report that their + // legacy projection is currently publishable. Repeat the RPC gate inside this + // atomic actor view so a protocol transition between the two messages is safe. + if admission_protocol < ProtocolVersion::V2_1 + && stake.body.output.authorization_terms.is_some() + { + return Err(ChainManagerError::DelegatedStakingNotActive.into()); + } + Transaction::Stake(stake.normalized_for_protocol(admission_protocol)) + } + transaction @ Transaction::Unstake(_) => transaction, + _ => { + return Err( + witnet_data_structures::error::TransactionError::NotValidTransaction.into(), + ); + } + }; + let transaction_hash = transaction.versioned_hash(admission_protocol); + + // Ordinary admission is idempotent for an exact retained or in-flight body. Preserve that + // behavior without acquiring a second reservation or repeating signature work. + if self.seen_transactions.contains(&transaction) { + return Ok(()); + } + match self + .transactions_pool + .contains_for_protocol(&transaction, admission_protocol) + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => return Err(error.into()), + } + + if let Transaction::Stake(stake) = &transaction { + let timestamp = u64::try_from(get_timestamp()) + .map_err(|_| ChainManagerError::ChainNotReady)?; + for input in &stake.body.inputs { + let output = *input.output_pointer(); + let locally_available = self + .chain_state + .own_utxos + .get(&output) + .is_some_and(|usage_timeout| *usage_timeout <= timestamp); + if !locally_available { + return Err(ChainManagerError::PreviewInputUnavailable { output }.into()); + } + } + } + + let chain_info = self + .chain_state + .chain_info + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)?; + let reputation_engine = self + .chain_state + .reputation_engine + .as_ref() + .ok_or(ChainManagerError::ChainNotReady)?; + let current_epoch = self + .current_epoch + .ok_or(ChainManagerError::ChainNotReady)?; + // Keep this read-only admission proof on the same paired schedule/clock snapshot as the + // protocol selected above. A queued actor notification may leave the local cache on the + // preceding clock after the process-wide runtime has already advanced. + let epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => constants, + (None, Some(constants)) if protocol_info.all_versions.is_empty() => constants, + (None, _) => return Err(ChainManagerError::ChainNotReady.into()), + }; + + let mut signatures_to_verify = Vec::new(); + let mut vrf_input = chain_info.highest_vrf_output; + vrf_input.checkpoint = current_epoch; + let active_wips = ActiveWips { + active_wips: self.chain_state.tapi_engine.wip_activation.clone(), + block_epoch: current_epoch, + }; + let protocol_version = protocol_info.all_versions.version_for_epoch(current_epoch); + if matches!(&transaction, Transaction::Stake(_)) + && protocol_version != admission_protocol + { + return Err(ChainManagerError::ChainNotReady.into()); + } + + let collateral_age = self + .consensus_constants_wit2 + .get_collateral_age(&active_wips); + let max_rounds = chain_info + .consensus_constants + .extra_rounds + .checked_add(1) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let fee = validate_new_transaction_for_protocol( + &transaction, + ( + reputation_engine, + &self.chain_state.unspent_outputs_pool, + &self.chain_state.data_request_pool, + ), + vrf_input, + current_epoch, + epoch_constants, + self.chain_state.block_number(), + &mut signatures_to_verify, + chain_info.consensus_constants.collateral_minimum, + collateral_age, + chain_info.consensus_constants.max_vt_weight, + chain_info.consensus_constants.max_dr_weight, + chain_info.consensus_constants.minimum_difficulty, + PSEUDO_CONSENSUS_CONSTANTS_WIP0022_REWARD_COLLATERAL_RATIO, + &active_wips, + chain_info.consensus_constants.superblock_period, + &self.chain_state.stakes, + &self.chain_state.delegation_state, + self.get_magic(), + protocol_version, + max_rounds, + &self.consensus_constants_wit2, + &protocol_info, + )?; + + // Stake and Unstake standalone validation queues only secp256k1 checks. Use an ephemeral + // context rather than the SignatureManager's actor-owned context so the complete answer is + // derived in this one ChainManager turn with no external mutable state. + let mut vrf_ctx = VrfCtx::secp256k1()?; + verify_signatures(signatures_to_verify, &mut vrf_ctx)?; + let (current_protocol_info, _, current_protocol_generation) = get_protocol_runtime(); + anyhow::ensure!( + current_protocol_generation == protocol_generation + && current_protocol_info.current_version == admission_protocol, + "protocol schedule changed while validating delegated transaction preview" + ); + + let retained = self.transactions_pool.would_retain_for_protocol( + &transaction, + fee, + admission_protocol, + )?; + // The mempool-policy read is the final fallible operation in this synchronous preview. + // Recheck after it as well as before it: a schedule replacement in between must not let + // this request publish a result derived from a superseded protocol generation. + let (current_protocol_info, _, current_protocol_generation) = get_protocol_runtime(); + anyhow::ensure!( + current_protocol_generation == protocol_generation + && current_protocol_info.current_version == admission_protocol, + "protocol schedule changed while validating delegated transaction preview" + ); + if retained { + Ok(()) + } else { + Err(ChainManagerError::MempoolPolicyRejected { transaction_hash }.into()) + } + } + #[must_use] fn add_transaction( &mut self, @@ -1501,24 +4300,57 @@ impl ChainManager { }, }; + // Canonicalize ignored pre-Wit/2.1 stake terms before any identity-sensitive operation. + // Otherwise two wire variants with the same legacy consensus identity can bypass `seen` + // and `contains`, poison unconfirmed hashes, and overwrite one pool entry while its weight + // is counted twice. + let (protocol_info, published_epoch_constants, admission_protocol_generation) = + get_protocol_runtime(); + // A schedule publication can reach the process-wide runtime before the matching actor + // notification. Pin transaction timing to that same clock; only an empty compatibility + // registry may fall back to the actor-local legacy clock. A configured schedule without a + // paired clock falls through to the existing ChainNotReady rollback below. + let admission_epoch_constants = match (published_epoch_constants, self.epoch_constants) { + (Some(constants), _) => Some(constants), + (None, Some(constants)) if protocol_info.all_versions.is_empty() => Some(constants), + (None, _) => None, + }; + let admission_protocol = protocol_info.current_version; + let AddTransaction { + transaction, + broadcast_flag, + } = msg; + let msg = AddTransaction { + transaction: match transaction { + Transaction::Stake(stake) => Transaction::Stake( + stake.normalized_for_protocol(admission_protocol), + ), + transaction => transaction, + }, + broadcast_flag, + }; + // Preserve the exact identity used by the pending-admission tracker. Stake hashing is + // protocol-versioned, and signature verification yields back to the actor, so recomputing + // the hash after an epoch/protocol transition could remove a different key on rollback. + let transaction_hash = msg.transaction.versioned_hash(admission_protocol); + if self.seen_transactions.contains(&msg.transaction) { log::trace!( "Transaction is already in the pool: {}", - msg.transaction.hash() + transaction_hash ); return Box::pin(actix::fut::ok(())); } - self.seen_transactions.insert(msg.transaction.clone()); - match self.transactions_pool.contains(&msg.transaction) { - Ok(false) => { - self.transactions_pool - .insert_unconfirmed_transactions(msg.transaction.hash()); - } + match self + .transactions_pool + .contains_for_protocol(&msg.transaction, admission_protocol) + { + Ok(false) => {} Ok(true) => { log::trace!( "Transaction is already in the pool: {}", - msg.transaction.hash() + transaction_hash ); return Box::pin(actix::fut::ok(())); } @@ -1526,7 +4358,89 @@ impl ChainManager { log::warn!("Cannot add transaction: {e}"); return Box::pin(actix::fut::err(e.into())); } - } + }; + + let delegated_action_reservation = match self + .transactions_pool + .reserve_pending_delegated_action_for_protocol( + &msg.transaction, + admission_protocol, + ) + { + Ok(reservation) => reservation, + Err(error) => { + log::warn!("Cannot reserve pending delegated action: {error}"); + return Box::pin(actix::fut::err(error.into())); + } + }; + let transaction_admission_reservation = match self + .transactions_pool + .reserve_pending_transaction_admission_for_protocol( + &msg.transaction, + admission_protocol, + &self.chain_state.data_request_pool, + ) + { + PendingTransactionAdmissionOutcome::Reserved(reservation) => reservation, + PendingTransactionAdmissionOutcome::AlreadyPending => { + // The body hash, including any signature-only wire variant, is already being + // verified. Normally delegated admission catches its own same-body retry above; + // compare-and-release any reservation acquired here as a defensive collision + // path without touching the existing generic owner. + if let Some(reservation) = delegated_action_reservation { + self.transactions_pool + .release_pending_delegated_action(reservation); + } + log::trace!( + "Transaction is already pending admission: {}", + transaction_hash + ); + return Box::pin(actix::fut::ok(())); + } + PendingTransactionAdmissionOutcome::Wit21CommitCapacityReached { + dr_pointer, + limit, + } => { + if let Some(reservation) = delegated_action_reservation { + self.transactions_pool + .release_pending_delegated_action(reservation); + } + log::debug!( + "Cannot admit Wit/2.1 Commit {transaction_hash} for data request {dr_pointer}: all {limit} consensus-derived verifier slots are occupied" + ); + return Box::pin(actix::fut::err( + ChainManagerError::MempoolPolicyRejected { transaction_hash }.into(), + )); + } + PendingTransactionAdmissionOutcome::Wit21CommitRequestUnavailable { dr_pointer } => { + if let Some(reservation) = delegated_action_reservation { + self.transactions_pool + .release_pending_delegated_action(reservation); + } + log::debug!( + "Cannot admit Wit/2.1 Commit {transaction_hash}: data request {dr_pointer} is absent or no longer accepting commitments" + ); + return Box::pin(actix::fut::err( + ChainManagerError::MempoolPolicyRejected { transaction_hash }.into(), + )); + } + PendingTransactionAdmissionOutcome::GenerationExhausted => { + if let Some(reservation) = delegated_action_reservation { + self.transactions_pool + .release_pending_delegated_action(reservation); + } + log::error!( + "Cannot admit transaction {transaction_hash}: asynchronous admission generation exhausted" + ); + return Box::pin(actix::fut::err( + TransactionError::UtxoReservationGenerationOverflow.into(), + )); + } + }; + // Mark the exact representation only after retained, semantic, and body-hash admission + // indexes accepted it. The generic reservation registered the unconfirmed hash under the + // same generation ownership used by every later asynchronous callback. + self.seen_transactions.insert(msg.transaction.clone()); if let ( Some(chain_info), @@ -1537,18 +4451,43 @@ impl ChainManager { self.chain_state.chain_info.as_ref(), self.chain_state.reputation_engine.as_ref(), self.current_epoch, - self.epoch_constants, + admission_epoch_constants, ) { if let Transaction::Commit(_) | Transaction::Reveal(_) = &msg.transaction { - let timestamp_mining = epoch_constants - .block_mining_timestamp(current_epoch) - .unwrap(); + let timestamp_mining = match epoch_constants.block_mining_timestamp(current_epoch) + { + Ok(timestamp) => timestamp, + Err(error) => { + // Admission ownership was acquired before the actor reached this timing + // boundary. A configured clock that cannot represent the current mining + // timestamp must reject without leaving the body, semantic action, seen + // hash, unconfirmed hash, or Wit/2.1 Commit work token occupied. + self.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return Box::pin(actix::fut::err(anyhow::anyhow!( + "Cannot derive the mining timestamp for transaction {} at epoch {}: {}", + transaction_hash, + current_epoch, + error + ))); + } + }; if timestamp_now > timestamp_mining { // Only reveals are saved since commits which arrive too late will be invalid in the next epoch if let Transaction::Reveal(reveal) = &msg.transaction { self.temp_reveals.push(reveal.clone()); } + // This transaction was deferred synchronously and has no verifier callback. + // Retain the existing seen/unconfirmed lifecycle, but release its generic + // in-flight slot so only genuinely asynchronous work owns a generation. + self.transactions_pool + .complete_pending_transaction_admission( + transaction_admission_reservation, + ); return Box::pin(actix::fut::ok(())); } } @@ -1573,20 +4512,47 @@ impl ChainManager { .data_request_pool .data_request_state(&co_tx.body.dr_pointer) { - ProtocolVersion::from_epoch(dr_state.epoch) + protocol_info.all_versions.version_for_epoch(dr_state.epoch) } else { - ProtocolVersion::from_epoch(current_epoch) + protocol_info.all_versions.version_for_epoch(current_epoch) } } else { - ProtocolVersion::from_epoch(current_epoch) + protocol_info.all_versions.version_for_epoch(current_epoch) }; + if matches!(&msg.transaction, Transaction::Stake(_)) + && protocol_version != admission_protocol + { + self.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return Box::pin(actix::fut::err(ChainManagerError::ChainNotReady.into())); + } + let validation_tip = chain_info.highest_block_checkpoint; + let validation_sm_state = self.sm_state; let collateral_age = self .consensus_constants_wit2 .get_collateral_age(&active_wips); let required_reward_collateral_ratio = PSEUDO_CONSENSUS_CONSTANTS_WIP0022_REWARD_COLLATERAL_RATIO; - let max_rounds = chain_info.consensus_constants.extra_rounds + 1; - let fut = future::ready(validate_new_transaction( + let max_rounds = match chain_info.consensus_constants.extra_rounds.checked_add(1) { + Some(max_rounds) => max_rounds, + None => { + // This transaction already owns its body and any delegated semantic slot. + // A malformed consensus configuration must release those exact generations + // instead of panicking or leaving verifier capacity permanently occupied. + self.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return Box::pin(actix::fut::err( + ConsensusStakesError::ArithmeticOverflow.into(), + )); + } + }; + let fut = future::ready(validate_new_transaction_for_protocol( &msg.transaction, ( reputation_engine, @@ -1607,9 +4573,12 @@ impl ChainManager { &active_wips, chain_info.consensus_constants.superblock_period, &self.chain_state.stakes, + &self.chain_state.delegation_state, + self.get_magic(), protocol_version, max_rounds, &self.consensus_constants_wit2, + &protocol_info, )) .into_actor(self) .and_then(move |fee, act, _ctx| { @@ -1619,24 +4588,196 @@ impl ChainManager { }) .then(move |res, act, _ctx| match res { Ok(fee) => { - // Broadcast valid transaction - if msg.broadcast_flag { + // Epoch/state recovery may clear all admission slots and admit the same body + // again before this verifier returns. The state snapshot can subsequently + // become byte-identical, so its comparison alone cannot distinguish the old + // callback. Require the exact body-hash generation before any relay or pool + // mutation for every transaction family. + if !act + .transactions_pool + .owns_pending_transaction_admission( + &transaction_admission_reservation, + ) + { + log::debug!( + "Retrying transaction {} after its admission slot was replaced", + transaction_hash + ); + act.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return actix::fut::err(ChainManagerError::ChainNotReady.into()); + } + + let current_tip = act + .chain_state + .chain_info + .as_ref() + .map(|chain_info| chain_info.highest_block_checkpoint); + let (current_protocol_info, _, current_protocol_generation) = + get_protocol_runtime(); + if act.current_epoch != Some(current_epoch) + || act.sm_state != validation_sm_state + || current_tip != Some(validation_tip) + || current_protocol_generation != admission_protocol_generation + || current_protocol_info.current_version != admission_protocol + { + log::debug!( + "Retrying transaction {} after chain or protocol state changed during signature verification", + transaction_hash + ); + act.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return actix::fut::err(ChainManagerError::ChainNotReady.into()); + } + + // Delegated actions additionally own their consensus-semantic nonce slot. The + // generic body token prevents ABA for every transaction; this token prevents a + // different commission/unstake body from winning the same state transition. + if delegated_action_reservation.as_ref().is_some_and(|reservation| { + !act + .transactions_pool + .owns_pending_delegated_action(reservation) + }) { + log::debug!( + "Retrying transaction {} after its delegated-action admission slot was replaced", + transaction_hash + ); + act.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return actix::fut::err(ChainManagerError::ChainNotReady.into()); + } + + // Signature verification yields back to the actor. A different valid body + // for the same delegated-action nonce may have completed admission in the + // meantime even though the first pool check passed. Recheck the semantic + // indexes before broadcasting so first-seen-wins is enforced at the relay + // boundary as well as inside the pool API. + if let Err(error) = act + .transactions_pool + .contains_for_protocol(&msg.transaction, admission_protocol) + { + log::warn!( + "Cannot add transaction {} after concurrent admission: {error}", + transaction_hash + ); + act.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + return actix::fut::err(error.into()); + } + + // A Wit/2.1 Commit may lose the deterministic per-request hash-cap race during + // insertion. Stake and Unstake are likewise subject to dedicated fee floors, + // semantic slots, and shared pool-pressure eviction. Do not relay any of these + // policy-sensitive transactions until the pool confirms the exact body was + // retained; legacy Commits and ordinary transactions keep their established + // broadcast-before-insertion behavior. + let policy_sensitive_admission = requires_retention_confirmation( + &msg.transaction, + admission_protocol, + ); + let defer_policy_sensitive_broadcast = + msg.broadcast_flag && policy_sensitive_admission; + if msg.broadcast_flag && !defer_policy_sensitive_broadcast { act.broadcast_item(InventoryItem::Transaction(msg.transaction.clone())); } + let deferred_transaction = defer_policy_sensitive_broadcast + .then(|| msg.transaction.clone()); // Add valid transaction to transactions_pool - let tx_hash = msg.transaction.hash(); - let removed_transactions = act.transactions_pool.insert(msg.transaction, fee); - log_removed_transactions(&removed_transactions, tx_hash); + let retained_candidate = msg.transaction.clone(); + let removed_transactions = act.transactions_pool.insert_for_protocol( + msg.transaction, + fee, + admission_protocol, + ); + let rejected_incoming = rejected_incoming_transaction( + policy_sensitive_admission, + transaction_hash, + &removed_transactions, + admission_protocol, + ); + let retained_exact = !policy_sensitive_admission + || act + .transactions_pool + .contains_exact_for_protocol( + &retained_candidate, + admission_protocol, + ); + log_removed_transactions_for_protocol( + &removed_transactions, + transaction_hash, + admission_protocol, + ); - actix::fut::ok(()) + if let Some(rejected_transaction) = rejected_incoming { + // The transaction is neither retained nor relayed. Treat this as a rejected + // admission, not a completed one: otherwise `seen` and the unconfirmed hash + // would make a later retry appear accepted even though no pool entry exists. + act.rollback_pending_transaction_admission( + rejected_transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); + actix::fut::err( + ChainManagerError::MempoolPolicyRejected { transaction_hash }.into(), + ) + } else if !retained_exact { + // A protocol alias or a concurrent pool mutation can make insertion a + // no-op without returning the incoming body in the eviction list. Do not + // mark that attempt as accepted or relay a body that is not actually + // addressable under its pinned admission identity. + act.rollback_pending_transaction_admission( + &retained_candidate, + transaction_admission_reservation, + delegated_action_reservation, + ); + actix::fut::err( + ChainManagerError::MempoolPolicyRejected { transaction_hash }.into(), + ) + } else { + // Keep the only post-insertion relay site inside the confirmed-retained + // branch. A policy-sensitive rejection therefore cannot reach relay even + // if later bookkeeping is rearranged around this match. + if let Some(transaction) = deferred_transaction { + act.broadcast_item(InventoryItem::Transaction(transaction)); + } + // Insertion retained this exact action. Its asynchronous verifier slots are + // no longer owned by the attempt, while the unconfirmed hash remains in the + // ordinary post-admission lifecycle. + if let Some(reservation) = delegated_action_reservation { + act.transactions_pool + .release_pending_delegated_action(reservation); + } + act.transactions_pool + .complete_pending_transaction_admission( + transaction_admission_reservation, + ); + actix::fut::ok(()) + } } Err(e) => { log::warn!( "Error when validating transaction {}: {}", - msg.transaction.hash(), + transaction_hash, e ); + act.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); actix::fut::err(e) } @@ -1644,6 +4785,11 @@ impl ChainManager { Box::pin(fut) } else { + self.rollback_pending_transaction_admission( + &msg.transaction, + transaction_admission_reservation, + delegated_action_reservation, + ); Box::pin(actix::fut::err(ChainManagerError::ChainNotReady.into())) } } @@ -1664,7 +4810,20 @@ impl ChainManager { &mut self, superblock_epoch: u32, ) -> ResponseActFuture> { - let fut = self.construct_superblock(superblock_epoch, None).and_then( + let (_, _, protocol_generation) = get_protocol_runtime(); + let fut = self + .construct_superblock(superblock_epoch, None) + .and_then(|(superblock, pending_consolidation), _, _| { + if pending_consolidation.is_some() { + log::error!( + "Refusing to vote for a candidate superblock before consolidation is durable" + ); + actix::fut::err(()) + } else { + actix::fut::ok(superblock) + } + }) + .and_then( move |superblock, act, _ctx| { let superblock_hash = superblock.hash(); log::debug!( @@ -1706,7 +4865,13 @@ impl ChainManager { } } .into_actor(act) - .and_then(|res, act, ctx| { + .and_then(move |res, act, ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding signed candidate superblock vote from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } act.add_superblock_vote(res, ctx); actix::fut::ok(()) @@ -1725,26 +4890,80 @@ impl ChainManager { sync_target: SyncTarget, sync_superblock: Option, ) -> ResponseActFuture> { + let (_, _, protocol_generation) = get_protocol_runtime(); let fut = self .construct_superblock(block_epoch, sync_superblock) - .and_then(move |superblock, act, ctx| { + .and_then( + move |(superblock, pending_consolidation), act, ctx| -> ResponseActFuture> { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding synchronized superblock consolidation from superseded protocol generation {protocol_generation}" + ); + return Box::pin(actix::fut::err(())); + } if superblock.hash() == sync_target.superblock.hash_prev_block { // In synchronizing state, the consensus beacon is the one we just created - act.chain_state - .chain_info - .as_mut() - .unwrap() - .highest_superblock_checkpoint = + let consolidated_beacon = act.chain_state.superblock_state.get_beacon(); + let Some(chain_info) = act.chain_state.chain_info.as_mut() else { + log::error!( + "Cannot publish synchronized superblock consensus without ChainInfo" + ); + return Box::pin(actix::fut::err(())); + }; + chain_info.highest_superblock_checkpoint = consolidated_beacon; log::info!( "Consensus while sync! Superblock {:?}", act.get_superblock_beacon() ); - // Copy current chain state into previous chain state, and persist it - act.move_chain_state_forward(sync_target.superblock.checkpoint); + // Copy current chain state into previous chain state, and persist it. Do not + // submit a persistence future if the snapshot journal rejected the state. + if act + .move_chain_state_forward(sync_target.superblock.checkpoint) + .is_err() + { + return Box::pin(actix::fut::err(())); + } - act.persist_chain_state(Some(sync_target.superblock.checkpoint)) + let superblock_notification = pending_consolidation + .as_ref() + .and_then(|pending| pending.notify.clone()); + Box::pin( + act.persist_chain_state( + Some(sync_target.superblock.checkpoint), + superblock_notification, + ) + .map_err(|(), act, ctx| { + let error = anyhow::anyhow!( + "failed to persist synchronized superblock ChainState" + ); + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(move |(), act, ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding persisted synchronized superblock from superseded protocol generation {protocol_generation}" + ); + act.recover_after_chain_commit_failure( + ctx, + &anyhow::anyhow!( + "protocol schedule changed after synchronized superblock persistence" + ), + ); + return actix::fut::err(()); + } + if let Some(pending) = pending_consolidation { + if let Some(notify) = pending.notify { + act.publish_superblock_consolidation(notify); + } + } + act.publish_superblock_session_state( + act.get_superblock_beacon(), + ); + actix::fut::ok(()) + }), + ) } else { // The superblock hash is different from what it should be. log::error!( @@ -1762,30 +4981,46 @@ impl ChainManager { act.sync_waiting_for_add_blocks_since = None; Box::pin(actix::fut::err(())) } - }); + }, + ); Box::pin(fut) } /// Construct superblock process which uses futures #[must_use] - pub fn construct_superblock( + pub(crate) fn construct_superblock( &mut self, block_epoch: u32, sync_superblock: Option, - ) -> ResponseActFuture> { + ) -> ResponseActFuture< + Self, + Result<(SuperBlock, Option), ()>, + > { let consensus_constants = self.consensus_constants(); + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let protocol_version = protocol_info.all_versions.version_for_epoch(block_epoch); let superblock_period = u32::from(consensus_constants.superblock_period); + if superblock_period == 0 { + log::error!( + "Cannot construct superblock for block epoch {block_epoch}: superblock period is zero" + ); + return Box::pin(actix::fut::err(())); + } let superblock_index = block_epoch / superblock_period; if superblock_index == 0 { - panic!("Superblock 0 should not be created! Block epoch: {block_epoch}"); + log::error!( + "Refusing to construct bootstrap superblock from block epoch {block_epoch}" + ); + return Box::pin(actix::fut::err(())); } // This is the superblock for which we will be counting votes, and if there is consensus, // it will be the new consolidated superblock let voted_superblock_beacon = self.chain_state.superblock_state.get_beacon(); let last_consolidated_beacon = self.chain_state.get_superblock_beacon(); + let defer_superblock_publication = self.sm_state == StateMachine::Synchronizing; let inventory_manager = InventoryManager::from_registry(); @@ -1801,7 +5036,10 @@ impl ChainManager { let block_hashes = res.into_iter().map(|(_epoch, hash)| hash); let aux = block_hashes.map(move |hash| { inventory_manager - .send(GetItemBlock { hash }) + .send(GetItemBlock { + hash, + protocol_info: Some(protocol_info.clone()), + }) .then(move |res| match res { Ok(Ok(block)) => future::ready(Ok(block.block_header)), Ok(Err(e)) => { @@ -1832,7 +5070,13 @@ impl ChainManager { actix::fut::ok((block_headers, last_hash)) }) - .and_then(move |(block_headers, last_hash), act, ctx| { + .and_then(move |(block_headers, last_hash), act, ctx| -> ResponseActFuture> { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding superblock consolidation from superseded protocol generation {protocol_generation}" + ); + return Box::pin(actix::fut::err(())); + } let consensus = if act.sm_state == StateMachine::Synced || act.sm_state == StateMachine::AlmostSynced { if voted_superblock_beacon.checkpoint == last_consolidated_beacon.checkpoint { @@ -1884,24 +5128,48 @@ impl ChainManager { }; match consensus { - SuperBlockConsensus::SameAsLocal => { - // At this point we need to persist previous_chain_state, - // Take last beacon from superblock state and use it in current chain_info - act.chain_state.chain_info.as_mut().unwrap().highest_superblock_checkpoint = - act.chain_state.superblock_state.get_beacon(); - - let fut: Pin>>> = if act.sm_state == StateMachine::Synced || act.sm_state == StateMachine::AlmostSynced { - // Persist previous_chain_state with current superblock_state - Box::pin(act.persist_chain_state(Some(voted_superblock_beacon.checkpoint)).map(move |_res: Result<(), ()>, act, _ctx| { - act.move_chain_state_forward(superblock_index); - - Ok((block_headers, last_hash)) - })) - } else { - Box::pin(actix::fut::ok((block_headers, last_hash))) - }; + SuperBlockConsensus::SameAsLocal => { + if last_consolidated_beacon.checkpoint > voted_superblock_beacon.checkpoint + || (last_consolidated_beacon.checkpoint + == voted_superblock_beacon.checkpoint + && last_consolidated_beacon.hash_prev_block + != voted_superblock_beacon.hash_prev_block) + { + let error = anyhow::anyhow!( + "refusing to replace durable superblock beacon {:?} with {:?}", + last_consolidated_beacon, + voted_superblock_beacon + ); + act.recover_after_chain_commit_failure(ctx, &error); + return Box::pin(actix::fut::err(())); + } - fut + match act.prepare_superblock_consolidation() { + // Keep a fresh notification process-local until the matching + // ChainState/UTXO checkpoint can carry it through one storage-worker + // batch. The already-persisted repair path below is the sole safe + // inventory-only exception. + Ok(Some(notify)) => Box::pin(actix::fut::ok(( + block_headers, + last_hash, + Some(PendingSuperblockConsolidation { + beacon: voted_superblock_beacon, + notify: Some(notify), + }), + ))), + Ok(None) => Box::pin(actix::fut::ok(( + block_headers, + last_hash, + Some(PendingSuperblockConsolidation { + beacon: voted_superblock_beacon, + notify: None, + }), + ))), + Err(error) => { + act.recover_after_chain_commit_failure(ctx, &error); + Box::pin(actix::fut::err(())) + } + } } SuperBlockConsensus::Different(target_superblock_hash) => { // No consensus: move to waiting consensus and restore chain_state from storage @@ -1928,10 +5196,20 @@ impl ChainManager { // Update last superblock consensus in ChainManager act.last_superblock_consensus = Some(consensus_superblock); - act.initialize_from_storage(ctx); - act.update_state_machine(StateMachine::WaitingConsensus, ctx); - - Box::pin(actix::fut::err(())) + Box::pin( + act.recover_reverted_block_inventory(false) + .map_err(|error, _act, _ctx| { + log::error!( + "Cannot safely clean reverted reports after different superblock consensus; stopping the node: {error}" + ); + System::current().stop_with_code(1); + }) + .and_then(|(), act, ctx| { + act.initialize_from_storage(ctx); + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + actix::fut::err(()) + }), + ) } SuperBlockConsensus::NoConsensus => { // No consensus: move to AlmostSynced and restore chain_state from storage @@ -1955,12 +5233,20 @@ impl ChainManager { let sessions_manager_addr = SessionsManager::from_registry(); sessions_manager_addr.do_send(SetSuperBlockTargetBeacon {beacon: None}); - act.reinsert_transactions_from_unconfirmed_blocks(init_epoch.saturating_sub(superblock_period)).map(|_res: Result<(), ()>, _act, _ctx| ()).wait(ctx); - - act.initialize_from_storage(ctx); - act.update_state_machine(StateMachine::AlmostSynced, ctx); - - Box::pin(actix::fut::err(())) + Box::pin( + act.recover_reverted_block_inventory(true) + .map_err(|error, _act, _ctx| { + log::error!( + "Cannot safely recover transactions after superblock consensus failure; stopping the node: {error}" + ); + System::current().stop_with_code(1); + }) + .and_then(|(), act, ctx| { + act.initialize_from_storage(ctx); + act.update_state_machine(StateMachine::AlmostSynced, ctx); + actix::fut::err(()) + }), + ) } SuperBlockConsensus::Unknown => { // Consensus unknown: move to waiting consensus and restore chain_state from storage @@ -1984,39 +5270,139 @@ impl ChainManager { let sessions_manager_addr = SessionsManager::from_registry(); sessions_manager_addr.do_send(SetSuperBlockTargetBeacon {beacon: None}); - act.reinsert_transactions_from_unconfirmed_blocks(init_epoch.saturating_sub(superblock_period)).map(|_res: Result<(), ()>, _act, _ctx| ()).wait(ctx); - - act.initialize_from_storage(ctx); - act.update_state_machine(StateMachine::WaitingConsensus, ctx); - - Box::pin(actix::fut::err(())) + Box::pin( + act.recover_reverted_block_inventory(true) + .map_err(|error, _act, _ctx| { + log::error!( + "Cannot safely recover transactions after unknown superblock consensus; stopping the node: {error}" + ); + System::current().stop_with_code(1); + }) + .and_then(|(), act, ctx| { + act.initialize_from_storage(ctx); + act.update_state_machine(StateMachine::WaitingConsensus, ctx); + actix::fut::err(()) + }), + ) } } }) - .and_then(move |(block_headers, last_hash), act, _ctx| { - if let Some(consolidated_superblock) = act.chain_state.superblock_state.get_current_superblock() { - let sb_hash = consolidated_superblock.hash(); - // Let JSON-RPC clients know that the blocks in the previous superblock can now - // be considered consolidated - act.notify_superblock_consolidation(consolidated_superblock); - - log::info!("Consensus reached for Superblock #{} with {} out of {} votes. Committee size: {}", - voted_superblock_beacon.checkpoint, - act.chain_state.superblock_state.votes_counter_from_superblock(&sb_hash), - act.chain_state.superblock_state.valid_votes_counter(), - act.chain_state.superblock_state.get_committee_length(), - ); - log::debug!("Current tip of the chain: {:?}", act.get_chain_beacon()); + .and_then(move |(block_headers, last_hash, pending_consolidation), act, ctx| -> ResponseActFuture> { + if get_protocol_runtime().2 != protocol_generation { log::debug!( - "The last block of the consolidated superblock is {last_hash}" + "Discarding superblock consolidation from superseded protocol generation {protocol_generation}" + ); + return Box::pin(actix::fut::err(())); + } + if act.sm_state == StateMachine::Synced || act.sm_state == StateMachine::AlmostSynced { + let superblock_notification = pending_consolidation + .as_ref() + .and_then(|pending| pending.notify.clone()); + Box::pin( + act.persist_chain_state( + Some(voted_superblock_beacon.checkpoint), + superblock_notification, + ) + .map_err(|(), act, ctx| { + let error = anyhow::anyhow!( + "failed to persist consolidated superblock ChainState" + ); + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(move |(), act, _ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding persisted superblock transition from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } + if act.move_chain_state_forward(superblock_index).is_err() { + return actix::fut::err(()); + } + if let Some(pending) = pending_consolidation { + if let Some(notify) = pending.notify { + act.publish_superblock_consolidation(notify); + } + } + actix::fut::ok((block_headers, last_hash, None)) + }), + ) + } else if !defer_superblock_publication + && pending_consolidation.as_ref().is_some_and(|pending| { + act.chain_state_snapshot + .is_persisted(pending.beacon.checkpoint) + }) + { + let Some(pending) = pending_consolidation else { + return Box::pin(actix::fut::err(())); + }; + if let Some(notify) = pending.notify { + if notify.superblock.index != pending.beacon.checkpoint { + let error = anyhow::anyhow!( + "already-persisted superblock #{} carries notification #{}", + pending.beacon.checkpoint, + notify.superblock.index ); - - // Update mempool after superblock consolidation - act.transactions_pool.update_unconfirmed_transactions(); + act.recover_after_chain_commit_failure(ctx, &error); + return Box::pin(actix::fut::err(())); + } + let notification_for_inventory = notify.clone(); + let chain_state_key = storage_keys::chain_state_key(act.get_magic()); + Box::pin( + storage_mngr::repair_ordinary_superblock_inventory( + chain_state_key, + notification_for_inventory, + ) + .into_actor(act) + .map_err(|error, act, ctx| { + act.recover_after_chain_commit_failure(ctx, &error); + }) + .and_then(move |(), act, _ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding repaired superblock inventory from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } + // This repair/exact-replay write is ordered after a checkpoint that + // the snapshot journal already proved durable and was rebound to the + // notification by the storage worker. Publish only after the + // inventory acknowledgement too. + act.publish_superblock_consolidation(notify); + actix::fut::ok((block_headers, last_hash, None)) + }), + ) + } else { + Box::pin(actix::fut::ok((block_headers, last_hash, None))) + } + } else { + Box::pin(actix::fut::ok((block_headers, last_hash, pending_consolidation))) } - - let chain_info = act.chain_state.chain_info.as_ref().unwrap(); - let reputation_engine = act.chain_state.reputation_engine.as_ref().unwrap(); + }) + .and_then(move |(block_headers, last_hash, pending_consolidation), act, _ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::debug!( + "Discarding superblock assembly from superseded protocol generation {protocol_generation}" + ); + return actix::fut::err(()); + } + // The inventory fetch and possible consolidation persistence above are await + // boundaries. A concurrent recovery/reinitialization may temporarily remove + // either optional component, so construct no committee from a partial ChainState. + let (chain_info, reputation_engine) = match ( + act.chain_state.chain_info.as_ref(), + act.chain_state.reputation_engine.as_ref(), + ) { + (Some(chain_info), Some(reputation_engine)) => { + (chain_info, reputation_engine) + } + _ => { + log::error!( + "Cannot construct a superblock without ChainInfo and ReputationEngine" + ); + return actix::fut::err(()); + } + }; let last_superblock_signed_by_bootstrap = last_superblock_signed_by_bootstrap(&chain_info.consensus_constants); let ars_members = @@ -2029,12 +5415,21 @@ impl ChainManager { } else if superblock_index >= last_superblock_signed_by_bootstrap { reputation_engine.get_rep_ordered_ars_list() } else { - chain_info + let bootstrap_committee = chain_info .consensus_constants .bootstrapping_committee .iter() - .map(|add| add.parse().expect("Malformed bootstrapping committee")) - .collect() + .map(|address| address.parse::()) + .collect::, _>>(); + match bootstrap_committee { + Ok(committee) => committee, + Err(error) => { + log::error!( + "Cannot construct a superblock from a malformed bootstrapping committee: {error}" + ); + return actix::fut::err(()); + } + } }; // Get the list of members of the ARS with reputation greater than 0 @@ -2050,18 +5445,27 @@ impl ChainManager { }; // Committee size should decrease if sufficient epochs have elapsed since last confirmed superblock + // Use the newly consolidated beacon for committee decay without mutating the + // live ChainInfo before the final generation guard. A schedule replacement that + // supersedes this continuation must leave all in-memory superblock bookkeeping + // untouched. + let highest_superblock_checkpoint = pending_consolidation + .as_ref() + .map_or(voted_superblock_beacon.checkpoint, |pending| { + pending.beacon.checkpoint + }); let committee_size = current_committee_size_requirement( consensus_constants.superblock_signing_committee_size, act.chain_state.superblock_state.get_committee_length(), consensus_constants.superblock_committee_decreasing_period, consensus_constants.superblock_committee_decreasing_step, - chain_info.highest_superblock_checkpoint.checkpoint, + highest_superblock_checkpoint, superblock_index, last_superblock_signed_by_bootstrap, min_committee_size, ); - let superblock = act.chain_state.superblock_state.build_superblock( + let superblock = act.chain_state.superblock_state.build_superblock_for_protocol( &block_headers, ars_identities, committee_size, @@ -2070,6 +5474,7 @@ impl ChainManager { &act.chain_state.alt_keys, sync_superblock, block_epoch, + protocol_version, ); // Put the local superblock into chain state @@ -2080,21 +5485,13 @@ impl ChainManager { // Update last superblock consensus in ChainManager act.last_superblock_consensus = Some(voted_superblock_beacon); - // Set last beacon in sessions manager - let sessions_manager_addr = SessionsManager::from_registry(); - let chain_beacon = act.get_chain_beacon(); - sessions_manager_addr.do_send(SetLastBeacon { - beacon: LastBeacon{ - highest_block_checkpoint: chain_beacon, - highest_superblock_checkpoint: voted_superblock_beacon, - }, - }); - - // Remove superblock beacon target in order to use our own SuperBlockBeacon that - // in this case is the same that the consensus one - sessions_manager_addr.do_send(SetSuperBlockTargetBeacon {beacon: None}); + // During synchronization, the outer ChainState commit owns publication. In every + // other path the consolidated beacon is already durable at this point. + if pending_consolidation.is_none() { + act.publish_superblock_session_state(voted_superblock_beacon); + } - actix::fut::ok(superblock) + actix::fut::ok((superblock, pending_consolidation)) }); Box::pin(fut) @@ -2114,6 +5511,47 @@ impl ChainManager { chain_beacon: CheckpointBeacon, epoch_constants: EpochConstants, ) -> ResponseActFuture> { + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + self.future_process_validations_with_protocol( + block, + previous_block_epoch, + current_epoch, + vrf_input, + chain_beacon, + epoch_constants, + protocol_info, + protocol_generation, + ) + } + + /// Block validation process using a caller-owned protocol snapshot. + /// + /// Mining captures the protocol schedule before starting asynchronous VRF and tally work. + /// Keeping that snapshot through validation prevents a concurrent schedule publication from + /// making the candidate validate under a different set of versioned rules. The generation is + /// checked again before and after asynchronous transaction/signature validation so superseded + /// candidates fail closed. + #[must_use] + pub fn future_process_validations_with_protocol( + &mut self, + block: Block, + previous_block_epoch: Epoch, + current_epoch: Epoch, + vrf_input: CheckpointVRF, + chain_beacon: CheckpointBeacon, + epoch_constants: EpochConstants, + protocol_info: ProtocolInfo, + protocol_generation: u64, + ) -> ResponseActFuture> { + // The caller normally checks immediately before installing this future, but keep the + // explicit entry point fail-closed as well. A direct caller must not run synchronous block + // validation with a snapshot that has already been superseded while the future was being + // constructed. + if get_protocol_runtime().2 != protocol_generation { + return Box::pin(actix::fut::err(anyhow::anyhow!( + "protocol schedule changed before validating mined block" + ))); + } let block_number = self.chain_state.block_number(); let mut signatures_to_verify = vec![]; let consensus_constants = self.consensus_constants(); @@ -2121,20 +5559,30 @@ impl ChainManager { active_wips: self.chain_state.tapi_engine.wip_activation.clone(), block_epoch: block.block_header.beacon.checkpoint, }; - let protocol_version = ProtocolVersion::from_epoch(block.block_header.beacon.checkpoint); - let replication_factor = self - .consensus_constants_wit2 - .get_replication_factor(block.block_header.beacon.checkpoint, previous_block_epoch); + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + let replication_factor = ConsensusConstantsWit2::get_replication_factor_for_protocol( + block.block_header.beacon.checkpoint, + previous_block_epoch, + protocol_version, + ); + let Some(reputation_engine) = self.chain_state.reputation_engine.as_ref() else { + return Box::pin(actix::fut::err(ChainManagerError::ChainNotReady.into())); + }; let res = validate_block( &block, current_epoch, vrf_input, chain_beacon, &mut signatures_to_verify, - self.chain_state.reputation_engine.as_ref().unwrap(), + reputation_engine, &consensus_constants, &active_wips, &self.chain_state.stakes, + &self.chain_state.delegation_state, + self.consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), protocol_version, replication_factor, ); @@ -2147,29 +5595,49 @@ impl ChainManager { } .into_actor(self) .and_then(move |(), act, _ctx| { + // Transaction validation mutates a private-in-effect data-request projection. Do not + // begin that mutation if a protocol schedule was replaced while block signatures + // were being verified; the block was derived under the captured schedule and must be + // rebuilt against the new generation instead of mixing historical hash rules. let mut signatures_to_verify = vec![]; - let res = validate_block_transactions( - &act.chain_state.unspent_outputs_pool, - &mut act.chain_state.data_request_pool, - &block, - vrf_input, - &mut signatures_to_verify, - act.chain_state.reputation_engine.as_ref().unwrap(), - epoch_constants, - block_number, - &consensus_constants, - &act.consensus_constants_wit2, - &active_wips, - None, - &act.chain_state.stakes, - ); + let res = if get_protocol_runtime().2 != protocol_generation { + Err(anyhow::anyhow!( + "protocol schedule changed while validating mined block" + )) + } else { + match act.chain_state.reputation_engine.as_ref() { + Some(reputation_engine) => validate_block_transactions_for_protocol( + &act.chain_state.unspent_outputs_pool, + &mut act.chain_state.data_request_pool, + &block, + vrf_input, + &mut signatures_to_verify, + reputation_engine, + epoch_constants, + block_number, + &consensus_constants, + &act.consensus_constants_wit2, + &active_wips, + None, + &act.chain_state.stakes, + &act.chain_state.delegation_state, + consensus_constants.get_magic(), + &protocol_info, + protocol_version, + ), + None => Err(ChainManagerError::ChainNotReady.into()), + } + }; async move { // Short-circuit if validation failed let diff = res?; - signature_mngr::verify_signatures(signatures_to_verify) - .await - .map(|()| diff) + signature_mngr::verify_signatures(signatures_to_verify).await?; + anyhow::ensure!( + get_protocol_runtime().2 == protocol_generation, + "protocol schedule changed while validating mined block" + ); + Ok(diff) } .into_actor(act) }); @@ -2207,6 +5675,20 @@ impl ChainManager { } fn request_blocks_batch(&mut self, ctx: &mut Context) { + let epoch = match self.current_epoch { + Some(epoch) => epoch, + None => { + // Synchronization requests need an epoch marker for retry bookkeeping. A + // partially initialized actor must fail closed instead of sending a request and + // panicking while recording its wait origin. + log::error!("Cannot request a blocks batch before the current epoch is initialized"); + if self.sm_state == StateMachine::Synchronizing { + self.update_state_machine(StateMachine::WaitingConsensus, ctx); + self.sync_waiting_for_add_blocks_since = None; + } + return; + } + }; // Send Anycast to a safu peer in order to begin the synchronization SessionsManager::from_registry() .send(Anycast { @@ -2233,7 +5715,6 @@ impl ChainManager { } }) .spawn(ctx); - let epoch = self.current_epoch.unwrap(); self.sync_waiting_for_add_blocks_since = Some(epoch); } @@ -2312,12 +5793,25 @@ impl ChainManager { num_processed_blocks += 1; let beacon = self.get_chain_beacon(); - show_sync_progress( - beacon, - sync_target, - self.epoch_constants.unwrap(), - self.current_epoch.unwrap(), - ); + let Some(epoch_constants) = self.epoch_constants else { + log::error!( + "Synchronization metadata disappeared after consolidating block {}; restoring the last durable ChainState", + beacon.hash_prev_block + ); + self.initialize_from_storage(ctx); + batch_succeeded = false; + break; + }; + let Some(current_epoch) = self.current_epoch else { + log::error!( + "Current epoch disappeared after consolidating block {}; restoring the last durable ChainState", + beacon.hash_prev_block + ); + self.initialize_from_storage(ctx); + batch_succeeded = false; + break; + }; + show_sync_progress(beacon, sync_target, epoch_constants, current_epoch); } (batch_succeeded, num_processed_blocks) @@ -2367,14 +5861,41 @@ impl ChainManager { } } - /// Let JSON-RPC clients know that the blocks in the previous superblock can now - /// be considered consolidated - fn notify_superblock_consolidation(&mut self, superblock: SuperBlock) { + /// Build the durable inventory record for the currently consolidated superblock. + /// Checkpoint zero is the bootstrap superblock and has no inventory payload. + fn prepare_superblock_consolidation( + &self, + ) -> Result, anyhow::Error> { + let beacon = self.chain_state.superblock_state.get_beacon(); + if beacon.checkpoint == 0 { + return Ok(None); + } + let superblock = self + .chain_state + .superblock_state + .get_current_superblock() + .ok_or_else(|| { + anyhow::anyhow!( + "superblock checkpoint {} has no current inventory payload", + beacon.checkpoint + ) + })?; + let superblock_hash = superblock.hash(); + if superblock.index != beacon.checkpoint || superblock_hash != beacon.hash_prev_block { + return Err(anyhow::anyhow!( + "current superblock #{} {} does not match consolidated beacon #{} {}", + superblock.index, + superblock_hash, + beacon.checkpoint, + beacon.hash_prev_block + )); + } + let superblock_period = u32::from(self.consensus_constants().superblock_period); let final_epoch = superblock .index .checked_mul(superblock_period) - .expect("Multiplying a superblock index by `superblock_period` should never overflow"); + .ok_or_else(|| anyhow::anyhow!("superblock inventory epoch range overflow"))?; let initial_epoch = final_epoch.saturating_sub(superblock_period); let beacons = self.get_blocks_epoch_range(GetBlocksEpochRange::new_with_limit( initial_epoch..final_epoch, @@ -2385,17 +5906,45 @@ impl ChainManager { // notification let consolidated_block_hashes: Vec = beacons.iter().cloned().map(|(_epoch, hash)| hash).collect(); - let superblock_notify = SuperBlockNotify { + Ok(Some(SuperBlockNotify { superblock, consolidated_block_hashes, - }; + })) + } - // Store the list of block hashes that pertain to this superblock - InventoryManager::from_registry().do_send(AddItem { - item: StoreInventoryItem::Superblock(superblock_notify.clone()), + /// Publish effects only after both the inventory record and matching ChainState are durable. + fn publish_superblock_consolidation(&mut self, notify: SuperBlockNotify) { + let superblock_hash = notify.superblock.hash(); + log::info!( + "Consensus reached for Superblock #{} with {} out of {} votes. Committee size: {}", + notify.superblock.index, + self.chain_state + .superblock_state + .votes_counter_from_superblock(&superblock_hash), + self.chain_state.superblock_state.valid_votes_counter(), + self.chain_state.superblock_state.get_committee_length(), + ); + log::debug!("Current tip of the chain: {:?}", self.get_chain_beacon()); + if let Some(last_hash) = notify.consolidated_block_hashes.last() { + log::debug!("The last block of the consolidated superblock is {last_hash}"); + } + + JsonRpcServer::from_registry().do_send(notify); + self.transactions_pool.update_unconfirmed_transactions(); + } + + /// Publish the durable consolidated beacon to peer sessions. + fn publish_superblock_session_state(&self, superblock_beacon: CheckpointBeacon) { + let sessions_manager_addr = SessionsManager::from_registry(); + sessions_manager_addr.do_send(SetLastBeacon { + beacon: LastBeacon { + highest_block_checkpoint: self.get_chain_beacon(), + highest_superblock_checkpoint: superblock_beacon, + }, }); - JsonRpcServer::from_registry().do_send(superblock_notify); + // Remove the synchronization target only after our matching state is durable. + sessions_manager_addr.do_send(SetSuperBlockTargetBeacon { beacon: None }); } /// Let JSON-RPC clients know when the node changes its status @@ -2448,78 +5997,209 @@ impl ChainManager { } } - /// This function takes the transactions included in unconfirmed blocks and it allows them to be - /// included again in the mempool when the node wold be Synced again + /// Remove completed reports produced by the exact live-chain suffix that is absent from the + /// durable `ChainState`, and optionally retain its transactions for later mempool reinsertion. + /// + /// All reverted blocks are read through one pinned inventory view. The report deletions then + /// compare-and-swap that view inside the storage worker, so an online snapshot generation + /// switch cannot redirect cleanup into a different namespace. The temporary transaction queue + /// is mutated only after every read and the synchronous delete batch succeed. #[must_use] - fn reinsert_transactions_from_unconfirmed_blocks( + fn recover_reverted_block_inventory( &mut self, - epoch: Epoch, - ) -> ResponseActFuture> { - let inventory_manager = InventoryManager::from_registry(); + reinsert_transactions: bool, + ) -> ResponseActFuture> { + let live_block_chain = self.chain_state.block_chain.clone(); + let live_chain_info = self.chain_state.chain_info.clone(); + let magic = self.get_magic(); - // Get all blocks since epoch - let res = self.get_blocks_epoch_range(GetBlocksEpochRange::new_with_limit(epoch.., 0)); + let fut = async move { + // Pin first. A snapshot commit publishes its ChainState and active inventory marker in + // one database batch; the final active-view CAS catches any switch after this point. + let view = storage_mngr::get_inventory_view().await?; + let live_chain_info = live_chain_info.ok_or_else(|| { + anyhow::anyhow!("cannot recover a live chain without ChainInfo") + })?; + let durable_chain_state = storage_mngr::get_chain_state( + storage_keys::chain_state_key(magic), + ) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "cannot recover a non-empty live chain without a durable ChainState" + ) + })?; + let durable_chain_info = durable_chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::anyhow!("cannot recover against a durable ChainState without ChainInfo") + })?; + anyhow::ensure!( + durable_chain_state.reputation_engine.is_some(), + "cannot recover against a durable ChainState without ReputationEngine" + ); + anyhow::ensure!( + durable_chain_info.environment == live_chain_info.environment, + "durable ChainState environment does not match the live chain" + ); + anyhow::ensure!( + durable_chain_info.consensus_constants == live_chain_info.consensus_constants, + "durable ChainState consensus constants do not match the live chain" + ); - let fut = async { - let block_hashes = res.into_iter().map(|(_epoch, hash)| hash); - // For each block, collect all the transactions that may be valid if this block is - // reverted. This includes value transfer transactions and data request transactions. - let aux = block_hashes.map(move |hash| { - inventory_manager - .send(GetItemBlock { hash }) - .then(move |res| match res { - Ok(Ok(block)) => { - let transactions: Vec = block - .txns - .value_transfer_txns - .iter() - .map(|vtt| Transaction::ValueTransfer(vtt.clone())) - .chain( - block - .txns - .data_request_txns - .iter() - .map(|drt| Transaction::DataRequest(drt.clone())), - ) - .collect(); + // Prove both indexes agree with their advertised tips before using either one as a + // destructive recovery witness. The empty-index exception is the same canonical + // bootstrap shape accepted by snapshot import and ordinary initialization. + for (label, chain_info, block_chain) in [ + ("live", &live_chain_info, &live_block_chain), + ( + "durable", + durable_chain_info, + &durable_chain_state.block_chain, + ), + ] { + let advertised_tip = chain_info.highest_block_checkpoint; + match block_chain.iter().next_back() { + Some((epoch, hash)) => anyhow::ensure!( + *epoch == advertised_tip.checkpoint + && *hash == advertised_tip.hash_prev_block, + "{label} block index tip {epoch} {hash} does not match its advertised tip {} {}", + advertised_tip.checkpoint, + advertised_tip.hash_prev_block + ), + None => anyhow::ensure!( + advertised_tip.checkpoint == 0 + && advertised_tip.hash_prev_block + == chain_info.consensus_constants.bootstrap_hash, + "empty {label} block index does not match its advertised bootstrap tip" + ), + } + } - // We do not reinsert RevealTransactions due to each node resend - // their reveal in case of a data request would be in REVEAL stage + // A merely overlapping map is not enough: the complete durable index must be exactly + // the live prefix through its own tip. Otherwise a corrupt or foreign durable state + // could authorize broad deletion before initialization rejects it. + if let Some((durable_tip_epoch, _)) = + durable_chain_state.block_chain.iter().next_back() + { + anyhow::ensure!( + live_block_chain + .range(..=*durable_tip_epoch) + .eq(durable_chain_state.block_chain.iter()), + "durable block index is not an exact prefix of the live block index" + ); + } - future::ready(Ok(transactions)) - } - Ok(Err(e)) => { - log::error!("Error in GetItemBlock {hash}: {e}"); - future::ready(Err(())) - } - Err(e) => { - log::error!("Error in GetItemBlock {hash}: {e}"); - future::ready(Err(())) - } - }) - // TODO: make sure that we want to ignore errors - .then(|x| future::ready(Ok(x.ok()))) - }); - try_join_all(aux) - .await - // Map Option>> to Vec, this returns all the non-error results - .map(|x| { - x.into_iter() - .flatten() - .flatten() - .collect::>() + // Epoch ranges are not exact in the Different-consensus branch and can over-delete a + // report that remains canonical. The prefix proof above makes every remaining live + // entry the exact suffix that initialization will discard. + let reverted = live_block_chain + .iter() + .filter_map(|(epoch, hash)| { + (!durable_chain_state.block_chain.contains_key(epoch)) + .then_some((*epoch, *hash)) }) + .collect::>(); + let reverted_hashes = reverted.iter().map(|(_, hash)| *hash).collect::>(); + // Pin the historical hash rules after the durable state read and pair them with the + // already-pinned inventory view. Rewind recovery may cross a dynamic schedule + // publication while reading the reverted suffix, so the generic helper could + // validate block identities under a different generation than the one used here. + let (protocol_info, _, protocol_generation) = get_protocol_runtime(); + let blocks = InventoryManager::get_multiple_blocks_in_view_for_protocol( + view, + reverted_hashes, + &protocol_info, + ) + .await + .map_err(|error| { + anyhow::anyhow!( + "failed to read the reverted block set from pinned inventory view: {error}" + ) + })?; + anyhow::ensure!( + blocks.len() == reverted.len(), + "pinned inventory returned an incomplete reverted block set" + ); + + let mut transactions = Vec::new(); + let mut reverted_report_blocks: HashMap, Hash> = HashMap::new(); + for ((expected_epoch, expected_hash), block) in reverted.into_iter().zip(blocks) { + anyhow::ensure!( + block.block_header.beacon.checkpoint == expected_epoch, + "reverted block {expected_hash} is indexed at epoch {expected_epoch} but contains epoch {}", + block.block_header.beacon.checkpoint + ); + + if reinsert_transactions { + // Reveals remain excluded: each node resends its own reveal when the restored + // request reaches REVEAL again. All user-authored transferable, request, stake, + // and unstake actions are retained for later revalidation. + transactions.extend( + block + .txns + .value_transfer_txns + .iter() + .map(|vtt| Transaction::ValueTransfer(vtt.clone())) + .chain( + block + .txns + .data_request_txns + .iter() + .map(|drt| Transaction::DataRequest(drt.clone())), + ) + .chain( + block + .txns + .stake_txns + .iter() + .map(|st| Transaction::Stake(st.clone())), + ) + .chain( + block + .txns + .unstake_txns + .iter() + .map(|ut| Transaction::Unstake(ut.clone())), + ), + ); + } + + for tally in &block.txns.tally_txns { + let logical_key = format!("DR-REPORT-{}", tally.dr_pointer).into_bytes(); + if let Some(existing_block) = + reverted_report_blocks.insert(logical_key, expected_hash) + { + anyhow::ensure!( + existing_block == expected_hash, + "data request {} is tallied by multiple reverted blocks", + tally.dr_pointer + ); + } + } + } + + anyhow::ensure!( + get_protocol_runtime().2 == protocol_generation, + "protocol schedule changed while reading reverted block inventory" + ); + + let mut reports = reverted_report_blocks.into_iter().collect::>(); + reports.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + // Even an empty batch performs the active-view comparison, closing the race between + // the pinned block reads/durable-state read and application of this recovery plan. + // The storage worker re-reads and verifies each current report value at the same + // linearization point as the delete, retaining any canonical replacement that reused + // the same data-request pointer. + storage_mngr::delete_reverted_reports_batch(view, reports).await?; + + Ok(transactions) } .into_actor(self) .and_then(move |transactions, act, _ctx| { - // Include in temporal vts and drs to include them later - act.temp_vts_and_drs.extend(transactions); + // Include recovered transactions to revalidate and enqueue later. Reveals remain in + // the separate temp_reveals path because they are regenerated/broadcast by nodes. + act.temp_reinserted_transactions.extend(transactions); actix::fut::ok(()) - }) - .map_err(|(), _, _| { - // Errors at this point should be impossible because we explicitly ignore them - panic!("Unknown error in reinsert_transactions_from_unconfirmed_blocks"); }); Box::pin(fut) @@ -2570,6 +6250,7 @@ impl ChainManager { let inventory_manager = InventoryManager::from_registry(); let res = self.get_blocks_epoch_range(GetBlocksEpochRange::new_with_limit(init..=end, 0)); + let protocol_info = get_protocol_info(); let fut = async move { let block_hashes = res.into_iter().map(|(_epoch, hash)| hash); @@ -2578,7 +6259,10 @@ impl ChainManager { let aux = block_hashes.map(move |hash| { block_counter += 1; inventory_manager - .send(GetItemBlock { hash }) + .send(GetItemBlock { + hash, + protocol_info: Some(protocol_info.clone()), + }) .then(move |res| match res { Ok(Ok(block)) => { if block_counter % 1000 == 0 { @@ -2596,14 +6280,13 @@ impl ChainManager { future::ready(Err(())) } }) - .then(|x| future::ready(Ok(x.ok()))) }); - try_join_all(aux) - .await - // Map Vec> to Vec, this returns all the non-error results and ignores - // the errors. - .map(|x| x.into_iter().flatten()) + // Every hash comes from the canonical staged block index. Silently dropping a failed + // inventory read would publish a TAPI engine reconstructed from only a subset of that + // chain, so make the enclosing initialization/import future wait for and propagate + // the complete replay instead. + try_join_all(aux).await } .into_actor(self) .and_then(move |block_headers, act, _ctx| { @@ -2678,17 +6361,44 @@ impl ChainManager { v } - /// Update own utxos as that is necessary when importing a new master key - fn update_own_utxos(&mut self) { - log::info!("Updating own UTXO's for {}", self.own_pkh.unwrap()); - - self.chain_state.own_utxos.drain(); - for (output_pointer, value_transfer_output) in self.chain_state.unspent_outputs_pool.iter() - { - if value_transfer_output.0.pkh == self.own_pkh.unwrap() { - self.chain_state.own_utxos.insert(output_pointer, 0); + /// Build a node-identity UTXO index without publishing it into a live ChainState. + /// + /// Snapshot/reorg reload uses this staging helper before it publishes the replacement chain + /// or advertises its beacon. A backend iterator failure must therefore leave the complete old + /// actor state visible and make the reload fail closed. + fn try_build_own_utxos( + unspent_outputs_pool: &UnspentOutputsPool, + own_pkh: PublicKeyHash, + ) -> Result { + let mut refreshed = OwnUnspentOutputsPool::new(); + for entry in unspent_outputs_pool.try_iter() { + let (output_pointer, value_transfer_output) = entry?; + if value_transfer_output.0.pkh == own_pkh { + refreshed.insert(output_pointer, 0); } } + Ok(refreshed) + } + + /// Update own UTXOs as that is necessary when importing a new master key. + /// + /// Build the replacement index off to the side and publish it only after the persistent + /// iterator has completed. A backend read error must not replace a complete index with a + /// partial one, because transaction construction could otherwise spend from an incomplete + /// view while the chain state remains healthy. + fn update_own_utxos(&mut self) -> Result<(), anyhow::Error> { + let Some(own_pkh) = self.own_pkh else { + log::debug!("Skipping own UTXO refresh until the node identity is loaded"); + return Ok(()); + }; + log::info!("Updating own UTXO's for {own_pkh}"); + + let refreshed = Self::try_build_own_utxos( + &self.chain_state.unspent_outputs_pool, + own_pkh, + )?; + self.chain_state.own_utxos = refreshed; + Ok(()) } } @@ -2731,6 +6441,18 @@ pub enum ImportError { /// The path of the file that cannot be read. path: String, }, + /// The imported ChainState and UTXO image could not be committed atomically. + #[display(fmt = "Error persisting imported chain state: {}", message)] + Persistence { + /// Backend or storage-manager error diagnostic. + message: String, + }, + /// Snapshot components disagree internally or contain an invalid sequence. + #[display(fmt = "Invalid snapshot contents: {}", message)] + SnapshotContents { + /// Deterministic validation diagnostic. + message: String, + }, /// Any kind of I/O error. #[display(fmt = "std::io error")] Io(std::io::Error), @@ -2764,10 +6486,29 @@ struct ChainStateSnapshot { } impl ChainStateSnapshot { + fn is_persisted(&self, superblock_index: u32) -> bool { + self.highest_persisted_superblock >= superblock_index + } + // Returns false if the snapshot did already exist // Returns true if the snapshot did not already exist - // Panics if a different chain state was already saved for this super epoch - fn take(&mut self, superblock_index: u32, state: &ChainState) -> bool { + // Returns an error if a snapshot invariant is violated. + fn try_take( + &mut self, + superblock_index: u32, + state: &ChainState, + ) -> Result { + // The checked ChainState clone intentionally reindexes stakes. Preserve the post-marker + // source-ledger invariant by rejecting malformed secondary indexes before taking a + // snapshot that would otherwise hide them. + if state.delegation_state.activation_initialized { + state.stakes.validate_validator_index_checked()?; + } + // Clone through the checked stake-ledger boundary before comparing or retaining this + // snapshot. `ChainState`'s derived equality and legacy clone both read stake locks + // infallibly, so operating on the fresh checked copy prevents a poisoned live entry from + // turning a recoverable snapshot failure into a process panic. + let state = state.try_clone_checked()?; let chain_beacon = state.get_chain_beacon(); let superblock_beacon = state.get_superblock_beacon(); @@ -2775,74 +6516,90 @@ impl ChainStateSnapshot { "Taking snapshot at superblock #{superblock_index}. Chain beacon {chain_beacon:?}, superblock beacon {superblock_beacon:?}" ); - let last_block_according_to_superblock = - (superblock_index * u32::from(self.superblock_period)).saturating_sub(1); + let last_block_according_to_superblock = superblock_index + .checked_mul(u32::from(self.superblock_period)) + .ok_or_else(|| { + anyhow::anyhow!( + "Invalid snapshot: superblock #{superblock_index} overflows the configured period" + ) + })? + .saturating_sub(1); if chain_beacon.checkpoint > last_block_according_to_superblock { - panic!( + return Err(anyhow::anyhow!( "Invalid snapshot: superblock #{} can only consolidate blocks up to #{}, but this chain state has block #{}", superblock_index, last_block_according_to_superblock, chain_beacon.checkpoint - ); + )); } if let Some((prev_chain_state, prev_super_epoch)) = self.previous_chain_state.as_mut() { if *prev_super_epoch == superblock_index { log::warn!("ChainState snapshot {superblock_index} already exists"); - if prev_chain_state == state { - false + let previous_matches = prev_chain_state.try_clone_checked()? == state; + if previous_matches { + Ok(false) } else { // Only allow overwriting a different chain state if the superblock index is 0 if superblock_index == 0 { log::warn!( "ChainState mismatch in superblock #{superblock_index}. Overwritting old with new" ); - *prev_chain_state = state.clone(); + *prev_chain_state = state; - true + Ok(true) } else { // Two snapshots of the same superblock should be identical, this is a bug - panic!( + Err(anyhow::anyhow!( "ChainState mismatch for superblock #{superblock_index}: `{prev_chain_state:?} != {state:?}`" - ); + )) } } } else { log::warn!( "Overwriting old chain state snapshot, it was superblock #{prev_super_epoch}" ); - self.previous_chain_state = Some((state.clone(), superblock_index)); + self.previous_chain_state = Some((state, superblock_index)); - true + Ok(true) } } else { - self.previous_chain_state = Some((state.clone(), superblock_index)); + self.previous_chain_state = Some((state, superblock_index)); - true + Ok(true) } } + // Compatibility wrapper for existing local tests. Production paths use + // `try_take` so invalid snapshots fail-stop instead of panicking here. + #[allow(dead_code)] + fn take(&mut self, superblock_index: u32, state: &ChainState) -> bool { + self.try_take(superblock_index, state) + .expect("invalid ChainState snapshot") + } + // Returns None if this super_epoch was already consolidated before - // Returns Some(chain_state) if this super_epoch can be consolidated - // It is assumed that the caller will persist the chain_state - fn restore(&mut self, super_epoch: u32) -> Option { + // Returns Some(chain_state) if this super_epoch can be consolidated. The retained snapshot and + // persisted marker are deliberately unchanged until `mark_persisted` runs after backend + // success, so validation or storage failure remains retryable. + fn try_restore(&self, super_epoch: u32) -> Result, anyhow::Error> { if super_epoch == 0 { // The superblock with index 0 is always consolidated, no need to persist it to storage // This is because the superblock 0 does not include any blocks, it is the bootstrap // superblock, so there is no state to persist log::debug!("Skip persisting superblock #0 because it is already persisted"); - None + Ok(None) } else if self.highest_persisted_superblock == super_epoch { // This can happen during reorganizations log::debug!( "Tried to persist chain state for superblock #{super_epoch} but it is already persisted" ); - None + Ok(None) } else if self.highest_persisted_superblock > super_epoch { - panic!( + Err(anyhow::anyhow!( "Tried to persist chain state for superblock #{} but it is already persisted. The highest persisted superblock is #{}", super_epoch, self.highest_persisted_superblock - ); + )) } else { let skipped_superblocks = super_epoch - self.highest_persisted_superblock - 1; if skipped_superblocks > 0 { @@ -2851,38 +6608,128 @@ impl ChainStateSnapshot { log::debug!("Skipped {skipped_superblocks} superblocks in consolidation"); } - // Replace self.previous_chain_state with None to prevent consolidating the same chain - // state more than once - if let Some((chain_state, prev_super_epoch)) = self.previous_chain_state.take() { - if prev_super_epoch != super_epoch { - panic!( + if let Some((chain_state, prev_super_epoch)) = self.previous_chain_state.as_ref() { + if *prev_super_epoch != super_epoch { + return Err(anyhow::anyhow!( "Cannot persist chain state. There is no snapshot for superblock #{super_epoch}. The current snapshot is for superblock #{prev_super_epoch}" - ); + )); } - self.highest_persisted_superblock = super_epoch; - - Some(chain_state) + if chain_state.delegation_state.activation_initialized { + chain_state.stakes.validate_validator_index_checked()?; + } + Ok(Some(chain_state.try_clone_checked()?)) } else { - panic!( + Err(anyhow::anyhow!( "Cannot persist chain state. There is no snapshot for superblock #{}. The highest persisted superblock is #{}", super_epoch, self.highest_persisted_superblock - ); + )) } } } + // Compatibility wrapper for existing local tests. Production paths use + // `try_restore` so ordering and missing-snapshot errors are recoverable. + #[allow(dead_code)] + fn restore(&self, super_epoch: u32) -> Option { + self.try_restore(super_epoch) + .expect("invalid ChainState snapshot restore") + } + + /// Consume a retained snapshot only after its ChainState and UTXO batch is durable. + fn try_mark_persisted(&mut self, super_epoch: u32) -> Result<(), anyhow::Error> { + if self.highest_persisted_superblock == super_epoch { + return Ok(()); + } + if self.highest_persisted_superblock > super_epoch { + return Err(anyhow::anyhow!( + "Tried to acknowledge chain state for superblock #{} after #{} was persisted", + super_epoch, self.highest_persisted_superblock + )); + } + match self.previous_chain_state.as_ref() { + Some((_, snapshot_super_epoch)) if *snapshot_super_epoch == super_epoch => {} + Some((_, snapshot_super_epoch)) => return Err(anyhow::anyhow!( + "Cannot acknowledge chain state for superblock #{super_epoch}. The current snapshot is for superblock #{snapshot_super_epoch}" + )), + None => return Err(anyhow::anyhow!( + "Cannot acknowledge chain state for superblock #{super_epoch}: no snapshot is retained" + )), + } + self.previous_chain_state = None; + self.highest_persisted_superblock = super_epoch; + Ok(()) + } + + // Compatibility wrapper for existing local tests. Production paths use + // `try_mark_persisted` so backend acknowledgement cannot panic the actor. + #[allow(dead_code)] + fn mark_persisted(&mut self, super_epoch: u32) { + self.try_mark_persisted(super_epoch) + .expect("invalid ChainState snapshot acknowledgement") + } + // Remove the taken snapshot fn clear(&mut self) { self.previous_chain_state = None; } } -/// Block validation process which doesn't use futures +/// Block validation process which doesn't use futures +/// +/// This uses a `Visitor` that will visit each transaction as well as its fee and weight. +#[allow(clippy::too_many_arguments)] +pub fn process_validations( + block: &Block, + current_epoch: Epoch, + vrf_input: CheckpointVRF, + chain_beacon: CheckpointBeacon, + rep_eng: &ReputationEngine, + epoch_constants: EpochConstants, + utxo_set: &UnspentOutputsPool, + dr_pool: &mut DataRequestPool, + vrf_ctx: &mut VrfCtx, + block_number: u32, + consensus_constants: &ConsensusConstants, + consensus_constants_wit2: &ConsensusConstantsWit2, + resynchronizing: bool, + active_wips: &ActiveWips, + transaction_visitor: Option<&mut dyn Visitor>, + stakes: &StakesTracker, + delegation_state: &DelegationState, + protocol_version: ProtocolVersion, +) -> Result { + let protocol_info = get_protocol_info(); + process_validations_for_protocol( + block, + current_epoch, + vrf_input, + chain_beacon, + rep_eng, + epoch_constants, + utxo_set, + dr_pool, + vrf_ctx, + block_number, + consensus_constants, + consensus_constants_wit2, + resynchronizing, + active_wips, + transaction_visitor, + stakes, + delegation_state, + protocol_version, + &protocol_info, + ) +} + +/// Block validation process using a caller-owned protocol schedule snapshot. /// -/// This uses a `Visitor` that will visit each transaction as well as its fee and weight. +/// Requested-block replay and asynchronous mining validation use this entry point so the +/// transaction pass cannot select a different protocol registry generation after block-header +/// validation or signature verification has yielded. #[allow(clippy::too_many_arguments)] -pub fn process_validations( +pub fn process_validations_for_protocol( block: &Block, current_epoch: Epoch, vrf_input: CheckpointVRF, @@ -2899,11 +6746,24 @@ pub fn process_validations( active_wips: &ActiveWips, transaction_visitor: Option<&mut dyn Visitor>, stakes: &StakesTracker, + delegation_state: &DelegationState, protocol_version: ProtocolVersion, + protocol_info: &ProtocolInfo, ) -> Result { - let replication_factor = consensus_constants_wit2.get_replication_factor( + let expected_protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + anyhow::ensure!( + expected_protocol_version == protocol_version, + "protocol snapshot/version mismatch for block epoch {}: snapshot selects {:?}, caller supplied {:?}", + block.block_header.beacon.checkpoint, + expected_protocol_version, + protocol_version + ); + let replication_factor = ConsensusConstantsWit2::get_replication_factor_for_protocol( block.block_header.beacon.checkpoint, chain_beacon.checkpoint, + protocol_version, ); if !resynchronizing { let mut signatures_to_verify = vec![]; @@ -2917,6 +6777,9 @@ pub fn process_validations( consensus_constants, active_wips, stakes, + delegation_state, + consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(protocol_version), protocol_version, replication_factor, )?; @@ -2925,7 +6788,7 @@ pub fn process_validations( } let mut signatures_to_verify = vec![]; - let utxo_dif = validate_block_transactions( + let utxo_dif = validate_block_transactions_for_protocol( utxo_set, dr_pool, block, @@ -2939,6 +6802,10 @@ pub fn process_validations( active_wips, transaction_visitor, stakes, + delegation_state, + consensus_constants.get_magic(), + protocol_info, + protocol_version, )?; if !resynchronizing { @@ -2960,7 +6827,7 @@ struct RequestResult { pub errors: u32, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct ReputationInfo { // Counter of "witnessing acts". // For every data request with a tally in this block, increment alpha_diff @@ -2984,405 +6851,1586 @@ impl ReputationInfo { node_stats: &mut NodeStats, ) { let dr_pointer = tally_transaction.dr_pointer; - let dr_state = &data_request_pool.data_request_pool[&dr_pointer]; + let Some(dr_state) = data_request_pool.data_request_state(&dr_pointer) else { + // Consensus validation/consolidation should normally guarantee that every tally + // points at an indexed request. Keep this local reputation/statistics path + // fail-closed if a partially restored pool violates that invariant rather than + // turning bookkeeping into a process panic after the block has been accepted. + log::warn!( + "Skipping reputation update for tally {} because its data-request state is missing", + dr_pointer + ); + return; + }; let commits = &dr_state.info.commits; // 1 reveal = 1 witnessing act - let reveals_count = u32::try_from(dr_state.info.reveals.len()).unwrap(); + let Ok(reveals_count) = u32::try_from(dr_state.info.reveals.len()) else { + log::warn!( + "Skipping reputation update for tally {} because its reveal count exceeds u32", + dr_pointer + ); + return; + }; self.alpha_diff += Alpha(reveals_count); - // Set of pkhs which were slashed in the tally transaction - let out_of_consensus = &tally_transaction.out_of_consensus; - let error_committers = &tally_transaction.error_committers; - for pkh in commits.keys() { - let result = self.result_count.entry(*pkh).or_default(); - if error_committers.contains(pkh) { - result.errors += 1; - } else if out_of_consensus.contains(pkh) { - result.lies += 1; - } else { - result.truths += 1; - } - } + // Set of pkhs which were slashed in the tally transaction + let out_of_consensus = &tally_transaction.out_of_consensus; + let error_committers = &tally_transaction.error_committers; + for pkh in commits.keys() { + let result = self.result_count.entry(*pkh).or_default(); + if error_committers.contains(pkh) { + result.errors = result.errors.saturating_add(1); + } else if out_of_consensus.contains(pkh) { + result.lies = result.lies.saturating_add(1); + } else { + result.truths = result.truths.saturating_add(1); + } + } + + // Update node stats + if out_of_consensus.contains(&own_pkh) && !error_committers.contains(&own_pkh) { + node_stats.slashed_count = node_stats.slashed_count.saturating_add(1); + } + } +} + +/// Apply Wit/2.0 or Wit/2.1 stake changes using the protocol selected for the block. It captures the schedule once and delegates the state transition to process_wit2_stakes_changes_for_protocol. +fn process_wit2_stakes_changes( + block: &Block, + consensus_constants_wit2: &ConsensusConstantsWit2, + data_request_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch_constants: EpochConstants, + collateral_minimum: u64, + checkpoint_zero_timestamp: i64, + utxo_diff: &UtxoDiff<'_>, +) -> Result<(), anyhow::Error> { + let protocol_info = get_protocol_info(); + // Keep this compatibility wrapper operation-consistent as well: callers that do not provide + // an explicit protocol still get one version selected from the same immutable schedule + // snapshot that is passed into the replay helper. Resolving the block version through the + // process-global lookup a second time could otherwise split a long consolidation turn if the + // registry is published concurrently. + let block_protocol = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + process_wit2_stakes_changes_for_protocol( + block, + consensus_constants_wit2, + data_request_pool, + stakes, + delegation_state, + epoch_constants, + collateral_minimum, + checkpoint_zero_timestamp, + utxo_diff, + block_protocol, + &protocol_info, + ) +} + +/// Apply the Wit/2 staking transition using the protocol selected for the containing block. It stages stakes and delegation state before delegating transaction replay to try_process_wit2_stakes_changes_for_protocol. +#[allow(clippy::too_many_arguments)] +fn process_wit2_stakes_changes_for_protocol( + block: &Block, + consensus_constants_wit2: &ConsensusConstantsWit2, + data_request_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch_constants: EpochConstants, + collateral_minimum: u64, + checkpoint_zero_timestamp: i64, + utxo_diff: &UtxoDiff<'_>, + block_protocol: ProtocolVersion, + protocol_info: &ProtocolInfo, +) -> Result<(), anyhow::Error> { + let mut projected_stakes = + if block_protocol >= ProtocolVersion::V2_1 { + // This helper is also reachable through the compatibility wrapper. Validate its + // source ledger before the projection clone can rebuild secondary indexes and hide + // malformed omitted, duplicate, detached, or mis-owned rows from delegated replay. + stakes.validate_validator_index_checked()?; + stakes.try_clone_checked()? + } else { + // Direct legacy callers retain the historical infallible clone behavior. + stakes.clone() + }; + let mut projected_delegation_state = delegation_state.clone(); + try_process_wit2_stakes_changes_for_protocol( + block, + consensus_constants_wit2, + data_request_pool, + &mut projected_stakes, + &mut projected_delegation_state, + epoch_constants, + collateral_minimum, + checkpoint_zero_timestamp, + utxo_diff, + block_protocol, + protocol_info, + )?; + *stakes = projected_stakes; + *delegation_state = projected_delegation_state; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +/// Replay one block's staking effects against a pinned protocol schedule. It initializes activation state, settles collateral and rewards, and applies native delegated actions through the checked transition helpers. +fn try_process_wit2_stakes_changes_for_protocol( + block: &Block, + consensus_constants_wit2: &ConsensusConstantsWit2, + data_request_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch_constants: EpochConstants, + collateral_minimum: u64, + checkpoint_zero_timestamp: i64, + utxo_diff: &UtxoDiff<'_>, + block_protocol: ProtocolVersion, + protocol_info: &ProtocolInfo, +) -> Result<(), anyhow::Error> { + let block_epoch = block.block_header.beacon.checkpoint; + if block_protocol < ProtocolVersion::V2_0 { + return Ok(()); + } + + let miner_pkh = block.block_header.proof.proof.pkh(); + let minimum_stakeable = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(block_protocol); + let minimum_stake = Wit::from_nanowits(minimum_stakeable); + + if block_protocol >= ProtocolVersion::V2_1 { + let activation_transition = !delegation_state.activation_initialized; + if activation_transition { + delegation_state + .initialize_legacy_validators_for_activation(stakes, minimum_stake)?; + } else { + delegation_state.initialize_legacy_validators(stakes)?; + } + delegation_state.activate_pending_commissions(block_epoch)?; + track_pre_activation_collateral_with_transition_check_for_protocol( + data_request_pool, + stakes, + delegation_state, + collateral_minimum, + false, + activation_transition, + protocol_info, + )?; + if activation_transition { + delegation_state + .validate_stake_and_reservation_accounting(stakes, minimum_stake)?; + } + } else { + // Cache the exact conceptual sole-owner debit while Wit/2.0 is still active. This metadata + // does not change legacy balances or validation and is not activation-owner provenance. If + // the live entry disappears while its commitment is outstanding, Wit/2.1 must not activate + // until that request settles or a canonical live entry is restored. + track_pre_activation_collateral_for_protocol( + data_request_pool, + stakes, + delegation_state, + collateral_minimum, + true, + protocol_info, + )?; + } + + let mut total_commit_reward = 0u64; + for co_tx in &block.txns.commit_txns { + if data_request_is_pre_wit2_for_protocol( + data_request_pool, + &Transaction::Commit(co_tx.clone()), + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) { + continue; + } + + let dr_state = data_request_pool + .data_request_state(&co_tx.body.dr_pointer) + .ok_or_else(|| { + anyhow::anyhow!( + "cannot process commit {}: data-request state {} is missing from the local pool", + co_tx.versioned_hash(block_protocol), + co_tx.body.dr_pointer + ) + })?; + let dr_protocol = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + validate_request_protocol_for_block(block_protocol, dr_protocol)?; + let dr_output = &dr_state.data_request; + let collateral = collateral_for_protocol( + dr_protocol, + dr_output.collateral, + collateral_minimum, + ); + let commit_pkh = co_tx.body.proof.proof.pkh(); + let reservation_key = CollateralReservationKey { + data_request: co_tx.body.dr_pointer, + validator: commit_pkh, + }; + log::debug!( + "Reserving {} wit collateral from {}", + Wit::from_nanowits(collateral), + commit_pkh, + ); + + if dr_protocol >= ProtocolVersion::V2_1 { + delegation_state.reserve_collateral( + stakes, + reservation_key, + Wit::from_nanowits(collateral), + block_epoch, + minimum_stake, + )?; + } else if block_protocol >= ProtocolVersion::V2_1 { + delegation_state.reserve_legacy_collateral( + stakes, + reservation_key, + Wit::from_nanowits(collateral), + block_epoch, + minimum_stake, + )?; + } else { + // Preserve the historical Wit/2.0 balance transition, including its ignored + // cumulative below-minimum debit error. Persist the conceptual sole-owner debit as + // sidecar metadata for shape/total validation at the Wit/2.1 boundary. The activation + // owner still comes exclusively from then-live stake. Post-activation Wit/2.0 requests + // use the strict branch above and are projected sequentially by validation. + // An existing sidecar may have been written by an upgraded node at a different + // height, and is intentionally inert while Wit/2.0 remains the containing protocol. + // Do not let its shape or cached total alter legacy block validity; activation will + // validate the cache against the authoritative request and either canonicalize or + // fail closed. Only materialize a missing compatibility record for this newly seen + // commitment. + if !delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + delegation_state.track_legacy_collateral( + stakes, + co_tx.body.dr_pointer, + commit_pkh, + Wit::from_nanowits(collateral), + )?; + } + // Apply the historical owner-only debit. The sidecar remains inert metadata; a full + // removal makes activation unsafe while the commitment remains outstanding. + stakes.reserve_collateral( + commit_pkh, + Wit::from_nanowits(collateral), + minimum_stake, + )?; + } + + total_commit_reward = add_value_for_protocol( + total_commit_reward, + dr_output.commit_and_reveal_fee, + block_protocol, + )?; + } + if block_protocol < ProtocolVersion::V2_1 { + reward_validator( + stakes, + delegation_state, + miner_pkh, + Wit::from_nanowits(total_commit_reward), + block_epoch, + block_protocol, + )?; + } + + let mut total_reveal_reward = 0u64; + for re_tx in &block.txns.reveal_txns { + if data_request_is_pre_wit2_for_protocol( + data_request_pool, + &Transaction::Reveal(re_tx.clone()), + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) { + continue; + } + let dr_state = data_request_pool + .data_request_state(&re_tx.body.dr_pointer) + .ok_or_else(|| { + anyhow::anyhow!( + "cannot process reveal {}: data-request state {} is missing from the local pool", + re_tx.versioned_hash(block_protocol), + re_tx.body.dr_pointer + ) + })?; + let dr_protocol = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + validate_request_protocol_for_block(block_protocol, dr_protocol)?; + let dr_output = &dr_state.data_request; + total_reveal_reward = add_value_for_protocol( + total_reveal_reward, + dr_output.commit_and_reveal_fee, + block_protocol, + )?; + } + if block_protocol < ProtocolVersion::V2_1 { + reward_validator( + stakes, + delegation_state, + miner_pkh, + Wit::from_nanowits(total_reveal_reward), + block_epoch, + block_protocol, + )?; + } else { + project_wit2_inclusion_rewards_for_protocol( + stakes, + delegation_state, + miner_pkh, + block_epoch, + data_request_pool, + &block.txns.commit_txns, + &block.txns.reveal_txns, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + )?; + } + + for ta_tx in &block.txns.tally_txns { + if data_request_is_pre_wit2_for_protocol( + data_request_pool, + &Transaction::Tally(ta_tx.clone()), + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + ) { + continue; + } + + let dr_state = data_request_pool + .data_request_state(&ta_tx.dr_pointer) + .ok_or_else(|| { + anyhow::anyhow!( + "cannot process tally {}: data-request state {} is missing from the local pool", + ta_tx.versioned_hash(block_protocol), + ta_tx.dr_pointer + ) + })?; + let dr_protocol = protocol_info.all_versions.version_for_epoch(dr_state.epoch); + validate_request_protocol_for_block(block_protocol, dr_protocol)?; + + if block_protocol >= ProtocolVersion::V2_1 { + // Keep accepted-block settlement on the same authoritative request-total, exact + // refund/slash, witness-reward, and commission transition used by full validation and + // candidate construction. The outer staking/delegation clones make the complete block + // transition failure-atomic; the legacy branch below remains release-compatible. + project_tally_transition_for_protocol( + ta_tx, + data_request_pool, + stakes, + delegation_state, + block_epoch, + collateral_minimum, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + )?; + continue; + } + + let collateral = Wit::from_nanowits(collateral_for_protocol( + dr_protocol, + dr_state.data_request.collateral, + collateral_minimum, + )); + let reward = Wit::from_nanowits(dr_state.data_request.witness_reward); + let mut committers = dr_state.info.commits.keys().copied().collect::>(); + committers.sort_unstable(); + + for validator in committers { + let reservation_key = CollateralReservationKey { + data_request: ta_tx.dr_pointer, + validator, + }; + let is_error = ta_tx.error_committers.contains(&validator); + let is_slashed = ta_tx.out_of_consensus.contains(&validator) && !is_error; + + if delegation_state + .legacy_activation_obligations + .contains_key(&reservation_key) + { + delegation_state.settle_legacy_activation_obligation( + stakes, + reservation_key, + is_error, + is_slashed, + block_epoch, + )?; + continue; + } + + if is_slashed { + if dr_protocol >= ProtocolVersion::V2_1 + || delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + if dr_protocol >= ProtocolVersion::V2_1 { + // This compatibility branch normally cannot see a native request because + // the block protocol is still pre-Wit/2.1. Keep the direct slash caller + // fail-closed if a custom protocol schedule ever presents one: the checked + // native slash boundary proves immutable reward identities before the + // sidecar can be consumed. + delegation_state + .slash_reserved_collateral_checked(stakes, reservation_key)?; + } else { + delegation_state.slash_legacy_collateral(reservation_key)?; + } + } else if block_protocol >= ProtocolVersion::V2_1 && collateral.nanowits() != 0 { + // A positive Wit/2.0 collateral was already debited at commit time and must + // have an exact reservation after activation. Do not silently burn a missing + // sidecar here while the honest/error path correctly fails closed; otherwise + // malformed state could cross the fork with inconsistent reservation + // coverage. + return Err(ConsensusStakesError::CollateralReservationNotFound { + validator, + } + .into()); + } + continue; + } + + if dr_protocol >= ProtocolVersion::V2_1 { + delegation_state.refund_collateral(stakes, reservation_key, block_epoch)?; + if !is_error { + reward_validator( + stakes, + delegation_state, + validator, + reward, + block_epoch, + dr_protocol, + )?; + } + } else if block_protocol >= ProtocolVersion::V2_1 + && delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + delegation_state.settle_legacy_collateral( + stakes, + reservation_key, + if is_error { + Wit::from_nanowits(0) + } else { + reward + }, + block_epoch, + )?; + } else { + if block_protocol >= ProtocolVersion::V2_1 { + // A no-sidecar legacy request can cross activation when its serialized + // collateral is zero. A positive collateral must have been reconstructed by + // activation or reserved by the post-activation commit path; accepting a + // missing sidecar would credit collateral already debited at commit time. + if collateral.nanowits() != 0 { + return Err(ConsensusStakesError::CollateralReservationNotFound { + validator, + } + .into()); + } + delegation_state.settle_legacy_tally_without_reservation( + stakes, + validator, + Wit::from_nanowits(0), + if is_error { + Wit::from_nanowits(0) + } else { + reward + }, + block_epoch, + )?; + } else { + // Pure Wit/2.0 behavior: the sole entry receives its collateral and reward + // through the historical compatibility wrapper. + let amount = if is_error { + collateral + } else { + Wit::from_nanowits( + collateral.nanowits().wrapping_add(reward.nanowits()), + ) + }; + stakes.add_reward(validator, amount, block_epoch)?; + // The reservation sidecar is only an advisory pre-activation cache. Once the + // authoritative Wit/2.0 tally settles, discard it regardless of its cached + // shape so a completed request cannot become an orphan that blocks the later + // Wit/2.1 activation transition. Do not inspect or validate the cache here: + // legacy block validity must remain independent of upgrade-history metadata. + delegation_state.collateral_reservations.remove(&reservation_key); + } + } + } + } + + let transaction_fees = if block_protocol >= ProtocolVersion::V2_1 { + sequential_wit2_transaction_fees( + block, + utxo_diff, + block_epoch, + epoch_constants, + checkpoint_zero_timestamp, + block_protocol, + )? + } else { + // Wit/2.0 calculated every fee against the same pre-block view and treated a failed lookup + // as zero. Preserve that exact reward behavior until the V2.1 activation boundary. + legacy_wit2_transaction_fees( + block, + utxo_diff, + block_epoch, + epoch_constants, + block_protocol, + )? + }; + + let block_reward = consensus_constants_wit2 + .get_validator_block_reward_for_protocol(block_protocol); + log::debug!( + "Rewarding {} with {} wit transaction fees and {} wit block reward for proposing a block", + miner_pkh, + Wit::from_nanowits(transaction_fees), + Wit::from_nanowits(block_reward), + ); + if block_protocol >= ProtocolVersion::V2_1 { + project_wit2_transaction_rewards( + stakes, + delegation_state, + miner_pkh, + block_epoch, + transaction_fees, + block_reward, + )?; + } else { + reward_validator( + stakes, + delegation_state, + miner_pkh, + Wit::from_nanowits(transaction_fees.wrapping_add(block_reward)), + block_epoch, + block_protocol, + )?; + } + + // IMPORTANT: Always perform age resets after adding rewards and before stake actions. Block + // validation and construction call this same transition on their projected ledgers. + apply_wit2_age_resets_for_protocol( + stakes, + delegation_state, + miner_pkh, + block_epoch, + minimum_stake, + block_protocol, + &block.txns.commit_txns, + &block.txns.tally_txns, + |pointer| { + data_request_pool + .data_request_state(&pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + }, + )?; + Ok(()) +} + +/// Calculate legacy Wit/2 transaction fees using historical block semantics. It sums fee-bearing transactions with the pre-block UTXO view and legacy fallback behavior. +fn legacy_wit2_transaction_fees( + block: &Block, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + protocol_version: ProtocolVersion, +) -> Result { + let mut transaction_fees = 0u64; + for transaction in &block.txns.value_transfer_txns { + transaction_fees = transaction_fees.wrapping_add( + vt_transaction_fee(transaction, utxo_diff, epoch, epoch_constants) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.data_request_txns { + transaction_fees = transaction_fees.wrapping_add( + dr_transaction_fee(transaction, utxo_diff, epoch, epoch_constants) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.stake_txns { + transaction_fees = transaction_fees.wrapping_add( + st_transaction_fee_for_protocol( + transaction, + utxo_diff, + epoch, + epoch_constants, + protocol_version, + ) + .unwrap_or_default(), + ); + } + for transaction in &block.txns.unstake_txns { + transaction_fees = transaction_fees.wrapping_add(transaction.body.fee); + } + Ok(transaction_fees) +} + +/// Calculate Wit/2.1 transaction fees in canonical transaction order. It advances a temporary UTXO view after each transaction so later fees observe the same state as consensus replay. +fn sequential_wit2_transaction_fees( + block: &Block, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, + protocol: ProtocolVersion, +) -> Result { + let mut fee_utxo_diff = (*utxo_diff).clone(); + let mut transaction_fees = 0u64; + for transaction in &block.txns.value_transfer_txns { + transaction_fees = transaction_fees + .checked_add(vt_transaction_fee( + transaction, + &fee_utxo_diff, + epoch, + epoch_constants, + )?) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol, + )?; + } + for transaction in &block.txns.commit_txns { + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + transaction.body.collateral.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol, + )?; + } + for transaction in &block.txns.data_request_txns { + transaction_fees = transaction_fees + .checked_add(dr_transaction_fee( + transaction, + &fee_utxo_diff, + epoch, + epoch_constants, + )?) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.outputs.iter(), + transaction.versioned_hash(protocol), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol, + )?; + } + for transaction in &block.txns.tally_txns { + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + vec![], + transaction.outputs.iter(), + transaction.versioned_hash(protocol), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol, + )?; + } + for transaction in &block.txns.stake_txns { + transaction_fees = transaction_fees + .checked_add(st_transaction_fee_for_protocol( + transaction, + &fee_utxo_diff, + epoch, + epoch_constants, + protocol, + )?) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + try_update_utxo_diff_for_protocol( + &mut fee_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.change.iter(), + transaction.versioned_hash(protocol), + epoch, + epoch_constants, + checkpoint_zero_timestamp, + protocol, + )?; + } + for transaction in &block.txns.unstake_txns { + transaction_fees = transaction_fees + .checked_add(transaction.body.fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + // Unstake transactions are the final fee-bearing section in canonical block order, so + // their outputs cannot fund another transaction whose fee is calculated in this temporary + // view. + } + Ok(transaction_fees) +} + +/// Resolve serialized collateral into the protocol-specific effective amount. It substitutes the configured minimum only for zero collateral under Wit/2.1. +fn collateral_for_protocol( + protocol: ProtocolVersion, + collateral: u64, + collateral_minimum: u64, +) -> u64 { + if protocol >= ProtocolVersion::V2_1 && collateral == 0 { + collateral_minimum + } else { + collateral + } +} - // Update node stats - if out_of_consensus.contains(&own_pkh) && !error_committers.contains(&own_pkh) { - node_stats.slashed_count += 1; - } +/// Credit a validator reward using the active protocol's staking economics. It routes native Wit/2.1 rewards through DelegationState and legacy rewards through StakesTracker. +fn reward_validator( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + validator: PublicKeyHash, + reward: Wit, + epoch: Epoch, + protocol: ProtocolVersion, +) -> DelegationResult<()> { + if reward == Wit::from_nanowits(0) { + return Ok(()); + } + if protocol >= ProtocolVersion::V2_1 { + delegation_state.distribute_reward(stakes, validator, reward, epoch)?; + } else { + stakes.add_reward(validator, reward, epoch)?; } + Ok(()) } -fn process_wit2_stakes_changes( - block: &Block, - consensus_constants_wit2: &ConsensusConstantsWit2, - data_request_pool: &DataRequestPool, +/// Construct the canonical `TooManyWitnesses` Radon value without allowing a conversion failure +/// to unwind the chain manager. The conversion is currently total for the protocol enum, but this +/// is a consensus-facing replay boundary and must fail closed if the representation ever changes. +fn too_many_witnesses_radon_type() -> Result { + let error = RadonError::try_from(TooManyWitnesses) + .map_err(|error| anyhow::anyhow!("cannot construct TooManyWitnesses sentinel: {error}"))?; + Ok(RadonTypes::RadonError(error)) +} + +/// Apply a batch of native delegated stake and commission-update actions atomically. It stages a checked stake/delegation projection before process_delegated_stake_transactions_inner can mutate the canonical state. +#[allow(clippy::too_many_arguments)] +fn process_delegated_stake_transactions<'a>( stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + transactions: impl Iterator, + epoch: Epoch, + _minimum_stakeable: u64, epoch_constants: EpochConstants, - utxo_diff: &UtxoDiff<'_>, -) { - let block_epoch = block.block_header.beacon.checkpoint; - let miner_pkh = block.block_header.proof.proof.pkh(); + network_magic: u16, + protocol_version: ProtocolVersion, +) -> Result<(), anyhow::Error> { + if protocol_version < ProtocolVersion::V2_1 { + return Err(anyhow::anyhow!( + "delegated stake transaction processing requires Wit/2.1" + )); + } - // Reset the coin age of the miner for all staked coins - if ProtocolVersion::from_epoch(block_epoch) == ProtocolVersion::V2_0 { - let minimum_stakeable = - consensus_constants_wit2.get_validator_min_stake_nanowits(block_epoch); + // `try_clone_checked` rebuilds the maintained secondary indexes from the canonical primary + // map. Prove the source projection first so a direct candidate/replay caller cannot silently + // canonicalize an omitted, duplicate, detached, or mis-owned row before the inner transition + // validates its delegated policy and applies the batch. + stakes.validate_validator_index_checked()?; + + let mut projected_stakes = stakes.try_clone_checked()?; + let mut projected_delegation_state = delegation_state.clone(); + process_delegated_stake_transactions_inner( + &mut projected_stakes, + &mut projected_delegation_state, + transactions, + epoch, + epoch_constants, + network_magic, + protocol_version, + )?; + *stakes = projected_stakes; + *delegation_state = projected_delegation_state; + Ok(()) +} - let mut total_commit_reward = 0; - for co_tx in &block.txns.commit_txns { - if data_request_is_pre_wit2(data_request_pool, &Transaction::Commit(co_tx.clone())) { - continue; +/// Mutate a projected Wit/2.1 ledger with a validated action batch. It enforces activation, policy, nonce, signer, and resource rules while preserving transaction-order semantics. +#[allow(clippy::too_many_arguments)] +fn process_delegated_stake_transactions_inner<'a>( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + transactions: impl Iterator, + epoch: Epoch, + epoch_constants: EpochConstants, + network_magic: u16, + protocol_version: ProtocolVersion, +) -> Result<(), anyhow::Error> { + // This helper is reached from consolidation and candidate replay, but it is also a private + // mutation boundary in its own right. Prove the source ledger/index and policy timeline once + // before any transaction can register a policy or alter a stake entry; otherwise a malformed + // secondary index or already-mature pending commission could be carried through a projected + // mutation and only discovered after partial state changes. + if protocol_version >= ProtocolVersion::V2_1 { + // Native actions are valid only against the one-time activated image. All production + // callers stage that image before reaching this helper; keep the assertion here as the + // final direct-boundary guard so a candidate/replay/consolidation caller cannot interpret + // preactivation owner or commission sidecars as authoritative native policy. + if !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native delegated-staking mutation requires the activation marker", } + .into()); + } + delegation_state.validate_authoritative_owner_policies(stakes)?; + delegation_state.validate_pending_commissions_at_epoch(epoch)?; + } - let commit_pkh = co_tx.body.proof.proof.pkh(); - total_commit_reward += - if let Some(dr_output) = data_request_pool.get_dr_output(&co_tx.body.dr_pointer) { - log::debug!( - "Reserving {} wit collateral from {}", - Wit::from(dr_output.collateral), - commit_pkh, - ); - // Subtract collateral from staked balance - stakes - .reserve_collateral( - commit_pkh, - Wit::from(dr_output.collateral), - Wit::from(minimum_stakeable), - ) - .unwrap(); + // Only deposits and owner registrations consume a live `(validator, + // withdrawer)` identity slot. Commission updates are sequenced by + // validator policy nonce and exit intents by exact key; counting those + // policy-only actions here would reject a valid owner top-up plus + // commission update in one block before their dedicated state checks run. + let mut seen_deposit_keys = HashSet::new(); + for transaction in transactions { + let stake_key = transaction.body.output.key.clone(); + let terms = transaction + .body + .output + .authorization_terms + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!("Wit/2.1 stake transaction is missing authorization terms") + })?; + // Consolidation normally follows full block validation, but this helper is also used by + // candidate/replay paths. Recheck the complete signed envelope here so an incompletely + // validated transaction cannot mutate the delegated ledger with an unsupported action, + // wrong network, expired authorization, invalid nonce/commission, mismatched signer, or + // forged signature. Keep this protocol-aware call at the same boundary as the mutation; + // it is intentionally not replaced by a public-key-hash comparison alone. + transaction + .body + .authorization_is_valid_at(protocol_version, network_magic, epoch) + .map_err(|error| anyhow::anyhow!("invalid stake authorization: {error}"))?; + let validator = transaction.body.output.key.validator; + let withdrawer = transaction.body.output.key.withdrawer; + anyhow::ensure!( + transaction.body.output.authorization.public_key.pkh() == terms.authorizer(), + "stake authorization was not signed by the declared action authorizer {}", + terms.authorizer() + ); + anyhow::ensure!( + terms.validator == validator, + "stake authorization validator {} does not match stake output {}", + terms.validator, + validator + ); + anyhow::ensure!( + terms.withdrawer == withdrawer, + "stake authorization withdrawer {} does not match stake output {}", + terms.withdrawer, + withdrawer + ); + // `validate_stake_transaction_inner` rejects input-free stake transactions after + // signature/fee validation. This mutation helper has no UTXO view from which to + // reconstruct the fee, but it is also reachable from candidate/replay projections that + // may be invoked with an incompletely validated envelope. Recheck the structural input + // requirement here so a direct caller cannot mutate stake or policy state with an + // authorization body that could never have passed block validation. + anyhow::ensure!( + !transaction.body.inputs.is_empty(), + "native stake action must consume at least one input" + ); + let commission = u8::try_from(terms.commission_percent) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?; + + match terms.action { + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + anyhow::ensure!( + seen_deposit_keys.insert(stake_key.clone()), + "native delegated stake batch contains duplicate deposit key {stake_key}" + ); + delegation_state.ensure_entry_accepts_deposit(&transaction.body.output.key)?; + anyhow::ensure!( + transaction.body.output.value >= DELEGATED_STAKING_MINIMUM_NANOWITS, + "delegated stake value {} is below the fixed minimum {}", + transaction.body.output.value, + DELEGATED_STAKING_MINIMUM_NANOWITS + ); - dr_output.commit_and_reveal_fee + // Admission validation enforces this cap before the transaction reaches the + // helper. Recheck it here as well because candidate/replay callers may invoke + // this private projection boundary with an incompletely validated transaction. + // Rewards are intentionally allowed to move an existing validator above the cap; + // only this new delegated deposit is rejected when it would increase the + // live-plus-reserved total beyond the fixed admission limit. + let live_total = match stakes + .query_stakes_primary_checked(QueryStakesKey::Validator(validator)) + { + Ok(entries) => entries.into_iter().try_fold(0u64, |total, entry| { + total + .checked_add(entry.value.coins.nanowits()) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + })?, + Err(StakesError::ValidatorNotFound { .. }) => 0, + Err(error) => return Err(error.into()), + }; + // Pre-activation reservation sidecars are compatibility caches and are not + // authoritative for native admission. Count them only after the one-time marker + // has made the reservation map canonical; otherwise upgrade timing could make a + // stale legacy cache consume capacity for a new delegated deposit. + let reserved_total = if delegation_state.activation_initialized { + delegation_state + .collateral_reservations + .iter() + .filter(|(key, _)| key.validator == validator) + .try_fold(0u64, |total, (_, reservation)| { + total + .checked_add(reservation.total.nanowits()) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + })? } else { 0 }; - } - // Add commit reward - if total_commit_reward > 0 { - log::debug!( - "Rewarding {} with {} wit for including commits", - miner_pkh, - Wit::from(total_commit_reward), - ); - stakes - .add_reward(miner_pkh, Wit::from(total_commit_reward), block_epoch) - .unwrap(); - } + let resulting_total = live_total + .checked_add(reserved_total) + .and_then(|total| total.checked_add(transaction.body.output.value)) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + anyhow::ensure!( + resulting_total <= DELEGATED_STAKING_MAXIMUM_NANOWITS, + "delegated stake total {resulting_total} exceeds the fixed cap {}", + DELEGATED_STAKING_MAXIMUM_NANOWITS + ); - // Add reveal rewards - let mut total_reveal_reward = 0; - for re_tx in &block.txns.reveal_txns { - if data_request_is_pre_wit2(data_request_pool, &Transaction::Reveal(re_tx.clone())) { - continue; - } + // Validator identity is authoritative in the primary stake map. An omitted + // secondary-index row must not make an existing validator look brand new and + // thereby assign its next delegation the wrong owner/commission history. + let validator_exists = match stakes + .query_stakes_primary_checked(QueryStakesKey::Validator(validator)) + { + Ok(_) => true, + Err(StakesError::ValidatorNotFound { .. }) => false, + Err(error) => return Err(error.into()), + }; + // A persisted policy survives a full owner exit so the owner can restore the + // validator without resetting commission or nonce history. Treat that policy as + // authoritative even when the live primary map is temporarily empty. + if terms.action == STAKE_AUTHORIZATION_ACTION_DELEGATE { + if !validator_exists && !delegation_state.validators.contains_key(&validator) { + return Err(ConsensusStakesError::ValidatorRegistrationRequired { validator }.into()); + } + let policy = delegation_state.ensure_validator_policy(stakes, validator)?; + // A persisted policy can outlive a full owner exit so the owner can restore + // the validator later. It must not, however, authorize a third-party + // delegation while the owner is inactive: validation applies the same + // owner-active gate before admitting the action. Keep this guard in the + // mutation helper as well because candidate/replay callers may reach this + // boundary without the full block-validation pass. + // A retained policy may outlive a full exit. In that state the checked + // owner lookup returns `false` because no owner entry exists yet; that is the + // expected inactive result for the owner's next restoring deposit, not + // malformed state. Preserve any actual checked-read error so a poisoned + // non-empty ledger still fails closed. + let owner_is_active = (!delegation_state.activation_initialized + || !delegation_state.exit_intents.contains(&policy.owner_key(validator))) + && stakes.validator_is_active_after_index_validation( + validator, + policy.owner_withdrawer, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + anyhow::ensure!( + withdrawer == policy.owner_withdrawer || owner_is_active, + "delegation for validator {validator} requires active owner {}", + policy.owner_withdrawer + ); + let effective_commission = delegation_state + .effective_commission_at(validator, epoch)?; + if commission != effective_commission { + return Err(anyhow::anyhow!( + "delegation commission {commission}% does not match validator {validator} effective commission {effective_commission}%" + )); + } + } else { + if validator_exists || delegation_state.validators.contains_key(&validator) { + return Err(ConsensusStakesError::ValidatorAlreadyRegistered { validator }.into()); + } + delegation_state.register_validator_if_absent( + validator, + withdrawer, + commission, + )?; + } - total_reveal_reward += - if let Some(dr_output) = data_request_pool.get_dr_output(&re_tx.body.dr_pointer) { - dr_output.commit_and_reveal_fee + // The policy-aware transition has already authenticated the complete native + // envelope, action, owner/commission rule, cap, and minimum. The generic + // stake-ledger helper intentionally rejects action 2 because it has no + // DelegationState; apply this final checked ledger mutation only after those + // policy checks have succeeded. + stakes.add_stake( + stake_key, + Wit::from_nanowits(transaction.body.output.value), + epoch, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + } + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + anyhow::ensure!(transaction.body.output.value == 0, "exit request carries stake value"); + // Exit intents are policy-only transactions and must consume a UTXO input even + // when this projection helper is reached without the full fee-aware envelope + // validator. The enclosing validation still proves that the resulting fee is + // strictly positive; this lower boundary prevents an input-free direct replay + // caller from mutating exit state that could never be admitted on-chain. + anyhow::ensure!( + !transaction.body.inputs.is_empty(), + "exit intent must consume an input to pay its transaction fee" + ); + delegation_state.request_exit(stakes, transaction.body.output.key.clone(), terms.nonce)?; + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => { + anyhow::ensure!( + transaction.body.output.value == 0, + "commission update carries nonzero stake value {}", + transaction.body.output.value + ); + // The actual fee is reconstructed from the transaction's UTXOs by the + // containing block validator and is not serialized in `StakeTransactionBody`. + // A fee-paying commission update must nevertheless carry at least one input; + // reject an input-free envelope here as well so a direct candidate/replay caller + // cannot schedule policy state from a body that could never have paid the + // mandatory positive fee. The full UTXO-derived fee check remains authoritative + // in `validate_v21_stake_action_envelope`. + anyhow::ensure!( + !transaction.body.inputs.is_empty(), + "commission update must consume an input to pay its transaction fee" + ); + let policy = delegation_state.ensure_validator_policy(stakes, validator)?; + let current_commission = delegation_state.effective_commission_at(validator, epoch)?; + let checkpoint_period = if commission > current_commission { + u64::from( + epoch_constants + .get_epoch_period(epoch) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?, + ) } else { 0 }; + let effective_epoch = commission_effective_epoch( + epoch, + current_commission, + commission, + checkpoint_period, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if policy.owner_withdrawer != withdrawer { + return Err(anyhow::anyhow!( + "commission update for {validator} is authorized by {withdrawer}, expected owner {}", + policy.owner_withdrawer + )); + } + delegation_state.schedule_commission( + validator, + commission, + epoch, + effective_epoch, + terms.nonce, + )?; + } + action => { + return Err(anyhow::anyhow!( + "unsupported stake authorization action during consolidation: {action}" + )); + } } - // Add reveal reward - if total_reveal_reward > 0 { - log::debug!( - "Rewarding {} with {} wit for including reveals", - miner_pkh, - Wit::from(total_reveal_reward), - ); - stakes - .add_reward(miner_pkh, Wit::from(total_reveal_reward), block_epoch) - .unwrap(); - } + } + Ok(()) +} - for ta_tx in &block.txns.tally_txns { - if data_request_is_pre_wit2(data_request_pool, &Transaction::Tally(ta_tx.clone())) { - continue; - } +/// Materialize a same-block data request whose tally immediately resolves with +/// `TooManyWitnesses`. +/// +/// Block validation and candidate construction keep this synthetic request in a +/// private `DataRequestPool` clone. Consolidation calls this helper both for its +/// staking projection and for the accepted live-pool update so collateral, +/// rewards, and pool indexes observe the same state without exposing a rejected +/// candidate's mutation. +fn materialize_immediate_too_many_witnesses_tally( + block: &Block, + tally_transaction: &TallyTransaction, + data_request_pool: &mut DataRequestPool, + protocol: ProtocolVersion, +) -> Result<(), anyhow::Error> { + if RadonTypes::try_from(tally_transaction.tally.as_slice()) + != Ok(too_many_witnesses_radon_type()?) + { + return Ok(()); + } - let (collateral, reward) = - if let Some(dr_output) = data_request_pool.get_dr_output(&ta_tx.dr_pointer) { - (dr_output.collateral, dr_output.witness_reward) - } else { - (0, 0) - }; - let commits: Vec<_> = data_request_pool.data_request_pool[&ta_tx.dr_pointer] - .info - .commits - .keys() - .cloned() - .collect(); + let epoch = block.block_header.beacon.checkpoint; + let block_hash = block.versioned_hash(protocol); - // Reward honest validators - let honest_pkhs: Vec = commits - .iter() - .filter(|pkh| { - !ta_tx.error_committers.contains(pkh) && !ta_tx.out_of_consensus.contains(pkh) - }) - .cloned() - .collect(); - for honest_pkh in honest_pkhs { - log::debug!( - "Refunding and rewarding {} for {} wit for solving data request {}", - honest_pkh, - collateral + reward, - ta_tx.dr_pointer - ); - stakes - .add_reward(honest_pkh, Wit::from(collateral + reward), block_epoch) - .unwrap(); + if data_request_pool + .data_request_state(&tally_transaction.dr_pointer) + .is_none() + { + let same_block_request = block.txns.data_request_txns.iter().find(|dr_tx| { + dr_tx.versioned_hash(protocol) == tally_transaction.dr_pointer + }); + match same_block_request { + Some(dr_tx) => { + if let Err(error) = data_request_pool.process_data_request_for_protocol( + dr_tx, + epoch, + Some(block_hash), + protocol, + ) { + if protocol >= ProtocolVersion::V2_1 { + return Err(anyhow::anyhow!( + "cannot materialize Wit/2.1 immediate TooManyWitnesses data request {}: {error}", + tally_transaction.dr_pointer + )); + } + log::error!( + "Error materializing immediate TooManyWitnesses data request {}: {}", + tally_transaction.dr_pointer, + error + ); + } } - - // Refund errored validators - for error_pkh in &ta_tx.error_committers { - log::debug!( - "Refunding {} with {} wit because it revealed an error for data request {}", - error_pkh, - collateral, - ta_tx.dr_pointer - ); - stakes - .add_reward(*error_pkh, Wit::from(collateral), block_epoch) - .unwrap(); + None if protocol >= ProtocolVersion::V2_1 => { + return Err(anyhow::anyhow!( + "cannot materialize Wit/2.1 immediate TooManyWitnesses data request {}: no active state or matching same-block data request", + tally_transaction.dr_pointer + )); } + None => {} } + } - // Add all transaction fees plus the block reward - let mut transaction_fees = 0; - for vt_tx in &block.txns.value_transfer_txns { - transaction_fees += vt_transaction_fee(vt_tx, utxo_diff, block_epoch, epoch_constants) - .unwrap_or_default(); - } - for dr_tx in &block.txns.data_request_txns { - transaction_fees += dr_transaction_fee(dr_tx, utxo_diff, block_epoch, epoch_constants) - .unwrap_or_default(); - } - for st_tx in &block.txns.stake_txns { - transaction_fees += st_transaction_fee(st_tx, utxo_diff, block_epoch, epoch_constants) - .unwrap_or_default(); + // A V2.1 TooManyWitnesses tally must settle the exact request it validated. If neither an + // existing active state nor the matching same-block request produced one, continuing would + // let later stake/collateral replay silently omit the tally's economic transition. + if protocol >= ProtocolVersion::V2_1 + && data_request_pool + .data_request_state(&tally_transaction.dr_pointer) + .is_none() + { + return Err(anyhow::anyhow!( + "cannot materialize Wit/2.1 immediate TooManyWitnesses data request {}", + tally_transaction.dr_pointer + )); + } + + if let Some(dr_state) = data_request_pool + .data_request_state_mutable(&tally_transaction.dr_pointer) + { + if dr_state.stage != DataRequestStage::TALLY { + dr_state.try_update_stage(0, true)?; } - for ut_tx in &block.txns.unstake_txns { - transaction_fees += ut_tx.body.fee; + let request_epoch = dr_state.epoch; + + // This manual immediate transition bypasses + // `update_data_request_stages_with_legacy_validator_count`, which normally removes TALLY + // requests from the commit-eligible epoch index and pending-reveal map. Keep those live + // pool indexes consistent before `process_tally` consumes the request. + if let Some(requests) = data_request_pool + .data_requests_by_epoch + .get_mut(&request_epoch) + { + requests.remove(&tally_transaction.dr_pointer); + if requests.is_empty() { + data_request_pool + .data_requests_by_epoch + .remove(&request_epoch); + } } + data_request_pool + .waiting_for_reveal + .remove(&tally_transaction.dr_pointer); + } - let block_reward = consensus_constants_wit2.get_validator_block_reward(block_epoch); - - log::debug!( - "Rewarding {} with {} wit transaction fees and {} wit block reward for proposing a block", - miner_pkh, - Wit::from(transaction_fees), - Wit::from(block_reward), - ); - stakes - .add_reward( - miner_pkh, - Wit::from(transaction_fees) + Wit::from(block_reward), - block_epoch, - ) - .unwrap(); + Ok(()) +} - // IMPORTANT: Always perform age resets after adding rewards +/// Complete accepted-block pool projection held privately across the fallible replay and protocol +/// registration boundary. The already validated UTXO diff is applied to a private pool while this +/// value is built; publishing therefore replaces only complete projected fields, so the chain beacon +/// and its economic/oracle state can advance together in the ordinary execution path. +struct PendingPoolUpdates { + unspent_outputs_pool: UnspentOutputsPool, + data_request_pool: DataRequestPool, + transactions_pool: TransactionsPool, + own_utxos: OwnUnspentOutputsPool, + node_stats: NodeStats, + reputation_info: ReputationInfo, +} - // Reset mining power to block proposer, and higher ranked eligible candidates: - log::debug!( - "Resetting mining age for {} to {}", - miner_pkh, - block_epoch + 1 - ); - stakes.reset_mining_age(miner_pkh, block_epoch).unwrap(); +impl PendingPoolUpdates { + fn publish( + self, + unspent_outputs_pool: &mut UnspentOutputsPool, + data_request_pool: &mut DataRequestPool, + transactions_pool: &mut TransactionsPool, + own_utxos: &mut OwnUnspentOutputsPool, + node_stats: &mut NodeStats, + ) -> ReputationInfo { + *unspent_outputs_pool = self.unspent_outputs_pool; + *data_request_pool = self.data_request_pool; + *transactions_pool = self.transactions_pool; + *own_utxos = self.own_utxos; + *node_stats = self.node_stats; + + self.reputation_info + } +} - // Reset witnessing power - for co_tx in &block.txns.commit_txns { - if data_request_is_pre_wit2(data_request_pool, &Transaction::Commit(co_tx.clone())) { - continue; - } +#[derive(Debug)] +struct PendingDataRequestEffects { + to_be_stored: Vec, + reveals: Vec, +} - let commit_pkh = co_tx.body.proof.proof.pkh(); - log::debug!( - "Resetting witnessing age for {} to {}", - commit_pkh, - block_epoch + 1, - ); - stakes - .reset_age(commit_pkh, Capability::Witnessing, block_epoch + 1, 1) - .unwrap(); - } +/// Finish the accepted block's data-request transition on the private pool projection. +/// +/// Finished reports are drained in the same actor states as the historical live-state path. +/// Synchronizing batches retain them for the enclosing AddBlocks persistence flow. Returned Reveal +/// transactions are side effects only after the caller publishes the complete pending state. +fn stage_data_request_progression( + data_request_pool: &mut DataRequestPool, + state_machine: StateMachine, + validator_count: usize, + legacy_validator_count: Option, + block_epoch: Epoch, +) -> Result { + let protocol_info = get_protocol_info(); + stage_data_request_progression_for_protocol( + data_request_pool, + state_machine, + validator_count, + legacy_validator_count, + block_epoch, + &protocol_info, + ) +} - // Slash lieing validators - // Collateral was already reserved, so not returning it results in losing it - // Reset the age for witnessing power to 10 epochs in the future - for ta_tx in &block.txns.tally_txns { - if data_request_is_pre_wit2(data_request_pool, &Transaction::Tally(ta_tx.clone())) { - continue; - } +/// Advance accepted data-request stages using the protocol schedule selected for the enclosing +/// block. The compatibility wrapper above remains available for direct helper callers. +fn stage_data_request_progression_for_protocol( + data_request_pool: &mut DataRequestPool, + state_machine: StateMachine, + validator_count: usize, + legacy_validator_count: Option, + block_epoch: Epoch, + protocol_info: &ProtocolInfo, +) -> Result { + // Validate the terminal-round configuration before touching the pool. The checked progression + // below can still reject malformed persisted request state (for example, a TALLY request that + // already contains a tally), so defer draining finished reports until that fallible operation + // has succeeded as well. This keeps the private projection unchanged on every error path and + // preserves the historical synchronization-specific drain contract on success. + data_request_pool.validate_stage_round_configuration()?; + let reveals = data_request_pool + .try_update_data_request_stages_with_legacy_validator_count_for_protocol( + Some(validator_count), + legacy_validator_count, + Some(block_epoch), + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + )?; + let to_be_stored = if state_machine == StateMachine::Synchronizing { + Vec::new() + } else { + data_request_pool.finished_data_requests() + }; - let liar_pkhs: Vec = ta_tx - .out_of_consensus - .iter() - .filter(|&pkh| !ta_tx.error_committers.contains(pkh)) - .cloned() - .collect(); - for liar_pkh in &liar_pkhs { - log::debug!( - "Slashing {} because it revealed a lie for data request {}", - liar_pkh, - ta_tx.dr_pointer, - ); - stakes - .reset_age(*liar_pkh, Capability::Witnessing, block_epoch + 10, 1) - .unwrap(); - } - } - } + Ok(PendingDataRequestEffects { + to_be_stored, + reveals, + }) } // Helper methods #[allow(clippy::too_many_arguments)] -fn update_pools( +fn stage_pool_updates( block: &Block, - unspent_outputs_pool: &mut UnspentOutputsPool, - data_request_pool: &mut DataRequestPool, - transactions_pool: &mut TransactionsPool, + unspent_outputs_pool: &UnspentOutputsPool, + data_request_pool: &DataRequestPool, + transactions_pool: &TransactionsPool, utxo_diff: Diff, own_pkh: PublicKeyHash, - own_utxos: &mut OwnUnspentOutputsPool, - node_stats: &mut NodeStats, + own_utxos: &OwnUnspentOutputsPool, + node_stats: &NodeStats, state_machine: StateMachine, -) -> ReputationInfo { - let mut rep_info = ReputationInfo::new(); + protocol: ProtocolVersion, +) -> Result { let epoch = block.block_header.beacon.checkpoint; - let protocol = ProtocolVersion::from_epoch(epoch); let block_hash = block.versioned_hash(protocol); - let mut data_requests_with_too_many_witnesses = HashSet::::new(); - for ta_tx in &block.txns.tally_txns { - // Track data requests which were already processed with a TooManyWitnesses error - if RadonTypes::try_from(ta_tx.tally.as_slice()) - == Ok(RadonTypes::RadonError( - RadonError::try_from(TooManyWitnesses).unwrap(), - )) - { - data_requests_with_too_many_witnesses.insert(ta_tx.dr_pointer); + // Under Wit/2.1, validation and staking settlement have already proved one exact sequential + // data-request transition. Replaying that transition into the accepted live pools must either + // publish in full or fail: logging and continuing after a represented pool error could otherwise + // commit stake refunds/slashes while retaining (or omitting) the corresponding oracle request. + // Keep Wit/2.0 and earlier on their release-compatible log-and-continue behavior. + let strict_pool_updates = protocol >= ProtocolVersion::V2_1; + let mut working_unspent_outputs_pool = unspent_outputs_pool.clone(); + let mut working_data_request_pool = data_request_pool.clone(); + let mut working_transactions_pool = transactions_pool.clone(); + let mut working_node_stats = node_stats.clone(); + let mut working_own_utxos = own_utxos.clone(); + + // Project both UTXO views before replaying the accepted oracle sequence. These writes remain + // private, so a later strict replay error discards them with every other successful prefix. + utxo_diff.visit( + &mut working_own_utxos, + |own_utxos, output_pointer, output| { + // Insert new outputs + if output.pkh == own_pkh { + own_utxos.insert(*output_pointer, 0); + } + }, + |own_utxos, output_pointer| { + // Remove spent inputs + own_utxos.remove(output_pointer); + }, + ); + + // Accepted-block publication needs a typed rejection boundary rather than the historical + // assertion-based `Diff::apply` contract. The complete diff is preflighted and applied to this + // private clone, so a stale or malformed pending entry leaves every live pool and the process- + // global protocol schedule untouched. + utxo_diff + .try_apply(&mut working_unspent_outputs_pool) + .map_err(|error| anyhow::anyhow!("cannot stage accepted-block UTXO diff: {error}"))?; + + let rep_info = { + let mut rep_info = ReputationInfo::new(); + let mut data_requests_with_too_many_witnesses = HashSet::::new(); - if let Some(dr_state) = data_request_pool.data_request_state_mutable(&ta_tx.dr_pointer) - && dr_state.stage != DataRequestStage::TALLY + for ta_tx in &block.txns.tally_txns { + // Track data requests which were already processed with a TooManyWitnesses error + if RadonTypes::try_from(ta_tx.tally.as_slice()) + == Ok(too_many_witnesses_radon_type()?) { - dr_state.update_stage(0, true); + data_requests_with_too_many_witnesses.insert(ta_tx.dr_pointer); + + // Validation and candidate construction use private pool clones for this synthetic + // same-block request. Materialize it only now that the block has been accepted, just + // before applying its immediate tally, preserving the legacy consolidation result + // without leaking state from failed candidates. + materialize_immediate_too_many_witnesses_tally( + block, + ta_tx, + &mut working_data_request_pool, + protocol, + )?; } - } - if ProtocolVersion::from_epoch(epoch) < ProtocolVersion::V2_0 { - // Process tally transactions: used to update reputation engine - rep_info.update(ta_tx, data_request_pool, own_pkh, node_stats); - } + if protocol < ProtocolVersion::V2_0 { + // Process tally transactions: used to update reputation engine + rep_info.update( + ta_tx, + &working_data_request_pool, + own_pkh, + &mut working_node_stats, + ); + } - // IMPORTANT: Update the data request pool after updating reputation info - if let Err(e) = data_request_pool.process_tally(ta_tx, &block_hash) { - log::error!("Error processing tally transaction:\n{e}"); - } + // IMPORTANT: Update the data request pool after updating reputation info + if let Err(error) = working_data_request_pool.process_tally_for_protocol( + ta_tx, + &block_hash, + protocol, + ) { + if strict_pool_updates { + return Err(anyhow::anyhow!( + "cannot apply validated Wit/2.1 tally {} to the accepted data-request pool: {error}", + ta_tx.dr_pointer + )); + } + log::error!("Error processing tally transaction:\n{error}"); + } - transactions_pool.clear_reveals_from_finished_dr(&ta_tx.dr_pointer); - } + working_transactions_pool.clear_reveals_from_finished_dr(&ta_tx.dr_pointer); + } - for vt_tx in &block.txns.value_transfer_txns { - transactions_pool.vt_remove(vt_tx); - } + for vt_tx in &block.txns.value_transfer_txns { + working_transactions_pool.vt_remove(vt_tx); + } - for dr_tx in &block.txns.data_request_txns { - let dr_hash = dr_tx.versioned_hash(protocol); - if data_requests_with_too_many_witnesses.contains(&dr_hash) { - log::debug!( - "Skipping data request {dr_hash} as it was already processed with a TooManyWitnesses error" - ); - transactions_pool.dr_remove(dr_tx); - continue; + for dr_tx in &block.txns.data_request_txns { + let dr_hash = dr_tx.versioned_hash(protocol); + if data_requests_with_too_many_witnesses.contains(&dr_hash) { + log::debug!( + "Skipping data request {dr_hash} as it was already processed with a TooManyWitnesses error" + ); + working_transactions_pool.dr_remove(dr_tx); + continue; + } + if let Err(error) = working_data_request_pool.process_data_request_for_protocol( + dr_tx, + block.block_header.beacon.checkpoint, + Some(block_hash), + protocol, + ) { + if strict_pool_updates { + return Err(anyhow::anyhow!( + "cannot apply validated Wit/2.1 data request {dr_hash} to the accepted pool: {error}" + )); + } + log::error!("Error processing data request transaction:\n{error}"); + } else { + working_transactions_pool.dr_remove(dr_tx); + } } - if let Err(e) = data_request_pool.process_data_request( - dr_tx, - block.block_header.beacon.checkpoint, - Some(block_hash), - ) { - log::error!("Error processing data request transaction:\n{e}"); - } else { - transactions_pool.dr_remove(dr_tx); + + for co_tx in &block.txns.commit_txns { + if let Err(error) = working_data_request_pool.process_commit_for_protocol( + co_tx, + epoch, + &block_hash, + protocol, + ) { + if strict_pool_updates { + return Err(anyhow::anyhow!( + "cannot apply validated Wit/2.1 commit {} to data request {}: {error}", + co_tx.versioned_hash(protocol), + co_tx.body.dr_pointer + )); + } + log::error!("Error processing commit transaction:\n{error}"); + } else { + if co_tx.body.proof.proof.pkh() == own_pkh { + working_node_stats.commits_count += 1; + if state_machine != StateMachine::Synced { + // During synchronization, we assume that every consolidated commit had, + // at least, one data requests valid proof and one commit proposed. + working_node_stats.dr_eligibility_count += 1; + working_node_stats.commits_proposed_count += 1; + } + } + working_transactions_pool.remove_inputs(&co_tx.body.collateral); + } } - } + // Candidate construction selects Wit/2.1 commits without mutating the pool so any fallible + // projection/signing/validation stage can retry. Once a block is actually consolidated, + // expire the whole epoch's commit set exactly as the legacy destructive builder did. In + // particular, stake-backed commits have no collateral UTXO inputs whose removal could evict + // them here. + working_transactions_pool.clear_commits(); - for co_tx in &block.txns.commit_txns { - if let Err(e) = data_request_pool.process_commit(co_tx, epoch, &block_hash) { - log::error!("Error processing commit transaction:\n{e}"); - } else { - if co_tx.body.proof.proof.pkh() == own_pkh { - node_stats.commits_count += 1; - if state_machine != StateMachine::Synced { - // During synchronization, we assume that every consolidated commit had, - // at least, one data requests valid proof and one commit proposed - node_stats.dr_eligibility_count += 1; - node_stats.commits_proposed_count += 1; + for re_tx in &block.txns.reveal_txns { + if let Err(error) = working_data_request_pool.process_reveal_for_protocol( + re_tx, + epoch, + &block_hash, + protocol, + ) { + if strict_pool_updates { + return Err(anyhow::anyhow!( + "cannot apply validated Wit/2.1 reveal {} to data request {}: {error}", + re_tx.versioned_hash(protocol), + re_tx.body.dr_pointer + )); } + log::error!("Error processing reveal transaction:\n{error}"); } - transactions_pool.remove_inputs(&co_tx.body.collateral); + let re_hash = re_tx.versioned_hash(protocol); + working_transactions_pool.remove_one_reveal( + &re_tx.body.dr_pointer, + &re_tx.body.pkh, + &re_hash, + ); } - } - for re_tx in &block.txns.reveal_txns { - if let Err(e) = data_request_pool.process_reveal(re_tx, epoch, &block_hash) { - log::error!("Error processing reveal transaction:\n{e}"); + for st_tx in &block.txns.stake_txns { + working_transactions_pool.st_remove_for_protocol(st_tx, protocol); } - let re_hash = re_tx.versioned_hash(protocol); - transactions_pool.remove_one_reveal(&re_tx.body.dr_pointer, &re_tx.body.pkh, &re_hash); - } - for st_tx in &block.txns.stake_txns { - transactions_pool.st_remove(st_tx); - } - - for ut_tx in &block.txns.unstake_txns { - transactions_pool.ut_remove(ut_tx); - } + for ut_tx in &block.txns.unstake_txns { + working_transactions_pool.ut_remove(ut_tx); + } - // Update own_utxos - utxo_diff.visit( - own_utxos, - |own_utxos, output_pointer, output| { - // Insert new outputs - if output.pkh == own_pkh { - own_utxos.insert(*output_pointer, 0); - } - }, - |own_utxos, output_pointer| { - // Remove spent inputs - own_utxos.remove(output_pointer); - }, - ); + rep_info + }; - utxo_diff.apply(unspent_outputs_pool); + Ok(PendingPoolUpdates { + unspent_outputs_pool: working_unspent_outputs_pool, + data_request_pool: working_data_request_pool, + transactions_pool: working_transactions_pool, + own_utxos: working_own_utxos, + node_stats: working_node_stats, + reputation_info: rep_info, + }) +} - rep_info +/// Compatibility wrapper used by direct helper fixtures. Production consolidation stages the same +/// value before protocol publication and calls [`PendingPoolUpdates::publish`] only after every +/// represented error boundary has succeeded. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +fn update_pools( + block: &Block, + unspent_outputs_pool: &mut UnspentOutputsPool, + data_request_pool: &mut DataRequestPool, + transactions_pool: &mut TransactionsPool, + utxo_diff: Diff, + own_pkh: PublicKeyHash, + own_utxos: &mut OwnUnspentOutputsPool, + node_stats: &mut NodeStats, + state_machine: StateMachine, + protocol: ProtocolVersion, +) -> Result { + let pending = stage_pool_updates( + block, + unspent_outputs_pool, + data_request_pool, + transactions_pool, + utxo_diff, + own_pkh, + own_utxos, + node_stats, + state_machine, + protocol, + )?; + Ok(pending.publish( + unspent_outputs_pool, + data_request_pool, + transactions_pool, + own_utxos, + node_stats, + )) } -fn data_request_is_pre_wit2( +fn data_request_is_pre_wit2_for_protocol( data_request_pool: &DataRequestPool, transaction: &Transaction, -) -> bool { + request_protocol_for: F, +) -> bool +where + F: Fn(Epoch) -> ProtocolVersion, +{ // Data requests which were included in a block prior to the activation of wit/2 do // not need to be processed using the wit/2 business logic. match transaction { Transaction::Commit(co_tx) => { if let Some(dr_state) = data_request_pool.data_request_state(&co_tx.body.dr_pointer) - && get_protocol_version(Some(dr_state.epoch)) < ProtocolVersion::V2_0 + && request_protocol_for(dr_state.epoch) < ProtocolVersion::V2_0 { return true; } } Transaction::Reveal(re_tx) => { if let Some(dr_state) = data_request_pool.data_request_state(&re_tx.body.dr_pointer) - && get_protocol_version(Some(dr_state.epoch)) < ProtocolVersion::V2_0 + && request_protocol_for(dr_state.epoch) < ProtocolVersion::V2_0 { return true; } } Transaction::Tally(ta_tx) => { if let Some(dr_state) = data_request_pool.data_request_state(&ta_tx.dr_pointer) - && get_protocol_version(Some(dr_state.epoch)) < ProtocolVersion::V2_0 + && request_protocol_for(dr_state.epoch) < ProtocolVersion::V2_0 { return true; } @@ -3586,8 +8634,12 @@ fn show_tally_info(tally_tx: &TallyTransaction, block_epoch: Epoch) { ); } -fn show_info_dr(data_request_pool: &DataRequestPool, block: &Block) { - let block_hash = block.hash(); +fn show_info_dr( + data_request_pool: &DataRequestPool, + block: &Block, + protocol_version: ProtocolVersion, +) { + let block_hash = block.versioned_hash(protocol_version); let block_epoch = block.block_header.beacon.checkpoint; let info = data_request_pool @@ -3606,7 +8658,7 @@ fn show_info_dr(data_request_pool: &DataRequestPool, block: &Block) { White.bold().paint(k.to_string()), White.bold().paint(format!("{:?}", v.stage)), current_round, - data_request_pool.extra_rounds + 1, + data_request_pool.extra_rounds.saturating_add(1), v.info.commits.len(), v.info.reveals.len() ) @@ -3748,9 +8800,50 @@ fn current_committee_size_requirement( } } -/// When the TransactionsPool is full, inserting a transaction can result in removing other -/// transactions. This will log the removed transactions. +/// Return whether relay must wait for proof that local insertion retained the exact transaction. +fn requires_retention_confirmation( + transaction: &Transaction, + protocol_version: ProtocolVersion, +) -> bool { + (protocol_version >= ProtocolVersion::V2_1 + && matches!(transaction, Transaction::Commit(_))) + || matches!(transaction, Transaction::Stake(_) | Transaction::Unstake(_)) +} + +/// Distinguish rejection/self-eviction of the incoming policy-sensitive body from successful +/// insertion that merely evicted another pool transaction. +fn rejected_incoming_transaction( + requires_confirmation: bool, + incoming_hash: Hash, + removed: &[Transaction], + protocol_version: ProtocolVersion, +) -> Option<&Transaction> { + if !requires_confirmation { + return None; + } + removed + .iter() + .find(|transaction| transaction.versioned_hash(protocol_version) == incoming_hash) +} + +/// Local pool policy can reject the incoming transaction or evict other transactions during +/// insertion. Log that exact returned-removal result without assuming every rejection is fee-based. pub fn log_removed_transactions(removed_transactions: &[Transaction], inserted_tx_hash: Hash) { + log_removed_transactions_for_protocol( + removed_transactions, + inserted_tx_hash, + get_protocol_version(None), + ); +} + +/// Protocol-pinned form used by actor admission after asynchronous validation. The compatibility +/// wrapper above retains the historical public helper for callers that do not have a schedule +/// snapshot; consensus-sensitive admission must use this explicit form. +fn log_removed_transactions_for_protocol( + removed_transactions: &[Transaction], + inserted_tx_hash: Hash, + protocol_version: ProtocolVersion, +) { if removed_transactions.is_empty() { log::trace!("Transaction {inserted_tx_hash} added successfully"); } else { @@ -3759,7 +8852,7 @@ pub fn log_removed_transactions(removed_transactions: &[Transaction], inserted_t // In that case, do not log "Transaction {} added successfully" let mut removed_the_one_we_just_inserted = false; for tx in removed_transactions { - let removed_tx_hash = tx.hash(); + let removed_tx_hash = tx.versioned_hash(protocol_version); removed_tx_hashes.push(removed_tx_hash.to_string()); if removed_tx_hash == inserted_tx_hash { @@ -3768,7 +8861,9 @@ pub fn log_removed_transactions(removed_transactions: &[Transaction], inserted_t } if removed_the_one_we_just_inserted { - log::trace!("Transaction {inserted_tx_hash} was not added because the fee was too low"); + log::trace!( + "Transaction {inserted_tx_hash} was not retained by the local pool policy" + ); } else { log::trace!("Transaction {inserted_tx_hash} added successfully"); } @@ -3834,6 +8929,7 @@ mod tests { defaults::Testnet, }; use witnet_crypto::{ + hash::calculate_sha256, secp256k1::{ PublicKey as Secp256k1_PublicKey, Secp256k1, SecretKey as Secp256k1_SecretKey, }, @@ -3841,11 +8937,12 @@ mod tests { }; use witnet_data_structures::{ chain::{ - BlockMerkleRoots, BlockTransactions, ChainInfo, DataRequestState, Environment, Input, + BlockMerkleRoots, BlockTransactions, ChainInfo, ChainState, DataRequestState, Environment, Input, KeyedSignature, OutputPointer, PartialConsensusConstants, PublicKey, SecretKey, - Signature, StakeOutput, ValueTransferOutput, + Signature, StakeAuthorization, StakeOutput, ValueTransferOutput, }, proto::versioning::VersionedHashable, + register_protocol_version, transaction::{ CommitTransaction, DRTransaction, MintTransaction, RevealTransaction, StakeTransaction, StakeTransactionBody, UnstakeTransaction, UnstakeTransactionBody, VTTransaction, @@ -3859,11 +8956,276 @@ mod tests { use crate::{ actors::chain_manager::mining::build_block, config_mngr, - utils::{ActorFutureToNormalFuture, test_actix_system}, + utils::{ActorFutureToNormalFuture, file_name_compose, test_actix_system}, }; use super::*; + #[test] + fn genesis_and_pre_delegated_snapshot_imports_remain_compatible_without_anchor() { + let chain_info = ChainInfo::default(); + let chain_state = ChainState::default(); + + validate_snapshot_trust_anchor(None, &chain_info, &chain_state, None, None, &[]) + .expect("genesis/pre-delegated snapshots do not require an external anchor"); + } + + #[test] + fn non_genesis_delegated_snapshot_requires_anchor_before_force_can_matter() { + let mut chain_info = ChainInfo::default(); + chain_info.highest_block_checkpoint.checkpoint = 1; + let mut chain_state = ChainState::default(); + chain_state.delegation_state.activation_initialized = true; + + let error = validate_snapshot_trust_anchor( + None, + &chain_info, + &chain_state, + None, + None, + &[], + ) + .expect_err("a non-genesis delegated snapshot must be authorized"); + assert!(error.to_string().contains("requires an external trust anchor")); + } + + /// Verify that a signed snapshot anchor binds both the configured trust root and canonical schedule. It exercises `validate_snapshot_trust_anchor` with a non-empty `ProtocolInfo` schedule. + #[test] + fn snapshot_anchor_binds_signature_and_configured_root() { + let mut chain_info = ChainInfo::default(); + chain_info.highest_block_checkpoint.checkpoint = 1; + chain_info.highest_superblock_checkpoint.checkpoint = 1; + let mut chain_state = ChainState::default(); + chain_state.delegation_state.activation_initialized = true; + let component = SnapshotComponent { + byte_len: 0, + sha256: [7; 32], + }; + let mut protocol_info = ProtocolInfo::default(); + protocol_info.register(0, ProtocolVersion::V1_7, 45); + protocol_info.register(10, ProtocolVersion::V2_0, 15); + protocol_info.register(20, ProtocolVersion::V2_1, 15); + let secret_key = Secp256k1_SecretKey::from_slice(&[1; 32]).unwrap(); + let secp256k1_public_key = + Secp256k1_PublicKey::from_secret_key_global(&secret_key); + let signer = PublicKey::from(secp256k1_public_key); + let payload = SnapshotTrustAnchorPayload { + network_magic: chain_info.consensus_constants.get_magic(), + genesis_hash: chain_info.consensus_constants.genesis_hash, + finalized_superblock_checkpoint: chain_info.highest_superblock_checkpoint, + chain_state_sha256: component.sha256, + protocol_schedule_sha256: calculate_sha256( + &protocol_info.canonical_schedule_bytes().unwrap(), + ) + .0, + producer_release: "witnet-rust-test".to_string(), + }; + let digest = calculate_sha256(&bincode::serialize(&payload).unwrap()).0; + let anchor = SnapshotTrustAnchor { + magic: SNAPSHOT_TRUST_ANCHOR_MAGIC, + format_version: SNAPSHOT_TRUST_ANCHOR_VERSION, + payload, + signer: signer.clone(), + signature: Signature::from(sign(secret_key, &digest).unwrap()), + }; + let configured_root = hex::encode(signer.to_bytes()); + + validate_snapshot_trust_anchor( + Some(&anchor), + &chain_info, + &chain_state, + Some(&component), + Some(&protocol_info), + &[configured_root], + ) + .expect("a correctly signed and bound anchor should be accepted"); + + let error = validate_snapshot_trust_anchor( + Some(&anchor), + &chain_info, + &chain_state, + Some(&component), + Some(&protocol_info), + &[hex::encode([2; 33])], + ) + .expect_err("an anchor signed by an untrusted root must be rejected"); + assert!(error + .to_string() + .contains("not in the configured trust roots")); + } + + #[test] + fn snapshot_anchor_sidecar_round_trip_uses_manifest_path_and_fails_closed() { + let secret_key = Secp256k1_SecretKey::from_slice(&[3; 32]).unwrap(); + let signer = PublicKey::from(Secp256k1_PublicKey::from_secret_key_global(&secret_key)); + let payload = SnapshotTrustAnchorPayload { + network_magic: 0, + genesis_hash: Hash::default(), + finalized_superblock_checkpoint: CheckpointBeacon::default(), + chain_state_sha256: [9; 32], + protocol_schedule_sha256: [8; 32], + producer_release: "witnet-rust-sidecar-test".to_string(), + }; + let digest = calculate_sha256(&bincode::serialize(&payload).unwrap()).0; + let anchor = SnapshotTrustAnchor { + magic: SNAPSHOT_TRUST_ANCHOR_MAGIC, + format_version: SNAPSHOT_TRUST_ANCHOR_VERSION, + payload, + signer, + signature: Signature::from(sign(secret_key, &digest).unwrap()), + }; + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let snapshot_path = std::env::temp_dir().join(format!( + "witnet-snapshot-trust-anchor-{}-{suffix}.bin", + std::process::id() + )); + let anchor_path = snapshot_path.with_extension("anchor"); + std::fs::write( + &anchor_path, + bincode::serialize(&anchor).expect("serialize snapshot anchor"), + ) + .expect("write snapshot anchor sidecar"); + + assert_eq!( + load_snapshot_trust_anchor(&snapshot_path) + .expect("valid sidecar must load") + .expect("sidecar must be discovered beside the manifest"), + anchor + ); + + let mut malformed = anchor.clone(); + malformed.magic = [0; 16]; + std::fs::write( + &anchor_path, + bincode::serialize(&malformed).expect("serialize malformed snapshot anchor"), + ) + .expect("replace snapshot anchor sidecar"); + assert!(matches!( + load_snapshot_trust_anchor(&snapshot_path), + Err(ImportError::SnapshotContents { .. }) + )); + + let _ = std::fs::remove_file(snapshot_path); + let _ = std::fs::remove_file(anchor_path); + } + + #[test] + fn historical_release_v5_payload_is_accepted_as_a_legacy_snapshot() { + // The archived release exported an unversioned ChainState component beside separate + // superblock and block-batch files. Exercise that actual legacy layout through the + // current snapshot loader, rather than only calling the migration helper directly. + let chain_state_bytes = hex::decode( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/historical/chainstate_v5_complete_from_release_33c42f0e.hex" + )) + .trim(), + ) + .expect("decode historical ChainState fixture"); + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let snapshot_path = std::env::temp_dir().join(format!( + "witnet-historical-release-legacy-snapshot-{}-{suffix}.bin", + std::process::id() + )); + let superblocks_path = file_name_compose( + snapshot_path.clone(), + Some("superblocks".to_string()), + ); + + std::fs::write(&snapshot_path, chain_state_bytes) + .expect("write historical legacy ChainState component"); + std::fs::write( + &superblocks_path, + bincode::serialize(&Vec::::new()) + .expect("serialize empty historical superblock component"), + ) + .expect("write historical legacy superblock component"); + + let import = legacy_chain_import(&snapshot_path) + .expect("current loader must recognize the historical snapshot layout"); + let chain_state = futures::executor::block_on(import.chain_state) + .expect("historical snapshot ChainState must migrate and validate"); + assert_eq!(chain_state.node_stats.block_proposed_count, 0x1122_3344); + assert_eq!(chain_state.node_stats.block_mined_count, 0x5566_7788); + assert_eq!(chain_state.node_stats.commits_count, 0x99aa_bbcc); + assert!(futures::executor::block_on(import.superblocks) + .expect("historical superblock component must deserialize") + .is_empty()); + assert!(import.blocks.expect("historical block batches must enumerate").is_empty()); + assert!(!import.reports_required); + assert!(import.protocol_info.is_none()); + + let _ = std::fs::remove_file(snapshot_path); + let _ = std::fs::remove_file(superblocks_path); + } + + #[test] + fn producer_generated_v2_0_27_testnet_snapshot_is_accepted_as_a_legacy_snapshot() { + // These bytes were exported by the archived v2.0.27 node itself after it created the + // testnet-2 genesis ChainState. Keep the producer-generated legacy layout checked in so + // the current loader is continuously tested against a real snapshot, not only a hand-built + // ChainState payload. + let chain_state_bytes = hex::decode(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/historical/chain_snapshot_testnet_0_from_release_33c42f0e.bin.hex" + )).trim()) + .expect("decode producer-generated historical ChainState snapshot"); + let superblocks_bytes = hex::decode(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/historical/chain_snapshot_testnet_0_superblocks_from_release_33c42f0e.bin.hex" + )).trim()) + .expect("decode producer-generated historical superblock snapshot"); + assert_eq!(chain_state_bytes.len(), 1_544); + assert_eq!(superblocks_bytes.len(), 8); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let snapshot_path = std::env::temp_dir().join(format!( + "witnet-producer-generated-legacy-snapshot-{}-{suffix}.bin", + std::process::id() + )); + let superblocks_path = file_name_compose( + snapshot_path.clone(), + Some("superblocks".to_string()), + ); + + std::fs::write(&snapshot_path, chain_state_bytes) + .expect("write producer-generated historical ChainState component"); + std::fs::write(&superblocks_path, superblocks_bytes) + .expect("write producer-generated historical superblock component"); + + let import = legacy_chain_import(&snapshot_path) + .expect("current loader must recognize the producer-generated legacy layout"); + let chain_state = futures::executor::block_on(import.chain_state) + .expect("producer-generated snapshot ChainState must migrate and validate"); + let chain_info = chain_state + .chain_info + .as_ref() + .expect("producer-generated snapshot must carry ChainInfo"); + assert_eq!(chain_info.environment, Environment::Testnet); + assert_eq!(chain_info.highest_block_checkpoint.checkpoint, 0); + assert_eq!(chain_info.highest_block_checkpoint.hash_prev_block.to_string(), + "666564676f6573627272727c2f3030312f3738392f3432382f6130312e676966"); + assert!(futures::executor::block_on(import.superblocks) + .expect("producer-generated superblock component must deserialize") + .is_empty()); + assert!(import.blocks.expect("producer-generated block batches must enumerate").is_empty()); + assert!(!import.reports_required); + assert!(import.protocol_info.is_none()); + + let _ = std::fs::remove_file(snapshot_path); + let _ = std::fs::remove_file(superblocks_path); + } + #[test] fn test_rep_info_update() { let mut rep_info = ReputationInfo::default(); @@ -3955,30 +9317,430 @@ mod tests { &mut NodeStats::default(), ); - assert_eq!( - rep_info.result_count[&pk1.pkh()], - RequestResult { - truths: 1, - lies: 0, - errors: 0, - } + assert_eq!( + rep_info.result_count[&pk1.pkh()], + RequestResult { + truths: 1, + lies: 0, + errors: 0, + } + ); + assert_eq!( + rep_info.result_count[&pk2.pkh()], + RequestResult { + truths: 0, + lies: 0, + errors: 1, + } + ); + assert_eq!( + rep_info.result_count[&pk3.pkh()], + RequestResult { + truths: 0, + lies: 1, + errors: 0, + } + ); + } + + #[test] + fn accepted_immediate_too_many_request_is_materialized_and_consumed_once() { + let epoch = 0; + let protocol = ProtocolVersion::from_epoch(epoch); + let mut dr_tx = DRTransaction::default(); + // Pool materialization needs the request creator even though this fixture exercises only + // accepted-block bookkeeping rather than transaction signature validation. + dr_tx.signatures.push(KeyedSignature::default()); + let dr_pointer = dr_tx.versioned_hash(protocol); + let tally_bytes = Vec::::try_from(RadonTypes::RadonError( + RadonError::try_from(TooManyWitnesses).unwrap(), + )) + .unwrap(); + let tally_tx = TallyTransaction::new( + dr_pointer, + tally_bytes, + vec![], + vec![], + vec![], + ); + let block = Block::new( + BlockHeader { + beacon: CheckpointBeacon { + checkpoint: epoch, + ..Default::default() + }, + ..Default::default() + }, + KeyedSignature::default(), + BlockTransactions { + data_request_txns: vec![dr_tx], + tally_txns: vec![tally_tx.clone()], + ..Default::default() + }, + ); + let block_hash = block.versioned_hash(protocol); + + let mut unspent_outputs_pool = UnspentOutputsPool::default(); + let mut data_request_pool = DataRequestPool::default(); + let mut transactions_pool = TransactionsPool::default(); + let mut own_utxos = OwnUnspentOutputsPool::default(); + let mut node_stats = NodeStats::default(); + update_pools( + &block, + &mut unspent_outputs_pool, + &mut data_request_pool, + &mut transactions_pool, + Diff::default(), + PublicKeyHash::default(), + &mut own_utxos, + &mut node_stats, + StateMachine::Synced, + protocol, + ) + .unwrap(); + + // The immediate request exists just long enough for process_tally to consume it. The + // ordinary request loop must skip it, otherwise it would remain active a second time. + assert!(data_request_pool.data_request_state(&dr_pointer).is_none()); + assert!( + data_request_pool + .get_dr_output_pointers_by_epoch(epoch) + .is_empty() + ); + assert!(!data_request_pool.waiting_for_reveal.contains_key(&dr_pointer)); + assert_eq!(data_request_pool.to_be_stored.len(), 1); + let stored = &data_request_pool.to_be_stored[0]; + assert_eq!(stored.tally.as_ref(), Some(&tally_tx)); + assert_eq!(stored.block_hash_dr_tx, Some(block_hash)); + assert_eq!(stored.block_hash_tally_tx, Some(block_hash)); + assert_eq!(stored.current_stage, None); + } + + #[test] + fn staged_data_request_progression_preserves_finished_report_drain_contract() { + for (state_machine, drains_finished_reports) in [ + (StateMachine::WaitingConsensus, true), + (StateMachine::Synchronizing, false), + (StateMachine::AlmostSynced, true), + (StateMachine::Synced, true), + ] { + let mut pending_pool = DataRequestPool::default(); + pending_pool.to_be_stored.push(DataRequestInfo::default()); + + let effects = stage_data_request_progression( + &mut pending_pool, + state_machine, + 1, + None, + 0, + ) + .unwrap(); + + assert_eq!( + effects.to_be_stored.len(), + if drains_finished_reports { 1 } else { 0 } + ); + assert!(effects.reveals.is_empty()); + assert_eq!( + pending_pool.to_be_stored.len(), + if drains_finished_reports { 0 } else { 1 } + ); + } + } + + #[test] + fn staged_data_request_progression_error_is_confined_to_private_pool() { + let mut live_pool = DataRequestPool::new(u16::MAX); + live_pool.to_be_stored.push(DataRequestInfo::default()); + let live_pool_before = live_pool.clone(); + let mut pending_pool = live_pool.clone(); + + let error = stage_data_request_progression( + &mut pending_pool, + StateMachine::Synced, + 1, + None, + 0, + ) + .unwrap_err(); + + assert!(error.to_string().contains("no representable terminal round")); + assert_eq!(live_pool, live_pool_before); + assert_eq!(pending_pool, live_pool_before); + } + + #[test] + fn staged_data_request_progression_rejects_finished_active_tally_before_publication() { + let pointer = Hash::default(); + let mut active_finished = DataRequestState::default(); + active_finished.set_stage(DataRequestStage::TALLY); + active_finished.info.tally = Some(TallyTransaction::default()); + let mut live_pool = DataRequestPool::default(); + live_pool + .data_request_pool + .insert(pointer, active_finished); + let live_pool_before = live_pool.clone(); + let mut pending_pool = live_pool.clone(); + + let error = stage_data_request_progression( + &mut pending_pool, + StateMachine::Synced, + 1, + None, + 0, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("Finished data request remained active") + ); + assert_eq!(live_pool, live_pool_before); + assert_eq!(pending_pool, live_pool_before); + } + + #[test] + fn staged_data_request_progression_does_not_drain_reports_on_state_error() { + let pointer = Hash::default(); + let mut active_finished = DataRequestState::default(); + active_finished.set_stage(DataRequestStage::TALLY); + active_finished.info.tally = Some(TallyTransaction::default()); + + let mut live_pool = DataRequestPool::default(); + live_pool.to_be_stored.push(DataRequestInfo::default()); + live_pool + .data_request_pool + .insert(pointer, active_finished); + let live_pool_before = live_pool.clone(); + let mut pending_pool = live_pool.clone(); + + let error = stage_data_request_progression( + &mut pending_pool, + StateMachine::Synced, + 1, + None, + 0, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("Finished data request remained active") + ); + assert_eq!(pending_pool, live_pool_before); + } + + #[test] + fn accepted_pool_duplicate_utxo_diff_returns_error_without_publication() { + let duplicate_removal = OutputPointer { + transaction_id: Hash::SHA256([0x7D; 32]), + output_index: 0, + }; + let mut unspent_outputs_pool = UnspentOutputsPool::default(); + unspent_outputs_pool.remove(&duplicate_removal); + let unspent_outputs_pool_before = unspent_outputs_pool.clone(); + let mut utxo_diff = UtxoDiff::new(&unspent_outputs_pool, 1); + utxo_diff.remove_utxo(duplicate_removal); + let utxo_diff = utxo_diff.take_diff(); + + let mut data_request_pool = DataRequestPool::default(); + let data_request_pool_before = data_request_pool.clone(); + let mut transactions_pool = TransactionsPool::default(); + let mut own_utxos = OwnUnspentOutputsPool::default(); + let own_utxos_before = own_utxos.clone(); + let mut node_stats = NodeStats::default(); + let node_stats_before = node_stats.clone(); + + let error = update_pools( + &Block::default(), + &mut unspent_outputs_pool, + &mut data_request_pool, + &mut transactions_pool, + utxo_diff, + PublicKeyHash::default(), + &mut own_utxos, + &mut node_stats, + StateMachine::Synced, + ProtocolVersion::V2_1, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("cannot stage accepted-block UTXO diff") + ); + assert_eq!(unspent_outputs_pool, unspent_outputs_pool_before); + assert_eq!(data_request_pool, data_request_pool_before); + assert!(transactions_pool.is_empty()); + assert_eq!(own_utxos, own_utxos_before); + assert_eq!(node_stats, node_stats_before); + } + + fn pool_replay_block_with_late_commit_error( + protocol: ProtocolVersion, + ) -> (Block, DRTransaction, PublicKeyHash) { + let epoch = 0; + let mut dr_tx = DRTransaction::default(); + dr_tx.signatures.push(KeyedSignature::default()); + let dr_pointer = dr_tx.versioned_hash(protocol); + + let mut valid_commit = CommitTransaction::default(); + valid_commit.body.dr_pointer = dr_pointer; + valid_commit.signatures.push(KeyedSignature::default()); + let own_pkh = valid_commit.body.proof.proof.pkh(); + let mut invalid_commit = CommitTransaction::default(); + invalid_commit.body.dr_pointer = Hash::SHA256([0xA5; 32]); + invalid_commit.signatures.push(KeyedSignature::default()); + + ( + Block::new( + BlockHeader { + beacon: CheckpointBeacon { + checkpoint: epoch, + ..Default::default() + }, + ..Default::default() + }, + KeyedSignature::default(), + BlockTransactions { + data_request_txns: vec![dr_tx.clone()], + commit_txns: vec![valid_commit, invalid_commit], + ..Default::default() + }, + ), + dr_tx, + own_pkh, + ) + } + + fn owned_output_diff( + unspent_outputs_pool: &UnspentOutputsPool, + own_pkh: PublicKeyHash, + ) -> (Diff, OutputPointer) { + let output_pointer = OutputPointer { + transaction_id: Hash::SHA256([0x5C; 32]), + output_index: 0, + }; + let mut utxo_diff = UtxoDiff::new(unspent_outputs_pool, 1); + utxo_diff.insert_utxo( + output_pointer, + ValueTransferOutput { + pkh: own_pkh, + value: 100, + time_lock: 0, + }, + None, + ); + + (utxo_diff.take_diff(), output_pointer) + } + + #[test] + fn wit21_pool_replay_error_does_not_publish_staged_mutations() { + let protocol = ProtocolVersion::V2_1; + let (block, dr_tx, own_pkh) = pool_replay_block_with_late_commit_error(protocol); + + let mut unspent_outputs_pool = UnspentOutputsPool::default(); + let (utxo_diff, added_output_pointer) = + owned_output_diff(&unspent_outputs_pool, own_pkh); + let mut data_request_pool = DataRequestPool::default(); + let mut transactions_pool = TransactionsPool::default(); + assert!( + transactions_pool + .insert_for_protocol(Transaction::DataRequest(dr_tx.clone()), 0, protocol) + .is_empty() ); - assert_eq!( - rep_info.result_count[&pk2.pkh()], - RequestResult { - truths: 0, - lies: 0, - errors: 1, - } + let pooled_dr_hash = dr_tx.hash(); + assert!(transactions_pool.dr_contains(&pooled_dr_hash)); + let mut own_utxos = OwnUnspentOutputsPool::default(); + own_utxos.insert(OutputPointer::default(), 7); + let mut node_stats = NodeStats { + commits_count: 11, + ..Default::default() + }; + + let data_request_pool_before = data_request_pool.clone(); + let own_utxos_before = own_utxos.clone(); + let node_stats_before = node_stats.clone(); + let transactions_dr_len_before = transactions_pool.dr_len(); + let unspent_outputs_pool_before = unspent_outputs_pool.clone(); + + let error = update_pools( + &block, + &mut unspent_outputs_pool, + &mut data_request_pool, + &mut transactions_pool, + utxo_diff, + own_pkh, + &mut own_utxos, + &mut node_stats, + StateMachine::Synced, + protocol, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("cannot apply validated Wit/2.1 commit") ); - assert_eq!( - rep_info.result_count[&pk3.pkh()], - RequestResult { - truths: 0, - lies: 1, - errors: 0, - } + assert_eq!(data_request_pool, data_request_pool_before); + assert_eq!(transactions_pool.dr_len(), transactions_dr_len_before); + assert!(transactions_pool.dr_contains(&pooled_dr_hash)); + assert_eq!(own_utxos, own_utxos_before); + assert_eq!(node_stats, node_stats_before); + assert_eq!(unspent_outputs_pool, unspent_outputs_pool_before); + assert!(!unspent_outputs_pool.contains_key(&added_output_pointer)); + assert!(!own_utxos.contains_key(&added_output_pointer)); + } + + #[test] + fn wit20_pool_replay_error_publishes_successful_prefix() { + let protocol = ProtocolVersion::V2_0; + let (block, dr_tx, own_pkh) = pool_replay_block_with_late_commit_error(protocol); + let dr_pointer = dr_tx.versioned_hash(protocol); + + let mut unspent_outputs_pool = UnspentOutputsPool::default(); + let (utxo_diff, added_output_pointer) = + owned_output_diff(&unspent_outputs_pool, own_pkh); + let mut data_request_pool = DataRequestPool::default(); + let mut transactions_pool = TransactionsPool::default(); + assert!( + transactions_pool + .insert_for_protocol(Transaction::DataRequest(dr_tx.clone()), 0, protocol) + .is_empty() ); + let pooled_dr_hash = dr_tx.hash(); + let mut own_utxos = OwnUnspentOutputsPool::default(); + let mut node_stats = NodeStats { + commits_count: 11, + ..Default::default() + }; + + update_pools( + &block, + &mut unspent_outputs_pool, + &mut data_request_pool, + &mut transactions_pool, + utxo_diff, + own_pkh, + &mut own_utxos, + &mut node_stats, + StateMachine::Synced, + protocol, + ) + .unwrap(); + + let dr_state = data_request_pool.data_request_state(&dr_pointer).unwrap(); + assert_eq!(dr_state.info.commits.len(), 1); + assert_eq!(transactions_pool.dr_len(), 0); + assert!(!transactions_pool.dr_contains(&pooled_dr_hash)); + assert!(unspent_outputs_pool.contains_key(&added_output_pointer)); + assert!(own_utxos.contains_key(&added_output_pointer)); + assert_eq!(node_stats.commits_count, 12); } #[test] @@ -4525,6 +10287,459 @@ mod tests { Transaction::ValueTransfer(vtt) } + #[test] + fn stale_generic_admission_rollback_cannot_erase_same_body_retry() { + let transaction = Transaction::ValueTransfer(VTTransaction::default()); + let transaction_hash = transaction.hash(); + let mut chain_manager = ChainManager::default(); + + let stale_token = chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + chain_manager.seen_transactions.insert(transaction.clone()); + + // Model an epoch/recovery invalidation followed by an exact same-body retry before the + // first signature verifier returns. + chain_manager + .transactions_pool + .clear_pending_transaction_admissions(); + chain_manager.seen_transactions.clear(); + let current_token = chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + chain_manager.seen_transactions.insert(transaction.clone()); + + chain_manager.rollback_pending_transaction_admission( + &transaction, + stale_token, + None, + ); + assert!(chain_manager.seen_transactions.contains(&transaction)); + assert!( + chain_manager + .transactions_pool + .owns_pending_transaction_admission(¤t_token) + ); + + chain_manager.rollback_pending_transaction_admission( + &transaction, + current_token, + None, + ); + assert!(!chain_manager.seen_transactions.contains(&transaction)); + assert!( + !chain_manager + .transactions_pool + .owns_pending_transaction_admission(¤t_token) + ); + } + + #[test] + fn retention_confirmation_classifies_and_matches_only_policy_sensitive_incoming_bodies() { + let commit = Transaction::Commit(CommitTransaction::default()); + let stake = Transaction::Stake(StakeTransaction::default()); + let unstake = Transaction::Unstake(UnstakeTransaction::default()); + let value_transfer = Transaction::ValueTransfer(VTTransaction::default()); + let data_request = Transaction::DataRequest(DRTransaction::default()); + let reveal = Transaction::Reveal(RevealTransaction::default()); + + assert!(requires_retention_confirmation( + &commit, + ProtocolVersion::V2_1, + )); + assert!(!requires_retention_confirmation( + &commit, + ProtocolVersion::V2_0, + )); + for protocol_version in [ProtocolVersion::V2_0, ProtocolVersion::V2_1] { + assert!(requires_retention_confirmation(&stake, protocol_version)); + assert!(requires_retention_confirmation(&unstake, protocol_version)); + assert!(!requires_retention_confirmation( + &value_transfer, + protocol_version, + )); + assert!(!requires_retention_confirmation( + &data_request, + protocol_version, + )); + assert!(!requires_retention_confirmation(&reveal, protocol_version)); + } + + let incoming_hash = unstake.hash(); + let removed = [value_transfer.clone(), unstake.clone()]; + assert_eq!( + rejected_incoming_transaction(true, incoming_hash, &removed, ProtocolVersion::V2_1), + Some(&unstake), + ); + assert_eq!( + rejected_incoming_transaction( + true, + incoming_hash, + &[value_transfer.clone()], + ProtocolVersion::V2_1, + ), + None, + ); + assert_eq!( + rejected_incoming_transaction( + false, + value_transfer.hash(), + &[value_transfer], + ProtocolVersion::V2_1, + ), + None, + ); + + // A terms-bearing Stake body has distinct legacy and native identities. The rejection + // helper must compare the same pinned protocol projection as admission, rather than the + // raw/native body hash, or a pre-activation eviction could be misclassified as a retained + // policy-sensitive transaction. + let validator = PublicKeyHash::default(); + let mut versioned_stake = StakeTransaction::default(); + versioned_stake.body.output = StakeOutput { + key: StakeKey { + validator, + withdrawer: validator, + }, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + validator, + validator, + 10, + 0, + )), + ..Default::default() + }; + let versioned_transaction = Transaction::Stake(versioned_stake.clone()); + let legacy_hash = versioned_transaction.versioned_hash(ProtocolVersion::V2_0); + assert_ne!(legacy_hash, versioned_transaction.hash()); + assert!(rejected_incoming_transaction( + true, + legacy_hash, + &[versioned_transaction], + ProtocolVersion::V2_0, + ) + .is_some()); + } + + #[test] + fn self_evicted_wit21_commit_rolls_back_seen_and_pending_admission() { + let mut commit = CommitTransaction::default(); + commit.signatures.push(KeyedSignature::default()); + let transaction = Transaction::Commit(commit); + let transaction_hash = transaction.versioned_hash(ProtocolVersion::V2_1); + let mut chain_manager = ChainManager::default(); + chain_manager + .transactions_pool + .set_wit21_commit_candidates_per_dr_limit(0); + let reservation = chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + chain_manager.seen_transactions.insert(transaction.clone()); + + let removed = chain_manager.transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ); + assert_eq!(removed, vec![transaction.clone()]); + let rejected = rejected_incoming_transaction( + requires_retention_confirmation(&transaction, ProtocolVersion::V2_1), + transaction_hash, + &removed, + ProtocolVersion::V2_1, + ) + .unwrap(); + chain_manager.rollback_pending_transaction_admission( + rejected, + reservation, + None, + ); + + assert!(!chain_manager.seen_transactions.contains(&transaction)); + assert!( + !chain_manager + .transactions_pool + .owns_pending_transaction_admission(&reservation) + ); + assert!( + !chain_manager + .transactions_pool + .contains_for_protocol(&transaction, ProtocolVersion::V2_1) + .unwrap() + ); + assert!( + chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .is_some() + ); + } + + #[test] + fn native_consolidation_requires_explicit_owner_registration() { + use witnet_data_structures::chain::StakeAuthorization; + + let validator = pkh(&PRIV_KEY_1); + let owner = pkh(&PRIV_KEY_2); + let delegate = PublicKeyHash::from_bytes(&[73; 20]).unwrap(); + let transaction_for_terms = |terms: StakeAuthorization| { + let secret_key = Secp256k1_SecretKey::from_slice(&PRIV_KEY_1).unwrap(); + let public_key = PublicKey::from(Secp256k1_PublicKey::from_secret_key_global(&secret_key)); + let authorization = KeyedSignature { + signature: Signature::from(sign(secret_key, &terms.signing_digest().unwrap()).unwrap()), + public_key, + }; + StakeTransaction::new(StakeTransactionBody::new( + vec![Input::default()], + StakeOutput { + authorization, + key: StakeKey::from((validator, terms.withdrawer)), + authorization_terms: Some(terms), + value: DELEGATED_STAKING_MINIMUM_NANOWITS, + }, + None, + ), vec![]) + }; + let mut stakes = StakesTracker::default(); + let mut delegation_state = DelegationState::default(); + let implicit_owner = transaction_for_terms(StakeAuthorization::delegation( + 0, validator, owner, Epoch::MAX, 15, + )); + let error = process_delegated_stake_transactions( + &mut stakes, &mut delegation_state, [&implicit_owner].into_iter(), 1, + DELEGATED_STAKING_MINIMUM_NANOWITS, EpochConstants::default(), 0, + ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native delegated-staking mutation requires the activation marker" + )); + assert!(!delegation_state.activation_initialized); + assert!(delegation_state.validators.is_empty()); + assert!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap().is_empty()); + + delegation_state.activation_initialized = true; + let error = process_delegated_stake_transactions( + &mut stakes, &mut delegation_state, [&implicit_owner].into_iter(), 1, + DELEGATED_STAKING_MINIMUM_NANOWITS, EpochConstants::default(), 0, + ProtocolVersion::V2_1, + ).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::ValidatorRegistrationRequired { validator: rejected }) + if *rejected == validator + )); + assert!(delegation_state.validators.is_empty()); + assert!(stakes.query_stakes_primary_checked(QueryStakesKey::All).unwrap().is_empty()); + + let registration = transaction_for_terms(StakeAuthorization::register_owner( + 0, validator, owner, Epoch::MAX, 15, + )); + let delegation = transaction_for_terms(StakeAuthorization::delegation( + 0, validator, delegate, Epoch::MAX, 15, + )); + process_delegated_stake_transactions( + &mut stakes, &mut delegation_state, [®istration, &delegation].into_iter(), 1, + DELEGATED_STAKING_MINIMUM_NANOWITS, EpochConstants::default(), 0, + ProtocolVersion::V2_1, + ).unwrap(); + assert_eq!(delegation_state.validators[&validator].owner_withdrawer, owner); + assert_eq!(stakes.query_stakes_primary_checked(QueryStakesKey::Validator(validator)).unwrap().len(), 2); + + let replacement = transaction_for_terms(StakeAuthorization::register_owner( + 0, validator, delegate, Epoch::MAX, 0, + )); + let error = process_delegated_stake_transactions( + &mut stakes, &mut delegation_state, [&replacement].into_iter(), 2, + DELEGATED_STAKING_MINIMUM_NANOWITS, EpochConstants::default(), 0, + ProtocolVersion::V2_1, + ).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::ValidatorAlreadyRegistered { validator: rejected }) + if *rejected == validator + )); + assert_eq!(delegation_state.validators[&validator].owner_withdrawer, owner); + assert_eq!(delegation_state.validators[&validator].commission_percent, 15); + } + + #[test] + fn self_rejected_commission_update_rolls_back_body_and_semantic_admission() { + let validator = pkh(&PRIV_KEY_1); + let withdrawer = pkh(&PRIV_KEY_2); + let transaction = Transaction::Stake(StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::default()], + StakeOutput { + value: 0, + key: StakeKey { + validator, + withdrawer, + }, + authorization_terms: Some( + witnet_data_structures::chain::StakeAuthorization::commission_update( + 0, + validator, + withdrawer, + Epoch::MAX, + 1, + 1, + ), + ), + ..Default::default() + }, + None, + ), + vec![], + )); + let transaction_hash = transaction.versioned_hash(ProtocolVersion::V2_1); + let mut chain_manager = ChainManager::default(); + chain_manager.transactions_pool.set_minimum_st_fee(1); + let delegated = chain_manager + .transactions_pool + .reserve_pending_delegated_action_for_protocol( + &transaction, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + let admission = chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + chain_manager.seen_transactions.insert(transaction.clone()); + + let removed = chain_manager.transactions_pool.insert_for_protocol( + transaction.clone(), + 0, + ProtocolVersion::V2_1, + ); + assert_eq!(removed, vec![transaction.clone()]); + let rejected = rejected_incoming_transaction( + requires_retention_confirmation(&transaction, ProtocolVersion::V2_1), + transaction_hash, + &removed, + ProtocolVersion::V2_1, + ) + .unwrap(); + chain_manager.rollback_pending_transaction_admission( + rejected, + admission, + Some(delegated), + ); + + assert!(!chain_manager.seen_transactions.contains(&transaction)); + assert!(!chain_manager + .transactions_pool + .owns_pending_transaction_admission(&admission)); + assert!(!chain_manager + .transactions_pool + .owns_pending_delegated_action(&delegated)); + assert!(!chain_manager + .transactions_pool + .contains_for_protocol(&transaction, ProtocolVersion::V2_1) + .unwrap()); + assert!(chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .is_some()); + assert!(chain_manager + .transactions_pool + .reserve_pending_delegated_action_for_protocol( + &transaction, + ProtocolVersion::V2_1, + ) + .unwrap() + .is_some()); + } + + #[test] + fn self_evicted_unstake_rolls_back_body_and_semantic_admission() { + let validator = pkh(&PRIV_KEY_1); + let withdrawer = pkh(&PRIV_KEY_2); + let transaction = Transaction::Unstake(UnstakeTransaction::new( + UnstakeTransactionBody::new( + validator, + ValueTransferOutput { + pkh: withdrawer, + value: 1, + time_lock: 0, + }, + 0, + 1, + ), + KeyedSignature::default(), + )); + let transaction_hash = transaction.versioned_hash(ProtocolVersion::V2_1); + let mut chain_manager = ChainManager::default(); + assert!(chain_manager + .transactions_pool + .set_total_weight_limit(0, 1.0) + .is_empty()); + let delegated = chain_manager + .transactions_pool + .reserve_pending_delegated_action_for_protocol( + &transaction, + ProtocolVersion::V2_1, + ) + .unwrap() + .unwrap(); + let admission = chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .unwrap(); + chain_manager.seen_transactions.insert(transaction.clone()); + + let removed = chain_manager.transactions_pool.insert_for_protocol( + transaction.clone(), + 1, + ProtocolVersion::V2_1, + ); + assert_eq!(removed, vec![transaction.clone()]); + let rejected = rejected_incoming_transaction( + requires_retention_confirmation(&transaction, ProtocolVersion::V2_1), + transaction_hash, + &removed, + ProtocolVersion::V2_1, + ) + .unwrap(); + chain_manager.rollback_pending_transaction_admission( + rejected, + admission, + Some(delegated), + ); + + assert!(!chain_manager.seen_transactions.contains(&transaction)); + assert!(!chain_manager + .transactions_pool + .owns_pending_transaction_admission(&admission)); + assert!(!chain_manager + .transactions_pool + .owns_pending_delegated_action(&delegated)); + assert!(!chain_manager + .transactions_pool + .contains_for_protocol(&transaction, ProtocolVersion::V2_1) + .unwrap()); + assert!(chain_manager + .transactions_pool + .reserve_pending_transaction_admission(transaction_hash) + .is_some()); + assert!(chain_manager + .transactions_pool + .reserve_pending_delegated_action_for_protocol( + &transaction, + ProtocolVersion::V2_1, + ) + .unwrap() + .is_some()); + } + #[test] fn test_add_transaction_malleability() { let _ = env_logger::builder().is_test(true).try_init(); @@ -4643,7 +10858,7 @@ mod tests { } #[test] - fn test_stakes_tracker_snapshot() { + fn test_stakes_and_delegation_state_snapshot() { let mut stakes = StakesTracker::default(); let pkh_1 = pkh(&PRIV_KEY_1); @@ -4675,6 +10890,11 @@ mod tests { highest_vrf_output: CheckpointVRF::default(), }); chain_manager.chain_state.stakes = stakes; + chain_manager + .chain_state + .delegation_state + .register_validator_if_absent(pkh_1, pkh_1, 7) + .unwrap(); // Take a snapshot let mut snapshot = ChainStateSnapshot::default(); @@ -4692,6 +10912,28 @@ mod tests { Ok(20_000) ); + // Mutating the live delegation map after the snapshot must not mutate the rollback copy. + let pkh_2 = pkh(&PRIV_KEY_2); + chain_manager + .chain_state + .delegation_state + .register_validator_if_absent(pkh_2, pkh_2, 3) + .unwrap(); + let snapshotted_delegation_state = &snapshot + .previous_chain_state + .as_ref() + .unwrap() + .0 + .delegation_state; + assert_eq!( + snapshotted_delegation_state + .validator_policy(pkh_1) + .unwrap() + .commission_percent, + 7 + ); + assert!(!snapshotted_delegation_state.validators.contains_key(&pkh_2)); + // Reset capability age of staker chain_manager .chain_state @@ -4721,6 +10963,71 @@ mod tests { ); } + #[test] + fn chain_state_snapshot_checked_lifecycle_is_retryable() { + let mut snapshot = ChainStateSnapshot { + superblock_period: 10, + ..Default::default() + }; + let mut chain_manager = ChainManager::default(); + chain_manager.chain_state.chain_info = Some(ChainInfo { + environment: Environment::default(), + consensus_constants: consensus_constants_from_partial( + &PartialConsensusConstants::default(), + &Testnet, + ), + highest_block_checkpoint: CheckpointBeacon::default(), + highest_superblock_checkpoint: CheckpointBeacon::default(), + highest_vrf_output: CheckpointVRF::default(), + }); + let state = chain_manager.chain_state.clone(); + + assert!(snapshot.try_take(1, &state).unwrap()); + assert!(!snapshot.try_take(1, &state).unwrap()); + assert_eq!(snapshot.try_restore(1).unwrap(), Some(state.clone())); + + // Acknowledging a different superblock must fail without consuming the retained copy. + assert!(snapshot.try_mark_persisted(2).is_err()); + assert_eq!(snapshot.try_restore(1).unwrap(), Some(state.clone())); + + assert!(snapshot.try_mark_persisted(1).is_ok()); + assert!(snapshot.try_restore(1).unwrap().is_none()); + assert_eq!(snapshot.highest_persisted_superblock, 1); + } + + #[test] + fn chain_state_snapshot_checked_take_rejects_mismatch_and_overflow() { + let mut snapshot = ChainStateSnapshot { + superblock_period: 10, + ..Default::default() + }; + let mut chain_manager = ChainManager::default(); + chain_manager.chain_state.chain_info = Some(ChainInfo { + environment: Environment::default(), + consensus_constants: consensus_constants_from_partial( + &PartialConsensusConstants::default(), + &Testnet, + ), + highest_block_checkpoint: CheckpointBeacon::default(), + highest_superblock_checkpoint: CheckpointBeacon::default(), + highest_vrf_output: CheckpointVRF::default(), + }); + let state = chain_manager.chain_state.clone(); + snapshot.try_take(1, &state).unwrap(); + + let mut different_state = state.clone(); + different_state.delegation_state.activation_initialized = true; + assert!(snapshot.try_take(1, &different_state).is_err()); + assert_eq!(snapshot.try_restore(1).unwrap(), Some(state.clone())); + + let mut overflowing = ChainStateSnapshot { + superblock_period: u16::MAX, + ..Default::default() + }; + assert!(overflowing.try_take(u32::MAX, &state).is_err()); + assert!(overflowing.previous_chain_state.is_none()); + } + static LAST_VRF_INPUT: &str = "4da71b67e7e50ae4ad06a71e505244f8b490da55fc58c50386c908f7146d2239"; @@ -4764,6 +11071,7 @@ mod tests { 0, // max_dr_weight beacon, block_proof, + vrf_input, &tally_transactions, PublicKeyHash::default(), EpochConstants::default(), @@ -4779,8 +11087,12 @@ mod tests { &active_wips, Some(stakes.validator_count()), stakes, + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); // Create a KeyedSignature let Hash::SHA256(data) = block_header.versioned_hash(get_protocol_version(Some(0))); @@ -4804,6 +11116,8 @@ mod tests { #[test] fn test_stake_changes() { + let _protocol_runtime_guard = crate::utils::test_protocol_runtime_guard(); + witnet_data_structures::clear_protocol_info(); register_protocol_version(ProtocolVersion::V2_0, 80, 15); let pkh_1 = pkh(&PRIV_KEY_1); @@ -4813,6 +11127,7 @@ mod tests { // Create stakes tracker let mut stakes = StakesTracker::default(); + let mut delegation_state = DelegationState::default(); let mut validators = vec![]; for pkh in [pkh_1, pkh_2, pkh_3, pkh_4] { validators.push(StakeKey { @@ -4879,9 +11194,13 @@ mod tests { &ConsensusConstantsWit2::default(), &data_request_pool, &mut stakes, + &mut delegation_state, EpochConstants::default(), + 1_000_000_000, + 0, &UtxoDiff::new(&unspent_outputs_pool, epoch), - ); + ) + .unwrap(); // The first staker receives a 50 WIT block reward, his 1 WIT collateral and a 1_000 WIT data request reward let new_coins = stakes @@ -4924,9 +11243,13 @@ mod tests { &ConsensusConstantsWit2::default(), &data_request_pool, &mut stakes, + &mut delegation_state, EpochConstants::default(), + 1_000_000_000, + 0, &UtxoDiff::new(&unspent_outputs_pool, epoch), - ); + ) + .unwrap(); // The first staker receives a 50 WIT block reward and his 1 WIT collateral back let new_coins = stakes @@ -4969,9 +11292,13 @@ mod tests { &ConsensusConstantsWit2::default(), &data_request_pool, &mut stakes, + &mut delegation_state, EpochConstants::default(), + 1_000_000_000, + 0, &UtxoDiff::new(&unspent_outputs_pool, epoch), - ); + ) + .unwrap(); // The first staker receives a 50 WIT block reward, his 1 WIT collateral and a reward of 1_000 WIT let new_coins = stakes @@ -5023,9 +11350,13 @@ mod tests { &ConsensusConstantsWit2::default(), &data_request_pool, &mut stakes, + &mut delegation_state, EpochConstants::default(), + 1_000_000_000, + 0, &UtxoDiff::new(&unspent_outputs_pool, epoch), - ); + ) + .unwrap(); // The first staker receives a 50 WIT block reward, his 1 WIT collateral and a reward of 1_000 WIT let new_coins = stakes @@ -5077,9 +11408,13 @@ mod tests { &ConsensusConstantsWit2::default(), &data_request_pool, &mut stakes, + &mut delegation_state, EpochConstants::default(), + 1_000_000_000, + 0, &UtxoDiff::new(&unspent_outputs_pool, epoch), - ); + ) + .unwrap(); // The first staker receives a 50 WIT block reward, his 1 WIT collateral and a reward of 1_000 WIT let new_coins = stakes @@ -5121,6 +11456,8 @@ mod tests { #[test] fn test_unstake_unique_nonce() { + let _protocol_runtime_guard = crate::utils::test_protocol_runtime_guard(); + witnet_data_structures::clear_protocol_info(); register_protocol_version(ProtocolVersion::V2_0, 100, 15); // Create inputs diff --git a/node/src/actors/epoch_manager/handlers.rs b/node/src/actors/epoch_manager/handlers.rs index bd9bb295fb..fc92d83fbe 100644 --- a/node/src/actors/epoch_manager/handlers.rs +++ b/node/src/actors/epoch_manager/handlers.rs @@ -1,12 +1,15 @@ -use actix::{Context, Handler}; +use actix::{Context, Handler, System}; use witnet_data_structures::chain::Epoch; +use witnet_data_structures::get_protocol_runtime; use super::EpochManager; use crate::actors::{ epoch_manager::{EpochConstants, EpochManagerError}, messages::{ - EpochResult, GetEpoch, GetEpochConstants, SetEpochConstants, SubscribeAll, SubscribeEpoch, + EpochResult, GetEpoch, GetEpochConstants, GetProtocolEpochSnapshot, ProtocolEpochSnapshot, + SetEpochConstants, SubscribeAll, SubscribeAllWithProtocolEpochSnapshot, SubscribeEpoch, + SubscribedProtocolEpochSnapshot, }, }; use witnet_util::timestamp::get_timestamp; @@ -61,6 +64,41 @@ impl Handler for EpochManager { } } +impl Handler for EpochManager { + type Result = Result; + + /// Capture the paired runtime and install the subscription in one actor turn. EpochManager + /// cannot run a checkpoint callback between these operations, so an epoch cannot be missed + /// between initialization and notification delivery. + fn handle( + &mut self, + msg: SubscribeAllWithProtocolEpochSnapshot, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => self + .constants + .ok_or(EpochManagerError::UnknownEpochConstants)?, + None => return Err(EpochManagerError::UnknownEpochConstants), + }; + let current_epoch = epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); + + log::trace!("New subscription to every checkpoint with an atomic runtime snapshot"); + self.subscriptions_all.push(msg.notification); + + Ok(SubscribedProtocolEpochSnapshot { + current_epoch, + epoch_constants, + protocol_info, + protocol_generation, + }) + } +} + impl Handler for EpochManager { type Result = Option; @@ -68,32 +106,111 @@ impl Handler for EpochManager { /// checkpoint (the start of an epoch). This assumes that the /// checkpoint_zero_timestamp and checkpoints_period constants never change fn handle(&mut self, _msg: GetEpochConstants, _ctx: &mut Self::Context) -> Self::Result { - self.constants + self.effective_epoch_constants() + } +} + +impl Handler for EpochManager { + type Result = Result; + + fn handle( + &mut self, + _msg: GetProtocolEpochSnapshot, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => self + .constants + .ok_or(EpochManagerError::UnknownEpochConstants)?, + None => return Err(EpochManagerError::UnknownEpochConstants), + }; + let current_epoch = epoch_constants.epoch_at(get_timestamp())?; + + Ok(ProtocolEpochSnapshot { + current_epoch, + epoch_constants, + protocol_info, + protocol_generation, + }) } } impl Handler for EpochManager { type Result = (); - fn handle(&mut self, msg: SetEpochConstants, _ctx: &mut Context) -> Self::Result { + fn handle(&mut self, msg: SetEpochConstants, ctx: &mut Context) -> Self::Result { + let (protocol_info, published, _) = get_protocol_runtime(); + let next_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => msg.epoch_constants, + None => { + log::error!( + "Epoch manager refuses to install actor constants for an unpaired protocol schedule" + ); + System::current().stop_with_code(1); + return; + } + }; // Check if the epoch calculated with the current version of the epoch constants // and the last_checked_epoch are different and if they are, subtract that difference // from the new last_checked_epoch. let current_time = get_timestamp(); - let epoch_before_update = msg - .epoch_constants + let previous_constants = self.constants.unwrap_or(next_constants); + let epoch_before_update = match previous_constants + .epoch_at(current_time) + .map_err(EpochManagerError::from) + { + Ok(epoch) => Some(epoch), + Err(EpochManagerError::CheckpointZeroInTheFuture(_)) => None, + Err(error) => { + log::error!( + "Epoch manager could not evaluate the preceding epoch constants at the current time: {error:?}" + ); + System::current().stop_with_code(1); + return; + } + }; + + let epoch_after_update = match next_constants .epoch_at(current_time) - .unwrap_or_default(); - let epoch_diff = - epoch_before_update.saturating_sub(self.last_checked_epoch.unwrap_or_default()); - - self.constants = Some(msg.epoch_constants); - - self.last_checked_epoch = Some( - msg.epoch_constants - .epoch_at(current_time) - .unwrap_or_default() - .saturating_sub(epoch_diff), - ); + .map_err(EpochManagerError::from) + { + Ok(epoch) => Some(epoch), + Err(EpochManagerError::CheckpointZeroInTheFuture(_)) => None, + Err(error) => { + log::error!( + "Epoch manager could not apply updated epoch constants at the current time: {error:?}" + ); + System::current().stop_with_code(1); + return; + } + }; + + let next_last_checked_epoch = match (epoch_before_update, epoch_after_update) { + (Some(epoch_before_update), Some(epoch_after_update)) => { + let epoch_diff = match self.last_checked_epoch { + Some(last_checked_epoch) => { + epoch_before_update.saturating_sub(last_checked_epoch) + } + None => epoch_before_update, + }; + Some(epoch_after_update.saturating_sub(epoch_diff)) + } + // Crossing checkpoint zero in either direction has no meaningful arithmetic epoch + // offset. Let the replacement monitor establish the first live checkpoint instead. + _ => None, + }; + + self.constants = Some(next_constants); + self.last_checked_epoch = next_last_checked_epoch; + + // Invalidate the callback scheduled under the preceding clock and immediately run the + // sole live monitor chain from the atomically published replacement clock. In particular, + // a future-zero to live transition must announce its current epoch instead of sleeping + // until E+1. A callback already queued with the old token is a no-op, and the monitor's + // equality guard suppresses same-epoch replacement replays. + self.replace_checkpoint_monitor(ctx, next_constants, true); } } diff --git a/node/src/actors/epoch_manager/mod.rs b/node/src/actors/epoch_manager/mod.rs index 62912f5001..dde66ded2c 100644 --- a/node/src/actors/epoch_manager/mod.rs +++ b/node/src/actors/epoch_manager/mod.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, time::Duration}; +use std::{ + collections::BTreeMap, + ops::Bound::{Excluded, Included}, + time::Duration, +}; use actix::prelude::*; use ansi_term::Color::Purple; @@ -7,7 +11,7 @@ use rand::Rng; use witnet_data_structures::{ chain::{Epoch, EpochConstants}, error::EpochCalculationError, - get_protocol_version, get_protocol_version_activation_epoch, get_protocol_version_period, + get_protocol_runtime, proto::versioning::ProtocolVersion, }; use witnet_util::timestamp::{ @@ -48,6 +52,33 @@ impl From for EpochManagerError { } } +/// Relationship between a derived epoch notification and a consumer's last accepted epoch. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum EpochNotificationDisposition { + /// Initializes or strictly advances the accepted epoch. + Advance, + /// Repeats the already accepted epoch. + Replay, + /// Moves to a lower epoch and must be handled as a deliberate clock rollback. + ClockRebase, +} + +/// Classify an epoch while distinguishing equality replay from a deliberate clock rollback. +pub(crate) fn classify_epoch_notification( + previous_epoch: Option, + current_epoch: Epoch, +) -> EpochNotificationDisposition { + match previous_epoch { + Some(previous_epoch) if current_epoch == previous_epoch => { + EpochNotificationDisposition::Replay + } + Some(previous_epoch) if current_epoch < previous_epoch => { + EpochNotificationDisposition::ClockRebase + } + _ => EpochNotificationDisposition::Advance, + } +} + //////////////////////////////////////////////////////////////////////////////////////// // ACTOR BASIC STRUCTURE //////////////////////////////////////////////////////////////////////////////////////// @@ -65,6 +96,9 @@ pub struct EpochManager { /// Last epoch that was checked by the epoch monitor process last_checked_epoch: Option, + + /// Identity of the only checkpoint-monitor callback chain allowed to notify or re-arm itself. + checkpoint_monitor_token: u64, } impl Drop for EpochManager { @@ -82,6 +116,19 @@ impl SystemService for EpochManager {} /// Auxiliary methods for EpochManager actor impl EpochManager { + /// Prefer the clock published atomically with the process-wide protocol schedule. A local + /// fallback is only valid while the schedule itself is empty (library tests and the earliest + /// configuration bootstrap); a non-empty unpaired schedule must not be combined with a stale + /// actor clock. + fn effective_epoch_constants(&self) -> Option { + let (protocol_info, published, _) = get_protocol_runtime(); + match published { + Some(constants) => Some(constants), + None if protocol_info.all_versions.is_empty() => self.constants, + None => None, + } + } + /// Set the timestamp for the start of the epoch zero and the checkpoint /// period (epoch duration) pub fn set_checkpoint_zero_and_period( @@ -100,7 +147,7 @@ impl EpochManager { } /// Calculate the last checkpoint (current epoch) at the supplied timestamp pub fn epoch_at(&self, timestamp: i64) -> EpochResult { - match &self.constants { + match self.effective_epoch_constants() { Some(x) => Ok(x.epoch_at(timestamp)?), None => Err(EpochManagerError::UnknownEpochConstants), @@ -113,7 +160,7 @@ impl EpochManager { } /// Calculate the timestamp for a checkpoint (the start of an epoch) pub fn epoch_timestamp(&self, epoch: Epoch) -> EpochResult { - match &self.constants { + match self.effective_epoch_constants() { // Calculate (period * epoch + zero) with overflow checks Some(x) => { let (timestamp, _) = x.epoch_timestamp(epoch)?; @@ -127,36 +174,54 @@ impl EpochManager { config_mngr::get() .into_actor(self) .and_then(|config, act, ctx| { - let activation_epoch_wit2 = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - let checkpoint_zero_timestamp_wit2 = if activation_epoch_wit2 == u32::MAX { - i64::MAX - } else { - config.consensus_constants.checkpoint_zero_timestamp - + i64::from(get_protocol_version_activation_epoch(ProtocolVersion::V2_0)) - * i64::from(config.consensus_constants.checkpoints_period) + let (protocol_info, published_epoch_constants, _) = get_protocol_runtime(); + let activation_epoch_wit2 = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoints_period_wit2 = activation_epoch_wit2 + .and_then(|_| { + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + }) + .unwrap_or(u16::MAX); + let epoch_constants = match match published_epoch_constants { + Some(epoch_constants) => Ok(epoch_constants), + None if protocol_info.all_versions.is_empty() => EpochConstants::try_new( + config.consensus_constants.checkpoint_zero_timestamp, + config.consensus_constants.checkpoints_period, + activation_epoch_wit2, + checkpoints_period_wit2, + ), + None => { + log::error!( + "Epoch manager refuses a published protocol schedule without its paired epoch clock" + ); + System::current().stop_with_code(1); + return fut::err(anyhow::anyhow!( + "published protocol schedule has no paired epoch clock" + )); + } + } { + Ok(epoch_constants) => epoch_constants, + Err(error) => { + log::error!( + "Epoch manager could not derive configured Wit/2 epoch constants: {error}" + ); + System::current().stop_with_code(1); + return fut::err(anyhow::Error::from(error)); + } }; - act.set_checkpoint_zero_and_period( - config.consensus_constants.checkpoint_zero_timestamp, - config.consensus_constants.checkpoints_period, - checkpoint_zero_timestamp_wit2, - get_protocol_version_period(ProtocolVersion::V2_0), - ); + act.constants = Some(epoch_constants); log::info!( "Checkpoint zero timestamp: {}, checkpoints period: {}", act.constants.as_ref().unwrap().checkpoint_zero_timestamp, act.constants.as_ref().unwrap().checkpoints_period, ); - // Start checkpoint monitoring process - let time_to_next_checkpoint = act - .time_to_next_checkpoint(act.current_epoch()) - .unwrap_or_else(|_| { - let retry_seconds = act.constants.as_ref().unwrap().checkpoints_period; - log::warn!("Failed to calculate time to next checkpoint"); - Duration::from_secs(u64::from(retry_seconds)) - }); - act.checkpoint_monitor(ctx, time_to_next_checkpoint); + // Start the single token-owned checkpoint monitoring chain. + act.replace_checkpoint_monitor(ctx, epoch_constants, false); // Start ntp update process if config.ntp.enabled { @@ -173,10 +238,10 @@ impl EpochManager { .map(|_res: Result<(), ()>, _act, _ctx| ()) .wait(ctx); } - /// Method to compute time remaining to next checkpoint. - /// If the next checkpoint is in the past, return 0 seconds. - fn time_to_next_checkpoint( - &self, + /// Compute the next wakeup using one already captured clock, without consulting mutable + /// process-global runtime state between epoch and timestamp calculations. + fn time_to_next_checkpoint_with_clock( + epoch_constants: EpochConstants, current_epoch_res: EpochResult, ) -> EpochResult { // Get current timestamp and epoch @@ -188,11 +253,10 @@ impl EpochManager { let current_epoch = current_epoch_res?; // Get timestamp for the start of next checkpoint - self.epoch_timestamp( - current_epoch - .checked_add(1) - .ok_or(EpochManagerError::Overflow)?, - )? + let next_epoch = current_epoch + .checked_add(1) + .ok_or(EpochManagerError::Overflow)?; + epoch_constants.epoch_timestamp(next_epoch)?.0 } }; @@ -202,76 +266,174 @@ impl EpochManager { .unwrap_or_else(|| Duration::from_secs(0)), ) } - /// Method to monitor checkpoints and execute some actions on each - fn checkpoint_monitor(&self, ctx: &mut Context, time_to_next_checkpoint: Duration) { + /// Calculate a deterministic retry delay when the exact next checkpoint cannot be derived. + fn checkpoint_monitor_delay( + epoch_constants: EpochConstants, + current_epoch: EpochResult, + ) -> Duration { + Self::time_to_next_checkpoint_with_clock(epoch_constants, current_epoch) + .unwrap_or_else(|_| { + log::warn!("Failed to calculate time to next checkpoint"); + Duration::from_secs(u64::from(epoch_constants.checkpoints_period)) + }) + } + + /// Transfer checkpoint-monitor ownership to a fresh, monotonically increasing token. + fn invalidate_checkpoint_monitor(&mut self) -> u64 { + self.checkpoint_monitor_token = self + .checkpoint_monitor_token + .checked_add(1) + .expect("checkpoint-monitor token exhausted"); + self.checkpoint_monitor_token + } + + /// Return whether a callback still owns the checkpoint-monitor chain. + fn owns_checkpoint_monitor(&self, monitor_token: u64) -> bool { + self.checkpoint_monitor_token == monitor_token + } + + /// Invalidate every previously scheduled monitor callback and immediately start a new chain + /// from the currently effective epoch clock. + fn replace_checkpoint_monitor( + &mut self, + ctx: &mut Context, + epoch_constants: EpochConstants, + wake_immediately: bool, + ) { + let monitor_token = self.invalidate_checkpoint_monitor(); + let current_epoch = epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); + let time_to_next_checkpoint = if wake_immediately { + Duration::from_secs(0) + } else { + Self::checkpoint_monitor_delay(epoch_constants, current_epoch) + }; + self.checkpoint_monitor(ctx, time_to_next_checkpoint, monitor_token); + } + + /// Method to monitor checkpoints and execute some actions on each. Recurring callbacks retain + /// the token of their chain; only `replace_checkpoint_monitor` can transfer ownership. + fn checkpoint_monitor( + &self, + ctx: &mut Context, + time_to_next_checkpoint: Duration, + monitor_token: u64, + ) { // Wait until next checkpoint to execute the periodic function log::debug!("Checkpoint monitor: time to next checkpoint: {time_to_next_checkpoint:?}"); ctx.run_later(time_to_next_checkpoint, move |act, ctx| { - let current_epoch = act.current_epoch(); + if !act.owns_checkpoint_monitor(monitor_token) { + log::debug!( + "Discarding stale checkpoint monitor token {monitor_token}; current token is {}", + act.checkpoint_monitor_token + ); + return; + } + + // A schedule/clock replacement is process-global and atomic. Refresh the local cache + // before this turn does any epoch work so late actor messages cannot resurrect the + // preceding clock. + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => { + let Some(constants) = act.constants else { + log::error!("Checkpoint monitor has no bootstrap epoch clock"); + System::current().stop_with_code(1); + return; + }; + constants + } + None => { + log::error!( + "Checkpoint monitor refuses a protocol schedule without its paired epoch clock" + ); + System::current().stop_with_code(1); + return; + } + }; + act.constants = Some(epoch_constants); + let current_epoch = epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); log::debug!( "Current epoch {:?}. Last checked epoch {:?}", current_epoch, act.last_checked_epoch ); if let Ok(current_epoch) = current_epoch { - // Update epoch constants for wit/2 - if get_protocol_version(Some(current_epoch)) == ProtocolVersion::V1_8 { - if let Some(constants) = &mut act.constants { - if constants.checkpoint_zero_timestamp_wit2 == i64::MAX { - let checkpoints_period_wit2 = - get_protocol_version_period(ProtocolVersion::V2_0); - let activation_epoch_wit2 = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - if checkpoints_period_wit2 != u16::MAX - && activation_epoch_wit2 != Epoch::MAX - { - match constants.set_values_for_wit2( - checkpoints_period_wit2, - activation_epoch_wit2, - ) { - Ok(_) => (), - Err(_) => panic!("Could not set wit/2 checkpoint variables"), - }; - } - } + // Wit/2 clock changes are already present in `published`; rebuilding the local + // clock through separate schedule reads here would recreate the cross-generation + // mismatch this snapshot is intended to prevent. + let epoch_timestamp = match epoch_constants.epoch_timestamp(current_epoch) { + Ok((timestamp, _)) => timestamp, + Err(error) => { + log::error!( + "Checkpoint monitor could not derive epoch {current_epoch} timestamp: {error:?}" + ); + System::current().stop_with_code(1); + return; + } + }; + let last_checked_epoch = act.last_checked_epoch; + let notification_disposition = classify_epoch_notification( + last_checked_epoch, + current_epoch, + ); + + // Preserve `None` versus `Some(0)`: the first epoch-zero callback is live, while + // an equal callback is a replay. A lower callback is processed deliberately as a + // clock rollback instead of being mistaken for equality. + if notification_disposition == EpochNotificationDisposition::Replay { + if get_protocol_runtime().2 != protocol_generation { + act.checkpoint_monitor(ctx, Duration::from_secs(0), monitor_token); } else { - panic!("Could not set wit/2 checkpoint variables"); + let time_to_next_checkpoint = Self::checkpoint_monitor_delay( + epoch_constants, + Ok(last_checked_epoch.expect("replay requires an epoch lower bound")), + ); + act.checkpoint_monitor(ctx, time_to_next_checkpoint, monitor_token); } + return; } - let epoch_timestamp = act.epoch_timestamp(current_epoch).unwrap_or(0); - let last_checked_epoch = act.last_checked_epoch.unwrap_or(0); - - // Sometimes the checkpoint monitor wakes up just before the next epoch, and - // current_epoch == last_checked_epoch - // In that case we want to retry as soon as possible - if current_epoch <= last_checked_epoch && last_checked_epoch != 0 { - // Reschedule checkpoint monitor process - let time_to_next_checkpoint = act - .time_to_next_checkpoint(Ok(last_checked_epoch)) - .unwrap_or_else(|_| { - let retry_seconds = act.constants.as_ref().unwrap().checkpoints_period; - log::warn!("Failed to calculate time to next checkpoint"); - Duration::from_secs(u64::from(retry_seconds)) - }); - act.checkpoint_monitor(ctx, time_to_next_checkpoint); + // A paired runtime replacement can occur while this actor turn is computing. Do + // not enqueue a notification labelled with a generation that is no longer live; + // reschedule immediately under the replacement clock instead. + if get_protocol_runtime().2 != protocol_generation { + act.checkpoint_monitor(ctx, Duration::from_secs(0), monitor_token); return; } // Send message to actors which subscribed to all epochs for subscription in &mut act.subscriptions_all { - // Only send new epoch notification - subscription.send_notification(current_epoch, epoch_timestamp); + // Send a strict advance or a deliberate clock-rollback notification. + subscription.send_notification( + current_epoch, + epoch_timestamp, + protocol_generation, + ); } // Get all the checkpoints that had some subscription but were skipped for some // reason (process sent to background, checkpoint monitor process had no // resources to execute in time...) - let epoch_checkpoints: Vec<_> = act - .subscriptions_epoch - .range(last_checked_epoch..=current_epoch) - .map(|(k, _v)| *k) - .collect(); + let epoch_checkpoints: Vec<_> = match ( + last_checked_epoch, + notification_disposition, + ) { + (_, EpochNotificationDisposition::ClockRebase) | (None, _) => act + .subscriptions_epoch + .range(..=current_epoch) + .map(|(checkpoint, _)| *checkpoint) + .collect(), + (Some(last_checked_epoch), _) => act + .subscriptions_epoch + .range((Excluded(last_checked_epoch), Included(current_epoch))) + .map(|(checkpoint, _)| *checkpoint) + .collect(), + }; // Send notifications for skipped checkpoints for subscriptions to a particular // epoch @@ -284,7 +446,11 @@ impl EpochManager { for mut subscription in subscriptions { // TODO: should send messages or just drop? // TODO: send notifications also for subscriptions to all epochs? - subscription.send_notification(checkpoint, epoch_timestamp); + subscription.send_notification( + checkpoint, + epoch_timestamp, + protocol_generation, + ); } } } @@ -300,14 +466,13 @@ impl EpochManager { } // Reschedule checkpoint monitor process - let time_to_next_checkpoint = act - .time_to_next_checkpoint(current_epoch) - .unwrap_or_else(|_| { - let retry_seconds = act.constants.as_ref().unwrap().checkpoints_period; - log::warn!("Failed to calculate time to next checkpoint"); - Duration::from_secs(u64::from(retry_seconds)) - }); - act.checkpoint_monitor(ctx, time_to_next_checkpoint); + if get_protocol_runtime().2 != protocol_generation { + act.checkpoint_monitor(ctx, Duration::from_secs(0), monitor_token); + } else { + let time_to_next_checkpoint = + Self::checkpoint_monitor_delay(epoch_constants, current_epoch); + act.checkpoint_monitor(ctx, time_to_next_checkpoint, monitor_token); + } }); } @@ -335,10 +500,58 @@ impl EpochManager { } } +#[cfg(test)] +mod tests { + use super::{ + EpochManager, EpochNotificationDisposition, classify_epoch_notification, + }; + + #[test] + fn replacing_checkpoint_monitor_invalidates_the_preceding_callback_chain() { + let mut epoch_manager = EpochManager::default(); + let preceding = epoch_manager.invalidate_checkpoint_monitor(); + assert!(epoch_manager.owns_checkpoint_monitor(preceding)); + + let replacement = epoch_manager.invalidate_checkpoint_monitor(); + assert_ne!(preceding, replacement); + assert!(!epoch_manager.owns_checkpoint_monitor(preceding)); + assert!(epoch_manager.owns_checkpoint_monitor(replacement)); + } + + #[test] + fn epoch_notification_classification_preserves_epoch_zero_and_clock_rollback() { + assert_eq!( + classify_epoch_notification(None, 0), + EpochNotificationDisposition::Advance + ); + assert_eq!( + classify_epoch_notification(Some(0), 0), + EpochNotificationDisposition::Replay + ); + assert_eq!( + classify_epoch_notification(Some(10), 10), + EpochNotificationDisposition::Replay + ); + assert_eq!( + classify_epoch_notification(Some(10), 9), + EpochNotificationDisposition::ClockRebase + ); + assert_eq!( + classify_epoch_notification(Some(10), 11), + EpochNotificationDisposition::Advance + ); + } +} + /// Trait that must follow all notifications that will be sent back to subscriber actors pub trait SendableNotification: Send { /// Send notification back to the subscriber - fn send_notification(&mut self, current_epoch: Epoch, timestamp: i64); + fn send_notification( + &mut self, + current_epoch: Epoch, + timestamp: i64, + protocol_generation: u64, + ); } /// Notification for a particular epoch: instantiated by each actor that subscribes to a particular @@ -354,13 +567,14 @@ pub struct SingleEpochSubscription { /// Implementation of the SendableNotification trait for the SingleEpochSubscription impl SendableNotification for SingleEpochSubscription { /// Function to send notification back to the subscriber - fn send_notification(&mut self, epoch: Epoch, timestamp: i64) { + fn send_notification(&mut self, epoch: Epoch, timestamp: i64, protocol_generation: u64) { // Get the payload from the notification if let Some(payload) = self.payload.take() { // Build an EpochNotification message to send back to the subscriber let msg = EpochNotification { checkpoint: epoch, timestamp, + protocol_generation, payload, }; @@ -386,7 +600,7 @@ pub struct AllEpochSubscription { /// Implementation of the SendableNotification trait for the AllEpochSubscription impl SendableNotification for AllEpochSubscription { /// Function to send notification back to the subscriber - fn send_notification(&mut self, epoch: Epoch, timestamp: i64) { + fn send_notification(&mut self, epoch: Epoch, timestamp: i64, protocol_generation: u64) { // Clone the payload to be sent to the subscriber let payload = self.payload.clone(); @@ -394,6 +608,7 @@ impl SendableNotification for AllEpochSubscription { let msg = EpochNotification { checkpoint: epoch, timestamp, + protocol_generation, payload, }; diff --git a/node/src/actors/inventory_manager/handlers.rs b/node/src/actors/inventory_manager/handlers.rs index c2c1c86bfe..83f9d3b3b8 100644 --- a/node/src/actors/inventory_manager/handlers.rs +++ b/node/src/actors/inventory_manager/handlers.rs @@ -1,26 +1,35 @@ +use std::collections::HashMap; + use actix::{ActorFutureExt, Context, Handler, ResponseActFuture, WrapFuture, prelude::*}; +use bincode::serialize; use futures_util::FutureExt; use witnet_data_structures::{ + bincode_compatibility, chain::{ - Block, Epoch, Hash, Hashable, InventoryEntry, InventoryItem, PointerToBlock, SuperBlock, + Block, DataRequestInfo, Epoch, Hash, InventoryEntry, InventoryItem, PointerToBlock, + SuperBlock, }, - get_protocol_version, - proto::versioning::VersionedHashable, + get_protocol_info, get_protocol_version, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, transaction::Transaction, }; +use witnet_validations::validations::validate_merkle_tree_for_protocol; use crate::{ actors::messages::{ - AddItem, AddItems, GetItem, GetItemBlock, GetItemSuperblock, GetItemTransaction, - StoreInventoryItem, SuperBlockNotify, + AddItem, AddItems, AddSnapshotItems, GetItem, GetItemBlock, GetItemSuperblock, + GetItemTransaction, StoreInventoryItem, SuperBlockNotify, + }, + storage_mngr::{ + self, InventoryDomain, InventoryView, SerializedInventoryItem, SnapshotInventoryToken, }, - storage_mngr, }; use super::{InventoryManager, InventoryManagerError}; mod prefixes { pub static SUPERBLOCK: &str = "SUPERBLOCK-"; + pub static DATA_REQUEST_REPORT: &str = "DR-REPORT-"; } fn key_superblock(superblock_index: u32) -> Vec { @@ -29,30 +38,149 @@ fn key_superblock(superblock_index: u32) -> Vec { format!("{}{:010}", prefixes::SUPERBLOCK, superblock_index).into() } +fn key_data_request_report(dr_pointer: Hash) -> Vec { + format!("{}{}", prefixes::DATA_REQUEST_REPORT, dr_pointer).into_bytes() +} + +/// Decode a legacy bincode `String` key without allocating from an untrusted length prefix. +/// +/// Legacy inventory keys are encoded as an eight-byte little-endian sequence length followed by +/// UTF-8 bytes. Returning `None` lets the report scan ignore all other legacy domains while still +/// rejecting a report-shaped key whose length or payload is malformed. +fn legacy_report_logical_key(physical_key: &[u8]) -> Result>, anyhow::Error> { + let Some(length_bytes) = physical_key.get(..std::mem::size_of::()) else { + return Ok(None); + }; + let length = u64::from_le_bytes(length_bytes.try_into()?); + let key = physical_key + .get(std::mem::size_of::()..) + .ok_or_else(|| anyhow::anyhow!("legacy inventory key is truncated"))?; + if !key.starts_with(prefixes::DATA_REQUEST_REPORT.as_bytes()) { + return Ok(None); + } + let length = usize::try_from(length) + .map_err(|_| anyhow::anyhow!("legacy report key length does not fit usize"))?; + anyhow::ensure!( + key.len() == length, + "legacy data-request report key length does not match its payload" + ); + Ok(Some(key.to_vec())) +} + +enum InventoryWriteTarget { + Active, + Snapshot(SnapshotInventoryToken), +} + +/// Normalize all historical bincode layouts that can occur in block inventory values. +fn normalize_block_bytes(bytes: Vec) -> Vec { + // Prefer exact structural decoding for the current, pre-terms, and pre-staking layouts so the + // byte-offset heuristic below is never applied to a payload whose complete type is known. + if let Ok(normalized) = bincode_compatibility::normalize_block(&bytes) { + return normalized; + } + + // Retain the baseline fixed-offset repair as a last-resort compatibility path for historical + // inventory artifacts whose precise intermediate Rust type is unavailable. The structurally + // decoded pre-staking layout covers portable block batches, where nested variable-length blocks + // cannot be repaired by a single absolute offset. + if bytes.len() >= 264 && bytes[260..264] == [0x51, 0x00, 0x00, 0x00] { + let repaired = [&bytes[..260], &[0u8; 72], &bytes[260..], &[0u8; 16]].concat(); + bincode_compatibility::normalize_block(&repaired).unwrap_or_default() + } else { + Vec::new() + } +} + +/// Validate both locally available layers of inventory block identity. +/// +/// The protocol block hash commits to the header only. Transaction readers must additionally prove +/// that every protocol-visible transaction identity matches the protocol-aware Merkle roots carried +/// by that header. This is an integrity guard, not a replacement for cryptographic/state replay. +fn validate_inventory_block( + block: &Block, + expected_hash: Hash, +) -> Result<(), InventoryManagerError> { + let block_epoch = block.block_header.beacon.checkpoint; + let protocol_info = get_protocol_info(); + validate_inventory_block_for_protocol( + block, + expected_hash, + protocol_info.all_versions.version_for_epoch(block_epoch), + ) +} + +/// Validate inventory block identity using a caller-pinned protocol schedule. +fn validate_inventory_block_for_protocol( + block: &Block, + expected_hash: Hash, + protocol_version: ProtocolVersion, +) -> Result<(), InventoryManagerError> { + let stored_hash = block.versioned_hash(protocol_version); + if stored_hash != expected_hash { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "block inventory key {expected_hash} does not match stored block hash {stored_hash}" + ))); + } + + if !validate_merkle_tree_for_protocol(block, protocol_version) { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "block inventory value {expected_hash} has transaction bodies that do not match its protocol-valid header Merkle roots" + ))); + } + + Ok(()) +} + impl InventoryManager { fn handle_add_items( &mut self, - msg: AddItems, + items: Vec, + target: InventoryWriteTarget, + pinned_protocol_info: Option, ) -> ResponseActFuture> { let mut blocks_to_add = vec![]; + let mut reports_to_add = vec![]; let mut transactions_to_add = vec![]; let mut superblocks_to_add = vec![]; + let protocol_info = pinned_protocol_info.unwrap_or_else(get_protocol_info); - let total = msg.items.len(); - for (i, item) in msg.items.into_iter().enumerate() { + let total = items.len(); + for (i, item) in items.into_iter().enumerate() { log::trace!("Adding item {i} out of {total}"); match item { StoreInventoryItem::Block(block) => { - let block_hash = block.versioned_hash(get_protocol_version(Some( - block.block_header.beacon.checkpoint, - ))); + let block_epoch = block.block_header.beacon.checkpoint; + let protocol_version = protocol_info.all_versions.version_for_epoch(block_epoch); + let block_hash = block.versioned_hash(protocol_version); + // Reject a malformed block before publishing either its block record or any + // transaction pointers. The same caller-pinned protocol snapshot derives + // the key and validates the transaction Merkle roots, so a later read cannot + // discover an inventory value that this write path had already acknowledged. + if let Err(error) = validate_inventory_block_for_protocol( + &block, + block_hash, + protocol_version, + ) { + return Box::pin(actix::fut::err(error)); + } let key = match block_hash { Hash::SHA256(h) => h.to_vec(), }; // Store the block and all the transactions - let items_to_add = block.txns.create_pointers_to_transactions(block_hash); - blocks_to_add.push((key, block)); + let items_to_add = match block + .txns + .try_create_pointers_to_transactions(block_hash, protocol_version) + { + Ok(items) => items, + Err(error) => { + return Box::pin(actix::fut::err( + InventoryManagerError::MailBoxError(error), + )); + } + }; + blocks_to_add.push((key, block_hash, block)); transactions_to_add.extend(items_to_add.into_iter().map( |(tx_hash, pointer_to_block)| { let key = match tx_hash { @@ -63,6 +191,29 @@ impl InventoryManager { }, )); } + StoreInventoryItem::DataRequestReport(report) => { + let tally = match report.tally.as_ref() { + Some(tally) => tally, + None => { + return Box::pin(actix::fut::err( + InventoryManagerError::MailBoxError(anyhow::anyhow!( + "cannot persist an incomplete data-request report without a tally" + )), + )); + } + }; + if report.current_stage.is_some() + || report.block_hash_dr_tx.is_none() + || report.block_hash_tally_tx.is_none() + { + return Box::pin(actix::fut::err( + InventoryManagerError::MailBoxError(anyhow::anyhow!( + "cannot persist a data-request report without a finished stage and both containing block hashes" + )), + )); + } + reports_to_add.push((key_data_request_report(tally.dr_pointer), report)); + } StoreInventoryItem::Transaction(hash, pointer_to_block) => { let key = match hash { Hash::SHA256(h) => h.to_vec(), @@ -78,45 +229,140 @@ impl InventoryManager { } } + // Online report publication is part of the same inventory transaction as the block that + // contains its tally. Prove both directions before serializing anything: each report names + // one exact tally in this request, report pointers are unique, and—when this is a combined + // block/report request—every included tally has its matching report. Snapshot block-only + // batches intentionally remain valid because their report components are staged later. + let report_consistency = (|| -> Result<(), anyhow::Error> { + let mut blocks_by_hash = HashMap::with_capacity(blocks_to_add.len()); + for (_, block_hash, block) in &blocks_to_add { + anyhow::ensure!( + blocks_by_hash.insert(*block_hash, block.as_ref()).is_none(), + "inventory batch contains duplicate block hash {block_hash}" + ); + } + + let mut reports_by_pointer = HashMap::with_capacity(reports_to_add.len()); + for (_, report) in &reports_to_add { + let tally = report + .tally + .as_ref() + .ok_or_else(|| anyhow::anyhow!("data-request report lost its tally"))?; + anyhow::ensure!( + reports_by_pointer.insert(tally.dr_pointer, report.as_ref()).is_none(), + "inventory batch contains duplicate data-request report {}", + tally.dr_pointer + ); + let tally_block_hash = report.block_hash_tally_tx.ok_or_else(|| { + anyhow::anyhow!("data-request report lost its tally block hash") + })?; + let block = blocks_by_hash.get(&tally_block_hash).ok_or_else(|| { + anyhow::anyhow!( + "data-request report {} does not name a tally block in the same inventory batch", + tally.dr_pointer + ) + })?; + let matching_tallies = block + .txns + .tally_txns + .iter() + .filter(|candidate| candidate.dr_pointer == tally.dr_pointer) + .collect::>(); + anyhow::ensure!( + matching_tallies.len() == 1 && matching_tallies[0] == tally, + "data-request report {} does not contain the exact unique tally recorded by its block", + tally.dr_pointer + ); + } + + if !reports_to_add.is_empty() { + for (_, block_hash, block) in &blocks_to_add { + for tally in &block.txns.tally_txns { + let report = reports_by_pointer.get(&tally.dr_pointer).ok_or_else(|| { + anyhow::anyhow!( + "inventory block {block_hash} contains tally {} without a report in the same batch", + tally.dr_pointer + ) + })?; + anyhow::ensure!( + report.block_hash_tally_tx == Some(*block_hash) + && report.tally.as_ref() == Some(tally), + "inventory block {block_hash} and report disagree on tally {}", + tally.dr_pointer + ); + } + } + } + Ok(()) + })(); + if let Err(error) = report_consistency { + return Box::pin(actix::fut::err(InventoryManagerError::MailBoxError(error))); + } + let block_len = blocks_to_add.len(); + let report_len = reports_to_add.len(); let tx_len = transactions_to_add.len(); let superblock_len = superblocks_to_add.len(); log::trace!("Persisting {block_len} blocks to storage"); - // Store all the blocks, and then store all the transactions + // Serialize every heterogeneous inventory entry before touching storage, then publish the + // complete set through one backend write batch. A block must never become durable without + // all of its transaction pointers, and a mixed request must have one acknowledgement. + let serialized_items = (|| -> Result, bincode::Error> { + let mut items = + Vec::with_capacity(block_len + report_len + tx_len + superblock_len); + for (key, _, block) in blocks_to_add { + items.push((InventoryDomain::Block, key, serialize(&block)?)); + } + for (key, report) in reports_to_add { + items.push((InventoryDomain::Report, key, serialize(report.as_ref())?)); + } + for (key, pointer) in transactions_to_add { + items.push((InventoryDomain::Transaction, key, serialize(&pointer)?)); + } + for (key, superblock) in superblocks_to_add { + items.push((InventoryDomain::Superblock, key, serialize(&superblock)?)); + } + Ok(items) + })(); + let serialized_items = match serialized_items { + Ok(items) => items, + Err(error) => { + return Box::pin(actix::fut::err(InventoryManagerError::MailBoxError( + error.into(), + ))); + } + }; + + let persist = async move { + match target { + InventoryWriteTarget::Active => { + let view = storage_mngr::get_inventory_view().await?; + storage_mngr::put_inventory_serialized_batch(view, serialized_items).await + } + InventoryWriteTarget::Snapshot(token) => { + storage_mngr::put_snapshot_inventory_serialized_batch(&token, serialized_items) + .await + } + } + }; + Box::pin( - storage_mngr::put_batch(&blocks_to_add) + persist .into_actor(self) .map_err(|e, _, _| { - log::error!("Error when writing blocks to storage: {e}"); + log::error!("Error when writing inventory batch to storage: {e}"); InventoryManagerError::MailBoxError(e) }) - .and_then(move |(), act, _| { + .and_then(move |(), _, _| { log::trace!("Successfully persisted {block_len} blocks to storage"); - log::trace!("Persisting {tx_len} transactions to storage"); - - storage_mngr::put_batch(&transactions_to_add) - .into_actor(act) - .map_err(|e, _, _| { - log::error!("Error when writing transactions to storage: {e}"); - - InventoryManagerError::MailBoxError(e) - }) - }) - .and_then(move |(), act, _| { + log::trace!( + "Successfully persisted reports for {report_len} data requests to storage" + ); log::trace!("Successfully persisted {tx_len} transactions to storage"); - - storage_mngr::put_batch(&superblocks_to_add) - .into_actor(act) - .map_err(|e, _, _| { - log::error!("Error when writing superblocks to storage: {e}"); - - InventoryManagerError::MailBoxError(e) - }) - }) - .and_then(move |(), _, _| { log::trace!("Successfully persisted {superblock_len} superblocks to storage"); actix::fut::ok(()) @@ -128,34 +374,53 @@ impl InventoryManager { &mut self, msg: GetItemBlock, ) -> ResponseActFuture> { - let key = match msg.hash { - Hash::SHA256(x) => x.to_vec(), - }; - - // This closure fills in the missing bytes for backwards-compatibility of blocks encoded - // with V1_X using bincode. In particular, it checks whether the serialized bytes contain - // staking/unstaking merkle roots - let fix = |bytes: Vec| { - if bytes.len() >= 263 && bytes[260..264] == [0x51, 0x00, 0x00, 0x00] { - [&bytes[..260], &[0u8; 72], &bytes[260..], &[0u8; 16]].concat() - } else { - bytes + Box::pin( + async move { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + // Keep block identity validation on the same schedule snapshot as the caller + // when one was supplied. Legacy callers still snapshot after acquiring the view. + let protocol_info = msg.protocol_info.unwrap_or_else(get_protocol_info); + Self::get_item_block_in_view_for_protocol(view, msg.hash, &protocol_info).await } + .into_actor(self), + ) + } + + async fn get_item_block_in_view( + view: InventoryView, + hash: Hash, + ) -> Result { + let protocol_info = get_protocol_info(); + Self::get_item_block_in_view_for_protocol(view, hash, &protocol_info).await + } + + async fn get_item_block_in_view_for_protocol( + view: InventoryView, + hash: Hash, + protocol_info: &ProtocolInfo, + ) -> Result { + let key = match hash { + Hash::SHA256(x) => x.to_vec(), }; - // Uses `mapped_get` to alter the bytes before deserialization. Please note that this is - // not a proper migration, but rather a workaround that has a little performance penalty - let fut = storage_mngr::mapped_get::<_, Block, _>(&key, fix) - .into_actor(self) - .then(move |res, _, _| match res { - Ok(opt) => match opt { - None => fut::err(InventoryManagerError::ItemNotFound), - Some(block) => fut::ok(block), - }, - Err(e) => fut::err(InventoryManagerError::MailBoxError(e)), - }); - - Box::pin(fut) + // This remains a read-time compatibility normalization rather than an on-disk migration. + let block = storage_mngr::mapped_get_inventory::( + view, + InventoryDomain::Block, + key, + normalize_block_bytes, + ) + .await + .map_err(InventoryManagerError::MailBoxError)? + .ok_or(InventoryManagerError::ItemNotFound)?; + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + validate_inventory_block_for_protocol(&block, hash, protocol_version)?; + + Ok(block) } fn handle_get_item_transaction( @@ -163,149 +428,338 @@ impl InventoryManager { msg: GetItemTransaction, ) -> ResponseActFuture> { - let key = match msg.hash { + Box::pin( + async move { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + // Keep the block-pointer validation on the same schedule snapshot as the caller + // when one was supplied. Legacy callers still snapshot after acquiring the view. + let protocol_info = msg.protocol_info.unwrap_or_else(get_protocol_info); + Self::get_item_transaction_in_view_for_protocol(view, msg.hash, &protocol_info).await + } + .into_actor(self), + ) + } + + async fn get_item_transaction_in_view( + view: InventoryView, + hash: Hash, + ) -> Result<(Transaction, PointerToBlock, Epoch), InventoryManagerError> { + let protocol_info = get_protocol_info(); + Self::get_item_transaction_in_view_for_protocol(view, hash, &protocol_info).await + } + + async fn get_item_transaction_in_view_for_protocol( + view: InventoryView, + hash: Hash, + protocol_info: &ProtocolInfo, + ) -> Result<(Transaction, PointerToBlock, Epoch), InventoryManagerError> { + let key = match hash { Hash::SHA256(x) => x.to_vec(), }; + let pointer_to_block = storage_mngr::get_inventory::( + view, + InventoryDomain::Transaction, + key, + ) + .await + .map_err(|error| { + log::error!("Couldn't get transaction pointer from storage: {error}"); + InventoryManagerError::MailBoxError(error) + })? + .ok_or(InventoryManagerError::ItemNotFound)?; + + let block = match Self::get_item_block_in_view_for_protocol( + view, + pointer_to_block.block_hash, + protocol_info, + ) + .await + { + Ok(block) => block, + Err(InventoryManagerError::ItemNotFound) => { + return Err(InventoryManagerError::NoPointedBlock(pointer_to_block)); + } + Err(error) => { + log::error!("Couldn't get pointed block from storage: {error}"); + return Err(error); + } + }; - let fut = storage_mngr::get::<_, PointerToBlock>(&key) - .into_actor(self) - .then(move |res, act, _ctx| match res { - Ok(Some(pointer_to_block)) => { - // Recursion - let fut = act - .handle_get_item_block(GetItemBlock { - hash: pointer_to_block.block_hash, - }) - .then(move |res, _, _| { - match res { - Ok(block) => { - // Read transaction from block - let tx = block.txns.get(pointer_to_block.transaction_index); - match tx { - Some(tx) if tx.hash() == msg.hash => { - let block_epoch = block.block_header.beacon.checkpoint; - fut::ok((tx, pointer_to_block, block_epoch)) - } - Some(_tx) => { - // The transaction hash does not match - fut::err( - InventoryManagerError::NoTransactionInPointedBlock( - pointer_to_block, - ), - ) - } - None => fut::err( - InventoryManagerError::NoTransactionInPointedBlock( - pointer_to_block, - ), - ), - } - } - Err(InventoryManagerError::ItemNotFound) => fut::err( - InventoryManagerError::NoPointedBlock(pointer_to_block), - ), - Err(e) => { - log::error!("Couldn't get item from storage: {e}"); - fut::err(e) - } - } - }); - - Box::pin(fut) - } - Ok(None) => { - let fut: ResponseActFuture< - Self, - Result<(Transaction, PointerToBlock, Epoch), InventoryManagerError>, - > = Box::pin(fut::err(InventoryManagerError::ItemNotFound)); - fut - } - Err(e) => { - log::error!("Couldn't get item from storage: {e}"); - let fut: ResponseActFuture< - Self, - Result<(Transaction, PointerToBlock, Epoch), InventoryManagerError>, - > = Box::pin(fut::err(InventoryManagerError::MailBoxError(e))); - fut - } - }); + let Some(transaction) = block.txns.get(pointer_to_block.transaction_index) else { + return Err(InventoryManagerError::NoTransactionInPointedBlock( + pointer_to_block, + )); + }; + let block_epoch = block.block_header.beacon.checkpoint; + let protocol_version = protocol_info.all_versions.version_for_epoch(block_epoch); + if transaction.versioned_hash(protocol_version) != hash { + return Err(InventoryManagerError::NoTransactionInPointedBlock( + pointer_to_block, + )); + } - Box::pin(fut) + Ok((transaction, pointer_to_block, block_epoch)) } fn handle_get_item_superblock( &mut self, msg: GetItemSuperblock, ) -> ResponseActFuture> { - let key = key_superblock(msg.superblock_index); - - let fut = storage_mngr::get::<_, SuperBlockNotify>(&key) - .into_actor(self) - .then(move |res, _, _| match res { - Ok(opt) => match opt { - None => fut::err(InventoryManagerError::ItemNotFound), - Some(superblock) => fut::ok(superblock), - }, - Err(e) => { - log::error!("Couldn't get item from storage: {e}"); - - fut::err(InventoryManagerError::MailBoxError(e)) + Box::pin( + async move { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + let requested_index = msg.superblock_index; + let key = key_superblock(requested_index); + let notification = storage_mngr::get_inventory::( + view, + InventoryDomain::Superblock, + key, + ) + .await + .map_err(InventoryManagerError::MailBoxError)? + .ok_or(InventoryManagerError::ItemNotFound)?; + if notification.superblock.index != requested_index { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "superblock inventory key {requested_index} contains index {}", + notification.superblock.index + ))); } - }); - - Box::pin(fut) + Ok(notification) + } + .into_actor(self), + ) } /// Fetch all superblocks from storage. pub async fn get_all_superblocks() -> Result, InventoryManagerError> { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + Self::get_all_superblocks_in_view(view).await + } + + /// Fetch all superblocks from one caller-pinned inventory view. + pub async fn get_all_superblocks_in_view( + view: InventoryView, + ) -> Result, InventoryManagerError> { let backend = storage_mngr::get_backend() .await - .unwrap_or_else(|err| { - panic!("Failed to get storage backend: {err}"); - }) + .map_err(InventoryManagerError::MailBoxError)? .as_arc_dyn_storage(); - // This is a little hack to derive the actual prefix, which contains some leading bincode - // bytes that encode the bytes length of the key - let mut prefix = bincode::serialize(&format!("{}0000000000", prefixes::SUPERBLOCK)) - .expect("prefix serialization error"); - prefix.truncate(prefix.len() - 10); + let total_logical_key_len = key_superblock(0).len(); + let prefix = storage_mngr::inventory_physical_prefix( + view, + InventoryDomain::Superblock, + prefixes::SUPERBLOCK.as_bytes(), + total_logical_key_len, + ) + .map_err(InventoryManagerError::MailBoxError)?; - let all_superblocks = backend + let entries = backend .prefix_iterator(&prefix) - .expect("prefix iterator error") - .map(|(_k, v)| bincode::deserialize(&v).unwrap()) - .collect::>(); + .map_err(InventoryManagerError::MailBoxError)?; + let mut all_superblocks = Vec::new(); + for entry in entries { + let (physical_key, value) = entry.map_err(InventoryManagerError::MailBoxError)?; + // Stored values are full notifications. Decoding only the leading `SuperBlock` with + // permissive bincode would silently ignore the consolidated-block hashes suffix. + let notification: SuperBlockNotify = bincode_compatibility::deserialize_exact(&value) + .map_err(|error| InventoryManagerError::MailBoxError(error.into()))?; + let logical_key = key_superblock(notification.superblock.index); + let expected_physical_key = storage_mngr::inventory_physical_key( + view, + InventoryDomain::Superblock, + &logical_key, + ) + .map_err(InventoryManagerError::MailBoxError)?; + if physical_key != expected_physical_key { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "superblock inventory key does not match stored index {}", + notification.superblock.index + ))); + } + all_superblocks.push(notification.superblock); + } + // Storage backends do not share an iterator-order guarantee (the in-memory backend is a + // HashMap), while portable snapshots require a deterministic increasing sequence. + all_superblocks.sort_unstable_by_key(|superblock| superblock.index); + if all_superblocks + .windows(2) + .any(|pair| pair[0].index == pair[1].index) + { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "duplicate superblock index in inventory" + ))); + } Ok(all_superblocks) } + /// Fetch all completed data-request reports from one pinned inventory view. The raw serialized + /// values are returned so portable snapshots can preserve exact bytes instead of reserializing + /// HashMaps in an order that is not deterministic. + pub async fn get_all_data_request_reports( + ) -> Result, Vec)>, InventoryManagerError> { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + Self::get_all_data_request_reports_in_view(view).await + } + + /// Fetch all completed data-request reports from one caller-pinned inventory view. + pub async fn get_all_data_request_reports_in_view( + view: InventoryView, + ) -> Result, Vec)>, InventoryManagerError> { + let backend = storage_mngr::get_backend() + .await + .map_err(InventoryManagerError::MailBoxError)? + .as_arc_dyn_storage(); + let prefix = storage_mngr::inventory_domain_prefix(view, InventoryDomain::Report); + + let entries = backend + .prefix_iterator(&prefix) + .map_err(InventoryManagerError::MailBoxError)?; + let mut reports = Vec::new(); + for entry in entries { + let (physical_key, value) = entry.map_err(InventoryManagerError::MailBoxError)?; + // Legacy inventory has one undifferentiated key space, so filter its historical + // String representation before decoding any value. Generation namespaces are already + // isolated by the domain prefix and must account for every matching physical key. + let legacy_logical_key = if matches!(view, InventoryView::Legacy) { + match legacy_report_logical_key(&physical_key) + .map_err(InventoryManagerError::MailBoxError)? + { + Some(key) => Some(key), + None => continue, + } + } else { + None + }; + let report: DataRequestInfo = bincode_compatibility::deserialize_exact(&value) + .map_err(|error| InventoryManagerError::MailBoxError(error.into()))?; + let dr_pointer = report + .tally + .as_ref() + .ok_or_else(|| { + InventoryManagerError::MailBoxError(anyhow::anyhow!( + "data-request report inventory value has no tally" + )) + })? + .dr_pointer; + let derived_logical_key = key_data_request_report(dr_pointer); + let logical_key = legacy_logical_key.unwrap_or_else(|| derived_logical_key.clone()); + if logical_key != derived_logical_key { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "data-request report inventory key does not match its tally pointer" + ))); + } + let expected_physical_key = storage_mngr::inventory_physical_key( + view, + InventoryDomain::Report, + &logical_key, + ) + .map_err(InventoryManagerError::MailBoxError)?; + if physical_key != expected_physical_key { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "data-request report inventory key does not match its tally pointer" + ))); + } + reports.push((logical_key, value)); + } + + reports.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + if reports + .windows(2) + .any(|pair| pair[0].0 == pair[1].0) + { + return Err(InventoryManagerError::MailBoxError(anyhow::anyhow!( + "duplicate data-request report key in inventory" + ))); + } + Ok(reports) + } + /// Fetch multiple blocks at once. pub async fn get_multiple_blocks(hashes: I) -> Result, InventoryManagerError> where I: IntoIterator, { + let view = storage_mngr::get_inventory_view() + .await + .map_err(InventoryManagerError::MailBoxError)?; + // Read the protocol schedule only after the active inventory generation is pinned. This + // keeps ordinary multi-block reads from pairing a new view with an obsolete schedule. + let protocol_info = get_protocol_info(); + Self::get_multiple_blocks_in_view_for_protocol(view, hashes, &protocol_info).await + } + + /// Fetch multiple blocks from one caller-pinned inventory view. + /// + /// Recovery code must use this form so the blocks that identify reverted report keys and the + /// compare-and-delete operation are bound to the same inventory generation. + pub async fn get_multiple_blocks_in_view( + view: InventoryView, + hashes: I, + ) -> Result, InventoryManagerError> + where + I: IntoIterator, + { + let protocol_info = get_protocol_info(); + Self::get_multiple_blocks_in_view_for_protocol(view, hashes, &protocol_info).await + } + + /// Fetch and validate multiple blocks from a caller-pinned inventory view and protocol + /// schedule. Snapshot import/export callers use this to keep historical hashes independent of + /// the process-global schedule that may be published concurrently. + pub async fn get_multiple_blocks_in_view_for_protocol( + view: InventoryView, + hashes: I, + protocol_info: &ProtocolInfo, + ) -> Result, InventoryManagerError> + where + I: IntoIterator, + { + let hashes: Vec<_> = hashes.into_iter().collect(); let futs = hashes.into_iter().map(|hash| { let key = match hash { Hash::SHA256(x) => x.to_vec(), }; - storage_mngr::get(&key).map(move |response| (hash, response)) + storage_mngr::mapped_get_inventory::( + view, + InventoryDomain::Block, + key, + normalize_block_bytes, + ) + .map(move |response| (hash, response)) }); - let blocks = futures::future::join_all(futs) - .await - .into_iter() - .filter_map(|(hash, response)| { - if let Ok(Some(block)) = response { - Some(block) - } else { - log::error!("No block found with hash {hash}. Error: {response:?}"); - - None + let responses = futures::future::join_all(futs).await; + let mut blocks = Vec::with_capacity(responses.len()); + for (hash, response) in responses { + match response { + Ok(Some(block)) => { + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + validate_inventory_block_for_protocol(&block, hash, protocol_version)?; + blocks.push(block); } - }) - .collect(); + Ok(None) => { + log::error!("No block found with hash {hash}"); + return Err(InventoryManagerError::ItemNotFound); + } + Err(error) => { + log::error!("Failed to decode block with hash {hash}: {error}"); + return Err(InventoryManagerError::MailBoxError(error)); + } + } + } Ok(blocks) } @@ -320,10 +774,8 @@ impl Handler for InventoryManager { type Result = ResponseActFuture>; fn handle(&mut self, msg: AddItem, _ctx: &mut Context) -> Self::Result { - // Simply calls AddItems with 1 item - self.handle_add_items(AddItems { - items: vec![msg.item], - }) + // Use the same active-generation batch path as AddItems. + self.handle_add_items(vec![msg.item], InventoryWriteTarget::Active, None) } } @@ -332,7 +784,24 @@ impl Handler for InventoryManager { type Result = ResponseActFuture>; fn handle(&mut self, msg: AddItems, _ctx: &mut Context) -> Self::Result { - self.handle_add_items(msg) + self.handle_add_items( + msg.items, + InventoryWriteTarget::Active, + msg.protocol_info, + ) + } +} + +/// Handler for snapshot-scoped inventory writes. +impl Handler for InventoryManager { + type Result = ResponseActFuture>; + + fn handle(&mut self, msg: AddSnapshotItems, _ctx: &mut Context) -> Self::Result { + self.handle_add_items( + msg.items, + InventoryWriteTarget::Snapshot(msg.token), + msg.protocol_info, + ) } } @@ -343,11 +812,17 @@ impl Handler for InventoryManager { fn handle(&mut self, msg: GetItem, _ctx: &mut Context) -> Self::Result { let fut: Self::Result = match msg.item { InventoryEntry::Tx(hash) => Box::pin( - self.handle_get_item_transaction(GetItemTransaction { hash }) + self.handle_get_item_transaction(GetItemTransaction { + hash, + protocol_info: None, + }) .map_ok(|(tx, _pointer_to_block, _epoch), _, _| InventoryItem::Transaction(tx)), ), InventoryEntry::Block(hash) => Box::pin( - self.handle_get_item_block(GetItemBlock { hash }) + self.handle_get_item_block(GetItemBlock { + hash, + protocol_info: None, + }) .map_ok(|block, _, _| InventoryItem::Block(block)), ), InventoryEntry::SuperBlock(superblock_index) => Box::pin( @@ -403,11 +878,13 @@ mod tests { use witnet_config::config::{Config, StorageBackend}; use witnet_data_structures::{ chain::{ - CheckpointBeacon, ConsensusConstantsWit2, EpochConstants, Input, KeyedSignature, - OutputPointer, PublicKeyHash, TransactionsPool, ValueTransferOutput, tapi::ActiveWips, + BlockMerkleRoots, CheckpointBeacon, CheckpointVRF, ConsensusConstantsWit2, + EpochConstants, Input, KeyedSignature, OutputPointer, PublicKeyHash, TransactionsPool, + ValueTransferOutput, + tapi::ActiveWips, }, data_request::DataRequestPool, - staking::prelude::StakesTracker, + staking::{delegation::DelegationState, prelude::StakesTracker}, transaction::{Transaction, VTTransaction, VTTransactionBody}, utxo_pool::UnspentOutputsPool, vrf::BlockEligibilityClaim, @@ -493,6 +970,7 @@ mod tests { max_dr_weight, block_beacon, block_proof, + CheckpointVRF::default(), &[], PublicKeyHash::default(), EpochConstants::default(), @@ -508,12 +986,44 @@ mod tests { &active_wips, None, &StakesTracker::default(), + &DelegationState::default(), + 0, &ConsensusConstantsWit2::default(), - ); + 1, + ) + .expect("block construction should succeed"); Block::new(block_header, KeyedSignature::default(), txns) } + #[test] + fn block_inventory_rejects_a_body_not_committed_by_its_header() { + let epoch = 1; + let protocol_version = get_protocol_version(Some(epoch)); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = epoch; + block.block_header.merkle_roots = + BlockMerkleRoots::from_transactions(&block.txns, protocol_version); + let block_hash = block.versioned_hash(protocol_version); + validate_inventory_block(&block, block_hash).unwrap(); + + block.txns.value_transfer_txns.push(VTTransaction::new( + VTTransactionBody::new( + Vec::new(), + vec![ValueTransferOutput { + value: 1, + ..Default::default() + }], + ), + Vec::new(), + )); + assert_eq!(block.versioned_hash(protocol_version), block_hash); + assert!(matches!( + validate_inventory_block(&block, block_hash), + Err(InventoryManagerError::MailBoxError(_)) + )); + } + #[test] fn persist_same_transaction_twice_overwrites() { // Create two blocks with the same transaction, to simulate a reorganization. @@ -531,8 +1041,10 @@ mod tests { // Create first block with value transfer transactions let block = build_block_with_vt_transactions(1); - let block_hash1 = block.hash(); - let tx_hash1 = block.txns.value_transfer_txns[0].hash(); + let protocol_version = + get_protocol_version(Some(block.block_header.beacon.checkpoint)); + let block_hash1 = block.versioned_hash(protocol_version); + let tx_hash1 = block.txns.value_transfer_txns[0].versioned_hash(protocol_version); let item = StoreInventoryItem::Block(Box::new(block)); // Persist first block @@ -541,7 +1053,10 @@ mod tests { // Get first transaction of that block let res = inventory_manager - .send(GetItemTransaction { hash: tx_hash1 }) + .send(GetItemTransaction { + hash: tx_hash1, + protocol_info: None, + }) .await .unwrap(); @@ -551,8 +1066,10 @@ mod tests { // Create a different block with the same transactions let block = build_block_with_vt_transactions(2); - let block_hash2 = block.hash(); - let tx_hash2 = block.txns.value_transfer_txns[0].hash(); + let protocol_version = + get_protocol_version(Some(block.block_header.beacon.checkpoint)); + let block_hash2 = block.versioned_hash(protocol_version); + let tx_hash2 = block.txns.value_transfer_txns[0].versioned_hash(protocol_version); assert_ne!(block_hash1, block_hash2); assert_eq!(tx_hash1, tx_hash2); let item = StoreInventoryItem::Block(Box::new(block)); @@ -563,7 +1080,10 @@ mod tests { // Get first transaction again let res = inventory_manager - .send(GetItemTransaction { hash: tx_hash1 }) + .send(GetItemTransaction { + hash: tx_hash1, + protocol_info: None, + }) .await .unwrap(); diff --git a/node/src/actors/json_rpc/api.rs b/node/src/actors/json_rpc/api.rs index 7e37717f29..49a581d56b 100644 --- a/node/src/actors/json_rpc/api.rs +++ b/node/src/actors/json_rpc/api.rs @@ -23,21 +23,28 @@ use std::{ use self::mock_actix::SystemService; use crate::{ actors::{ - chain_manager::{ChainManager, ChainManagerError, run_dr_locally}, + chain_manager::{ChainManager, ChainManagerError, StakeRecoveryError, run_dr_locally}, epoch_manager::{EpochManager, EpochManagerError}, inventory_manager::{InventoryManager, InventoryManagerError}, json_rpc::Subscriptions, messages::{ AddCandidates, AddPeers, AddTransaction, AuthorizeStake, BuildDrt, BuildStake, - BuildStakeParams, BuildStakeResponse, BuildUnstake, BuildUnstakeParams, BuildVtt, + BuildStakeParams, BuildStakeResponse, BuildUnstake, BuildUnstakeByDebitParams, + BuildUnstakeParams, BuildVtt, ClearPeers, DropAllPeers, EstimatePriority, GetBalance, GetBalance2, GetBalance2Limits, GetBalanceTarget, GetBlocksEpochRange, GetConsolidatedPeers, GetDataRequestInfo, GetEpoch, GetEpochConstants, GetHighestCheckpointBeacon, GetItemBlock, - GetItemSuperblock, GetItemTransaction, GetKnownPeers, GetMemoryTransaction, GetMempool, + GetItemSuperblock, GetItemTransaction, GetKnownPeers, + GetDelegatedStakingActivationClassification, GetDelegatedStakingActivationPreflight, + GetMemoryTransaction, GetMempool, GetNodeStats, GetProtocolInfo, GetReputation, GetSignalingInfo, GetState, GetSupplyInfo, GetSupplyInfo2, GetUtxoInfo, InitializePeers, IsConfirmedBlock, - MagicEither, QueryStakes, QueryStakingPowers, Rewind, SearchDataRequests, - SnapshotExport, SnapshotImport, StakeAuthorization, + MagicEither, QueryStakeEntriesAtTip, QueryStakeEntriesAtTipByWithdrawers, + QueryStakeRecoveryEvidence, + QueryStakeEntriesAtTipParams, QueryStakes, QueryStakesAtTip, QueryStakingPowers, Rewind, + SearchDataRequests, QueryValidatorPolicy, SnapshotExport, SnapshotImport, + StakeAuthorization, ValidateTransactionReadOnly, GetProtocolEpochSnapshot, + ProtocolEpochSnapshot, }, peers_manager::PeersManager, sessions_manager::SessionsManager, @@ -49,21 +56,46 @@ use witnet_crypto::{hash::calculate_sha256, key::KeyPath}; use witnet_data_structures::{ chain::{ Block, DataRequestInfo, DataRequestOutput, DataRequestStage, Epoch, EpochConstants, Hash, - Hashable, KeyedSignature, PublicKeyHash, RADType, StakeOutput, StateMachine, SyncStatus, - ValueTransferOutput, tapi::ActiveWips, + Hashable, KeyedSignature, PublicKeyHash, RADType, STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, + STAKE_AUTHORIZATION_ACTION_DELEGATE, STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, STAKE_AUTHORIZATION_VERSION_V1, + STAKE_AUTHORIZATION_VERSION_V2, + StakeAuthorization as StakeAuthorizationTerms, StakeAuthorizationCode, StakeOutput, + StateMachine, SyncStatus, ValueTransferOutput, tapi::ActiveWips, }, - get_environment, get_protocol_version, + get_environment, get_protocol_runtime, proto::{ ProtobufConvert, - versioning::{ProtocolVersion, VersionedHashable}, + versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, }, - serialization_helpers::number_from_string, + serialization_helpers::{number_from_string, number_to_string}, staking::prelude::*, transaction::{Transaction, VTTransaction}, vrf::VrfMessage, + wit::Wit, }; +#[cfg(test)] +use witnet_data_structures::fee::Fee; type JsonRpcResult = jsonrpc_core::Result; +type ConsensusStakesError = StakesError; + +const STAKE_ENTRY_NOT_FOUND_RPC_CODE: &str = "stake_entry_not_found"; +const VALIDATOR_POLICY_NOT_FOUND_RPC_CODE: &str = "validator_policy_not_found"; +const STAKE_RECOVERY_CHAIN_NOT_READY_RPC_CODE: &str = "stake_recovery_chain_not_ready"; +const STAKE_RECOVERY_CANONICAL_TIP_UNAVAILABLE_RPC_CODE: &str = "stake_recovery_canonical_tip_unavailable"; +const STAKE_RECOVERY_FINALITY_UNAVAILABLE_RPC_CODE: &str = "stake_recovery_finality_unavailable"; +const STAKE_RECOVERY_TRANSACTION_RETAINED_RPC_CODE: &str = "stake_recovery_transaction_retained"; +const STAKE_RECOVERY_INPUT_UNAVAILABLE_RPC_CODE: &str = "stake_recovery_input_unavailable"; +const STAKE_RECOVERY_PROTOCOL_CHANGED_RPC_CODE: &str = "stake_recovery_protocol_changed"; + +async fn protocol_epoch_snapshot() -> Result { + EpochManager::from_registry() + .send(GetProtocolEpochSnapshot) + .await + .map_err(internal_error_s)? + .map_err(internal_error) +} /// Guard methods that assume that JSON-RPC is only accessible by the owner of the node. /// @@ -155,9 +187,31 @@ pub fn attach_regular_methods( }); server.add_actix_method(system, "priority", |_params: Params| Box::pin(priority())); server.add_actix_method(system, "protocol", |_params: Params| Box::pin(protocol())); + server.add_actix_method( + system, + "delegatedStakingActivationPreflight", + |_params: Params| Box::pin(delegated_staking_activation_preflight()), + ); + server.add_actix_method( + system, + "delegatedStakingActivationClassification", + |_params: Params| Box::pin(delegated_staking_activation_classification()), + ); server.add_actix_method(system, "queryStakes", |params: Params| { Box::pin(query_stakes(params.parse())) }); + server.add_actix_method(system, "queryStakesAtTip", |params: Params| { + Box::pin(query_stakes_at_tip(params.parse())) + }); + server.add_actix_method(system, "queryStakeRecoveryEvidence", |params: Params| { + Box::pin(query_stake_recovery_evidence(params.parse())) + }); + server.add_actix_method(system, "stakeEntriesAtTip", |params: Params| { + Box::pin(query_stake_entries_at_tip_with_params(params.parse())) + }); + server.add_actix_method(system, "validatorPolicy", |params: Params| { + Box::pin(query_validator_policy(params.parse())) + }); server.add_actix_method(system, "queryPowers", |params: Params| { Box::pin(query_powers(params.parse())) }); @@ -313,6 +367,14 @@ pub fn attach_sensitive_methods( |params| unstake(params.parse()), )) }); + server.add_actix_method(system, "unstakeByDebit", move |params| { + Box::pin(if_authorized( + enable_sensitive_methods, + "unstakeByDebit", + params, + |params| unstake_by_debit(params.parse()), + )) + }); server.add_actix_method(system, "getSupplyInfo2", move |params| { Box::pin(if_authorized( enable_sensitive_methods, @@ -496,6 +558,82 @@ fn internal_error_s(e: T) -> jsonrpc_core::Error { } } +/// Preserve the existing JSON-RPC error and message while attaching a stable, machine-readable +/// identity for expected empty stake queries. Older clients can continue matching the message; +/// upgraded clients no longer depend on Rust error wording. +fn query_stakes_error(error: anyhow::Error) -> jsonrpc_core::Error { + let data = error + .downcast_ref::() + .and_then(|source| match source { + StakesError::EntryNotFound { key } => Some(serde_json::json!({ + "code": STAKE_ENTRY_NOT_FOUND_RPC_CODE, + "validator": key.validator, + "withdrawer": key.withdrawer, + })), + StakesError::ValidatorNotFound { validator } => Some(serde_json::json!({ + "code": STAKE_ENTRY_NOT_FOUND_RPC_CODE, + "validator": validator, + })), + StakesError::WithdrawerNotFound { withdrawer } => Some(serde_json::json!({ + "code": STAKE_ENTRY_NOT_FOUND_RPC_CODE, + "withdrawer": withdrawer, + })), + _ => None, + }); + let mut rpc_error = internal_error_s(&error); + rpc_error.data = data; + rpc_error +} + +/// Attach an exact validator identity when a policy lookup proves that no live or retained policy +/// exists. Do not classify owner-entry corruption or any other staking failure as normal absence. +fn query_validator_policy_error(error: anyhow::Error) -> jsonrpc_core::Error { + let data = error + .downcast_ref::() + .and_then(|source| match source { + StakesError::ValidatorNotFound { validator } + | StakesError::ValidatorPolicyNotFound { validator } => Some(serde_json::json!({ + "code": VALIDATOR_POLICY_NOT_FOUND_RPC_CODE, + "validator": validator, + })), + _ => None, + }); + let mut rpc_error = internal_error_s(&error); + rpc_error.data = data; + rpc_error +} + +/// Preserve stable recovery failure identities while keeping the existing internal-error code +/// and human-readable message for older JSON-RPC clients. +fn query_stake_recovery_error(error: anyhow::Error) -> jsonrpc_core::Error { + let data = error + .downcast_ref::() + .map(|source| match source { + StakeRecoveryError::ChainNotReady => serde_json::json!({ + "code": STAKE_RECOVERY_CHAIN_NOT_READY_RPC_CODE, + }), + StakeRecoveryError::CanonicalTipUnavailable => serde_json::json!({ + "code": STAKE_RECOVERY_CANONICAL_TIP_UNAVAILABLE_RPC_CODE, + }), + StakeRecoveryError::FinalityUnavailable(_) => serde_json::json!({ + "code": STAKE_RECOVERY_FINALITY_UNAVAILABLE_RPC_CODE, + }), + StakeRecoveryError::TransactionRetained => serde_json::json!({ + "code": STAKE_RECOVERY_TRANSACTION_RETAINED_RPC_CODE, + }), + StakeRecoveryError::InputUnavailable { output } => serde_json::json!({ + "code": STAKE_RECOVERY_INPUT_UNAVAILABLE_RPC_CODE, + "output_pointer": output.to_string(), + }), + StakeRecoveryError::ProtocolChanged => serde_json::json!({ + "code": STAKE_RECOVERY_PROTOCOL_CHANGED_RPC_CODE, + }), + }); + let mut rpc_error = internal_error_s(&error); + rpc_error.data = data; + rpc_error +} + /// Message that appears when calling a sensitive method when sensitive methods are disabled fn unauthorized_message(method_name: &str) -> String { format!( @@ -741,16 +879,23 @@ pub async fn get_block(params: Params) -> Result { )); }; + // Pin the schedule before the inventory read so block validation and the response's + // versioned hashes/weights cannot observe different protocol generations. + let (protocol_info, _, _) = get_protocol_runtime(); let inventory_manager = InventoryManager::from_registry(); let res = inventory_manager - .send(GetItemBlock { hash: block_hash }) + .send(GetItemBlock { + hash: block_hash, + protocol_info: Some(protocol_info.clone()), + }) .await; match res { Ok(Ok(mut output)) => { let block_epoch = output.block_header.beacon.checkpoint; - let block_hash = output.versioned_hash(get_protocol_version(Some(block_epoch))); + let block_protocol = protocol_info.all_versions.version_for_epoch(block_epoch); + let block_hash = output.versioned_hash(block_protocol); let dr_weight = match serde_json::to_value(output.dr_weight()) { Ok(x) => x, @@ -768,7 +913,7 @@ pub async fn get_block(params: Params) -> Result { } }; - let block_weight = match serde_json::to_value(output.weight()) { + let block_weight = match serde_json::to_value(output.weight_for_protocol(block_protocol)) { Ok(x) => x, Err(e) => { let err = internal_error(e); @@ -820,12 +965,12 @@ pub async fn get_block(params: Params) -> Result { "tally" : tt_hashes })); - if ProtocolVersion::from_epoch(block_epoch) >= ProtocolVersion::V1_8 { + if block_protocol >= ProtocolVersion::V1_8 { let st_hashes: Vec<_> = output .txns .stake_txns .iter() - .map(|txn| txn.hash()) + .map(|txn| txn.versioned_hash(block_protocol)) .collect(); if let Some(ref mut hashes) = hashes { hashes @@ -835,7 +980,7 @@ pub async fn get_block(params: Params) -> Result { } } - if ProtocolVersion::from_epoch(block_epoch) == ProtocolVersion::V2_0 { + if block_protocol >= ProtocolVersion::V2_0 { let ut_hashes: Vec<_> = output .txns .unstake_txns @@ -875,12 +1020,12 @@ pub async fn get_block(params: Params) -> Result { "data_request": drt_weights, })); - if ProtocolVersion::from_epoch(block_epoch) >= ProtocolVersion::V1_8 { + if block_protocol >= ProtocolVersion::V1_8 { let st_weights: Vec<_> = output .txns .stake_txns .iter() - .map(|txn| txn.weight()) + .map(|txn| txn.weight_for_protocol(block_protocol)) .collect(); if let Some(ref mut weights) = weights { weights @@ -890,7 +1035,7 @@ pub async fn get_block(params: Params) -> Result { } } - if ProtocolVersion::from_epoch(block_epoch) >= ProtocolVersion::V2_0 { + if block_protocol >= ProtocolVersion::V2_0 { let ut_weights: Vec<_> = output .txns .unstake_txns @@ -1041,6 +1186,23 @@ pub struct GetTransactionOutput { pub confirmations: Option, } +/// Return the externally reported weight for a transaction using the protocol that admits it. +/// +/// `Transaction::weight()` predates stake and unstake transaction support and intentionally +/// returns zero for those enum variants. Keep the historical values for the older transaction +/// families, but expose the actual delegated-staking weights in RPC reports. Stake authorization +/// terms are part of the Wit/2.1 weight and are ignored by the pre-Wit/2.1 representation. +fn transaction_weight_for_protocol( + transaction: &Transaction, + protocol_version: ProtocolVersion, +) -> u32 { + match transaction { + Transaction::Stake(transaction) => transaction.weight_for_protocol(protocol_version), + Transaction::Unstake(transaction) => transaction.weight(), + _ => transaction.weight(), + } +} + /// Get transaction by hash pub async fn get_transaction(hash: Result<(Hash,), Error>) -> JsonRpcResult { let hash = match hash { @@ -1048,27 +1210,28 @@ pub async fn get_transaction(hash: Result<(Hash,), Error>) -> JsonRpcResult { Err(e) => return Err(e), }; - // Get current epoch - let current_epoch = match EpochManager::from_registry().send(GetEpoch).await { - Ok(Ok(current_epoch)) => current_epoch, - Ok(Err(e)) => return Err(internal_error(e)), - Err(e) => return Err(internal_error(e)), - }; - - // Get epoch constants - let epoch_constants = match EpochManager::from_registry().send(GetEpochConstants).await { - Ok(Some(epoch_constats)) => epoch_constats, - Err(e) => return Err(internal_error(e)), - _ => return Err(internal_error("")), - }; + let protocol_epoch = protocol_epoch_snapshot().await?; + let current_epoch = protocol_epoch.current_epoch; + let epoch_constants = protocol_epoch.epoch_constants; let inventory_manager = InventoryManager::from_registry(); - let res = inventory_manager.send(GetItemTransaction { hash }).await; + let res = inventory_manager + .send(GetItemTransaction { + hash, + protocol_info: Some(protocol_epoch.protocol_info.clone()), + }) + .await; match res { Ok(Ok((transaction, pointer_to_block, block_epoch))) => { - let weight = transaction.weight(); + let weight = transaction_weight_for_protocol( + &transaction, + protocol_epoch + .protocol_info + .all_versions + .version_for_epoch(block_epoch), + ); let block_hash = pointer_to_block.block_hash; // Check if this block is confirmed by a majority of superblock votes let chain_manager = ChainManager::from_registry(); @@ -1151,8 +1314,7 @@ pub async fn get_transaction(hash: Result<(Hash,), Error>) -> JsonRpcResult { let res = chain_manager.send(GetMemoryTransaction { hash }).await; match res { - Ok(Ok(transaction)) => { - let weight = transaction.weight(); + Ok(Ok((transaction, weight))) => { let output = GetTransactionOutput { transaction, weight, @@ -1609,37 +1771,46 @@ pub async fn get_utxo_info(params: Result) -> JsonRpcResult { if let Some(UtxoFilter { min_value, from_signer: _, - }) = filter + }) = filter.as_ref() { - utxos_info.utxos.retain(|utxo| utxo.value >= min_value); + utxos_info.utxos.retain(|utxo| utxo.value >= *min_value); } // If a signer filter is applied, get rid of utxos not created by the specified signer address if let Some(UtxoFilter { min_value: _, from_signer: Some(from_signer), - }) = filter + }) = filter.as_ref() { let inventory_manager = InventoryManager::from_registry(); + // Pin the protocol schedule for the whole asynchronous lookup. A runtime schedule + // change must not make otherwise identical results use different transaction IDs. + let (protocol_info, _, _) = get_protocol_runtime(); let futures = utxos_info.utxos.iter().map(|utxo| { inventory_manager.send(GetItemTransaction { hash: utxo.output_pointer.transaction_id, + protocol_info: Some(protocol_info.clone()), }) }); - // The strategy here is to first create a set containing only the hashes of the transactions - // that were signed by the specified signer address + // The strategy here is to first create a set containing only the hashes of the + // transactions that were signed by the specified signer address. A Stake transaction + // carrying Wit/2.1 authorization terms has a protocol-versioned transaction identity; + // use the containing block epoch returned by inventory rather than the process-global + // raw hash, otherwise the filter silently drops valid delegated-stake UTXOs whose + // output pointer was indexed under the V2.1 body hash. let filtered_hashes = futures::future::join_all(futures) .await .into_iter() .flatten() .flatten() - .filter_map(|(transaction, _, _)| { + .filter_map(|(transaction, _, block_epoch)| { if transaction .signatures() .iter() - .any(|signature| signature.public_key.pkh().eq(&from_signer)) + .any(|signature| signature.public_key.pkh().eq(from_signer)) { - Some(transaction.hash()) + let protocol = protocol_info.all_versions.version_for_epoch(block_epoch); + Some(transaction.versioned_hash(protocol)) } else { None } @@ -2085,6 +2256,30 @@ pub async fn protocol() -> JsonRpcResult { serde_json::to_value(protocol_info).map_err(internal_error_s) } +/// Run the deterministic, read-only Wit/2.1 delegated-staking activation preflight. +pub async fn delegated_staking_activation_preflight() -> JsonRpcResult { + ChainManager::from_registry() + .send(GetDelegatedStakingActivationPreflight) + .map(|response| match response { + Ok(Ok(report)) => serde_json::to_value(report).map_err(internal_error_s), + Ok(Err(error)) => Err(internal_error_s(error)), + Err(error) => Err(internal_error_s(error)), + }) + .await +} + +/// Classify an actor-owned canonical legacy state; this diagnostic never authorizes activation. +pub async fn delegated_staking_activation_classification() -> JsonRpcResult { + ChainManager::from_registry() + .send(GetDelegatedStakingActivationClassification) + .map(|response| match response { + Ok(Ok(report)) => serde_json::to_value(report).map_err(internal_error_s), + Ok(Err(error)) => Err(internal_error_s(error)), + Err(error) => Err(internal_error_s(error)), + }) + .await +} + /// Parameters of snapshot_export #[derive(Debug, Deserialize)] pub struct SnapshotExportParams { @@ -2161,6 +2356,13 @@ pub async fn snapshot_import(params: Result) -> Jso pub async fn stake(params: Result) -> JsonRpcResult { // Short-circuit if parameters are wrong let params = params?; + let dry_run = params.dry_run; + let validate_admission = params.validate_admission; + if validate_admission && !dry_run { + return Err(Error::invalid_params( + "validate_admission can only be used with dry_run", + )); + } let withdrawer = params .withdrawer @@ -2169,43 +2371,134 @@ pub async fn stake(params: Result) -> JsonRpcResult { .map_err(internal_error)?; log::debug!("[STAKE] Creating stake transaction with withdrawer address: {withdrawer}"); - // This is the actual message that gets signed as part of the authorization - let msg = withdrawer.as_secp256k1_msg(); - - // Perform some sanity checks on the authorization string - match params.authorization { - MagicEither::Left(ref hex_str) => { - // Authorization string is not a hexadecimal string - if !hex_str.chars().all(|c| c.is_ascii_hexdigit()) { + let expected_network = config_mngr::get() + .await + .map_err(internal_error)? + .consensus_constants + .get_magic(); + let legacy_msg = withdrawer.as_secp256k1_msg(); + + // Decode both the historical 85-byte code and the self-contained Wit/2.1 + // code. Structured callers may alternatively supply a KeyedSignature plus + // `authorization_terms`. + let (authorization, authorization_terms) = match params.authorization.clone() { + MagicEither::Left(hex_str) => { + // HexString callers (including the TypeScript SDK) may use the conventional optional + // `0x` prefix. Normalize that presentation-only prefix before selecting the historical + // 170-character V1 layout or the fixed-width V2 portable-code decoder. + let normalized_hex = hex_str + .strip_prefix("0x") + .or_else(|| hex_str.strip_prefix("0X")) + .unwrap_or(&hex_str); + if !normalized_hex.chars().all(|c| c.is_ascii_hexdigit()) { return Err(Error::invalid_params( - "The authorization string is not a hexadecimal string", + "The authorization string is not hexadecimal", + )); + } + if params.authorization_terms.is_some() { + return Err(Error::invalid_params( + "authorization_terms must not be provided with a portable authorization code", )); } - // Invalid authorization length - if hex_str.len() != 170 { - return Err(Error::invalid_params(format!( - "Authorization string has an unexpected length: {} != {}", - hex_str.len(), - 170 - ))); + if normalized_hex.len() == 170 { + let declared_validator = + PublicKeyHash::from_hex(&normalized_hex[..40]).map_err(internal_error)?; + let signature = + KeyedSignature::from_recoverable_hex(&normalized_hex[40..], &legacy_msg) + .map_err(internal_error)?; + if signature.public_key.pkh() != declared_validator { + return Err(Error::invalid_params( + "legacy authorization validator prefix does not match its signature", + )); + } + + (signature, None) + } else { + let code = StakeAuthorizationCode::from_hex(normalized_hex) + .map_err(internal_error)?; + if code.terms.network != u32::from(expected_network) { + return Err(Error::invalid_params( + "stake authorization was created for a different network", + )); + } + if code.terms.withdrawer != withdrawer { + return Err(Error::invalid_params( + "stake authorization withdrawer does not match the requested withdrawer", + )); + } + + (code.signature, Some(code.terms)) + } + } + MagicEither::Right(signature) => { + if let Some(terms) = params.authorization_terms.clone() { + let code = StakeAuthorizationCode { + terms: terms.clone(), + signature: signature.clone(), + }; + code.verify().map_err(internal_error)?; + if terms.network != u32::from(expected_network) { + return Err(Error::invalid_params( + "stake authorization was created for a different network", + )); + } + if terms.withdrawer != withdrawer { + return Err(Error::invalid_params( + "stake authorization withdrawer does not match the requested withdrawer", + )); + } + + (signature, Some(terms)) + } else { + (signature, None) } } - MagicEither::Right(_) => (), }; - let authorization = params - .authorization - .try_do_magic(|hex_str| { - KeyedSignature::from_recoverable_hex( - &hex_str[hex_str.char_indices().nth_back(129).unwrap().0..], - &msg, - ) - }) - .map_err(internal_error)?; - let validator = PublicKeyHash::from_public_key(&authorization.public_key); + let protocol_epoch = protocol_epoch_snapshot().await?; + let active_protocol = protocol_epoch + .protocol_info + .all_versions + .version_for_epoch(protocol_epoch.current_epoch); + if let Some(terms) = &authorization_terms { + if active_protocol < ProtocolVersion::V2_1 && !dry_run { + return Err(Error::invalid_params( + "Wit/2.1 stake authorizations cannot be broadcast before protocol activation", + )); + } + if active_protocol < ProtocolVersion::V2_1 && validate_admission { + return Err(Error::invalid_params( + "Wit/2.1 stake authorizations are not currently admissible before protocol activation", + )); + } + if active_protocol >= ProtocolVersion::V2_1 { + terms + .validate_at(expected_network, protocol_epoch.current_epoch) + .map_err(|error| { + Error::invalid_params(format!( + "invalid Wit/2.1 stake authorization: {error}" + )) + })?; + } + if matches!(terms.action, STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE | STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT) + && params.value != 0 { + return Err(Error::invalid_params( + "commission update and exit-intent transactions must have a zero stake value", + )); + } + } else if active_protocol >= ProtocolVersion::V2_1 { + return Err(Error::invalid_params( + "legacy stake authorizations cannot be used after Wit/2.1 activation", + )); + } + + let validator = authorization_terms.as_ref().map_or_else( + || PublicKeyHash::from_public_key(&authorization.public_key), + |terms| terms.validator, + ); log::debug!( - "[STAKE] A stake authorization was provided, and it was signed by validator {validator}" + "[STAKE] A stake authorization was provided for validator {validator}" ); let key = StakeKey { @@ -2218,55 +2511,80 @@ pub async fn stake(params: Result) -> JsonRpcResult { dry_run: params.dry_run, fee: params.fee, utxo_strategy: params.utxo_strategy, + protocol_epoch: Some(protocol_epoch.current_epoch), + protocol_generation: Some(protocol_epoch.protocol_generation), stake_output: StakeOutput { authorization, + authorization_terms, key, value: params.value, }, }; - ChainManager::from_registry() + let transaction = ChainManager::from_registry() .send(build_stake) - .map(|res| match res { - Ok(Ok(transaction)) => { - // In the event that this is a dry run, we want to inject some additional information into the - // response, so that the user can confirm the facts surrounding the stake transaction before - // submitting it - if params.dry_run { - let staker = transaction - .signatures - .iter() - .map(|signature| signature.public_key.pkh()) - .collect(); + .await + .map_err(internal_error_s)? + .map_err(internal_error_s)?; - let bsr = BuildStakeResponse { - transaction, - staker, - validator, - withdrawer, - }; + if validate_admission { + ChainManager::from_registry() + .send(ValidateTransactionReadOnly { + transaction: Transaction::Stake(transaction.clone()), + }) + .await + .map_err(internal_error_s)? + .map_err(internal_error_s)?; + } - serde_json::to_value(bsr).map_err(internal_error) - } else { - serde_json::to_value(transaction).map_err(internal_error) - } - } - Ok(Err(e)) => { - let err = internal_error_s(e); - Err(err) - } - Err(e) => { - let err = internal_error_s(e); - Err(err) - } - }) - .await + // A dry run has no side effects, so reject a preview that was superseded while signing. + // For a broadcast, ChainManager's admission-time checks are authoritative; checking here + // after `add_transaction` could report an error after the transaction was already retained + // and relayed. + if dry_run { + let completed_protocol_epoch = protocol_epoch_snapshot().await?; + if completed_protocol_epoch.protocol_generation != protocol_epoch.protocol_generation + || completed_protocol_epoch.current_epoch != protocol_epoch.current_epoch + { + return Err(Error::invalid_params( + "protocol epoch or schedule changed while the stake transaction was being built; retry", + )); + } + } + + // In the event that this is a dry run, inject the same additional information as the legacy + // construction-only preview. Opt-in admission validation changes only whether this response + // is returned, never its successful shape. + if dry_run { + let staker = transaction + .signatures + .iter() + .map(|signature| signature.public_key.pkh()) + .collect(); + + let bsr = BuildStakeResponse { + transaction, + staker, + validator, + withdrawer, + }; + serde_json::to_value(bsr).map_err(internal_error) + } else { + serde_json::to_value(transaction).map_err(internal_error) + } } /// Build an unstake transaction pub async fn unstake(params: Result) -> JsonRpcResult { // Short-circuit if parameters are wrong let params = params?; + let dry_run = params.dry_run; + let validate_admission = params.validate_admission; + if validate_admission && !dry_run { + return Err(Error::invalid_params( + "validate_admission can only be used with dry_run", + )); + } let operator = params .operator @@ -2277,24 +2595,120 @@ pub async fn unstake(params: Result) -> JsonRpcResult let build_unstake = BuildUnstake { operator, value: params.value, + debit: None, + all: false, fee: params.fee, dry_run: params.dry_run, + protocol_epoch: None, + protocol_generation: None, }; - ChainManager::from_registry() - .send(build_unstake) - .map(|res| match res { - Ok(Ok(transaction)) => serde_json::to_value(transaction).map_err(internal_error), - Ok(Err(e)) => { - let err = internal_error_s(e); - Err(err) - } - Err(e) => { - let err = internal_error_s(e); - Err(err) + build_unstake_and_maybe_validate(build_unstake, validate_admission).await +} + +/// Validate the strict gross-debit selector before any key access or actor message is attempted. +fn validate_unstake_by_debit_params(params: &BuildUnstakeByDebitParams) -> Result<(), Error> { + match (params.debit > 0, params.all) { + (true, false) => { + if params.debit <= params.fee { + return Err(Error::invalid_params( + "unstake debit must exceed its fee", + )); } - }) + Ok(()) + } + (false, true) => Ok(()), + (false, false) => Err(Error::invalid_params( + "exactly one of a positive debit or all must be selected", + )), + (true, true) => Err(Error::invalid_params( + "debit and all are mutually exclusive", + )), + } +} + +/// Build an unstake transaction whose requested amount is the gross stake debit, including fee. +/// +/// `all` is resolved from the exact live `(operator, local withdrawer)` entry by ChainManager in +/// the construction turn. It is not a promise to remove a later balance at eventual inclusion. +pub async fn unstake_by_debit( + params: Result, +) -> JsonRpcResult { + let params = params?; + validate_unstake_by_debit_params(¶ms)?; + let validate_admission = params.validate_admission; + if validate_admission && !params.dry_run { + return Err(Error::invalid_params( + "validate_admission can only be used with dry_run", + )); + } + + let operator = params + .operator + .try_do_magic(|hex_str| PublicKeyHash::from_bech32(get_environment(), &hex_str)) + .map_err(internal_error)?; + let debit = if params.all { + None + } else { + Some(params.debit) + }; + let build_unstake = BuildUnstake { + operator, + value: 0, + debit, + all: params.all, + fee: params.fee, + dry_run: params.dry_run, + protocol_epoch: None, + protocol_generation: None, + }; + + build_unstake_and_maybe_validate(build_unstake, validate_admission).await +} + +async fn build_unstake_and_maybe_validate( + mut build_unstake: BuildUnstake, + validate_admission: bool, +) -> JsonRpcResult { + // Bind construction to one actor/runtime protocol snapshot. BuildUnstake performs an + // asynchronous signature step and must not silently switch timelock or validation rules if a + // schedule/epoch notification arrives before the actor handles it. + let dry_run = build_unstake.dry_run; + let protocol_epoch = protocol_epoch_snapshot().await?; + build_unstake.protocol_epoch = Some(protocol_epoch.current_epoch); + build_unstake.protocol_generation = Some(protocol_epoch.protocol_generation); + let transaction = ChainManager::from_registry() + .send(build_unstake) .await + .map_err(internal_error_s)? + .map_err(internal_error_s)?; + + if validate_admission { + ChainManager::from_registry() + .send(ValidateTransactionReadOnly { + transaction: Transaction::Unstake(transaction.clone()), + }) + .await + .map_err(internal_error_s)? + .map_err(internal_error_s)?; + } + + // A dry run has no side effects, so reject a preview that was superseded while signing or + // while its optional admission assertion was being evaluated. Broadcasts have already passed + // ChainManager's admission-time checks by this point; do not turn a successful relay into an + // RPC error merely because the next snapshot changed. + if dry_run { + let completed_protocol_epoch = protocol_epoch_snapshot().await?; + if completed_protocol_epoch.protocol_generation != protocol_epoch.protocol_generation + || completed_protocol_epoch.current_epoch != protocol_epoch.current_epoch + { + return Err(Error::invalid_params( + "protocol epoch or schedule changed while the unstake transaction was being built; retry", + )); + } + } + + serde_json::to_value(transaction).map_err(internal_error) } /// Create a stake authorization for the given address. @@ -2307,30 +2721,224 @@ pub async fn authorize_stake(params: Result) -> JsonRpcRe // Short-circuit if parameters are wrong let params = params?; - // If a withdrawer address is not specified, default to local node address + let signer = signature_mngr::public_key() + .await + .map_err(internal_error)? + .pkh(); + // If a withdrawer address is not specified, default to local node address. let withdrawer = if let Some(address) = params.withdrawer { PublicKeyHash::from_bech32(get_environment(), &address).map_err(internal_error)? } else { - let pk = signature_mngr::public_key().await.unwrap(); - - PublicKeyHash::from_public_key(&pk) + signer }; + let validator = params.validator.as_ref().map(|address| { + PublicKeyHash::from_bech32(get_environment(), address).map_err(internal_error) + }).transpose()?.unwrap_or(signer); + if params.action == STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT { + if withdrawer != signer { + return Err(Error::invalid_params("exit intent must be signed by the local withdrawer")); + } + } else if validator != signer { + return Err(Error::invalid_params("deposit and commission actions must use the local validator")); + } - // This is the actual message that gets signed as part of the authorization - let msg = withdrawer.as_secp256k1_msg(); + let issuance_protocol_epoch = protocol_epoch_snapshot().await?; + let issuance_protocol = issuance_protocol_epoch + .protocol_info + .all_versions + .version_for_epoch(issuance_protocol_epoch.current_epoch); - signature_mngr::sign_data(msg) - .map(|res| { - res.map_err(internal_error).and_then(|signature| { - let authorization = StakeAuthorization { - withdrawer, - signature, - }; + let version = params.version.unwrap_or_else(|| { + if issuance_protocol >= ProtocolVersion::V2_1 { + STAKE_AUTHORIZATION_VERSION_V2 + } else { + STAKE_AUTHORIZATION_VERSION_V1 + } + }); - serde_json::to_value(authorization).map_err(internal_error) - }) - }) - .await + if version == STAKE_AUTHORIZATION_VERSION_V1 && issuance_protocol >= ProtocolVersion::V2_1 { + return Err(Error::invalid_params( + "legacy stake authorizations cannot be created after Wit/2.1 activation", + )); + } + + let authorization = match version { + STAKE_AUTHORIZATION_VERSION_V1 => { + if params.expiration_epoch.is_some() + // A present commission field is a V2 term even when its value is the + // valid zero-percent commission. Keep the explicit-version boundary + // aligned with the SDK, while the omitted-version legacy default remains + // represented by `Some(0)` by older CLI clients. A nonzero commission is + // always a V2 request, even when the version itself is omitted. + || (params.version == Some(STAKE_AUTHORIZATION_VERSION_V1) + && params.commission_percent.is_some()) + || params.commission_percent.is_some_and(|commission| commission != 0) + || params.action != STAKE_AUTHORIZATION_ACTION_DELEGATE + || params.nonce != 0 + { + return Err(Error::invalid_params( + "delegation terms require authorization version 2", + )); + } + + let signature = signature_mngr::sign_data(withdrawer.as_secp256k1_msg()) + .await + .map_err(internal_error)?; + let signed_protocol_epoch = protocol_epoch_snapshot().await?; + if signed_protocol_epoch.protocol_generation + != issuance_protocol_epoch.protocol_generation + { + return Err(Error::invalid_params( + "protocol schedule changed while the legacy stake authorization was being created; retry", + )); + } + let signed_protocol = signed_protocol_epoch + .protocol_info + .all_versions + .version_for_epoch(signed_protocol_epoch.current_epoch); + if signed_protocol >= ProtocolVersion::V2_1 { + return Err(Error::invalid_params( + "legacy stake authorization became unavailable while the signature was being created; retry with version 2", + )); + } + StakeAuthorization { + withdrawer, + signature, + authorization_terms: None, + code: None, + } + } + STAKE_AUTHORIZATION_VERSION_V2 => { + let network = config_mngr::get() + .await + .map_err(internal_error)? + .consensus_constants + .get_magic(); + let expiration_epoch = params.expiration_epoch.ok_or_else(|| { + Error::invalid_params( + "Wit/2.1 stake authorizations require an explicit expiration_epoch", + ) + })?; + let commission_percent = params.commission_percent.ok_or_else(|| { + Error::invalid_params( + "Wit/2.1 stake authorizations require an explicit commission_percent", + ) + })?; + let terms = match params.action { + STAKE_AUTHORIZATION_ACTION_DELEGATE => { + if params.nonce != 0 { + return Err(Error::invalid_params( + "delegation authorizations require nonce zero", + )); + } + StakeAuthorizationTerms::delegation( + network, + validator, + withdrawer, + expiration_epoch, + commission_percent, + ) + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => { + if params.nonce == 0 { + return Err(Error::invalid_params( + "commission updates require a nonzero nonce", + )); + } + StakeAuthorizationTerms::commission_update( + network, + validator, + withdrawer, + expiration_epoch, + commission_percent, + params.nonce, + ) + } + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + if params.nonce != 0 { + return Err(Error::invalid_params("owner registration requires nonce zero")); + } + StakeAuthorizationTerms::register_owner( + network, validator, withdrawer, expiration_epoch, commission_percent, + ) + } + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + if commission_percent != 0 { + return Err(Error::invalid_params("exit intent requires canonical commission zero")); + } + StakeAuthorizationTerms::request_exit( + network, validator, withdrawer, expiration_epoch, params.nonce, + ) + } + action => { + return Err(Error::invalid_params(format!( + "unsupported stake authorization action: {action}" + ))); + } + }; + terms.validate_structure().map_err(internal_error)?; + // V2 authorization expiry is exclusive. Reject an already-expired (or same-epoch) + // code at issuance time so the RPC cannot hand callers a signature that consensus + // will reject at every possible inclusion height. This applies to dry-runs too: a + // dry-run should describe a constructible transaction, not an irrecoverably stale + // authorization. + let terms_protocol_epoch = protocol_epoch_snapshot().await?; + if terms_protocol_epoch.protocol_generation + != issuance_protocol_epoch.protocol_generation + { + return Err(Error::invalid_params( + "protocol schedule changed while the Wit/2.1 authorization terms were being created; retry", + )); + } + let current_epoch = terms_protocol_epoch.current_epoch; + if expiration_epoch <= current_epoch { + return Err(Error::invalid_params(format!( + "Wit/2.1 authorization expiration_epoch {} must be later than current epoch {}", + expiration_epoch, current_epoch + ))); + } + let signature = signature_mngr::sign_data( + terms.signing_digest().map_err(internal_error)?, + ) + .await + .map_err(internal_error)?; + let signed_protocol_epoch = protocol_epoch_snapshot().await?; + if signed_protocol_epoch.protocol_generation + != terms_protocol_epoch.protocol_generation + { + return Err(Error::invalid_params( + "protocol schedule changed while the Wit/2.1 stake authorization was being created; retry", + )); + } + let signed_at_epoch = signed_protocol_epoch.current_epoch; + if expiration_epoch <= signed_at_epoch { + return Err(Error::invalid_params(format!( + "Wit/2.1 authorization expiration_epoch {} was reached while the signature was being created (current epoch {})", + expiration_epoch, signed_at_epoch + ))); + } + let code = StakeAuthorizationCode { + terms: terms.clone(), + signature: signature.clone(), + } + .to_hex() + .map_err(internal_error)?; + + StakeAuthorization { + withdrawer, + signature, + authorization_terms: Some(terms.into()), + code: Some(code), + } + } + version => { + return Err(Error::invalid_params(format!( + "unsupported stake authorization version: {version}" + ))); + } + }; + + serde_json::to_value(authorization).map_err(internal_error) } /// Query the amount of nanowits staked by an address. @@ -2343,10 +2951,7 @@ pub async fn query_stakes(params: Result, Error>) -> JsonRpc .send(msg) .map(|res| match res { Ok(Ok(stakes)) => serde_json::to_value(stakes).map_err(internal_error), - Ok(Err(e)) => { - let err = internal_error_s(e); - Err(err) - } + Ok(Err(e)) => Err(query_stakes_error(e)), Err(e) => { let err = internal_error_s(e); Err(err) @@ -2355,6 +2960,88 @@ pub async fn query_stakes(params: Result, Error>) -> JsonRpc .await } +/// Query the live stake ledger using `queryStakes` semantics and label the projection with the +/// canonical post-block tip observed in the same ChainManager turn. +pub async fn query_stakes_at_tip( + params: Result, Error>, +) -> JsonRpcResult { + let params = params?; + let msg = params.unwrap_or_default(); + ChainManager::from_registry() + .send(msg) + .map(|res| match res { + Ok(Ok(snapshot)) => serde_json::to_value(snapshot).map_err(internal_error), + Ok(Err(e)) => Err(query_stakes_error(e)), + Err(e) => Err(internal_error_s(e)), + }) + .await +} + +/// Return one coherent, bounded, read-only recovery proof. A not-found transaction response alone +/// is deliberately insufficient: all original UTXOs must still exist at a finalized-expiry view. +pub async fn query_stake_recovery_evidence( + params: Result, +) -> JsonRpcResult { + let msg = params?; + msg.validate().map_err(|error| Error::invalid_params(error.to_string()))?; + match ChainManager::from_registry().send(msg).await { + Ok(Ok(evidence)) => serde_json::to_value(evidence).map_err(internal_error), + Ok(Err(error)) => Err(query_stake_recovery_error(error)), + Err(error) => Err(internal_error_s(error)), + } +} + +/// Query a deterministic live stake-ledger projection and the canonical tip that owns that state. It serves complete or bounded withdrawer-filtered projections through the checked ChainManager handlers. +pub async fn query_stake_entries_at_tip_with_params( + params: Result, Error>, +) -> JsonRpcResult { + let params = params?; + let result = match params.and_then(|params| params.withdrawers) { + None => ChainManager::from_registry() + .send(QueryStakeEntriesAtTip) + .await, + Some(withdrawers) => { + if withdrawers.len() > MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS { + return Err(Error::invalid_params(format!( + "stakeEntriesAtTip accepts at most {} withdrawers, got {}", + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS, + withdrawers.len() + ))); + } + let msg = QueryStakeEntriesAtTipByWithdrawers::try_new(withdrawers) + .map_err(|error| Error::invalid_params(error.to_string()))?; + ChainManager::from_registry().send(msg).await + } + }; + + match result { + Ok(Ok(snapshot)) => serde_json::to_value(snapshot).map_err(internal_error), + Ok(Err(e)) => Err(query_stakes_error(e)), + Err(e) => Err(internal_error_s(e)), + } +} + +/// Query the complete live stake ledger using the original no-parameter RPC helper. It delegates to query_stake_entries_at_tip_with_params so both routes share validation and serialization. +pub async fn query_stake_entries_at_tip() -> JsonRpcResult { + query_stake_entries_at_tip_with_params(Ok(None)).await +} + +/// Query persistent owner, commission, and delegated-staking capacity metadata for a validator. It validates the request and delegates policy lookup to the ChainManager actor. +pub async fn query_validator_policy( + params: Result, Error>, +) -> JsonRpcResult { + let params = params?; + let msg = params.ok_or(Error::invalid_params("A 'validator' must be specified"))?; + ChainManager::from_registry() + .send(msg) + .map(|res| match res { + Ok(Ok(policy)) => serde_json::to_value(policy).map_err(internal_error), + Ok(Err(e)) => Err(query_validator_policy_error(e)), + Err(e) => Err(internal_error_s(e)), + }) + .await +} + /// Query data requests transaction hashes by providing a RAD hash value pub async fn search_data_requests( params: Result, Error>, @@ -2383,6 +3070,10 @@ pub async fn search_data_requests( #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct QueryStakingPowersRecord { /// Staking power + #[serde( + serialize_with = "number_to_string", + deserialize_with = "number_from_string" + )] pub power: u64, /// Ranking index (1 .. N) pub ranking: usize, @@ -2393,14 +3084,16 @@ pub struct QueryStakingPowersRecord { } /// Query the amount of nanowits staked by an address. -pub async fn query_powers(params: Result) -> JsonRpcResult { - // Short-circuit if parameters are wrong - let msg = params?; +pub async fn query_powers(params: Result, Error>) -> JsonRpcResult { + // Match the SDK's optional query object and the other read-only query methods: an omitted or + // explicit null params value uses the historical defaults, while a malformed object still + // fails at the JSON-RPC boundary before reaching ChainManager. + let msg = params?.unwrap_or_default(); ChainManager::from_registry() .send(msg) .map(|res| match res { - Ok(candidates) => { + Ok(Ok(candidates)) => { let candidates: Vec = candidates .iter() .map(|(ranking, key, power)| QueryStakingPowersRecord { @@ -2413,6 +3106,10 @@ pub async fn query_powers(params: Result) -> JsonRpcR let candidates = serde_json::to_value(candidates); candidates.map_err(internal_error) } + Ok(Err(e)) => { + let err = internal_error_s(e); + Err(err) + } Err(e) => { let err = internal_error_s(e); Err(err) @@ -2756,7 +3453,12 @@ fn get_transaction_output_value(transaction: &Transaction, output_index: usize) .map(ValueTransferOutput::value) .unwrap_or_default(), Transaction::Unstake(un) => un.body.value(), - Transaction::Stake(st) if st.body.change.is_some() => st.body.change.clone().unwrap().value, + Transaction::Stake(st) => st + .body + .change + .as_ref() + .map(ValueTransferOutput::value) + .unwrap_or_default(), // Commits, reveals, tallies and mints don't have inputs nor outputs anymore _ => 0u64, @@ -2897,18 +3599,15 @@ pub async fn get_data_request(params: Result) -> Js // For the "full" mode, the response resembles that of `get_transaction` GetDataRequestOutput::Full(GetDataRequestFullOutput { status, report }) } else { - // Get current epoch - let current_epoch = match EpochManager::from_registry().send(GetEpoch).await { - Ok(Ok(current_epoch)) => current_epoch, - Ok(Err(e)) => return Err(internal_error(e)), - Err(e) => return Err(internal_error(e)), - }; + let protocol_epoch = protocol_epoch_snapshot().await?; + let current_epoch = protocol_epoch.current_epoch; + let epoch_constants = protocol_epoch.epoch_constants; // Get data request transaction's block epoch: let block_hash = report.block_hash_dr_tx; let block_epoch = if let Some(block_hash) = block_hash { - match get_block_epoch(block_hash).await { + match get_block_epoch(block_hash, &protocol_epoch.protocol_info).await { Ok((block_epoch, _)) => Some(block_epoch), Err(_) => None, } @@ -2922,7 +3621,7 @@ pub async fn get_data_request(params: Result) -> Js // Get data request tally transaction's block epoch: let (_, finalized) = if let Some(block_hash_tally_tx) = report.block_hash_tally_tx { - match get_block_epoch(block_hash_tally_tx).await { + match get_block_epoch(block_hash_tally_tx, &protocol_epoch.protocol_info).await { Ok((tally_epoch, confirmed)) => (Some(tally_epoch), confirmed), Err(_) => (None, false), } @@ -2930,14 +3629,8 @@ pub async fn get_data_request(params: Result) -> Js (None, false) }; - // The timestamp can be only derived from the block epoch once the epoch constants are - // known (this is specially relevant after the V2_0+ fork) - let epoch_constants: EpochConstants = - match EpochManager::from_registry().send(GetEpochConstants).await { - Ok(Some(epoch_constats)) => epoch_constats, - Err(e) => Err(internal_error(e))?, - _ => Err(internal_error(""))?, - }; + // The timestamp and confirmation count use the same protocol/clock generation even if a + // restored schedule is published while the intervening inventory reads are in flight. let timestamp = match block_epoch { Some(block_epoch) => epoch_constants .epoch_timestamp( @@ -2975,8 +3668,12 @@ pub async fn get_data_request(params: Result) -> Js #[allow(clippy::cast_possible_truncation)] async fn get_drt_query_params(dr_tx_hash: Hash) -> Result { let inventory_manager = InventoryManager::from_registry(); + let (protocol_info, _, _) = get_protocol_runtime(); let res = inventory_manager - .send(GetItemTransaction { hash: dr_tx_hash }) + .send(GetItemTransaction { + hash: dr_tx_hash, + protocol_info: Some(protocol_info), + }) .await; match res { Ok(Ok((transaction, _, _))) => match transaction { @@ -3011,7 +3708,7 @@ async fn get_drt_query_params(dr_tx_hash: Hash) -> Result match transaction { + Ok(Ok((transaction, _admission_weight))) => match transaction { Transaction::DataRequest(dr_tx) => { let bytecode = dr_tx .body @@ -3057,17 +3754,25 @@ async fn get_drt_query_params(dr_tx_hash: Hash) -> Result Result<(u32, bool), Error> { +async fn get_block_epoch( + block_hash: Hash, + protocol_info: &ProtocolInfo, +) -> Result<(u32, bool), Error> { let inventory_manager = InventoryManager::from_registry(); let res = inventory_manager - .send(GetItemBlock { hash: block_hash }) + .send(GetItemBlock { + hash: block_hash, + protocol_info: Some(protocol_info.clone()), + }) .await; match res { Ok(Ok(output)) => { let block_epoch = output.block_header.beacon.checkpoint; - let block_hash = output.versioned_hash(get_protocol_version(Some(block_epoch))); + let block_hash = output.versioned_hash( + protocol_info.all_versions.version_for_epoch(block_epoch), + ); // Check if this block is confirmed by a majority of superblock votes let chain_manager = ChainManager::from_registry(); @@ -3138,6 +3843,149 @@ mod tests { use super::*; + #[test] + fn stake_entries_at_tip_omitted_and_explicit_empty_filters_are_distinct() { + let omitted: QueryStakeEntriesAtTipParams = + serde_json::from_value(serde_json::json!({})).unwrap(); + let explicit_empty: QueryStakeEntriesAtTipParams = + serde_json::from_value(serde_json::json!({ "withdrawers": [] })).unwrap(); + + assert_eq!(omitted.withdrawers, None); + assert_eq!(explicit_empty.withdrawers, Some(Vec::new())); + assert!( + QueryStakeEntriesAtTipByWithdrawers::try_new( + explicit_empty.withdrawers.unwrap() + ) + .unwrap() + .into_withdrawers() + .is_empty() + ); + } + + #[test] + fn staking_power_json_is_lossless_at_the_u64_boundary() { + let record = QueryStakingPowersRecord { + power: u64::MAX, + ranking: 1, + validator: "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4".to_string(), + withdrawer: "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4".to_string(), + }; + + let encoded = serde_json::to_value(&record).unwrap(); + assert_eq!( + encoded["power"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + record + ); + } + + #[test] + fn query_stakes_at_tip_preserves_exact_query_shape() { + let validator = PublicKeyHash::from_bytes(&[3; 20]).unwrap(); + let query: QueryStakesAtTip = serde_json::from_value(serde_json::json!({ + "filter": { "validator": validator }, + "params": { "limit": 11, "offset": 7, "since": 0 } + })) + .unwrap(); + let legacy: QueryStakes = query.into(); + assert_eq!(legacy.params.unwrap().limit, Some(11)); + assert!(legacy.filter.unwrap().withdrawer.is_none()); + } + + #[test] + fn stake_entries_at_tip_filter_is_bounded_canonical_and_strictly_decoded() { + let first = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let second = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let query = QueryStakeEntriesAtTipByWithdrawers::try_new(vec![second, first, second]) + .unwrap(); + assert_eq!(query.into_withdrawers(), vec![first, second]); + + assert!(QueryStakeEntriesAtTipByWithdrawers::try_new(vec![ + first; + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS + 1 + ]) + .is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "withdrawers": ["not-a-witnet-address"] + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "withdrawers": [], + "unexpected": true + })) + .is_err() + ); + } + + #[test] + fn staking_absence_errors_include_machine_readable_identity() { + let validator = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let withdrawer = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let stake_error = query_stakes_error(anyhow::Error::new( + ConsensusStakesError::EntryNotFound { + key: StakeKey { + validator, + withdrawer, + }, + }, + )); + assert_eq!( + stake_error.data, + Some(serde_json::json!({ + "code": STAKE_ENTRY_NOT_FOUND_RPC_CODE, + "validator": validator, + "withdrawer": withdrawer, + })) + ); + + let policy_error = query_validator_policy_error(anyhow::Error::new( + ConsensusStakesError::ValidatorNotFound { validator }, + )); + assert_eq!( + policy_error.data, + Some(serde_json::json!({ + "code": VALIDATOR_POLICY_NOT_FOUND_RPC_CODE, + "validator": validator, + })) + ); + } + + #[test] + fn stake_recovery_errors_include_machine_readable_identity() { + use witnet_data_structures::chain::OutputPointer; + + let output = OutputPointer { + transaction_id: Hash::SHA256([7; 32]), + output_index: 3, + }; + let input_error = query_stake_recovery_error(anyhow::Error::new( + StakeRecoveryError::InputUnavailable { output }, + )); + assert_eq!( + input_error.data, + Some(serde_json::json!({ + "code": STAKE_RECOVERY_INPUT_UNAVAILABLE_RPC_CODE, + "output_pointer": output.to_string(), + })) + ); + + let retained_error = query_stake_recovery_error(anyhow::Error::new( + StakeRecoveryError::TransactionRetained, + )); + assert_eq!( + retained_error.data, + Some(serde_json::json!({ + "code": STAKE_RECOVERY_TRANSACTION_RETAINED_RPC_CODE, + })) + ); + } + #[test] fn empty_string_parse_error() { // An empty message should return a parse error @@ -3390,6 +4238,172 @@ mod tests { assert_eq!(s, expected, "\n{s}\n"); } + #[test] + fn delegated_admission_validation_flag_is_additive_and_opt_in() { + let legacy_stake = serde_json::to_value(BuildStakeParams::default()).unwrap(); + let legacy_unstake = serde_json::to_value(BuildUnstakeParams::default()).unwrap(); + assert!(legacy_stake.get("validate_admission").is_none()); + assert!(legacy_unstake.get("validate_admission").is_none()); + + let decoded_stake: BuildStakeParams = serde_json::from_value(legacy_stake).unwrap(); + let decoded_unstake: BuildUnstakeParams = serde_json::from_value(legacy_unstake).unwrap(); + assert!(!decoded_stake.validate_admission); + assert!(!decoded_unstake.validate_admission); + + let validated_stake = BuildStakeParams { + dry_run: true, + validate_admission: true, + ..BuildStakeParams::default() + }; + let validated_unstake = BuildUnstakeParams { + dry_run: true, + validate_admission: true, + ..BuildUnstakeParams::default() + }; + assert_eq!( + serde_json::to_value(validated_stake).unwrap()["validate_admission"], + serde_json::Value::Bool(true) + ); + assert_eq!( + serde_json::to_value(validated_unstake).unwrap()["validate_admission"], + serde_json::Value::Bool(true) + ); + } + + #[test] + fn legacy_stake_and_unstake_amounts_are_lossless_in_human_json() { + let stake = BuildStakeParams { + value: u64::MAX, + fee: Fee::absolute_from_nanowits(u64::MAX), + ..BuildStakeParams::default() + }; + let encoded_stake = serde_json::to_value(&stake).unwrap(); + assert_eq!( + encoded_stake["value"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + encoded_stake["fee"]["absolute"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + serde_json::from_value::(encoded_stake).unwrap(), + stake + ); + + let unstake = BuildUnstakeParams { + value: u64::MAX, + fee: u64::MAX, + ..BuildUnstakeParams::default() + }; + let encoded_unstake = serde_json::to_value(&unstake).unwrap(); + assert_eq!( + encoded_unstake["value"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + encoded_unstake["fee"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!( + serde_json::from_value::(encoded_unstake).unwrap(), + unstake + ); + } + + #[test] + fn unstake_by_debit_params_are_lossless_strict_and_unambiguous() { + let operator = PublicKeyHash::from_bytes(&[8; 20]).unwrap(); + let explicit = BuildUnstakeByDebitParams { + operator: MagicEither::Right(operator), + debit: u64::MAX, + fee: 7, + dry_run: true, + ..BuildUnstakeByDebitParams::default() + }; + assert!(validate_unstake_by_debit_params(&explicit).is_ok()); + + let encoded = serde_json::to_value(&explicit).unwrap(); + assert_eq!( + encoded["debit"], + serde_json::Value::String(u64::MAX.to_string()) + ); + assert_eq!(encoded["fee"], serde_json::Value::String("7".to_string())); + assert!(encoded.get("all").is_none()); + let round_tripped = + serde_json::from_value::(encoded.clone()).unwrap(); + assert_eq!( + round_tripped + .operator + .try_do_magic(|address| PublicKeyHash::from_bech32(get_environment(), &address)) + .unwrap(), + operator + ); + assert_eq!(round_tripped.debit, explicit.debit); + assert_eq!(round_tripped.fee, explicit.fee); + assert_eq!(round_tripped.dry_run, explicit.dry_run); + + let mut numeric = encoded.clone(); + numeric["debit"] = serde_json::json!(11); + numeric["fee"] = serde_json::json!(3); + let numeric = serde_json::from_value::(numeric).unwrap(); + assert_eq!(numeric.debit, 11); + assert_eq!(numeric.fee, 3); + + let all = BuildUnstakeByDebitParams { + operator: MagicEither::Right(operator), + all: true, + fee: 7, + ..BuildUnstakeByDebitParams::default() + }; + assert!(validate_unstake_by_debit_params(&all).is_ok()); + let all_encoded = serde_json::to_value(&all).unwrap(); + assert!(all_encoded.get("debit").is_none()); + assert_eq!(all_encoded["all"], serde_json::Value::Bool(true)); + + for invalid in [ + BuildUnstakeByDebitParams { + operator: MagicEither::Right(operator), + ..BuildUnstakeByDebitParams::default() + }, + BuildUnstakeByDebitParams { + operator: MagicEither::Right(operator), + debit: 7, + all: true, + ..BuildUnstakeByDebitParams::default() + }, + BuildUnstakeByDebitParams { + operator: MagicEither::Right(operator), + debit: 7, + fee: 7, + ..BuildUnstakeByDebitParams::default() + }, + ] { + assert!(validate_unstake_by_debit_params(&invalid).is_err()); + } + + let mut unknown = encoded; + unknown["value"] = serde_json::json!(1); + assert!(serde_json::from_value::(unknown).is_err()); + } + + #[test] + fn unstake_by_debit_is_registered_as_sensitive() { + let mut server = WittyMultiServer::new(); + attach_sensitive_methods(&mut server, false, &None); + let method_name = "unstakeByDebit"; + let request = format!(r#"{{"jsonrpc":"2.0","method":"{method_name}","id":1}}"#); + let expected = format!( + r#"{{"jsonrpc":"2.0","error":{{"code":-32603,"message":{:?}}},"id":1}}"#, + unauthorized_message(method_name) + ); + + assert_eq!( + server.handle_request_sync(&request, Default::default()).unwrap(), + expected + ); + } + #[ignore] #[test] fn list_jsonrpc_methods() { @@ -3411,6 +4425,8 @@ mod tests { "clearPeers", "createVRF", "dataRequestReport", + "delegatedStakingActivationClassification", + "delegatedStakingActivationPreflight", "getBalance", "getBalance2", "getBlock", @@ -3424,6 +4440,7 @@ mod tests { "getReputationAll", "getSuperblock", "getSupplyInfo", + "getSupplyInfo2", "getTransaction", "getUtxoInfo", "getValueTransfer", @@ -3434,14 +4451,24 @@ mod tests { "nodeStats", "peers", "priority", + "protocol", + "queryPowers", + "queryStakeRecoveryEvidence", + "queryStakes", + "queryStakesAtTip", "rewind", + "searchDataRequests", "sendRequest", "sendValue", "sign", "signalingInfo", "stake", + "stakeEntriesAtTip", "syncStatus", "tryRequest", + "unstake", + "unstakeByDebit", + "validatorPolicy", "witnet_subscribe", "witnet_unsubscribe", ] @@ -3464,6 +4491,7 @@ mod tests { "getPkh", "getPublicKey", "getUtxoInfo", + "getSupplyInfo2", "initializePeers", "masterKeyExport", "rewind", @@ -3472,6 +4500,8 @@ mod tests { "sign", "tryRequest", "stake", + "unstake", + "unstakeByDebit", ]; for method_name in expected_sensitive_methods { diff --git a/node/src/actors/messages.rs b/node/src/actors/messages.rs index aeb3e0c044..0e311a9576 100644 --- a/node/src/actors/messages.rs +++ b/node/src/actors/messages.rs @@ -13,15 +13,16 @@ use actix::{ Actor, Addr, Handler, Message, dev::{MessageResponse, OneshotSender, ToEnvelope}, }; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; use tokio::net::TcpStream; use witnet_data_structures::{ chain::{ Block, CheckpointBeacon, DataRequestInfo, DataRequestOutput, Epoch, EpochConstants, Hash, - InventoryEntry, InventoryItem, KeyedSignature, NodeStats, PointerToBlock, PublicKeyHash, - PublicKeyHashParseError, RADRequest, RADTally, Reputation, StakeOutput, StateMachine, - SuperBlock, SuperBlockVote, SupplyInfo, SupplyInfo2, ValueTransferOutput, + InventoryEntry, InventoryItem, KeyedSignature, NodeStats, OutputPointer, PointerToBlock, PublicKeyHash, + PublicKeyHashParseError, RADRequest, RADTally, Reputation, + StakeAuthorization as StakeAuthorizationTerms, StakeOutput, StateMachine, SuperBlock, + SuperBlockVote, SupplyInfo, SupplyInfo2, ValueTransferOutput, priority::PrioritiesEstimate, tapi::{ActiveWips, BitVotesCounter}, }, @@ -29,6 +30,7 @@ use witnet_data_structures::{ get_environment, proto::versioning::{ProtocolInfo, ProtocolVersion}, radon_report::RadonReport, + serialization_helpers::number_from_string, staking::prelude::*, transaction::{ CommitTransaction, DRTransaction, RevealTransaction, StakeTransaction, Transaction, @@ -56,6 +58,7 @@ use crate::{ rad_manager::RadManager, session::Session, }, + storage_mngr::SnapshotInventoryToken, utils::Force, }; @@ -66,6 +69,14 @@ use crate::{ /// Message result of unit pub type SessionUnitResult = (); +fn is_false(value: &bool) -> bool { + !*value +} + +fn is_zero_u64(value: &u64) -> bool { + *value == 0 +} + /// Message to obtain the highest block checkpoint managed by the `ChainManager` /// actor. pub struct GetHighestCheckpointBeacon; @@ -126,6 +137,20 @@ impl Message for AddTransaction { type Result = Result<(), anyhow::Error>; } +/// Validate one already-built transaction against the ChainManager's current standalone and +/// mempool-admission view without publishing it or acquiring any admission reservation. +/// +/// This is intentionally an internal message. The delegated-action JSON-RPC builders expose it +/// only through their opt-in `validate_admission` dry-run flag, after the preview has been signed. +pub struct ValidateTransactionReadOnly { + /// Signed transaction whose current local admissibility should be inspected. + pub transaction: Transaction, +} + +impl Message for ValidateTransactionReadOnly { + type Result = Result<(), anyhow::Error>; +} + /// Ask for a block identified by its hash pub struct GetBlock { /// Block hash @@ -239,6 +264,14 @@ pub struct BuildStake { /// Construct the transaction but do not broadcast it #[serde(default)] pub dry_run: bool, + /// Optional protocol-epoch pin captured by a JSON-RPC caller before asynchronous + /// construction. When present, ChainManager must use the same actor epoch and protocol + /// schedule generation or reject the request as stale. Internal legacy callers may omit it. + #[serde(skip)] + pub protocol_epoch: Option, + /// Monotonic schedule/clock generation paired with `protocol_epoch`. + #[serde(skip)] + pub protocol_generation: Option, } impl Message for BuildStake { @@ -250,15 +283,30 @@ impl Message for BuildStake { pub struct BuildUnstake { /// Node address operating the staked coins pub operator: PublicKeyHash, - /// Amount to unstake + /// Historical net withdrawal output; `fee` is debited from stake in addition to this value. #[serde(default)] pub value: u64, + /// Gross amount to debit from the exact `(operator, withdrawer)` stake entry, including fee. + /// `None` preserves the historical net-output interpretation of `value`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub debit: Option, + /// Debit the exact live balance of the `(operator, withdrawer)` stake entry at construction + /// time. This is deliberately resolved inside ChainManager rather than by an RPC client. + #[serde(default, skip_serializing_if = "is_false")] + pub all: bool, /// Fee for the unstake transaction #[serde(default)] pub fee: u64, /// Construct the transaction but do not broadcast it #[serde(default)] pub dry_run: bool, + /// Optional protocol-epoch pin captured before asynchronous construction. When present, + /// ChainManager rejects a request if the actor epoch or schedule generation changed. + #[serde(skip)] + pub protocol_epoch: Option, + /// Monotonic schedule/clock generation paired with `protocol_epoch`. + #[serde(skip)] + pub protocol_generation: Option, } impl Message for BuildUnstake { @@ -270,15 +318,68 @@ impl Message for BuildUnstake { pub struct BuildUnstakeParams { /// Node address operating the staked coins pub operator: MagicEither, - /// Amount to unstake - #[serde(default)] + /// Historical net withdrawal output; `fee` is debited from stake in addition to this value. + /// Human-readable JSON uses a decimal string so large nanowit amounts remain lossless. + #[serde( + default, + serialize_with = "serialize_u64_lossless", + deserialize_with = "number_from_string" + )] pub value: u64, - /// Fee for the unstake transaction - #[serde(default)] + /// Fee for the unstake transaction. Human-readable JSON uses a decimal string. + #[serde( + default, + serialize_with = "serialize_u64_lossless", + deserialize_with = "number_from_string" + )] pub fee: u64, /// Construct the transaction but do not broadcast it #[serde(default)] pub dry_run: bool, + /// During a dry run, additionally require the signed preview to pass the node's current + /// standalone validation and local mempool-retention policy. Omitted/false preserves the + /// historical construction-only preview, including while the node is still synchronizing. + #[serde(default, skip_serializing_if = "is_false")] + pub validate_admission: bool, +} + +/// Builds an `UnstakeTransaction` by gross stake debit. +/// +/// This is intentionally a separate, strict JSON-RPC shape. Extending the historical `unstake` +/// parameters would be unsafe because an older node can ignore unknown fields and interpret an +/// omitted `value` as a zero-valued net withdrawal. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildUnstakeByDebitParams { + /// Node address operating the staked coins. + pub operator: MagicEither, + /// Gross amount removed from the exact local withdrawer's stake entry, including `fee`. + /// Exactly one of a positive `debit` or `all` must be selected. + #[serde( + default, + skip_serializing_if = "is_zero_u64", + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string" + )] + pub debit: u64, + /// Debit the entry's entire live balance as observed atomically by ChainManager while the + /// transaction is constructed. The balance can change before eventual block inclusion. + #[serde(default, skip_serializing_if = "is_false")] + pub all: bool, + /// Fee included within the gross debit. + #[serde( + default, + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string" + )] + pub fee: u64, + /// Construct the transaction but do not broadcast it. + #[serde(default)] + pub dry_run: bool, + /// During a dry run, additionally require the signed preview to pass the node's current + /// standalone validation and local mempool-retention policy. + #[serde(default, skip_serializing_if = "is_false")] + pub validate_admission: bool, } /// Builds a `StakeTransaction` from a list of `ValueTransferOutput`s @@ -287,14 +388,28 @@ pub struct BuildStakeParams { /// Authorization signature and public key #[serde(default)] pub authorization: MagicEither, - /// List of `ValueTransferOutput`s - #[serde(default)] + /// Structured Wit/2.1 terms when `authorization` is supplied as a + /// `KeyedSignature`. A portable V2 string already embeds these terms. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_terms: Option, + /// List of `ValueTransferOutput`s. Human-readable JSON uses a decimal string so large + /// nanowit amounts remain lossless. + #[serde( + default, + serialize_with = "serialize_u64_lossless", + deserialize_with = "number_from_string" + )] pub value: u64, /// Withdrawer #[serde(default)] pub withdrawer: MagicEither, - /// Fee - #[serde(default)] + /// Fee. Absolute nanowits use a decimal string in human-readable JSON; relative fees retain + /// their historical numeric representation. + #[serde( + default, + serialize_with = "serialize_fee_lossless", + deserialize_with = "deserialize_fee_backwards_compatible" + )] pub fee: Fee, /// Strategy to sort the unspent outputs pool #[serde(default)] @@ -302,6 +417,11 @@ pub struct BuildStakeParams { /// Construct the transaction but do not broadcast it #[serde(default)] pub dry_run: bool, + /// During a dry run, additionally require the signed preview to pass the node's current + /// standalone validation and local mempool-retention policy. Omitted/false preserves the + /// historical construction-only preview, including pre-activation preparation. + #[serde(default, skip_serializing_if = "is_false")] + pub validate_admission: bool, } /// The response to a `BuildStake` message. It gives important feedback about the addresses that will be involved in a @@ -323,15 +443,79 @@ pub struct BuildStakeResponse { /// Builds an `AuthorizeStake` #[derive(Clone, Debug, Default, Hash, Eq, PartialEq, Serialize, Deserialize)] pub struct AuthorizeStake { + /// Target validator for a withdrawer-signed exit. Other actions use the local validator. + #[serde(default)] + pub validator: Option, /// Address that can withdraw the stake #[serde(default)] pub withdrawer: Option, + /// Authorization format. Omit for the format appropriate to the active protocol. + #[serde(default)] + pub version: Option, + /// Exclusive expiration epoch. Required for Wit/2.1. + #[serde(default)] + pub expiration_epoch: Option, + /// Validator-wide whole-number commission percentage (0 through 15). Keep this optional at + /// the JSON boundary so Wit/2.1 can distinguish an explicitly requested 0% commission from an + /// omitted mandatory field. The CLI supplies `Some(0)` when its legacy-compatible default is + /// selected. + #[serde(default)] + pub commission_percent: Option, + /// 0 delegates/top-ups; 1 updates commission; 2 registers owner; 3 requests entry exit. + #[serde(default)] + pub action: u32, + /// Commission-update nonce or exact entry nonce for exit; deposit actions use zero. + #[serde(default, deserialize_with = "number_from_string")] + pub nonce: u64, } impl Message for AuthorizeStake { type Result = Result; } +/// JSON response representation of Wit/2.1 stake authorization terms. +/// +/// This keeps large nonces lossless for clients without changing the +/// serialization of the persistent transaction type. +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub struct StakeAuthorizationTermsInfo { + /// Authorization format version. + pub version: u32, + /// Network identifier bound into the authorization. + pub network: u32, + /// Validator receiving the delegated stake. + pub validator: PublicKeyHash, + /// Address allowed to withdraw the stake. + pub withdrawer: PublicKeyHash, + /// Exclusive expiration epoch. + pub expiration_epoch: Epoch, + /// Validator-wide whole-number commission percentage. + pub commission_percent: u32, + /// Authorization action. + pub action: u32, + /// Replay-protection nonce. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string" + )] + pub nonce: u64, +} + +impl From for StakeAuthorizationTermsInfo { + fn from(terms: StakeAuthorizationTerms) -> Self { + Self { + version: terms.version, + network: terms.network, + validator: terms.validator, + withdrawer: terms.withdrawer, + expiration_epoch: terms.expiration_epoch, + commission_percent: terms.commission_percent, + action: terms.action, + nonce: terms.nonce, + } + } +} + /// Builds an `StakeAuthorization` #[derive(Clone, Debug, Default, Hash, Eq, PartialEq, Serialize, Deserialize)] pub struct StakeAuthorization { @@ -339,6 +523,12 @@ pub struct StakeAuthorization { pub withdrawer: PublicKeyHash, /// A node's signature of a withdrawer's address pub signature: KeyedSignature, + /// Signed Wit/2.1 terms. Omitted for a legacy authorization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_terms: Option, + /// Self-contained portable authorization code. Omitted for legacy responses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, } impl Message for StakeAuthorization { @@ -349,13 +539,22 @@ impl Message for StakeAuthorization { #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct QueryStakingPowers { - /// Retrieve only first appearence for every distinct validator (default: false) + /// Retrieve one record per validator (default: false). Before Wit/2.1 this retains the + /// historical first-entry projection; under Wit/2.1 it reports checked aggregate power keyed + /// to the canonical owner. pub distinct: Option, /// Limits max number of entries to return (default: 0 == u16::MAX) pub limit: Option, /// Skips first found entries (default: 0) pub offset: Option, /// Order by specified capability (default: reverse order by coins) + /// + /// Keep this field optional at the JSON-RPC boundary as well as in the + /// Rust `Default` implementation. The TypeScript provider accepts an + /// omitted query object, and older callers commonly send `{}`; without + /// this attribute serde would reject those requests before the manual + /// defaults could be applied. + #[serde(default)] pub order_by: Capability, } @@ -371,7 +570,7 @@ impl Default for QueryStakingPowers { } impl Message for QueryStakingPowers { - type Result = Vec<(usize, StakeKey, Power)>; + type Result = Result, Power)>, anyhow::Error>; } /// Stake key for quering stakes @@ -461,6 +660,371 @@ impl Message for QueryStakes { >; } +/// Query the live stake ledger together with the exact block tip that owns the result. +/// +/// This has the same filter, ordering, and pagination contract as [`QueryStakes`], but its +/// response is labelled inside the same ChainManager turn. A filter that has no matching live +/// entry returns an empty, tip-labelled projection instead of an unlabelled not-found error. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct QueryStakesAtTip { + /// Query's where clause. + pub filter: Option, + /// Query's ordering and pagination parameters. + pub params: Option, +} + +impl From for QueryStakes { + fn from(query: QueryStakesAtTip) -> Self { + Self { + filter: query.filter, + params: query.params, + } + } +} + +impl Message for QueryStakesAtTip { + type Result = Result; +} + +/// Bound work performed by one recovery-evidence request before reading storage. +pub const MAX_STAKE_RECOVERY_INPUTS: usize = 128; + +/// Read-only evidence for recovering a locally retained, expired stake transaction. +/// +/// The caller must bind these pointers to the exact signed transaction with this hash. This +/// endpoint does not accept a signed payload and cannot authenticate that binding itself. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QueryStakeRecoveryEvidence { + /// Exact hash of the retained signed transaction. + pub transaction_hash: Hash, + /// Every original input pointer, without duplicates; input order is immaterial. + pub input_pointers: Vec, +} + +impl QueryStakeRecoveryEvidence { + /// Validate the resource bound and reject duplicate pointers rather than hiding them. + pub fn validate(&self) -> anyhow::Result<()> { + if self.input_pointers.is_empty() || self.input_pointers.len() > MAX_STAKE_RECOVERY_INPUTS { + anyhow::bail!("stake recovery requires 1..={} input pointers", MAX_STAKE_RECOVERY_INPUTS); + } + let unique: HashSet<_> = self.input_pointers.iter().collect(); + if unique.len() != self.input_pointers.len() { + anyhow::bail!("stake recovery input pointers must be unique"); + } + Ok(()) + } +} + +impl Message for QueryStakeRecoveryEvidence { + type Result = Result; +} + +/// Coherent canonical state and finalized boundary captured in one ChainManager turn. +/// +/// A client may release its local reservation only after also proving that the retained signed +/// authorization has expired at `finalized_tip`, that the supplied inputs are precisely its +/// original inputs, and that every returned owner/value/time lock matches its original snapshot. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StakeRecoveryEvidence { + /// Requested transaction hash. + pub transaction_hash: Hash, + /// Consensus network magic, not a user-provided label. + pub network: u16, + /// Epoch observed together with the chain state. + pub queried_epoch: Epoch, + /// Canonical post-block tip at which the input projection was read. + pub tip: CheckpointBeacon, + /// Last canonical block covered by the consolidated superblock boundary. + pub finalized_tip: CheckpointBeacon, + /// True only when all supplied original inputs remain unspent and this hash is not in + /// the retained mempool. The signed-input binding documented above is mandatory. + pub transaction_absent: bool, + /// Exact, deterministic input projection. A spent/missing input makes the query fail closed. + pub unspent_inputs: Vec, +} + +/// Lossless original-UTXO metadata used to release a retained SDK reservation safely. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StakeRecoveryInput { + /// Canonical output pointer. + pub output_pointer: OutputPointer, + /// Owner committed in the UTXO. + pub pkh: PublicKeyHash, + /// Amount in nanowits, serialized as a decimal string. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub value: u64, + /// Absolute lock timestamp, serialized as a decimal string. + #[serde(serialize_with = "serialize_u64_as_string", deserialize_with = "number_from_string")] + pub time_lock: u64, +} + +/// Query the complete live stake ledger together with the exact block tip that owns it. +/// +/// Unlike two independent `getBlockChain` and `queryStakes` calls, this message is handled in one +/// ChainManager turn, so consumers can prove that the returned stake amounts belong to the +/// advertised tip. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct QueryStakeEntriesAtTip; + +impl Message for QueryStakeEntriesAtTip { + type Result = Result; +} + +/// Optional JSON-RPC parameters for [`QueryStakeEntriesAtTip`]. +/// +/// An omitted `withdrawers` field retains the historical complete-ledger behavior. An explicitly +/// empty list is intentionally different and selects no entries. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QueryStakeEntriesAtTipParams { + /// Select only entries controlled by any of these withdrawers. + #[serde(default)] + pub withdrawers: Option>, +} + +/// Query a bounded set of withdrawer indexes together with the exact block tip that owns them. +/// +/// Construction validates the raw request size before canonicalizing it, preventing a request +/// containing thousands of duplicate values from bypassing the resource bound. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QueryStakeEntriesAtTipByWithdrawers { + withdrawers: Vec, +} + +impl QueryStakeEntriesAtTipByWithdrawers { + /// Validate, sort, and deduplicate one filtered query. + pub fn try_new(mut withdrawers: Vec) -> anyhow::Result { + if withdrawers.len() > MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS { + anyhow::bail!( + "stakeEntriesAtTip accepts at most {} withdrawers, got {}", + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS, + withdrawers.len() + ); + } + withdrawers.sort_unstable(); + withdrawers.dedup(); + + Ok(Self { withdrawers }) + } + + /// Consume the request and return its canonical withdrawer list. + pub fn into_withdrawers(self) -> Vec { + self.withdrawers + } +} + +impl Message for QueryStakeEntriesAtTipByWithdrawers { + type Result = Result; +} + +/// Tip-bound, deterministic projection of live stake state. +/// +/// `stakeEntriesAtTip` returns canonical key order, while `queryStakesAtTip` preserves the exact +/// ordering and pagination requested through [`QueryStakesParams`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StakeEntriesAtTip { + /// Canonical block tip whose post-block stake state is represented by `entries`. + pub tip: CheckpointBeacon, + /// Exact live entries in the deterministic order defined by the serving query. + pub entries: Vec, +} + +/// Query persistent owner and commission metadata for one validator. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QueryValidatorPolicy { + /// Validator/operator public-key hash. + pub validator: MagicEither, +} + +impl Message for QueryValidatorPolicy { + type Result = Result; +} + +/// Read-only validator policy and delegated-staking capacity report. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValidatorPolicyInfo { + /// Validator/operator public-key hash. + pub validator: PublicKeyHash, + /// Node epoch used to resolve the effective commission and activation projection. + /// + /// This field is additive so an upgraded CLI can still deserialize a response from an older + /// node that already implemented `validatorPolicy` during the dormant rollout. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queried_epoch: Option, + /// Canonical post-block tip whose stake, policy, and reservation state is represented. + /// + /// Together with `queried_epoch`, this lets a client bind every value in the response to the + /// single ChainManager turn that produced it instead of combining an unlabelled policy with a + /// separately observed status response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tip: Option, + /// Immutable owner withdrawer. + pub owner_withdrawer: PublicKeyHash, + /// Commission currently stored as active policy. + pub active_commission_percent: u8, + /// Commission effective at the queried current epoch. + pub effective_commission_percent: u8, + /// Last accepted or activated commission-update nonce. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub last_accepted_nonce: u64, + /// Pending commission change, if any. + pub pending_commission: Option, + /// Sum of all live stake entries for this validator, in nanowits. + #[serde( + serialize_with = "serialize_wit_as_nanowits_string", + deserialize_with = "deserialize_wit_from_nanowits", + )] + pub live_aggregate_stake: Wit, + /// Sum of all outstanding reservations for this validator, in nanowits. + #[serde( + serialize_with = "serialize_wit_as_nanowits_string", + deserialize_with = "deserialize_wit_from_nanowits", + )] + pub outstanding_reserved_collateral: Wit, + /// Outstanding collateral aggregated by the exact live stake entry that funded it. + /// + /// This read-only projection lets clients distinguish a reservation on the entry they are + /// about to remove from unrelated reservations belonging to the same validator. Amounts are + /// already absent from live stake and therefore must not be subtracted from `delegation_entries`. + #[serde(default)] + pub reservation_debits: Vec, + /// Withdrawers whose signed drain intent excludes their entry from new participation. + /// None means an older node omitted this capability, not proof that no entry is draining. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exiting_withdrawers: Option>, + /// Withdrawers with an earned native request reward still pending, including zero-debit + /// participants. These entries cannot be removed until the corresponding requests settle. + /// None is unknown for safety-sensitive clients; the current handler always emits Some. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_reward_withdrawers: Option>, + /// Whether the owner entry currently meets the minimum self-stake. + pub active_owner: bool, + /// Exact delegated stake entries belonging to this validator. + pub delegation_entries: Vec, +} + +/// JSON-facing stake entry for [`ValidatorPolicyInfo`]. +/// +/// This deliberately stores nanowits as `u64` fields with string serialization. The generic +/// staking entry type is also used by persistent state and therefore cannot change its +/// human-readable representation without changing unrelated serialization consumers. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValidatorPolicyStakeEntry { + /// Validator and withdrawer identity for this entry. + pub key: StakeKey, + /// Stake amount and age metadata. + pub value: ValidatorPolicyStake, +} + +/// JSON-facing aggregate of unresolved collateral debited from one exact stake entry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValidatorPolicyReservationDebit { + /// Validator and withdrawer identity of the live post-debit entry. + pub key: StakeKey, + /// Sum of every unresolved reservation debit for this entry, in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub amount: u64, +} + +/// JSON-facing stake amount and age metadata for a validator policy response. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValidatorPolicyStake { + /// Stake amount in nanowits. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub coins: u64, + /// Average mining and witnessing epochs for this entry. + pub epochs: CapabilityMap, + /// Replay-protection nonce for this entry. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub nonce: u64, +} + +/// JSON-facing pending commission data. Keep the persistent staking type separate so the +/// lossless string representation does not alter ChainState/bincode serialization. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PendingCommissionInfo { + /// Proposed whole-number validator commission percentage. + pub commission_percent: u8, + /// First epoch at which the proposed commission applies. + pub effective_epoch: Epoch, + /// Validator-scoped sequence number covered by the authorization. + #[serde( + serialize_with = "serialize_u64_as_string", + deserialize_with = "number_from_string", + )] + pub nonce: u64, +} + +fn serialize_u64_as_string(value: &u64, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +/// Serialize RPC `u64` values as decimal strings for human-readable formats while retaining the +/// native representation for binary serializers. +fn serialize_u64_lossless(value: &u64, serializer: S) -> Result +where + S: Serializer, +{ + if serializer.is_human_readable() { + serialize_u64_as_string(value, serializer) + } else { + value.serialize(serializer) + } +} + +/// Serialize RPC fee objects without exposing absolute nanowits as a JavaScript-unsafe number. +/// Relative fees retain the historical numeric representation, while binary serializers keep +/// the native `Fee` representation untouched. +fn serialize_fee_lossless(value: &Fee, serializer: S) -> Result +where + S: Serializer, +{ + if !serializer.is_human_readable() { + return value.serialize(serializer); + } + + let mut map = serializer.serialize_map(Some(1))?; + match value { + Fee::Absolute(absolute) => { + map.serialize_entry("absolute", &absolute.as_nanowits().to_string())?; + } + Fee::Relative(relative) => { + map.serialize_entry("relative", relative)?; + } + } + map.end() +} + +fn serialize_wit_as_nanowits_string(value: &Wit, serializer: S) -> Result +where + S: Serializer, +{ + serialize_u64_as_string(&value.nanowits(), serializer) +} + +fn deserialize_wit_from_nanowits<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + number_from_string::(deserializer).map(Wit::from_nanowits) +} + impl
TryFrom for QueryStakesKey
where Address: Default + Ord + From, @@ -788,7 +1352,8 @@ pub struct GetMemoryTransaction { } impl Message for GetMemoryTransaction { - type Result = Result; + /// Transaction and the exact weight retained by the mempool at admission. + type Result = Result<(Transaction, u32), ()>; } /// Used to set the target superblock needed for synchronization @@ -931,6 +1496,17 @@ impl Message for SubscribeAll { type Result = (); } +/// Subscribe to every new checkpoint and return the protocol/clock snapshot captured in the same +/// EpochManager actor turn. +pub struct SubscribeAllWithProtocolEpochSnapshot { + /// Notification installed before EpochManager can process another checkpoint-monitor turn. + pub notification: Box, +} + +impl Message for SubscribeAllWithProtocolEpochSnapshot { + type Result = Result; +} + impl Subscribe { /// Subscribe to a specific checkpoint to get an EpochNotification // TODO: rename to to_checkpoint? @@ -966,6 +1542,26 @@ impl Subscribe { }), } } + + /// Subscribe to all checkpoints and atomically capture the current protocol schedule, epoch + /// clock, generation, and epoch calculation in the same EpochManager actor turn. + #[allow(clippy::wrong_self_convention)] + pub fn to_all_with_protocol_epoch_snapshot( + addr: Addr, + payload: T, + ) -> SubscribeAllWithProtocolEpochSnapshot + where + T: 'static + Send + Clone, + U: Actor + Handler>, + U::Context: ToEnvelope>, + { + SubscribeAllWithProtocolEpochSnapshot { + notification: Box::new(AllEpochSubscription { + recipient: addr.recipient(), + payload, + }), + } + } } /// Message that the EpochManager sends to subscriber actors to notify a new epoch @@ -977,6 +1573,11 @@ pub struct EpochNotification { /// This is used to verify that the messages arrive on time pub timestamp: i64, + /// Generation of the atomically paired protocol schedule and epoch clock used to derive this + /// notification. Consumers can reject or recompute a message queued across a runtime + /// schedule replacement. + pub protocol_generation: u64, + /// Payload for the epoch notification pub payload: T, } @@ -994,6 +1595,43 @@ impl Message for GetEpochConstants { type Result = Option; } +/// One actor-turn snapshot of the current epoch, its clock, and the protocol schedule generation +/// that produced both values. +#[derive(Clone, Debug)] +pub struct ProtocolEpochSnapshot { + /// Epoch derived from the captured clock at the time of the actor turn. + pub current_epoch: Epoch, + /// Epoch clock paired with the captured protocol schedule. + pub epoch_constants: EpochConstants, + /// Protocol schedule captured in the same global read as the clock. + pub protocol_info: ProtocolInfo, + /// Monotonic identity of the captured schedule/clock pair. + pub protocol_generation: u64, +} + +/// Coherent runtime snapshot returned by an all-epoch subscription operation. +/// +/// The epoch calculation is carried separately so a node whose checkpoint zero is in the future +/// can retain the newly installed subscription and wake when the network starts. +#[derive(Clone, Debug)] +pub struct SubscribedProtocolEpochSnapshot { + /// Epoch derived from the captured clock, or the deterministic clock-calculation error. + pub current_epoch: EpochResult, + /// Epoch clock paired with the captured protocol schedule. + pub epoch_constants: EpochConstants, + /// Protocol schedule captured in the same global read as the clock. + pub protocol_info: ProtocolInfo, + /// Monotonic identity of the captured schedule/clock pair. + pub protocol_generation: u64, +} + +/// Ask EpochManager for one coherent protocol schedule, epoch clock, and current epoch snapshot. +pub struct GetProtocolEpochSnapshot; + +impl Message for GetProtocolEpochSnapshot { + type Result = Result; +} + //////////////////////////////////////////////////////////////////////////////////////// // MESSAGES FROM INVENTORY MANAGER //////////////////////////////////////////////////////////////////////////////////////// @@ -1003,6 +1641,8 @@ impl Message for GetEpochConstants { pub enum StoreInventoryItem { /// Blocks are stored with all the transactions inside Block(Box), + /// Completed data-request reports are stored beside the block batch that contains their tally. + DataRequestReport(Box), /// Transactions are stored as pointers to blocks Transaction(Hash, PointerToBlock), /// Superblocks are stored as the list of block hashes @@ -1023,12 +1663,32 @@ impl Message for AddItem { pub struct AddItems { /// Item pub items: Vec, + /// Optional caller-pinned protocol schedule used to derive historical block and transaction + /// identities. Internal legacy callers may omit it and let InventoryManager use its current + /// runtime schedule. + pub protocol_info: Option, } impl Message for AddItems { type Result = Result<(), InventoryManagerError>; } +/// Add inventory items to an explicit inactive snapshot generation. +pub struct AddSnapshotItems { + /// Capability identifying the snapshot import attempt and its private inventory namespace. + pub token: SnapshotInventoryToken, + /// Items to add to the snapshot inventory. + pub items: Vec, + /// Protocol schedule authenticated by the snapshot manifest. Block and transaction pointers + /// must be derived from this schedule even though it is published globally only after the + /// inactive generation is committed. + pub protocol_info: Option, +} + +impl Message for AddSnapshotItems { + type Result = Result<(), InventoryManagerError>; +} + /// Ask for an item identified by its hash pub struct GetItem { /// item kind and hash @@ -1043,6 +1703,10 @@ impl Message for GetItem { pub struct GetItemBlock { /// item hash pub hash: Hash, + /// Optional caller-pinned protocol schedule used to validate the block identity. Legacy + /// callers may omit it and let InventoryManager snapshot the current runtime schedule after + /// acquiring its inventory view. + pub protocol_info: Option, } impl Message for GetItemBlock { @@ -1053,6 +1717,10 @@ impl Message for GetItemBlock { pub struct GetItemTransaction { /// item hash pub hash: Hash, + /// Optional caller-pinned protocol schedule used to validate the pointed block and + /// transaction identity. Legacy callers may omit it and let InventoryManager snapshot the + /// current runtime schedule after acquiring its inventory view. + pub protocol_info: Option, } impl Message for GetItemTransaction { @@ -1630,6 +2298,22 @@ impl Message for crate::actors::messages::GetProtocolInfo { type Result = Result, anyhow::Error>; } +/// Message for running the read-only Wit/2.1 delegated-staking activation preflight. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GetDelegatedStakingActivationPreflight; + +impl Message for GetDelegatedStakingActivationPreflight { + type Result = Result; +} + +/// Message for classifying canonical legacy activation-boundary facts without approving activation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GetDelegatedStakingActivationClassification; + +impl Message for GetDelegatedStakingActivationClassification { + type Result = Result; +} + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] #[serde(untagged)] /// A value that can either be L, R, where an R can always be obtained through the `do_magic` method. diff --git a/node/src/actors/session/actor.rs b/node/src/actors/session/actor.rs index c8d74a47ea..9c44613290 100644 --- a/node/src/actors/session/actor.rs +++ b/node/src/actors/session/actor.rs @@ -3,18 +3,20 @@ use actix::{ ContextFutureSpawner, Running, SystemService, WrapFuture, }; -use witnet_data_structures::types::Message as WitnetMessage; +use witnet_data_structures::{get_protocol_runtime, types::Message as WitnetMessage}; use witnet_p2p::sessions::{SessionStatus, SessionType}; use super::{Session, handlers::EveryEpochPayload}; use crate::actors::{ chain_manager::ChainManager, - epoch_manager::{EpochManager, EpochManagerError::CheckpointZeroInTheFuture}, - messages::{AddBlocks, GetEpoch, Register, Subscribe, Unregister}, + epoch_manager::{ + EpochManager, EpochManagerError, EpochManagerError::CheckpointZeroInTheFuture, + }, + messages::{AddBlocks, Register, Subscribe, Unregister}, sessions_manager::SessionsManager, }; -use witnet_util::timestamp::pretty_print; +use witnet_util::timestamp::{get_timestamp, pretty_print}; /// Implement actor trait for Session impl Actor for Session { @@ -133,43 +135,87 @@ impl Actor for Session { } impl Session { - /// Get epoch from EpochManager and subscribe to future epochs + /// Atomically subscribe to future epochs and initialize from the same EpochManager turn. fn subscribe_to_epoch_manager(&mut self, ctx: &mut Context) { // Get EpochManager address from registry let epoch_manager_addr = EpochManager::from_registry(); - // Start chain of actions epoch_manager_addr - // Send GetEpoch message to epoch manager actor - // This returns a RequestFuture, representing an asynchronous message sending process - .send(GetEpoch) - // Convert a normal future into an ActorFuture + .send(Subscribe::to_all_with_protocol_epoch_snapshot( + ctx.address(), + EveryEpochPayload, + )) .into_actor(self) - // Process the response from the EpochManager - // This returns a FutureResult containing the socket address if present .then(move |res, act, ctx| { - // Get ChainManager address - let chain_manager_addr = ctx.address(); - - // Check GetEpoch result match res { - Ok(Ok(epoch)) => { - // Subscribe to all epochs with an EveryEpochPayload - epoch_manager_addr - .do_send(Subscribe::to_all(chain_manager_addr, EveryEpochPayload)); - - // Set current_epoch - act.current_epoch = epoch; + Ok(Ok(snapshot)) => { + // Runtime publication is process-global and may advance while the actor + // response is in flight. Rebase from the latest paired clock before this + // epoch can influence handshake rejection or peer icing. + let (protocol_info, published, protocol_generation) = + get_protocol_runtime(); + let current_epoch = if protocol_generation == snapshot.protocol_generation + { + // The subscription prevents a logical gap, while recomputing from its + // captured clock prevents an in-flight response from briefly exposing + // the preceding epoch to handshake validation. + let rebased_epoch = snapshot + .epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from); + if rebased_epoch != snapshot.current_epoch { + log::debug!( + "Session subscription snapshot crossed an epoch while in flight: {:?} -> {:?}", + snapshot.current_epoch, + rebased_epoch + ); + } + rebased_epoch + } else { + let epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => { + // Compatibility-only bootstrap has no process-wide clock; the + // EpochManager snapshot remains its authoritative local clock. + snapshot.epoch_constants + } + None => { + log::error!( + "Session refuses to initialize from an unpaired protocol schedule" + ); + ctx.stop(); + actix::System::current().stop_with_code(1); + return actix::fut::ready(()); + } + }; + log::warn!( + "Rebasing Session epoch initialization from protocol generation {} to {}", + snapshot.protocol_generation, + protocol_generation + ); + epoch_constants + .epoch_at(get_timestamp()) + .map_err(EpochManagerError::from) + }; + + match current_epoch { + Ok(epoch) => act.current_epoch = epoch, + Err(CheckpointZeroInTheFuture(zero)) => { + let date = pretty_print(zero, 0); + log::warn!("Network bootstrapping is scheduled for {date:?}. The node will remain idle and delay chain bootstrapping until then. Wait for it!"); + } + Err(error) => log::error!( + "Current epoch could not be derived from the subscribed EpochManager snapshot: {error:?}" + ), + } } - Ok(Err(CheckpointZeroInTheFuture(zero))) => { - let date = pretty_print(zero, 0); - log::warn!("Network bootstrapping is scheduled for {date:?}. The node will remain idle and delay chain bootstrapping until then. Wait for it!"); - // Subscribe to all epochs with an EveryEpochPayload - epoch_manager_addr - .do_send(Subscribe::to_all(chain_manager_addr, EveryEpochPayload)); + Ok(Err(error)) => { + log::error!( + "Could not atomically subscribe to EpochManager: {error:?}" + ); } - error => { - log::error!("Current epoch could not be retrieved from EpochManager: {error:?}"); + Err(error) => { + log::error!("Could not communicate with EpochManager: {error:?}"); } } diff --git a/node/src/actors/session/handlers.rs b/node/src/actors/session/handlers.rs index e8aac67033..d3e0172fbe 100644 --- a/node/src/actors/session/handlers.rs +++ b/node/src/actors/session/handlers.rs @@ -13,7 +13,7 @@ use witnet_data_structures::{ chain::{ Block, CheckpointBeacon, Epoch, InventoryEntry, InventoryItem, SuperBlock, SuperBlockVote, }, - get_protocol_version, + get_protocol_info, get_protocol_runtime, proto::versioning::{Versioned, VersionedHashable}, transaction::Transaction, types::{ @@ -26,6 +26,7 @@ use witnet_util::timestamp::get_timestamp; use crate::actors::{ chain_manager::ChainManager, + epoch_manager::{classify_epoch_notification, EpochNotificationDisposition}, inventory_manager::InventoryManager, messages::{ AddBlocks, AddCandidates, AddConsolidatedPeer, AddPeers, AddSuperBlock, AddSuperBlockVote, @@ -90,16 +91,69 @@ impl Handler> for Session { type Result = (); fn handle(&mut self, msg: EpochNotification, ctx: &mut Context) { - log::trace!("Periodic epoch notification received {:?}", msg.checkpoint); let current_timestamp = get_timestamp(); + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + if published.is_none() && !protocol_info.all_versions.is_empty() { + log::error!( + "Session received an epoch notification for an unpaired protocol schedule" + ); + ctx.stop(); + actix::System::current().stop_with_code(1); + return; + } + let (checkpoint, epoch_timestamp) = if msg.protocol_generation == protocol_generation { + (msg.checkpoint, msg.timestamp) + } else { + let Some(epoch_constants) = published else { + log::error!( + "Session cannot rebase a stale epoch notification without a paired clock" + ); + ctx.stop(); + actix::System::current().stop_with_code(1); + return; + }; + let checkpoint = match epoch_constants.epoch_at(current_timestamp) { + Ok(epoch) => epoch, + Err(error) => { + log::error!("Session could not rebase a stale epoch notification: {error}"); + ctx.stop(); + actix::System::current().stop_with_code(1); + return; + } + }; + let epoch_timestamp = match epoch_constants.epoch_timestamp(checkpoint) { + Ok((timestamp, _)) => timestamp, + Err(error) => { + log::error!( + "Session could not timestamp rebased epoch {checkpoint}: {error}" + ); + ctx.stop(); + actix::System::current().stop_with_code(1); + return; + } + }; + (checkpoint, epoch_timestamp) + }; + log::trace!("Periodic epoch notification received {checkpoint:?}"); log::trace!( "Timestamp diff: {}, Epoch timestamp: {}. Current timestamp: {}", - current_timestamp - msg.timestamp, - msg.timestamp, + current_timestamp - epoch_timestamp, + epoch_timestamp, current_timestamp ); - self.current_epoch = msg.checkpoint; + match classify_epoch_notification(Some(self.current_epoch), checkpoint) { + EpochNotificationDisposition::Replay => { + log::trace!("Discarding equal/replayed Session epoch notification {checkpoint}"); + return; + } + EpochNotificationDisposition::ClockRebase => log::warn!( + "Session epoch clock moved backwards from {} to {checkpoint}", + self.current_epoch + ), + EpochNotificationDisposition::Advance => {} + } + self.current_epoch = checkpoint; if self.blocks_timestamp != 0 && current_timestamp - self.blocks_timestamp > self.config.connections.blocks_timeout @@ -660,7 +714,10 @@ fn peer_discovery_peers( fn inventory_process_block(session: &mut Session, _ctx: &mut Context, block: Block) { // Get ChainManager address let chain_manager_addr = ChainManager::from_registry(); - let current_protocol = get_protocol_version(Some(block.block_header.beacon.checkpoint)); + let protocol_info = get_protocol_info(); + let current_protocol = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); let current_block_hash = block.versioned_hash(current_protocol); // This quickly checks if the hash of the received block matches a former inventory request. diff --git a/node/src/actors/sessions_manager/actor.rs b/node/src/actors/sessions_manager/actor.rs index 611729fda1..92b19c1c9a 100644 --- a/node/src/actors/sessions_manager/actor.rs +++ b/node/src/actors/sessions_manager/actor.rs @@ -2,7 +2,7 @@ use super::SessionsManager; use crate::config_mngr; use actix::prelude::*; use witnet_data_structures::{ - chain::EpochConstants, get_protocol_version_activation_epoch, get_protocol_version_period, + chain::EpochConstants, get_protocol_runtime, proto::versioning::ProtocolVersion, }; @@ -45,25 +45,56 @@ impl Actor for SessionsManager { let checkpoints_period = config.consensus_constants.checkpoints_period; let checkpoint_zero_timestamp = config.consensus_constants.checkpoint_zero_timestamp; - let activation_epoch_wit2 = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - let checkpoint_zero_timestamp_wit2 = if activation_epoch_wit2 == u32::MAX { - i64::MAX - } else { - config.consensus_constants.checkpoint_zero_timestamp - + i64::from(get_protocol_version_activation_epoch(ProtocolVersion::V2_0)) - * i64::from(config.consensus_constants.checkpoints_period) + let (protocol_info, published_epoch_constants, _) = get_protocol_runtime(); + let activation_epoch_wit2 = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoints_period_wit2 = activation_epoch_wit2 + .and_then(|_| { + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + }) + .unwrap_or(u16::MAX); + let epoch_constants = match match published_epoch_constants { + Some(epoch_constants) => Ok(epoch_constants), + None if protocol_info.all_versions.is_empty() => EpochConstants::try_new( + checkpoint_zero_timestamp, + checkpoints_period, + activation_epoch_wit2, + checkpoints_period_wit2, + ), + None => { + log::error!( + "Sessions manager refuses a published protocol schedule without its paired epoch clock" + ); + System::current().stop_with_code(1); + return fut::err(anyhow::anyhow!( + "published protocol schedule has no paired epoch clock" + )); + } + } { + Ok(epoch_constants) => epoch_constants, + Err(error) => { + log::error!( + "Sessions manager could not derive configured Wit/2 epoch constants: {error}" + ); + System::current().stop_with_code(1); + return fut::err(anyhow::Error::from(error)); + } }; - let checkpoints_period_wit2 = get_protocol_version_period(ProtocolVersion::V2_0); - let epoch_constants = EpochConstants { - checkpoint_zero_timestamp, - checkpoints_period, - checkpoint_zero_timestamp_wit2, - checkpoints_period_wit2, + act.current_epoch = match epoch_constants.epoch_at(get_timestamp()) { + Ok(current_epoch) => current_epoch, + Err(error) => { + log::error!( + "Sessions manager could not derive the current epoch from its published clock: {error}" + ); + System::current().stop_with_code(1); + return fut::err(anyhow::Error::from(error)); + } }; - act.current_epoch = epoch_constants - .epoch_at(get_timestamp()) - .unwrap_or_default(); + act.epoch_constants = Some(epoch_constants); act.sessions.set_magic_number(10700u16); diff --git a/node/src/actors/sessions_manager/handlers.rs b/node/src/actors/sessions_manager/handlers.rs index b3dd475ce5..56552587c0 100644 --- a/node/src/actors/sessions_manager/handlers.rs +++ b/node/src/actors/sessions_manager/handlers.rs @@ -5,8 +5,8 @@ use std::{ }; use actix::{ - Actor, AsyncContext, Context, Handler, Message, ResponseFuture, StreamHandler, SystemService, - io::FramedWrite, + Actor, AsyncContext, Context, Handler, Message, ResponseFuture, StreamHandler, System, + SystemService, io::FramedWrite, }; use ansi_term::Color::Cyan; use tokio_util::codec::FramedRead; @@ -15,6 +15,7 @@ use super::{NotSendingPeersBeaconsBecause, SessionsManager}; use crate::actors::{ chain_manager::ChainManager, codec::P2PCodec, + epoch_manager::{classify_epoch_notification, EpochNotificationDisposition}, messages::{ AddConsolidatedPeer, AddPeers, Anycast, Broadcast, Consolidate, Create, DropAllPeers, DropOutboundPeers, EpochNotification, GetConsolidatedPeers, LogMessage, NumSessions, @@ -25,10 +26,7 @@ use crate::actors::{ peers_manager::PeersManager, session::Session, }; -use witnet_data_structures::{ - chain::Epoch, get_protocol_version, get_protocol_version_activation_epoch, - get_protocol_version_period, proto::versioning::ProtocolVersion, -}; +use witnet_data_structures::get_protocol_runtime; use witnet_p2p::{ error::SessionsError, sessions::{SessionType, ip_range_string}, @@ -46,8 +44,24 @@ impl Handler for SessionsManager { // Get magic number let magic_number = self.sessions.magic_number; - // Get current epoch - let current_epoch = self.current_epoch; + // Bind new sessions to the epoch from the same runtime pair as the protocol schedule. + let (protocol_info, published, _) = get_protocol_runtime(); + let current_epoch = match published { + Some(constants) => match constants.epoch_at(get_timestamp()) { + Ok(epoch) => epoch, + Err(error) => { + log::error!("Cannot create a session with an invalid runtime clock: {error}"); + System::current().stop_with_code(1); + return; + } + }, + None if protocol_info.all_versions.is_empty() => self.current_epoch, + None => { + log::error!("Cannot create a session for an unpaired protocol schedule"); + System::current().stop_with_code(1); + return; + } + }; let config = self.config.as_ref().expect("Config should be set"); @@ -350,15 +364,84 @@ impl Handler> for SessionsManager { type Result = (); fn handle(&mut self, msg: EpochNotification<()>, ctx: &mut Context) { - log::debug!("Periodic epoch notification received {:?}", msg.checkpoint); + let (protocol_info, published, protocol_generation) = get_protocol_runtime(); + let published_epoch_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => { + let Some(constants) = self.epoch_constants else { + log::error!("Sessions manager received an epoch notification without a clock"); + System::current().stop_with_code(1); + return; + }; + constants + } + None => { + log::error!( + "Sessions manager received an epoch notification while the protocol schedule had no paired clock" + ); + System::current().stop_with_code(1); + return; + } + }; + self.epoch_constants = Some(published_epoch_constants); + let current_timestamp = get_timestamp(); + let (checkpoint, epoch_timestamp) = if msg.protocol_generation == protocol_generation { + (msg.checkpoint, msg.timestamp) + } else { + let checkpoint = match published_epoch_constants.epoch_at(current_timestamp) { + Ok(checkpoint) => checkpoint, + Err(error) => { + log::error!( + "Sessions manager could not rebase a stale epoch notification: {error}" + ); + System::current().stop_with_code(1); + return; + } + }; + let epoch_timestamp = match published_epoch_constants.epoch_timestamp(checkpoint) { + Ok((timestamp, _)) => timestamp, + Err(error) => { + log::error!( + "Sessions manager could not timestamp rebased epoch {checkpoint}: {error}" + ); + System::current().stop_with_code(1); + return; + } + }; + log::warn!( + "Rebased queued epoch notification from protocol generation {} to {} (epoch {} -> {})", + msg.protocol_generation, + protocol_generation, + msg.checkpoint, + checkpoint + ); + (checkpoint, epoch_timestamp) + }; + log::debug!("Periodic epoch notification received {checkpoint:?}"); log::debug!( "Timestamp diff: {}, Epoch timestamp: {}. Current timestamp: {}", - current_timestamp - msg.timestamp, - msg.timestamp, + current_timestamp - epoch_timestamp, + epoch_timestamp, current_timestamp ); + match classify_epoch_notification(Some(self.current_epoch), checkpoint) { + EpochNotificationDisposition::Replay => { + // The paired clock was refreshed above. Do not clear beacons or schedule a second + // mining timeout for an epoch already initialized or handled. + log::debug!( + "Discarding equal/replayed SessionsManager epoch notification {checkpoint}" + ); + return; + } + EpochNotificationDisposition::ClockRebase => log::warn!( + "SessionsManager epoch clock moved backwards from {} to {checkpoint}", + self.current_epoch + ), + EpochNotificationDisposition::Advance => {} + } + log::info!( "{} Inbound: {} | Outbound: {}", Cyan.bold().paint("[Sessions]"), @@ -372,7 +455,7 @@ impl Handler> for SessionsManager { // Clear the logging hashset self.logging_messages.clear(); - self.current_epoch = msg.checkpoint; + self.current_epoch = checkpoint; self.beacons.new_epoch(); // If for some reason we already have all the beacons, send message to ChainManager @@ -382,35 +465,22 @@ impl Handler> for SessionsManager { Err(e) => log::debug!("{e}"), } - // Check if we need to update the epoch constants - if get_protocol_version(Some(msg.checkpoint)) == ProtocolVersion::V1_8 { - if let Some(epoch_constants) = &mut self.epoch_constants { - if epoch_constants.checkpoint_zero_timestamp_wit2 == i64::MAX { - let checkpoints_period_wit2 = - get_protocol_version_period(ProtocolVersion::V2_0); - let activation_epoch_wit2 = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - if checkpoints_period_wit2 != u16::MAX && activation_epoch_wit2 != Epoch::MAX { - match epoch_constants - .set_values_for_wit2(checkpoints_period_wit2, activation_epoch_wit2) - { - Ok(_) => (), - Err(_) => panic!("Could not set wit/2 checkpoint variables"), - }; - } - } - } else { - panic!("Could not set wit/2 checkpoint variables"); - } - } - // Set timeout for receiving beacons // This timeout is also used to trigger block mining - let timestamp_mining = self + let timestamp_mining = match self .epoch_constants .unwrap() - .block_mining_timestamp(msg.checkpoint) - .unwrap(); + .block_mining_timestamp(checkpoint) + { + Ok(timestamp) => timestamp, + Err(error) => { + log::error!( + "Sessions manager could not derive the mining timestamp for epoch {checkpoint}: {error}" + ); + System::current().stop_with_code(1); + return; + } + }; let duration_until_mining = if let Some(d) = duration_until_timestamp(timestamp_mining, 0) { d } else { @@ -418,7 +488,7 @@ impl Handler> for SessionsManager { let delay = timestamp_now - timestamp_mining; if delay < 0 { log::error!("Time went backwards"); - } else if msg.checkpoint > 0 { + } else if checkpoint > 0 { log::warn!( "Epoch notification received too late, not sending beacons to ChainManager and not mining until next epoch" ); @@ -428,6 +498,12 @@ impl Handler> for SessionsManager { }; ctx.run_later(duration_until_mining, move |act, ctx| { + if get_protocol_runtime().2 != protocol_generation { + log::warn!( + "Discarding mining timeout from superseded protocol generation {protocol_generation}" + ); + return; + } // If some peers sent us beacons, but not all of them, the peers beacons message will be sent now match act.try_send_peers_beacons(ctx) { Ok(_) => {} @@ -446,7 +522,7 @@ impl Handler> for SessionsManager { // From this moment, all the received beacons are assumed to be for the next epoch // This fixes a race condition where sometimes we receive a beacon just before the epoch checkpoint act.clear_beacons(); - if msg.checkpoint > 0 { + if checkpoint > 0 { ChainManager::from_registry().do_send(TryMineBlock); } }); @@ -546,11 +622,28 @@ impl Handler for SessionsManager { type Result = (); fn handle(&mut self, msg: SetEpochConstants, _ctx: &mut Context) -> Self::Result { - self.epoch_constants = Some(msg.epoch_constants); - - self.current_epoch = msg - .epoch_constants - .epoch_at(get_timestamp()) - .unwrap_or_default(); + let (protocol_info, published, _) = get_protocol_runtime(); + let next_constants = match published { + Some(constants) => constants, + None if protocol_info.all_versions.is_empty() => msg.epoch_constants, + None => { + log::error!( + "Sessions manager refuses to install actor constants for an unpaired protocol schedule" + ); + System::current().stop_with_code(1); + return; + } + }; + self.current_epoch = match next_constants.epoch_at(get_timestamp()) { + Ok(current_epoch) => current_epoch, + Err(error) => { + log::error!( + "Sessions manager could not derive the current epoch from updated constants: {error}" + ); + System::current().stop_with_code(1); + return; + } + }; + self.epoch_constants = Some(next_constants); } } diff --git a/node/src/actors/sessions_manager/mod.rs b/node/src/actors/sessions_manager/mod.rs index c3c89f9563..aa550f592d 100644 --- a/node/src/actors/sessions_manager/mod.rs +++ b/node/src/actors/sessions_manager/mod.rs @@ -28,6 +28,7 @@ use thiserror::Error; use witnet_config::config::Config; use witnet_data_structures::{ chain::{CheckpointBeacon, Epoch, EpochConstants}, + get_protocol_runtime, types::LastBeacon, }; @@ -181,7 +182,21 @@ impl SessionsManager { .send(GetEpochConstants) .into_actor(self) .map(move |res, act, _ctx| match res { - Ok(Some(f)) => act.epoch_constants = Some(f), + Ok(Some(f)) => { + let (protocol_info, published, _) = get_protocol_runtime(); + match published { + Some(constants) => act.epoch_constants = Some(constants), + None if protocol_info.all_versions.is_empty() => { + act.epoch_constants = Some(f) + } + None => { + log::error!( + "Sessions manager refuses an epoch clock response for an unpaired protocol schedule" + ); + actix::System::current().stop_with_code(1); + } + } + } Ok(None) => log::error!("Failed to get epoch constants"), Err(err) => log::error!("Failed to get epoch constants: {err:?}"), }) diff --git a/node/src/actors/storage_keys.rs b/node/src/actors/storage_keys.rs index a7f32e3e8e..9a51c67dab 100644 --- a/node/src/actors/storage_keys.rs +++ b/node/src/actors/storage_keys.rs @@ -10,6 +10,34 @@ pub fn chain_state_key(magic: u16) -> String { format!("chain-{magic}-key") } +/// Database key for the locally authenticated certificate of a dynamically discovered Wit/2.0 +/// activation. It is deliberately separate from the versioned ChainState wire layout so legacy +/// snapshots and databases remain decodable. +#[inline] +pub fn wit2_activation_certificate_key(magic: u16) -> String { + wit2_activation_certificate_key_for_chain_state(&chain_state_key(magic)) +} + +/// Derive the activation-certificate companion key from the exact ChainState key used by a load. +#[inline] +pub fn wit2_activation_certificate_key_for_chain_state(chain_state_key: &str) -> String { + format!("{chain_state_key}-wit2-activation-certificate-v1") +} + +/// Database key for the protocol schedule authenticated by a portable snapshot import. The +/// schedule is kept separate from the ChainState wire layout so legacy state and snapshots remain +/// decodable, while the companion can be committed and restored with the exact ChainState image. +#[inline] +pub fn protocol_schedule_key(magic: u16) -> String { + protocol_schedule_key_for_chain_state(&chain_state_key(magic)) +} + +/// Derive the protocol-schedule companion key from the exact ChainState key used by a load. +#[inline] +pub fn protocol_schedule_key_for_chain_state(chain_state_key: &str) -> String { + format!("{chain_state_key}-protocol-schedule-v1") +} + /// Function to create a peers key for the storage #[inline] pub fn peers_key(magic: u16) -> String { diff --git a/node/src/signature_mngr.rs b/node/src/signature_mngr.rs index c0157de260..e9c42dd49a 100644 --- a/node/src/signature_mngr.rs +++ b/node/src/signature_mngr.rs @@ -479,7 +479,17 @@ impl Handler for SignatureManager { type Result = ::Result; fn handle(&mut self, msg: VerifySignatures, _ctx: &mut Self::Context) -> Self::Result { - validations::verify_signatures(msg.0, self.vrf_ctx.as_mut().unwrap()).map(|_| ()) + let Some(vrf) = self.vrf_ctx.as_mut() else { + // Signature verification can be requested while startup is still unwinding or after + // VRF-context initialization failed. Returning a structured error keeps the actor + // fail-closed; an unwrap here would panic the synchronous signature worker and turn a + // recoverable validation failure into process-wide termination. + return Err(anyhow::anyhow!( + "Signature Manager cannot verify signatures because it does not contain a VRF context" + )); + }; + + validations::verify_signatures(msg.0, vrf).map(|_| ()) } } diff --git a/node/src/storage_mngr.rs b/node/src/storage_mngr.rs index 1832854c3b..3b305b7cc6 100644 --- a/node/src/storage_mngr.rs +++ b/node/src/storage_mngr.rs @@ -3,6 +3,7 @@ //! This module provides a Storage Manager use std::{ any::{Any, TypeId}, + collections::{BTreeMap, HashSet}, future, sync::Arc, time::Duration, @@ -12,17 +13,30 @@ use actix::prelude::*; use bincode::{deserialize, serialize}; use futures::Future; use futures_util::FutureExt; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use witnet_crypto::hash::calculate_sha256; use crate::{ + actors::messages::SuperBlockNotify, config_mngr, utils::{FlattenResult, stop_system_if_panicking}, }; use witnet_config::{config, config::Config}; use witnet_data_structures::{ - chain::ChainState, + bincode_compatibility, + chain::{ + Block, ChainState, CheckpointBeacon, DataRequestInfo, Environment, Hash, Hashable, + PointerToBlock, + }, + get_protocol_info, + proto::versioning::{ProtocolInfo, VersionedHashable}, utxo_pool::{CacheUtxosByPkh, UtxoDb, UtxoDbWrapStorage, UtxoWriteBatch}, }; -use witnet_storage::{backends, storage::Storage}; +use witnet_storage::{ + backends, + storage::{Storage, WriteBatch}, +}; pub use node_migrations::*; @@ -34,6 +48,587 @@ macro_rules! as_failure { }; } +/// Database schema embedded in generation-qualified inventory keys. +/// +/// Descriptor evolution must not silently move an already-published generation into a different +/// physical namespace, so key and descriptor versions advance independently. +const INVENTORY_KEY_STORAGE_VERSION: u32 = 2; +/// Historical descriptor schema without a durable superblock-sequence commitment. +const LEGACY_INVENTORY_DESCRIPTOR_STORAGE_VERSION: u32 = 2; +/// Current descriptor schema, which durably binds the complete current superblock sequence. +const INVENTORY_DESCRIPTOR_STORAGE_VERSION: u32 = 3; +/// Domain separator embedded in every generation-qualified inventory key. +const INVENTORY_KEY_MAGIC: [u8; 16] = *b"WITNET-INV-KEY!!"; +/// Domain separator embedded in every active or per-generation descriptor value. +const INVENTORY_DESCRIPTOR_MAGIC: [u8; 16] = *b"WITNET-INV-DESC!"; +/// The active-generation descriptor is the sole inventory visibility commit point. Current +/// descriptors also carry the canonical full-sequence superblock commitment, advanced atomically +/// with normal superblock appends. +const ACTIVE_INVENTORY_DESCRIPTOR_KEY: &[u8] = b"\0witnet/inventory/active/v1\0"; +/// Registry entries make incomplete and superseded generations discoverable for safe cleanup. +const INVENTORY_GENERATION_DESCRIPTOR_PREFIX: &[u8] = + b"\0witnet/inventory/generation-descriptor/v1/"; +const INVENTORY_CLEANUP_BATCH_SIZE: usize = 10_000; +/// Historical logical-key prefix used by InventoryManager for persisted superblock notifications. +const SUPERBLOCK_LOGICAL_KEY_PREFIX: &str = "SUPERBLOCK-"; +const SUPERBLOCK_LOGICAL_INDEX_DIGITS: usize = 10; + +/// A logical inventory value domain. New generation-qualified keys separate blocks, transaction +/// pointers, superblocks, and data-request reports even though the legacy database used one key +/// space for all four. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[repr(u8)] +pub enum InventoryDomain { + /// A complete block, addressed by its versioned hash. + Block = 1, + /// A transaction-to-block pointer, addressed by transaction hash. + Transaction = 2, + /// A superblock notification, addressed by its padded logical index key. + Superblock = 3, + /// A data-request report, addressed by its historical `DR-REPORT-*` logical key. + Report = 4, +} + +impl InventoryDomain { + const ALL: [Self; 4] = [ + Self::Block, + Self::Transaction, + Self::Superblock, + Self::Report, + ]; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SuperblockValidationScope { + /// The inactive namespace must exactly equal the snapshot being published. + ExactSnapshot, + /// The active namespace may contain normally appended records beyond the imported tip. A v3 + /// descriptor commitment covers that complete current sequence and advances atomically with + /// each append; a legacy v2 descriptor can validate only its original structural prefix. + ActiveNamespace, +} + +/// Opaque, per-attempt database inventory generation identity. +/// +/// This must never be derived only from a snapshot manifest or chain tip: forcing the same snapshot +/// twice must allocate a different namespace instead of overwriting the active one in place. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct InventoryGeneration([u8; 32]); + +impl InventoryGeneration { + /// Return the opaque generation bytes for diagnostics or deterministic source fixtures. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Immutable metadata bound into both the staging registry entry and the active descriptor. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SnapshotInventoryTarget { + network_magic: u16, + environment: Environment, + highest_block_checkpoint: CheckpointBeacon, + highest_superblock_checkpoint: CheckpointBeacon, + block_count: u64, + superblock_count: u64, + report_count: u64, + chain_state_version: u32, +} + +impl SnapshotInventoryTarget { + /// Construct metadata for a snapshot inventory that will be committed with a current + /// [`ChainState`]. The persisted version is set here rather than trusted from a caller; all + /// inventory-domain counts are bound into the descriptor for commit-time coverage checks. + pub fn new( + network_magic: u16, + environment: Environment, + highest_block_checkpoint: CheckpointBeacon, + highest_superblock_checkpoint: CheckpointBeacon, + block_count: u64, + superblock_count: u64, + report_count: u64, + ) -> Self { + Self { + network_magic, + environment, + highest_block_checkpoint, + highest_superblock_checkpoint, + block_count, + superblock_count, + report_count, + chain_state_version: CURRENT_CHAIN_STATE_VERSION, + } + } + + /// Network magic of the chain whose inventory is being imported. + pub fn network_magic(self) -> u16 { + self.network_magic + } + + /// Chain environment of the imported inventory. + pub fn environment(self) -> Environment { + self.environment + } + + /// Advertised highest block beacon at the import commit point. + pub fn highest_block_checkpoint(self) -> CheckpointBeacon { + self.highest_block_checkpoint + } + + /// Advertised highest superblock beacon at the import commit point. + pub fn highest_superblock_checkpoint(self) -> CheckpointBeacon { + self.highest_superblock_checkpoint + } + + /// Exact block count staged for this generation. + pub fn block_count(self) -> u64 { + self.block_count + } + + /// Exact decoded superblock-record count staged for this generation. This is not derived from + /// the highest beacon because valid retained history may contain increasing, gapped indexes. + pub fn superblock_count(self) -> u64 { + self.superblock_count + } + + /// Exact decoded data-request-report count staged for this generation. + pub fn report_count(self) -> u64 { + self.report_count + } + + /// Version of the `ChainState` atomically committed with this generation. + pub fn chain_state_version(self) -> u32 { + self.chain_state_version + } +} + +/// Historical v2 descriptor persisted by earlier delegated-staking development snapshots. +/// +/// Keep this exact layout solely for backward-compatible decoding. New generations are always +/// written as v3 descriptors and therefore cannot lose their required sequence commitment while +/// still being accepted as a valid current descriptor. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct InventoryGenerationDescriptorV2 { + magic: [u8; 16], + storage_version: u32, + generation: InventoryGeneration, + target: SnapshotInventoryTarget, +} + +/// Current durable v3 encoding. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct InventoryGenerationDescriptorV3 { + magic: [u8; 16], + storage_version: u32, + generation: InventoryGeneration, + target: SnapshotInventoryTarget, + superblock_commitment: Hash, +} + +/// Normalized descriptor used internally after decoding either durable schema. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct InventoryGenerationDescriptor { + magic: [u8; 16], + storage_version: u32, + generation: InventoryGeneration, + target: SnapshotInventoryTarget, + superblock_commitment: Option, +} + +impl InventoryGenerationDescriptor { + fn new( + generation: InventoryGeneration, + target: SnapshotInventoryTarget, + superblock_commitment: Hash, + ) -> Self { + Self { + magic: INVENTORY_DESCRIPTOR_MAGIC, + storage_version: INVENTORY_DESCRIPTOR_STORAGE_VERSION, + generation, + target, + superblock_commitment: Some(superblock_commitment), + } + } + + fn from_v2(descriptor: InventoryGenerationDescriptorV2) -> Self { + Self { + magic: descriptor.magic, + storage_version: descriptor.storage_version, + generation: descriptor.generation, + target: descriptor.target, + superblock_commitment: None, + } + } + + fn from_v3(descriptor: InventoryGenerationDescriptorV3) -> Self { + Self { + magic: descriptor.magic, + storage_version: descriptor.storage_version, + generation: descriptor.generation, + target: descriptor.target, + superblock_commitment: Some(descriptor.superblock_commitment), + } + } + + fn validate(self) -> Result { + anyhow::ensure!( + self.magic == INVENTORY_DESCRIPTOR_MAGIC, + "inventory generation descriptor has invalid magic" + ); + match (self.storage_version, self.superblock_commitment) { + (LEGACY_INVENTORY_DESCRIPTOR_STORAGE_VERSION, None) + | (INVENTORY_DESCRIPTOR_STORAGE_VERSION, Some(_)) => {} + (LEGACY_INVENTORY_DESCRIPTOR_STORAGE_VERSION, Some(_)) => { + anyhow::bail!( + "legacy inventory descriptor unexpectedly carries a superblock commitment" + ); + } + (INVENTORY_DESCRIPTOR_STORAGE_VERSION, None) => { + anyhow::bail!( + "current inventory descriptor is missing its superblock commitment" + ); + } + (version, _) => { + anyhow::bail!( + "unsupported inventory descriptor storage version {version}, expected {} or {}", + LEGACY_INVENTORY_DESCRIPTOR_STORAGE_VERSION, + INVENTORY_DESCRIPTOR_STORAGE_VERSION + ); + } + } + anyhow::ensure!( + matches!( + self.target.chain_state_version, + PRE_EXIT_CHAIN_STATE_VERSION + | PRE_RECOVERY_CHAIN_STATE_VERSION + | CURRENT_CHAIN_STATE_VERSION + ), + "inventory generation targets ChainState version {}, expected {}, {}, or {}", + self.target.chain_state_version, + PRE_EXIT_CHAIN_STATE_VERSION, + PRE_RECOVERY_CHAIN_STATE_VERSION, + CURRENT_CHAIN_STATE_VERSION + ); + + // An existing pre-recovery generation authenticates its historical inventory prefix even + // after ChainState migrates to v9. Keep that provenance intact; creating a new generation still + // requires CURRENT_CHAIN_STATE_VERSION in BeginSnapshotInventoryImport. + + Ok(self) + } + + fn encode(self) -> Result, anyhow::Error> { + self.validate()?; + match self.superblock_commitment { + None => Ok(serialize(&InventoryGenerationDescriptorV2 { + magic: self.magic, + storage_version: self.storage_version, + generation: self.generation, + target: self.target, + })?), + Some(superblock_commitment) => { + Ok(serialize(&InventoryGenerationDescriptorV3 { + magic: self.magic, + storage_version: self.storage_version, + generation: self.generation, + target: self.target, + superblock_commitment, + })?) + } + } + } + + fn view(self) -> InventoryView { + InventoryView::Generation { + generation: self.generation, + target: self.target, + } + } +} + +/// A pinned logical view of inventory storage. Reads that require more than one database lookup +/// must reuse the same value so an online snapshot commit cannot mix generations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InventoryView { + /// Marker-absent databases use the historical bincode-serialized inventory keys verbatim. + Legacy, + /// An immutable generation-qualified inventory namespace. + Generation { + /// Opaque database generation identity. + generation: InventoryGeneration, + /// Metadata atomically published with this generation. + target: SnapshotInventoryTarget, + }, +} + +impl InventoryView { + /// Return the generation identity, or `None` for a legacy database. + pub fn generation(self) -> Option { + match self { + Self::Legacy => None, + Self::Generation { generation, .. } => Some(generation), + } + } + + /// Return the snapshot target metadata, or `None` for a legacy database. + pub fn target(self) -> Option { + match self { + Self::Legacy => None, + Self::Generation { target, .. } => Some(target), + } + } +} + +/// Capability returned only after a fresh inactive generation descriptor has been persisted. +#[derive(Clone, Debug)] +pub struct SnapshotInventoryToken { + descriptor: InventoryGenerationDescriptor, +} + +impl SnapshotInventoryToken { + /// Pinned view in which snapshot inventory batches must be written. + pub fn view(&self) -> InventoryView { + self.descriptor.view() + } + + /// Immutable metadata bound to this import attempt. + pub fn target(&self) -> SnapshotInventoryTarget { + self.descriptor.target + } + + /// Opaque identity allocated for this import attempt. + pub fn generation(&self) -> InventoryGeneration { + self.descriptor.generation + } +} + +/// An already-serialized logical inventory item `(domain, logical key, serialized value)`. +pub type SerializedInventoryItem = (InventoryDomain, Vec, Vec); +/// A reverted report candidate `(logical report key, expected tally block hash)`. +pub type RevertedReportKey = (Vec, Hash); + +/// One ordinary finalized-superblock inventory record pinned to the inventory view against which +/// its matching ChainState/UTXO checkpoint was prepared. +/// +/// This is deliberately private to the versioned ChainState persistence path. A standalone active +/// inventory append cannot prove that the corresponding canonical checkpoint shares its backend +/// transaction. +struct OrdinarySuperblockInventoryCommit { + expected_view: InventoryView, + notification: SuperBlockNotify, +} + +/// Commit to the complete semantic superblock-notification sequence independently of iterator +/// order and backend encoding. +/// +/// The actor computes this after authenticating every notification against canonical blocks. The +/// storage worker recomputes it from the inactive generation immediately before publication. This +/// closes the gap where a structurally valid non-tip value could be changed below the write-once +/// staging guard while leaving both the staged count and advertised tip unchanged. +pub(crate) fn snapshot_superblock_commitment( + notifications: &[SuperBlockNotify], +) -> Result { + const DOMAIN: &[u8] = b"WITNET-SNAPSHOT-SUPERBLOCKS-V1"; + + let mut ordered = notifications.iter().collect::>(); + ordered.sort_unstable_by_key(|notification| notification.superblock.index); + + let count = u64::try_from(ordered.len())?; + let mut initial_frame = Vec::with_capacity(DOMAIN.len() + std::mem::size_of::()); + initial_frame.extend_from_slice(DOMAIN); + initial_frame.extend_from_slice(&count.to_be_bytes()); + let mut commitment = calculate_sha256(&initial_frame).0; + let mut previous_index = None; + for notification in ordered { + let index = notification.superblock.index; + anyhow::ensure!( + previous_index.map_or(true, |previous| index > previous), + "snapshot superblock commitment contains duplicate index #{index}" + ); + previous_index = Some(index); + + let value = serialize(notification)?; + let value_len = u64::try_from(value.len())?; + let frame_capacity = commitment + .len() + .checked_add(std::mem::size_of::()) + .and_then(|capacity| capacity.checked_add(std::mem::size_of::())) + .and_then(|capacity| capacity.checked_add(value.len())) + .ok_or_else(|| anyhow::anyhow!("snapshot superblock commitment frame is too large"))?; + let mut frame = Vec::with_capacity(frame_capacity); + frame.extend_from_slice(&commitment); + frame.extend_from_slice(&index.to_be_bytes()); + frame.extend_from_slice(&value_len.to_be_bytes()); + frame.extend_from_slice(&value); + commitment = calculate_sha256(&frame).0; + } + + Ok(Hash::SHA256(commitment)) +} + +fn environment_tag(environment: Environment) -> u8 { + match environment { + Environment::Mainnet => 0, + Environment::Testnet => 1, + Environment::Development => 2, + } +} + +fn inventory_generation_descriptor_key(generation: InventoryGeneration) -> Vec { + let mut key = Vec::with_capacity( + INVENTORY_GENERATION_DESCRIPTOR_PREFIX.len() + generation.0.len(), + ); + key.extend_from_slice(INVENTORY_GENERATION_DESCRIPTOR_PREFIX); + key.extend_from_slice(&generation.0); + key +} + +fn inventory_superblock_logical_key(index: u32) -> Vec { + format!("{SUPERBLOCK_LOGICAL_KEY_PREFIX}{index:010}").into_bytes() +} + +fn generated_inventory_header( + generation: InventoryGeneration, + target: SnapshotInventoryTarget, + domain: InventoryDomain, +) -> Vec { + let mut key = Vec::with_capacity( + INVENTORY_KEY_MAGIC.len() + + std::mem::size_of::() + + std::mem::size_of::() + + 2 + + generation.0.len(), + ); + key.extend_from_slice(&INVENTORY_KEY_MAGIC); + key.extend_from_slice(&INVENTORY_KEY_STORAGE_VERSION.to_be_bytes()); + key.extend_from_slice(&target.network_magic.to_be_bytes()); + key.push(environment_tag(target.environment)); + key.push(domain as u8); + key.extend_from_slice(&generation.0); + key +} + +fn inventory_generation_domain_prefix( + descriptor: InventoryGenerationDescriptor, + domain: InventoryDomain, +) -> Vec { + generated_inventory_header(descriptor.generation, descriptor.target, domain) +} + +/// Reject generic storage mutations that target the generation-aware inventory control plane. +/// +/// The typed `put`, `put_batch`, and `delete` helpers serialize caller-provided keys, so ordinary +/// callers cannot normally reach these raw namespaces. A custom `Serialize` implementation can +/// nevertheless emit arbitrary bytes; enforcing the boundary in the storage worker keeps marker, +/// registry, and generation-qualified inventory writes behind their dedicated token-aware paths. +fn ensure_not_reserved_inventory_key(key: &[u8]) -> Result<(), anyhow::Error> { + anyhow::ensure!( + key != ACTIVE_INVENTORY_DESCRIPTOR_KEY + && !key.starts_with(INVENTORY_GENERATION_DESCRIPTOR_PREFIX) + && !key.starts_with(&INVENTORY_KEY_MAGIC), + "generic storage mutation cannot target the reserved inventory control plane" + ); + Ok(()) +} + +/// Reject generic single-key mutations that target the private ChainState companions. +/// +/// Activation certificates and portable-snapshot protocol schedules are resolved only by their +/// ChainState commit handlers, where the bytes are authenticated against the state that shares the +/// atomic write. The generic storage APIs still accept serialized arbitrary keys, so decode a +/// serialized string when possible and keep these two namespaces out of that bypass path. The +/// dedicated UTXO batch path deliberately does not call this helper: reset cleanup uses that path +/// to delete the schedule companion together with the pristine ChainState marker. +fn ensure_not_reserved_chain_state_companion_key(key: &[u8]) -> Result<(), anyhow::Error> { + let Ok(decoded) = bincode_compatibility::deserialize_exact::(key) else { + return Ok(()); + }; + anyhow::ensure!( + !decoded.ends_with("-wit2-activation-certificate-v1") + && !decoded.ends_with("-protocol-schedule-v1"), + "generic storage mutation cannot target a private ChainState companion" + ); + Ok(()) +} + +fn ensure_not_reserved_generic_storage_key(key: &[u8]) -> Result<(), anyhow::Error> { + ensure_not_reserved_inventory_key(key)?; + ensure_not_reserved_chain_state_companion_key(key) +} + +/// Resolve one logical inventory key into its physical database key in a pinned view. +pub fn inventory_physical_key( + view: InventoryView, + domain: InventoryDomain, + logical_key: &[u8], +) -> Result, anyhow::Error> { + match (view, domain) { + (InventoryView::Legacy, InventoryDomain::Report) => { + let logical_key = std::str::from_utf8(logical_key).map_err(|error| { + anyhow::anyhow!("legacy data-request report key is not valid UTF-8: {error}") + })?; + Ok(serialize(&logical_key.to_owned())?) + } + (InventoryView::Legacy, _) => Ok(serialize(&logical_key.to_vec())?), + (InventoryView::Generation { generation, target }, domain) => { + let logical_len = u64::try_from(logical_key.len())?; + let mut key = generated_inventory_header(generation, target, domain); + key.extend_from_slice(&logical_len.to_be_bytes()); + key.extend_from_slice(logical_key); + Ok(key) + } + } +} + +/// Build a physical prefix for logical keys of one exact total length. +/// +/// The total length is explicit because both legacy bincode `Vec` keys and generation keys +/// encode it before the logical bytes. For example, all padded superblock keys have one fixed total +/// length while only their `SUPERBLOCK-` prefix is supplied here. +pub fn inventory_physical_prefix( + view: InventoryView, + domain: InventoryDomain, + logical_prefix: &[u8], + total_logical_len: usize, +) -> Result, anyhow::Error> { + anyhow::ensure!( + logical_prefix.len() <= total_logical_len, + "inventory logical prefix is longer than its declared total key length" + ); + let total_logical_len = u64::try_from(total_logical_len)?; + + match view { + InventoryView::Legacy => { + // Bincode 1 encodes a sequence length as a fixed u64. This is the same prefix that + // serializing the complete historical Vec key would produce, without allocating its + // unknown suffix. + let mut prefix = serialize(&total_logical_len)?; + prefix.extend_from_slice(logical_prefix); + Ok(prefix) + } + InventoryView::Generation { generation, target } => { + let mut prefix = generated_inventory_header(generation, target, domain); + prefix.extend_from_slice(&total_logical_len.to_be_bytes()); + prefix.extend_from_slice(logical_prefix); + Ok(prefix) + } + } +} + +/// Build a physical prefix for every key in one inventory domain. +/// +/// Generation-qualified keys put the domain in a fixed header, so the returned prefix covers +/// every logical-key length. The legacy namespace has no domain discriminator; callers must use +/// the empty prefix and decode/filter the historical key representation themselves. +pub fn inventory_domain_prefix(view: InventoryView, domain: InventoryDomain) -> Vec { + match view { + InventoryView::Legacy => Vec::new(), + InventoryView::Generation { generation, target } => { + generated_inventory_header(generation, target, domain) + } + } +} + /// Start the storage manager pub fn start() { let addr = StorageManagerAdapter::start_default(); @@ -94,6 +689,247 @@ where futures::future::Either::Right(fut) } +/// Resolve and pin the inventory namespace that is active at one database linearization point. +/// +/// A missing marker returns [`InventoryView::Legacy`]. A present marker is accepted only if its +/// exact per-generation registry descriptor still exists and matches byte-for-byte. +pub async fn get_inventory_view() -> Result { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(GetInventoryView).await? +} + +/// Re-read and authenticate the complete active inventory namespace before a ChainState reload. +/// +/// Current v3 descriptors durably bind the full superblock sequence that actor-side snapshot +/// preflight authenticated, and normal superblock appends atomically advance that commitment. +/// Historical v2 descriptors remain loadable, but can receive only structural, count, and tip +/// checks for their snapshot-era superblock prefix (the next successful superblock append upgrades +/// them). Marker-absent legacy databases remain untouched. +pub async fn validate_active_inventory_namespace() -> Result { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(ValidateActiveInventoryNamespace).await? +} + +/// Read and deserialize an inventory value from one pinned view. +pub async fn get_inventory( + view: InventoryView, + domain: InventoryDomain, + logical_key: Vec, +) -> Result, anyhow::Error> +where + T: serde::de::DeserializeOwned + 'static, +{ + mapped_get_inventory(view, domain, logical_key, |bytes| bytes).await +} + +/// Read an inventory value from one pinned view, transform its serialized bytes, and deserialize +/// the result. This preserves the historical block-normalization hook without allowing its point +/// lookup to switch generations underneath a multi-read operation. +pub async fn mapped_get_inventory( + view: InventoryView, + domain: InventoryDomain, + logical_key: Vec, + mut mapper: F, +) -> Result, anyhow::Error> +where + T: serde::de::DeserializeOwned + 'static, + F: FnMut(Vec) -> Vec, +{ + if TypeId::of::() == TypeId::of::() { + panic!("Please use get_chain_state instead"); + } + + let key = inventory_physical_key(view, domain, &logical_key)?; + let addr = StorageManagerAdapter::from_registry(); + let bytes = addr.send(Get(key)).await??.map(&mut mapper); + + match bytes { + Some(bytes) => bincode_compatibility::deserialize_exact(&bytes) + .map(Some) + .map_err(|error| as_failure!(error)), + None => Ok(None), + } +} + +/// Atomically persist ordinary non-superblock inventory values only if the caller's pinned view is +/// still active. +/// +/// The active descriptor comparison and physical write execute inside the same single-worker +/// storage-manager handler. A delayed old-chain write therefore fails instead of leaking into a +/// newly activated snapshot generation. Finalized-superblock records are deliberately excluded: +/// fresh records must use the private ChainState/UTXO co-commit path, while an acknowledged +/// checkpoint can use only the durable-state-bound repair path. +pub async fn put_inventory_serialized_batch( + expected_view: InventoryView, + items: Vec, +) -> Result<(), anyhow::Error> { + anyhow::ensure!( + items + .iter() + .all(|(domain, _, _)| *domain != InventoryDomain::Superblock), + "ordinary finalized-superblock inventory must share its ChainState/UTXO checkpoint commit" + ); + let addr = StorageManagerAdapter::from_registry(); + + addr.send(PutActiveInventoryBatch { + expected_view, + items, + }) + .await? +} + +/// Repair or exact-replay one ordinary superblock notification only after proving that its matching +/// versioned ChainState checkpoint is already durable. +/// +/// The initial view read is a pin, not an authorization: the storage worker exact-compares it again, +/// reads and validates the durable ChainState, and writes the immutable notification during one +/// single-worker turn. This capability must never be used for a fresh checkpoint; fresh persistence +/// uses `put_chain_state_with_wit2_activation_in_batch` instead. +pub(crate) async fn repair_ordinary_superblock_inventory( + chain_state_key: String, + notification: SuperBlockNotify, +) -> Result<(), anyhow::Error> { + let expected_view = get_inventory_view().await?; + let chain_state_key = serialize(&chain_state_key)?; + let addr = StorageManagerAdapter::from_registry(); + + addr.send(RepairOrdinarySuperblockInventory { + chain_state_key, + expected_view, + notification, + }) + .await? +} + +/// Atomically delete proven reverted reports only if the caller's pinned view is still active. +/// +/// The active descriptor comparison, current report read/identity check, and physical deletion all +/// execute in the same single-worker storage handler. A same-generation replacement report is +/// retained unless its recorded tally block matches the exact reverted block supplied by the +/// caller. Generic [`delete`] must never be used for logical inventory keys because it cannot bind +/// the mutation to a generation or a report value. +pub async fn delete_reverted_reports_batch( + expected_view: InventoryView, + reports: Vec, +) -> Result<(), anyhow::Error> { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(DeleteRevertedReportsBatch { + expected_view, + reports, + }) + .await? +} + +/// Allocate and persist a fresh inactive snapshot-inventory generation. +/// +/// The returned capability is intentionally unrelated to the portable snapshot generation so a +/// forced repeat import cannot target the namespace that is currently active. The supplied +/// superblock commitment must come from the exact sequence already authenticated by actor-side +/// snapshot preflight. It is persisted in the inactive v3 descriptor before staging begins and +/// atomically copied into the active descriptor at final publication, allowing restart to +/// authenticate every retained superblock rather than trusting only its count and tip. +pub async fn begin_snapshot_inventory_import( + target: SnapshotInventoryTarget, + superblock_commitment: Hash, +) -> Result { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(BeginSnapshotInventoryImport { + target, + superblock_commitment, + }) + .await? +} + +/// Atomically persist one already-serialized snapshot inventory batch into its explicit inactive +/// generation. The per-generation registry descriptor is re-read and exact-compared on every call. +/// Generation keys are write-once: an exact replay is idempotent, while different replacement +/// bytes are rejected so earlier actor preflight cannot be invalidated before publication. +pub async fn put_snapshot_inventory_serialized_batch( + token: &SnapshotInventoryToken, + items: Vec, +) -> Result<(), anyhow::Error> { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(PutSnapshotInventoryBatch { + token: token.clone(), + items, + }) + .await? +} + +// Commit is deliberately private to the versioned ChainState persistence path. The storage worker +// must revalidate the durable token registry at the same linearization point where it appends the +// visibility marker; exposing marker staging separately would let an aborted token resurrect a +// deleted generation. +fn commit_snapshot_inventory_batch( + token: SnapshotInventoryToken, + batch: UtxoWriteBatch, + wit2_activation_context: node_migrations::Wit2ActivationChainContext, + certificate: Option, + protocol_info: Option, +) -> impl Future> { + let addr = StorageManagerAdapter::from_registry(); + + async move { + addr.send(CommitSnapshotInventoryBatch { + token, + batch, + wit2_activation_context, + certificate, + protocol_info, + }) + .await? + } +} + +// Ordinary ChainState persistence also owns the lifecycle of its activation-certificate +// companion. Resolve both during one storage-manager turn so replay cannot commit an alternate +// trigger and leave the old companion behind across a crash. +fn commit_chain_state_with_wit2_activation_batch( + batch: UtxoWriteBatch, + wit2_activation_context: node_migrations::Wit2ActivationChainContext, + certificate: Option, + protocol_info: Option, + ordinary_superblock: Option, +) -> impl Future> { + let addr = StorageManagerAdapter::from_registry(); + + async move { + addr.send(CommitChainStateWithWit2ActivationBatch { + batch, + wit2_activation_context, + certificate, + protocol_info, + ordinary_superblock, + }) + .await? + } +} + +/// Delete a failed inactive import in bounded physical-key batches. This operation is idempotent; +/// the registry descriptor is deleted last so a crash during cleanup remains discoverable. +pub async fn abort_snapshot_inventory_import( + token: SnapshotInventoryToken, +) -> Result<(), anyhow::Error> { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(AbortSnapshotInventoryImport { token }).await? +} + +/// Reclaim every inactive registered generation in bounded batches. +/// +/// Call this only during startup or another explicitly quiescent maintenance window: a live read +/// may legitimately retain the previously active pinned view after a successful marker flip. +pub async fn cleanup_abandoned_snapshot_inventory_imports() -> Result { + let addr = StorageManagerAdapter::from_registry(); + + addr.send(CleanupSnapshotInventoryGenerations).await? +} + /// Put a value associated to the key into the storage pub fn put(key: &K, value: &V) -> impl Future> + use where @@ -105,101 +941,1463 @@ where panic!("Please use put_chain_state instead"); } - let addr = StorageManagerAdapter::from_registry(); + let addr = StorageManagerAdapter::from_registry(); + + let key_bytes = match serialize(key) { + Ok(x) => x, + Err(e) => { + return futures::future::Either::Left(futures::future::Either::Left(future::ready( + Err(e.into()), + ))); + } + }; + + let value_bytes = match serialize(value) { + Ok(x) => x, + Err(e) => { + return futures::future::Either::Left(futures::future::Either::Right(future::ready( + Err(e.into()), + ))); + } + }; + + futures::future::Either::Right(async move { addr.send(Put(key_bytes, value_bytes)).await? }) +} + +/// Put a batch of values into the storage. +/// +/// `ChainState` must be persisted through [`put_chain_state_in_batch`], which stages the validated +/// state together with the protocol-specific persistence gates. Keep the type check here as well +/// as in [`put`] so a typed batch cannot accidentally bypass those gates. +pub fn put_batch(kv: &[(K, V)]) -> impl Future> + use +where + K: serde::Serialize, + V: serde::Serialize + Any, +{ + if kv + .iter() + .any(|(_, value)| value.type_id() == TypeId::of::()) + { + panic!("Please use put_chain_state_in_batch instead"); + } + + let addr = StorageManagerAdapter::from_registry(); + + let kv_bytes: Result, anyhow::Error> = kv + .iter() + .map(|(k, v)| Ok((serialize(k)?, serialize(v)?))) + .collect(); + + async move { + match kv_bytes { + Ok(kv_bytes) if kv_bytes.is_empty() => Ok(()), + Ok(kv_bytes) => addr.send(PutBatch(kv_bytes)).await?, + Err(e) => Err(e), + } + } +} + +/// Delete value associated to key +pub fn delete(key: &K) -> impl Future> +where + K: serde::Serialize, +{ + let addr = StorageManagerAdapter::from_registry(); + + let key_bytes = match serialize(key) { + Ok(x) => x, + Err(e) => return futures::future::Either::Left(future::ready(Err(e.into()))), + }; + + let fut = async move { addr.send(Delete(key_bytes)).await? }; + + futures::future::Either::Right(fut) +} + +/// Get an atomic reference to the storage backend +pub(crate) fn get_backend() +-> impl Future, anyhow::Error>> { + let addr = StorageManagerAdapter::from_registry(); + + async move { addr.send(GetBackend).await? } +} + +struct StorageManager { + backend: Arc, +} + +impl Drop for StorageManager { + fn drop(&mut self) { + log::trace!("Dropping StorageManager"); + stop_system_if_panicking("StorageManager"); + // FIXME(#2008): sometimes rocksdb is not closed correctly, resulting in error + // pure virtual method called + // This sleep seems to fix that error, but it doesn't look like a solid fix + std::thread::sleep(Duration::from_millis(500)); + } +} + +impl Default for StorageManager { + fn default() -> Self { + StorageManager { + backend: Arc::new(UtxoDbWrapStorage(backends::nobackend::Backend)), + } + } +} + +impl Actor for StorageManager { + type Context = SyncContext; +} + +impl StorageManager { + fn storage(&self) -> Arc { + self.backend.clone().as_arc_dyn_storage() + } + + fn decode_inventory_descriptor( + bytes: &[u8], + label: &str, + ) -> Result { + let v3_error = match bincode_compatibility::deserialize_exact::< + InventoryGenerationDescriptorV3, + >(bytes) + { + Ok(descriptor) => return InventoryGenerationDescriptor::from_v3(descriptor).validate(), + Err(error) => error, + }; + match bincode_compatibility::deserialize_exact::(bytes) { + Ok(descriptor) => InventoryGenerationDescriptor::from_v2(descriptor).validate(), + Err(v2_error) => Err(anyhow::anyhow!( + "invalid {label} inventory generation descriptor (v3: {v3_error}; v2: {v2_error})" + )), + } + } + + fn load_registered_inventory_descriptor( + storage: &dyn Storage, + generation: InventoryGeneration, + ) -> Result, anyhow::Error> { + storage + .get(&inventory_generation_descriptor_key(generation))? + .map(|bytes| Self::decode_inventory_descriptor(&bytes, "registered")) + .transpose() + } + + fn load_active_inventory_descriptor_from( + storage: &dyn Storage, + ) -> Result, anyhow::Error> { + let Some(active_bytes) = storage.get(ACTIVE_INVENTORY_DESCRIPTOR_KEY)? else { + return Ok(None); + }; + let active = Self::decode_inventory_descriptor(&active_bytes, "active")?; + let registered_bytes = storage + .get(&inventory_generation_descriptor_key(active.generation))? + .ok_or_else(|| { + anyhow::anyhow!( + "active inventory generation has no registered descriptor" + ) + })?; + let registered = Self::decode_inventory_descriptor(®istered_bytes, "registered")?; + anyhow::ensure!( + registered == active, + "active inventory descriptor disagrees with its registered generation descriptor" + ); + anyhow::ensure!( + registered_bytes == active_bytes, + "active inventory descriptor encoding disagrees with its registered generation descriptor" + ); + + Ok(Some(active)) + } + + fn load_inventory_view_from( + storage: &dyn Storage, + ) -> Result { + Ok(Self::load_active_inventory_descriptor_from(storage)? + .map_or(InventoryView::Legacy, InventoryGenerationDescriptor::view)) + } + + fn load_inventory_view(&self) -> Result { + let storage = self.storage(); + Self::load_inventory_view_from(storage.as_ref()) + } + + fn verify_snapshot_token( + storage: &dyn Storage, + token: &SnapshotInventoryToken, + ) -> Result { + let descriptor = token.descriptor.validate()?; + let registered_bytes = storage + .get(&inventory_generation_descriptor_key(descriptor.generation))? + .ok_or_else(|| anyhow::anyhow!("snapshot inventory generation is not registered"))?; + let registered = Self::decode_inventory_descriptor(®istered_bytes, "registered")?; + anyhow::ensure!( + registered == descriptor, + "snapshot inventory token disagrees with its registered descriptor" + ); + anyhow::ensure!( + registered_bytes == descriptor.encode()?, + "snapshot inventory token encoding disagrees with its registered descriptor" + ); + + Ok(descriptor) + } + + fn encode_inventory_items( + view: InventoryView, + items: Vec, + ) -> Result, Vec)>, anyhow::Error> { + let mut seen_keys = HashSet::with_capacity(items.len()); + items + .into_iter() + .map(|(domain, logical_key, value)| { + let physical_key = inventory_physical_key(view, domain, &logical_key)?; + anyhow::ensure!( + seen_keys.insert(physical_key.clone()), + "inventory batch contains a duplicate key in domain {:?}", + domain + ); + Ok(( + physical_key, + value, + )) + }) + .collect() + } + + /// Decode and structurally authenticate every marker-absent legacy superblock record. + /// Legacy keys serialize a fixed-width logical key as a `Vec`; retaining that encoded + /// length while trimming only the ten index digits yields an exact physical scan prefix. + fn validate_legacy_superblocks( + storage: &dyn Storage, + ) -> Result)>, anyhow::Error> { + let logical_key = inventory_superblock_logical_key(0); + let mut prefix = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Superblock, + &logical_key, + )?; + anyhow::ensure!( + prefix.len() >= SUPERBLOCK_LOGICAL_INDEX_DIGITS, + "legacy superblock physical-key prefix is unexpectedly short" + ); + prefix.truncate(prefix.len() - SUPERBLOCK_LOGICAL_INDEX_DIGITS); + + let mut seen_indexes = HashSet::new(); + let mut notifications = Vec::new(); + for entry in storage.prefix_iterator(&prefix)? { + let (physical_key, value) = entry?; + let notification: SuperBlockNotify = + bincode_compatibility::deserialize_exact(&value).map_err(|error| { + anyhow::anyhow!( + "legacy inventory superblock notification cannot be decoded: {error}" + ) + })?; + let index = notification.superblock.index; + anyhow::ensure!( + index != 0, + "legacy inventory contains a historical superblock notification at index zero" + ); + let expected_key = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Superblock, + &inventory_superblock_logical_key(index), + )?; + anyhow::ensure!( + physical_key == expected_key, + "legacy inventory superblock key does not match decoded index #{index}" + ); + anyhow::ensure!( + seen_indexes.insert(index), + "legacy inventory contains duplicate decoded superblock index #{index}" + ); + notifications.push((notification, value)); + } + + Ok(notifications) + } + + /// Prepare every raw put for an ordinary active-inventory batch without touching storage. + /// + /// Keeping preparation separate from submission lets an ordinary finalized-superblock record, + /// both descriptor copies, its ChainState, UTXO changes, and activation certificate share one + /// [`UtxoWriteBatch`]. Standalone block/report inventory writes use the same preparation logic + /// and then submit the returned puts through a regular [`WriteBatch`]. + fn prepare_inventory_items( + storage: &dyn Storage, + active_descriptor: Option, + view: InventoryView, + items: Vec, + ) -> Result, Vec)>, anyhow::Error> { + let superblock_items = items + .iter() + .filter(|(domain, _, _)| *domain == InventoryDomain::Superblock) + .map(|(_, logical_key, value)| (logical_key.clone(), value.clone())) + .collect::>(); + let mut physical_items = Self::encode_inventory_items(view, items)?; + if physical_items.is_empty() { + return Ok(physical_items); + } + + if !superblock_items.is_empty() { + if let Some(mut descriptor) = active_descriptor { + // Do not let an append legitimize corruption that happened before this batch. + // Authenticate the currently committed sequence first, then derive the successor + // commitment from that exact sequence plus byte-identical retries or new records. + let current_sequence = Self::validate_snapshot_superblocks( + storage, + descriptor, + SuperblockValidationScope::ActiveNamespace, + )?; + + let mut notifications = BTreeMap::)>::new(); + for (notification, value) in current_sequence { + let index = notification.superblock.index; + anyhow::ensure!( + notifications.insert(index, (notification, value)).is_none(), + "active inventory contains duplicate superblock index #{index}" + ); + } + let append_floor = notifications + .keys() + .next_back() + .copied() + .unwrap_or(descriptor.target.highest_superblock_checkpoint.checkpoint); + + for (logical_key, value) in superblock_items { + let notification: SuperBlockNotify = + bincode_compatibility::deserialize_exact(&value).map_err(|error| { + anyhow::anyhow!( + "new active inventory superblock notification cannot be decoded: {error}" + ) + })?; + let index = notification.superblock.index; + anyhow::ensure!( + index != 0, + "active inventory cannot persist a historical superblock notification at index zero" + ); + anyhow::ensure!( + logical_key == inventory_superblock_logical_key(index), + "new active inventory superblock key does not match decoded index #{index}" + ); + match notifications.get(&index) { + Some((_existing_notification, existing_value)) => { + anyhow::ensure!( + existing_value == &value, + "active inventory cannot overwrite immutable superblock #{index} with different bytes" + ); + } + None => { + anyhow::ensure!( + index > descriptor.target.highest_superblock_checkpoint.checkpoint, + "active inventory cannot add superblock #{index} inside the immutable snapshot-era prefix" + ); + anyhow::ensure!( + index > append_floor, + "active inventory cannot insert missing superblock #{index} below already committed superblock #{append_floor}" + ); + notifications.insert(index, (notification, value)); + } + } + } + + let complete_sequence = notifications + .into_values() + .map(|(notification, _value)| notification) + .collect::>(); + descriptor.storage_version = INVENTORY_DESCRIPTOR_STORAGE_VERSION; + descriptor.superblock_commitment = + Some(snapshot_superblock_commitment(&complete_sequence)?); + let descriptor_bytes = descriptor.encode()?; + physical_items.push(( + inventory_generation_descriptor_key(descriptor.generation), + descriptor_bytes.clone(), + )); + physical_items.push(( + ACTIVE_INVENTORY_DESCRIPTOR_KEY.to_vec(), + descriptor_bytes, + )); + } else { + anyhow::ensure!( + view == InventoryView::Legacy, + "generation-aware inventory view has no active descriptor" + ); + // Marker-absent databases have no descriptor commitment, but their structurally + // authenticated sequence is still append-only. Preserve exact replay while + // rejecting a different value or a missing historical backfill below its tip. + let current_sequence = Self::validate_legacy_superblocks(storage)?; + let mut notifications = BTreeMap::>::new(); + for (notification, value) in current_sequence { + let index = notification.superblock.index; + notifications.insert(index, value); + } + let append_floor = notifications.keys().next_back().copied().unwrap_or(0); + for (logical_key, value) in superblock_items { + let notification: SuperBlockNotify = + bincode_compatibility::deserialize_exact(&value).map_err(|error| { + anyhow::anyhow!( + "new legacy inventory superblock notification cannot be decoded: {error}" + ) + })?; + let index = notification.superblock.index; + anyhow::ensure!( + index != 0, + "legacy active inventory cannot persist a historical superblock notification at index zero" + ); + anyhow::ensure!( + logical_key == inventory_superblock_logical_key(index), + "new legacy inventory superblock key does not match decoded index #{index}" + ); + match notifications.get(&index) { + Some(existing_value) => anyhow::ensure!( + existing_value == &value, + "legacy active inventory cannot overwrite immutable superblock #{index} with different bytes" + ), + None => { + anyhow::ensure!( + index > append_floor, + "legacy active inventory cannot insert missing superblock #{index} below already committed superblock #{append_floor}" + ); + notifications.insert(index, value); + } + } + } + } + } + + Ok(physical_items) + } + + /// Persist an ordinary active-inventory batch. + /// + /// Superblocks are immutable once present. For generation-aware storage, validate the current + /// sequence against its descriptor before accepting an append and put the successor sequence + /// commitment into both descriptor records in the same atomic backend batch. Thus a lost + /// acknowledgement can replay exact bytes, but cannot fork or silently unseal the sequence. + fn write_inventory_items( + storage: &dyn Storage, + active_descriptor: Option, + view: InventoryView, + items: Vec, + ) -> Result<(), anyhow::Error> { + let physical_items = + Self::prepare_inventory_items(storage, active_descriptor, view, items)?; + if physical_items.is_empty() { + return Ok(()); + } + + let mut batch = WriteBatch::default(); + for (key, value) in physical_items { + batch.put(key, value); + } + storage.write(batch) + } + + /// Stage immutable inventory values into one inactive snapshot generation. + /// + /// A lost acknowledgement may cause the caller to replay the exact batch, so an existing + /// byte-identical value is an idempotent success. Different bytes under the same physical key + /// would invalidate the actor preflight that authenticated the first value and are rejected at + /// this single-worker linearization point. This guard does not authenticate a wrong first + /// historical value; actor preflight and the final domain-specific validators remain + /// authoritative for initial contents. + fn write_snapshot_inventory_items( + storage: &dyn Storage, + view: InventoryView, + items: Vec, + ) -> Result<(), anyhow::Error> { + let physical_items = Self::encode_inventory_items(view, items)?; + let mut batch = WriteBatch::default(); + let mut has_new_items = false; + for (key, value) in physical_items { + match storage.get(&key)? { + Some(existing) => { + anyhow::ensure!( + existing == value, + "snapshot inventory staging cannot overwrite an existing generation key with different bytes" + ); + } + None => { + batch.put(key, value); + has_new_items = true; + } + } + } + if !has_new_items { + return Ok(()); + } + + storage.write(batch) + } + + fn delete_reverted_reports( + storage: &dyn Storage, + view: InventoryView, + reports: Vec, + ) -> Result<(), anyhow::Error> { + let mut seen_keys = HashSet::with_capacity(reports.len()); + let physical_keys = reports + .into_iter() + .map(|(logical_key, expected_tally_block)| { + let physical_key = + inventory_physical_key(view, InventoryDomain::Report, &logical_key)?; + anyhow::ensure!( + seen_keys.insert(physical_key.clone()), + "reverted-report delete batch contains a duplicate logical key" + ); + let Some(value) = storage.get(&physical_key)? else { + return Ok(None); + }; + let report: DataRequestInfo = + bincode_compatibility::deserialize_exact(&value).map_err(|error| { + anyhow::anyhow!( + "failed to decode reverted-report candidate from inventory: {error}" + ) + })?; + let belongs_to_reverted_block = report.tally.as_ref().is_some_and(|tally| { + logical_key == format!("DR-REPORT-{}", tally.dr_pointer).into_bytes() + }) && report.current_stage.is_none() + && report.block_hash_tally_tx == Some(expected_tally_block); + + Ok(belongs_to_reverted_block.then_some(physical_key)) + }) + .collect::>>, anyhow::Error>>()? + .into_iter() + .flatten() + .collect::>(); + + if physical_keys.is_empty() { + return Ok(()); + } + + let mut batch = WriteBatch::default(); + for key in physical_keys { + batch.delete(key); + } + // Recovery must not reload the durable ChainState until the removal of reports from its + // reverted suffix is itself durable. + storage.write_sync(batch) + } + + fn delete_physical_prefix_bounded( + storage: &dyn Storage, + prefix: &[u8], + ) -> Result { + let mut deleted = 0_usize; + loop { + // Drop the iterator before acquiring the backend's write lock. This is required by the + // HashMap backend and also keeps every cleanup batch bounded for RocksDB. + let keys = { + let iterator = storage.prefix_iterator(prefix)?; + let mut keys = Vec::with_capacity(INVENTORY_CLEANUP_BATCH_SIZE); + for entry in iterator.take(INVENTORY_CLEANUP_BATCH_SIZE) { + let (key, _value) = entry?; + keys.push(key); + } + keys + }; + if keys.is_empty() { + return Ok(deleted); + } + + let count = keys.len(); + let mut batch = WriteBatch::default(); + for key in keys { + batch.delete(key); + } + storage.write(batch)?; + deleted = deleted.checked_add(count).ok_or_else(|| { + anyhow::anyhow!("inventory cleanup deleted-key count overflow") + })?; + } + } + + fn count_physical_prefix( + storage: &dyn Storage, + prefix: &[u8], + ) -> Result { + let iterator = storage.prefix_iterator(prefix)?; + let mut count = 0_u64; + for entry in iterator { + let (_key, _value) = entry?; + count = count + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("inventory entry count overflow"))?; + } + Ok(count) + } + + fn validate_snapshot_inventory_coverage( + storage: &dyn Storage, + descriptor: InventoryGenerationDescriptor, + ) -> Result<(), anyhow::Error> { + let block_count = Self::count_physical_prefix( + storage, + &inventory_generation_domain_prefix(descriptor, InventoryDomain::Block), + )?; + anyhow::ensure!( + block_count == descriptor.target.block_count, + "snapshot inventory generation contains {block_count} blocks, expected {}", + descriptor.target.block_count + ); + + let superblock_count = Self::count_physical_prefix( + storage, + &inventory_generation_domain_prefix(descriptor, InventoryDomain::Superblock), + )?; + anyhow::ensure!( + superblock_count == descriptor.target.superblock_count, + "snapshot inventory generation contains {superblock_count} superblocks, expected {}", + descriptor.target.superblock_count + ); + + let report_count = Self::count_physical_prefix( + storage, + &inventory_generation_domain_prefix(descriptor, InventoryDomain::Report), + )?; + anyhow::ensure!( + report_count == descriptor.target.report_count, + "snapshot inventory generation contains {report_count} reports, expected {}", + descriptor.target.report_count + ); + + Ok(()) + } + + /// Prove that the staged superblock namespace is keyed by its decoded indexes and contains the + /// exact notification advertised as the snapshot's highest superblock beacon. + /// + /// Actor-side snapshot preflight authenticates the full superblock sequence against canonical + /// blocks, but a backend defect or below-worker mutation can still bypass the write-once staging + /// guard before the final database commit. Re-read the inactive generation here, immediately + /// before the visibility marker is appended, prove every record is structurally addressed, and + /// compare a canonical full-sequence commitment with the durable v3 descriptor. Count and tip + /// checks remain useful diagnostics, while the commitment also binds every non-tip value. + /// Historical v2 descriptors receive all structural checks but have no sequence commitment to + /// compare, which is the deliberate compatibility boundary for already-published databases. + fn validate_snapshot_superblocks( + storage: &dyn Storage, + descriptor: InventoryGenerationDescriptor, + scope: SuperblockValidationScope, + ) -> Result)>, anyhow::Error> { + let view = descriptor.view(); + let target_tip = descriptor.target.highest_superblock_checkpoint; + let prefix = inventory_generation_domain_prefix( + descriptor, + InventoryDomain::Superblock, + ); + let entries = storage.prefix_iterator(&prefix)?; + let mut seen_indexes = HashSet::new(); + let mut advertised_tip_hash = None; + let mut committed_notifications = Vec::new(); + let mut all_notifications = Vec::new(); + let mut validated_records = Vec::new(); + + for entry in entries { + let (physical_key, value) = entry?; + let notification: SuperBlockNotify = + bincode_compatibility::deserialize_exact(&value).map_err(|error| { + anyhow::anyhow!( + "snapshot inventory superblock notification cannot be decoded: {error}" + ) + })?; + let index = notification.superblock.index; + anyhow::ensure!( + index != 0, + "snapshot inventory contains a historical superblock notification at index zero" + ); + if scope == SuperblockValidationScope::ExactSnapshot { + anyhow::ensure!( + index <= target_tip.checkpoint, + "snapshot inventory superblock #{index} exceeds advertised tip #{}", + target_tip.checkpoint + ); + } + + let logical_key = inventory_superblock_logical_key(index); + let expected_physical_key = + inventory_physical_key(view, InventoryDomain::Superblock, &logical_key)?; + anyhow::ensure!( + physical_key == expected_physical_key, + "snapshot inventory superblock key does not match decoded index #{index}" + ); + anyhow::ensure!( + seen_indexes.insert(index), + "snapshot inventory contains duplicate decoded superblock index #{index}" + ); + + if index == target_tip.checkpoint { + advertised_tip_hash = Some(notification.superblock.hash()); + } + if index <= target_tip.checkpoint { + committed_notifications.push(notification.clone()); + } + all_notifications.push(notification.clone()); + validated_records.push((notification, value)); + } + + let decoded_count = u64::try_from(committed_notifications.len())?; + anyhow::ensure!( + decoded_count == descriptor.target.superblock_count, + "snapshot inventory decodes {decoded_count} committed superblocks, expected {}", + descriptor.target.superblock_count + ); + if target_tip.checkpoint == 0 { + anyhow::ensure!( + committed_notifications.is_empty(), + "genesis snapshot prefix must not contain historical superblock notifications" + ); + } else { + let tip_hash = advertised_tip_hash.ok_or_else(|| { + anyhow::anyhow!( + "snapshot inventory has no notification for advertised superblock tip #{}", + target_tip.checkpoint + ) + })?; + anyhow::ensure!( + tip_hash == target_tip.hash_prev_block, + "snapshot inventory superblock tip #{} hashes to {tip_hash}, advertised {}", + target_tip.checkpoint, + target_tip.hash_prev_block + ); + } + + if let Some(expected_commitment) = descriptor.superblock_commitment { + let commitment_sequence = match scope { + SuperblockValidationScope::ExactSnapshot => &committed_notifications, + SuperblockValidationScope::ActiveNamespace => &all_notifications, + }; + let actual_commitment = snapshot_superblock_commitment(commitment_sequence)?; + anyhow::ensure!( + actual_commitment == expected_commitment, + "snapshot inventory superblock sequence commitment {actual_commitment} does not match the durable actor-preflighted commitment {expected_commitment}" + ); + } + + Ok(validated_records) + } + + /// Verify that every transaction pointer derived from the staged blocks is present and exact. + /// + /// Block and superblock counts catch missing or overwritten records in their own domains, but + /// transaction pointers are derived from block contents and therefore need a semantic check. + /// Counting the derived pointers against the physical transaction namespace also catches a + /// duplicate transaction hash across blocks without retaining decoded blocks or transaction + /// payloads in memory. + fn validate_snapshot_transaction_pointers( + storage: &dyn Storage, + descriptor: InventoryGenerationDescriptor, + supplied_protocol_info: Option<&ProtocolInfo>, + ) -> Result<(), anyhow::Error> { + let view = descriptor.view(); + // Keep the schedule snapshot stable for the complete validation pass. The inventory may + // span many epochs, and resolving each block against the live global schedule would make + // the derived hash/pointer set depend on a concurrent protocol update. Snapshot imports + // supply the manifest-authenticated schedule because it is not published globally until + // after this atomic visibility check; ordinary callers retain the process-runtime path. + let protocol_info = supplied_protocol_info + .cloned() + .unwrap_or_else(get_protocol_info); + let block_prefix = inventory_generation_domain_prefix(descriptor, InventoryDomain::Block); + // Some backends (notably the in-memory HashMap backend) keep a read lock for the lifetime + // of a prefix iterator. Derive the complete expected pointer set first, then drop that + // iterator before resolving transaction values with point reads. Otherwise a concurrent + // writer waiting for the same backend's write lock can deadlock this validation pass. + let expected_pointers = { + let mut expected_pointers = Vec::new(); + let blocks = storage.prefix_iterator(&block_prefix)?; + for entry in blocks { + let (physical_block_key, bytes) = entry?; + let block: Block = bincode_compatibility::deserialize_exact(&bytes).map_err(|error| { + anyhow::anyhow!( + "snapshot inventory block cannot be decoded while validating transaction pointers: {error}" + ) + })?; + let block_epoch = block.block_header.beacon.checkpoint; + let protocol_version = protocol_info.all_versions.version_for_epoch(block_epoch); + let block_hash = block.versioned_hash(protocol_version); + let block_logical_key = match block_hash { + Hash::SHA256(hash) => hash.to_vec(), + }; + let expected_block_key = inventory_physical_key( + view, + InventoryDomain::Block, + &block_logical_key, + )?; + anyhow::ensure!( + physical_block_key == expected_block_key, + "snapshot inventory block key does not match its decoded block hash" + ); + + let pointers = block + .txns + .try_create_pointers_to_transactions(block_hash, protocol_version)?; + for (transaction_hash, pointer) in pointers { + let transaction_logical_key = match transaction_hash { + Hash::SHA256(hash) => hash.to_vec(), + }; + let physical_transaction_key = inventory_physical_key( + view, + InventoryDomain::Transaction, + &transaction_logical_key, + )?; + expected_pointers.push((physical_transaction_key, pointer)); + } + } + expected_pointers + }; + + let expected_transaction_count = u64::try_from(expected_pointers.len())?; + for (physical_transaction_key, pointer) in expected_pointers { + let pointer_bytes = storage + .get(&physical_transaction_key)? + .ok_or_else(|| { + anyhow::anyhow!( + "snapshot inventory is missing a transaction pointer for block {:?}", + pointer.block_hash + ) + })?; + let stored_pointer: PointerToBlock = + bincode_compatibility::deserialize_exact(&pointer_bytes).map_err(|error| { + anyhow::anyhow!( + "snapshot transaction pointer value cannot be decoded: {error}" + ) + })?; + anyhow::ensure!( + stored_pointer == pointer, + "snapshot transaction pointer does not match its containing block" + ); + } + + let actual_transaction_count = Self::count_physical_prefix( + storage, + &inventory_generation_domain_prefix(descriptor, InventoryDomain::Transaction), + )?; + anyhow::ensure!( + actual_transaction_count == expected_transaction_count, + "snapshot inventory contains {actual_transaction_count} transaction pointers, expected {expected_transaction_count}" + ); + + Ok(()) + } + + /// Validate the namespace selected by the durable active marker without changing visibility. + /// + /// This is intentionally kept out of ordinary point reads: scanning the full snapshot on + /// every RPC inventory lookup would turn an integrity guard into an unbounded hot-path cost. + /// Storage configuration and every ChainManager reload call this boundary instead. + fn validate_active_inventory_namespace_from( + storage: &dyn Storage, + ) -> Result { + let Some(descriptor) = Self::load_active_inventory_descriptor_from(storage)? else { + return Ok(InventoryView::Legacy); + }; + + // Active generations are appendable after import. Their original block, report, and + // whole-domain counts are therefore no longer exact. Authenticate every current v3 + // superblock (or the immutable v2 snapshot-era prefix), while decoding/address-checking + // later values as well. Do not replay the snapshot transaction-pointer proof here: active + // inventory may legitimately retain fork blocks that share a transaction hash, whereas + // the imported snapshot was a single canonical block sequence. + Self::validate_snapshot_superblocks( + storage, + descriptor, + SuperblockValidationScope::ActiveNamespace, + )?; + Ok(descriptor.view()) + } + + fn delete_inactive_generation( + storage: &dyn Storage, + descriptor: InventoryGenerationDescriptor, + ) -> Result { + let mut deleted = 0_usize; + for domain in InventoryDomain::ALL { + let prefix = inventory_generation_domain_prefix(descriptor, domain); + let count = Self::delete_physical_prefix_bounded(storage, &prefix)?; + deleted = deleted.checked_add(count).ok_or_else(|| { + anyhow::anyhow!("inventory cleanup deleted-key count overflow") + })?; + } + + // Descriptor-last deletion makes cleanup idempotently discoverable after any interruption. + // Synchronizing this last record also orders every preceding bounded deletion before the + // registry entry is durably forgotten, avoiding undiscoverable orphaned values. + let mut batch = WriteBatch::default(); + batch.delete(inventory_generation_descriptor_key(descriptor.generation)); + storage.write_sync(batch)?; + Ok(deleted) + } + + fn registered_inventory_descriptors( + storage: &dyn Storage, + ) -> Result, anyhow::Error> { + let entries = storage.prefix_iterator(INVENTORY_GENERATION_DESCRIPTOR_PREFIX)?; + let mut descriptors = Vec::new(); + for entry in entries { + let (key, value) = entry?; + let descriptor = Self::decode_inventory_descriptor(&value, "registered")?; + anyhow::ensure!( + key == inventory_generation_descriptor_key(descriptor.generation), + "inventory generation registry key disagrees with its descriptor" + ); + descriptors.push(descriptor); + } + + Ok(descriptors) + } +} + +struct GetInventoryView; + +impl Message for GetInventoryView { + type Result = Result; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle(&mut self, _msg: GetInventoryView, _ctx: &mut Self::Context) -> Self::Result { + self.load_inventory_view() + } +} + +struct ValidateActiveInventoryNamespace; + +impl Message for ValidateActiveInventoryNamespace { + type Result = Result; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + _msg: ValidateActiveInventoryNamespace, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + Self::validate_active_inventory_namespace_from(storage.as_ref()) + } +} + +struct PutActiveInventoryBatch { + expected_view: InventoryView, + items: Vec, +} + +impl Message for PutActiveInventoryBatch { + type Result = Result<(), anyhow::Error>; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + PutActiveInventoryBatch { + expected_view, + items, + }: PutActiveInventoryBatch, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let active_descriptor = + Self::load_active_inventory_descriptor_from(storage.as_ref())?; + let active_view = active_descriptor.map_or( + InventoryView::Legacy, + InventoryGenerationDescriptor::view, + ); + anyhow::ensure!( + active_view == expected_view, + "active inventory generation changed before the inventory write" + ); + + Self::write_inventory_items( + storage.as_ref(), + active_descriptor, + active_view, + items, + ) + } +} + +struct RepairOrdinarySuperblockInventory { + chain_state_key: Vec, + expected_view: InventoryView, + notification: SuperBlockNotify, +} + +impl Message for RepairOrdinarySuperblockInventory { + type Result = Result<(), anyhow::Error>; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + RepairOrdinarySuperblockInventory { + chain_state_key, + expected_view, + notification, + }: RepairOrdinarySuperblockInventory, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let active_descriptor = + Self::load_active_inventory_descriptor_from(storage.as_ref())?; + let active_view = active_descriptor.map_or( + InventoryView::Legacy, + InventoryGenerationDescriptor::view, + ); + anyhow::ensure!( + active_view == expected_view, + "active inventory generation changed before the finalized-superblock repair" + ); + + let chain_state_bytes = storage.get(&chain_state_key)?.ok_or_else(|| { + anyhow::anyhow!( + "cannot repair ordinary superblock inventory without its durable ChainState" + ) + })?; + node_migrations::validate_persisted_ordinary_superblock_inventory_binding( + chain_state_bytes, + ¬ification, + )?; + let notification_index = notification.superblock.index; + Self::write_inventory_items( + storage.as_ref(), + active_descriptor, + active_view, + vec![( + InventoryDomain::Superblock, + inventory_superblock_logical_key(notification_index), + serialize(¬ification)?, + )], + ) + } +} + +struct DeleteRevertedReportsBatch { + expected_view: InventoryView, + reports: Vec, +} + +impl Message for DeleteRevertedReportsBatch { + type Result = Result<(), anyhow::Error>; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + DeleteRevertedReportsBatch { + expected_view, + reports, + }: DeleteRevertedReportsBatch, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let active_view = Self::load_inventory_view_from(storage.as_ref())?; + anyhow::ensure!( + active_view == expected_view, + "active inventory generation changed before the inventory delete" + ); + + Self::delete_reverted_reports(storage.as_ref(), active_view, reports) + } +} + +struct BeginSnapshotInventoryImport { + target: SnapshotInventoryTarget, + superblock_commitment: Hash, +} + +impl Message for BeginSnapshotInventoryImport { + type Result = Result; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + BeginSnapshotInventoryImport { + target, + superblock_commitment, + }: BeginSnapshotInventoryImport, + _ctx: &mut Self::Context, + ) -> Self::Result { + anyhow::ensure!( + target.chain_state_version == CURRENT_CHAIN_STATE_VERSION, + "snapshot inventory target uses stale ChainState version {}", + target.chain_state_version + ); + + let storage = self.storage(); + let active_view = Self::load_inventory_view_from(storage.as_ref())?; + if let Some(active_target) = active_view.target() { + anyhow::ensure!( + active_target.network_magic == target.network_magic + && active_target.environment == target.environment, + "snapshot inventory target does not match the active chain identity" + ); + } + + let generation = loop { + let generation = InventoryGeneration(rand::thread_rng().r#gen()); + if active_view.generation() == Some(generation) { + continue; + } + if storage + .get(&inventory_generation_descriptor_key(generation))? + .is_none() + { + break generation; + } + }; + let descriptor = InventoryGenerationDescriptor::new( + generation, + target, + superblock_commitment, + ) + .validate()?; + let mut batch = WriteBatch::default(); + batch.put( + inventory_generation_descriptor_key(generation), + descriptor.encode()?, + ); + // Persist the discoverability marker durably before the first staged inventory batch. The + // final synchronous ledger commit will also order and flush all later staged batch writes. + storage.write_sync(batch)?; + + Ok(SnapshotInventoryToken { descriptor }) + } +} + +struct PutSnapshotInventoryBatch { + token: SnapshotInventoryToken, + items: Vec, +} + +impl Message for PutSnapshotInventoryBatch { + type Result = Result<(), anyhow::Error>; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + PutSnapshotInventoryBatch { token, items }: PutSnapshotInventoryBatch, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let descriptor = Self::verify_snapshot_token(storage.as_ref(), &token)?; + let active_view = Self::load_inventory_view_from(storage.as_ref())?; + anyhow::ensure!( + active_view.generation() != Some(descriptor.generation), + "snapshot inventory staging generation is already active" + ); + + Self::write_snapshot_inventory_items(storage.as_ref(), descriptor.view(), items) + } +} + +struct CommitChainStateWithWit2ActivationBatch { + batch: UtxoWriteBatch, + wit2_activation_context: node_migrations::Wit2ActivationChainContext, + certificate: Option, + protocol_info: Option, + ordinary_superblock: Option, +} - let key_bytes = match serialize(key) { - Ok(x) => x, - Err(e) => { - return futures::future::Either::Left(futures::future::Either::Left(future::ready( - Err(e.into()), - ))); +impl Message for CommitChainStateWithWit2ActivationBatch { + type Result = Result<(), anyhow::Error>; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + CommitChainStateWithWit2ActivationBatch { + mut batch, + wit2_activation_context, + certificate, + protocol_info, + ordinary_superblock, + }: CommitChainStateWithWit2ActivationBatch, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + node_migrations::resolve_wit2_activation_certificate_for_chain_state( + storage.as_ref(), + &wit2_activation_context, + certificate.as_ref(), + protocol_info.as_ref(), + &mut batch, + )?; + node_migrations::resolve_protocol_schedule_for_chain_state( + storage.as_ref(), + &wit2_activation_context, + protocol_info.as_ref(), + &mut batch, + )?; + for key in batch.raw_keys() { + ensure_not_reserved_inventory_key(key)?; } - }; - let value_bytes = match serialize(value) { - Ok(x) => x, - Err(e) => { - return futures::future::Either::Left(futures::future::Either::Right(future::ready( - Err(e.into()), - ))); + if let Some(OrdinarySuperblockInventoryCommit { + expected_view, + notification, + }) = ordinary_superblock + { + let active_descriptor = + Self::load_active_inventory_descriptor_from(storage.as_ref())?; + let active_view = active_descriptor.map_or( + InventoryView::Legacy, + InventoryGenerationDescriptor::view, + ); + anyhow::ensure!( + active_view == expected_view, + "active inventory generation changed before the finalized-superblock checkpoint commit" + ); + + let notification_index = notification.superblock.index; + let committed_tip = match active_descriptor { + Some(descriptor) => Self::validate_snapshot_superblocks( + storage.as_ref(), + descriptor, + SuperblockValidationScope::ActiveNamespace, + )? + .into_iter() + .map(|(notification, _value)| notification.superblock.index) + .max(), + None => Self::validate_legacy_superblocks(storage.as_ref())? + .into_iter() + .map(|(notification, _value)| notification.superblock.index) + .max(), + }; + if let Some(committed_tip) = committed_tip { + anyhow::ensure!( + notification_index >= committed_tip, + "finalized-superblock checkpoint #{} cannot replace ChainState after active inventory already committed newer superblock #{}", + notification_index, + committed_tip + ); + } + let inventory_puts = Self::prepare_inventory_items( + storage.as_ref(), + active_descriptor, + active_view, + vec![( + InventoryDomain::Superblock, + inventory_superblock_logical_key(notification_index), + serialize(¬ification)?, + )], + )?; + let occupied_raw_keys = batch + .raw_keys() + .map(|key| key.to_vec()) + .collect::>(); + for (key, value) in inventory_puts { + anyhow::ensure!( + !occupied_raw_keys.contains(&key), + "finalized-superblock inventory commit collides with a caller-owned raw batch key" + ); + batch.put_raw(key, value); + } } - }; - futures::future::Either::Right(async move { addr.send(Put(key_bytes, value_bytes)).await? }) + // Typed UTXOs, versioned ChainState, activation-certificate/protocol-schedule resolution, + // the ordinary SuperBlockNotify, and both v3 descriptor copies now cross the backend in + // one atomic batch. Raw inventory operations do not affect UTXO-cache deltas. + self.backend.clone().as_arc_dyn_utxo_db().write(batch) + } } -/// Put a batch of values into the storage -pub fn put_batch(kv: &[(K, V)]) -> impl Future> + use -where - K: serde::Serialize, - V: serde::Serialize, -{ - let addr = StorageManagerAdapter::from_registry(); +struct CommitSnapshotInventoryBatch { + token: SnapshotInventoryToken, + batch: UtxoWriteBatch, + wit2_activation_context: node_migrations::Wit2ActivationChainContext, + certificate: Option, + protocol_info: Option, +} - let kv_bytes: Result, anyhow::Error> = kv - .iter() - .map(|(k, v)| Ok((serialize(k)?, serialize(v)?))) - .collect(); +impl Message for CommitSnapshotInventoryBatch { + type Result = Result; +} - async move { - match kv_bytes { - Ok(kv_bytes) if kv_bytes.is_empty() => Ok(()), - Ok(kv_bytes) => addr.send(PutBatch(kv_bytes)).await?, - Err(e) => Err(e), +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + CommitSnapshotInventoryBatch { + token, + mut batch, + wit2_activation_context, + certificate, + protocol_info, + }: CommitSnapshotInventoryBatch, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let descriptor = Self::verify_snapshot_token(storage.as_ref(), &token)?; + let active_view = Self::load_inventory_view_from(storage.as_ref())?; + if let Some(active_target) = active_view.target() { + anyhow::ensure!( + active_target.network_magic == descriptor.target.network_magic + && active_target.environment == descriptor.target.environment, + "snapshot inventory target does not match the active chain identity" + ); + } + anyhow::ensure!( + active_view.generation() != Some(descriptor.generation), + "snapshot inventory generation is already active" + ); + // ChainState and the activation certificate share one logical record. A process-owned + // pending certificate has not necessarily reached storage yet, so resolve it here in this + // sole visibility batch. With no pending certificate, preserve the legacy/existing path: + // authenticate the durable companion and re-stage its exact bytes unchanged. + if let Some(certificate) = certificate.as_ref() { + node_migrations::resolve_wit2_activation_certificate_for_chain_state( + storage.as_ref(), + &wit2_activation_context, + Some(certificate), + protocol_info.as_ref(), + &mut batch, + )?; + } else { + node_migrations::bind_existing_wit2_activation_certificate_to_snapshot( + storage.as_ref(), + &wit2_activation_context, + protocol_info.as_ref(), + &mut batch, + )?; + } + node_migrations::resolve_protocol_schedule_for_chain_state( + storage.as_ref(), + &wit2_activation_context, + protocol_info.as_ref(), + &mut batch, + )?; + // The commit handler adds the two descriptor records below itself. The caller-supplied + // UTXO batch must not contain any other inventory-control-plane raw operation, or it could + // smuggle an overwrite/deletion into the same atomic visibility transition. + for key in batch.raw_keys() { + ensure_not_reserved_inventory_key(key)?; } + Self::validate_snapshot_inventory_coverage(storage.as_ref(), descriptor)?; + Self::validate_snapshot_superblocks( + storage.as_ref(), + descriptor, + SuperblockValidationScope::ExactSnapshot, + )?; + Self::validate_snapshot_transaction_pointers( + storage.as_ref(), + descriptor, + protocol_info.as_ref(), + )?; + + let descriptor_bytes = descriptor.encode()?; + batch.put_raw( + inventory_generation_descriptor_key(descriptor.generation), + descriptor_bytes.clone(), + ); + batch.put_raw( + ACTIVE_INVENTORY_DESCRIPTOR_KEY.to_vec(), + descriptor_bytes, + ); + + // This synchronous atomic write is the sole inventory-generation visibility commit. It + // also orders every previously acknowledged staged inventory WAL record before activation. + self.backend + .clone() + .as_arc_dyn_utxo_db() + .write_sync(batch)?; + + Ok(descriptor.view()) } } -/// Delete value associated to key -pub fn delete(key: &K) -> impl Future> -where - K: serde::Serialize, -{ - let addr = StorageManagerAdapter::from_registry(); +struct AbortSnapshotInventoryImport { + token: SnapshotInventoryToken, +} - let key_bytes = match serialize(key) { - Ok(x) => x, - Err(e) => return futures::future::Either::Left(future::ready(Err(e.into()))), - }; +impl Message for AbortSnapshotInventoryImport { + type Result = Result<(), anyhow::Error>; +} - let fut = async move { addr.send(Delete(key_bytes)).await? }; +impl Handler for StorageManager { + type Result = ::Result; - futures::future::Either::Right(fut) -} + fn handle( + &mut self, + AbortSnapshotInventoryImport { token }: AbortSnapshotInventoryImport, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + let descriptor = token.descriptor.validate()?; + let active_view = Self::load_inventory_view_from(storage.as_ref())?; + anyhow::ensure!( + active_view.generation() != Some(descriptor.generation), + "cannot abort the active inventory generation" + ); -/// Get an atomic reference to the storage backend -pub fn get_backend() --> impl Future, anyhow::Error>> { - let addr = StorageManagerAdapter::from_registry(); + let Some(registered) = + Self::load_registered_inventory_descriptor(storage.as_ref(), descriptor.generation)? + else { + return Ok(()); + }; + anyhow::ensure!( + registered == descriptor, + "snapshot inventory token disagrees with its registered descriptor" + ); + Self::delete_inactive_generation(storage.as_ref(), descriptor)?; - async move { addr.send(GetBackend).await? } + Ok(()) + } } -struct StorageManager { - backend: Arc, -} +struct CleanupSnapshotInventoryGenerations; -impl Drop for StorageManager { - fn drop(&mut self) { - log::trace!("Dropping StorageManager"); - stop_system_if_panicking("StorageManager"); - // FIXME(#2008): sometimes rocksdb is not closed correctly, resulting in error - // pure virtual method called - // This sleep seems to fix that error, but it doesn't look like a solid fix - std::thread::sleep(Duration::from_millis(500)); - } +impl Message for CleanupSnapshotInventoryGenerations { + type Result = Result; } -impl Default for StorageManager { - fn default() -> Self { - StorageManager { - backend: Arc::new(UtxoDbWrapStorage(backends::nobackend::Backend)), +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + _msg: CleanupSnapshotInventoryGenerations, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.storage(); + // Cleanup is destructive and descriptor-last. Authenticate the active namespace before + // deleting any inactive/predecessor generation so corruption cannot erase the remaining + // recovery material merely because a caller skipped a separate preflight. + let active_view = + Self::validate_active_inventory_namespace_from(storage.as_ref())?; + let active_generation = active_view.generation(); + let descriptors = Self::registered_inventory_descriptors(storage.as_ref())?; + let mut reclaimed = 0_usize; + for descriptor in descriptors { + if active_generation == Some(descriptor.generation) { + continue; + } + let count = Self::delete_inactive_generation(storage.as_ref(), descriptor)?; + reclaimed = reclaimed.checked_add(count).ok_or_else(|| { + anyhow::anyhow!("inventory cleanup deleted-key count overflow") + })?; } - } -} -impl Actor for StorageManager { - type Context = SyncContext; + Ok(reclaimed) + } } struct Configure(Arc); @@ -215,7 +2413,35 @@ impl Handler for StorageManager { let storage_conf = &conf.storage; let backend = create_appropriate_backend(storage_conf)?; + // Keep the currently configured backend authoritative until the replacement has passed + // every fallible descriptor, namespace-integrity, and chain-identity check. Configure is + // normally boot-only, but retaining a rejected backend after an error would make a direct + // retry or test actor observe the very state this boundary refused to accept. + let storage = backend.clone().as_arc_dyn_storage(); + // Authenticate a committed generation before the adapter releases queued inventory + // requests. This catches durable post-publication corruption on restart even if an RPC or + // another actor reaches inventory before ChainManager begins its own reload validation. + let active_view = + Self::validate_active_inventory_namespace_from(storage.as_ref())?; + if let Some(target) = active_view.target() { + let configured_magic = conf.consensus_constants.get_magic(); + anyhow::ensure!( + target.network_magic == configured_magic, + "active inventory generation belongs to network magic {}, configured node uses {}", + target.network_magic, + configured_magic + ); + anyhow::ensure!( + target.environment == conf.environment, + "active inventory generation belongs to {} environment, configured node uses {}", + target.environment, + conf.environment + ); + } self.backend = backend; + // Do not reclaim inactive inventory here. Configure is normally boot-only, but its API + // does not prove that no live reader still holds a pinned view of the old generation. + // ChainManager performs the explicit cleanup during its quiescent startup sequence. log::info!( "Configured {:#?} as the storage backend", storage_conf.backend @@ -235,6 +2461,7 @@ impl Handler for StorageManager { type Result = ::Result; fn handle(&mut self, Put(key, value): Put, _ctx: &mut Self::Context) -> Self::Result { + ensure_not_reserved_generic_storage_key(&key)?; self.backend.clone().as_arc_dyn_storage().put(key, value) } } @@ -249,11 +2476,12 @@ impl Handler for StorageManager { type Result = ::Result; fn handle(&mut self, PutBatch(kvs): PutBatch, _ctx: &mut Self::Context) -> Self::Result { + let mut batch = WriteBatch::default(); for (key, value) in kvs { - self.backend.clone().as_arc_dyn_storage().put(key, value)?; + ensure_not_reserved_generic_storage_key(&key)?; + batch.put(key, value); } - - Ok(()) + self.backend.clone().as_arc_dyn_storage().write(batch) } } @@ -271,6 +2499,40 @@ impl Handler for StorageManager { } } +/// Read three related entries during one storage-manager actor turn. +/// +/// This is used for records that are committed in the same database batch and must not be +/// observed on opposite sides of a concurrently processed `Batch` message. The third entry is the +/// optional protocol-schedule companion used by portable snapshot imports. +struct GetTriple(Vec, Vec, Vec); + +impl Message for GetTriple { + type Result = Result< + ( + Option>, + Option>, + Option>, + ), + anyhow::Error, + >; +} + +impl Handler for StorageManager { + type Result = ::Result; + + fn handle( + &mut self, + GetTriple(first, second, third): GetTriple, + _ctx: &mut Self::Context, + ) -> Self::Result { + let storage = self.backend.clone().as_arc_dyn_storage(); + let first = storage.get(first.as_ref())?; + let second = storage.get(second.as_ref())?; + let third = storage.get(third.as_ref())?; + Ok((first, second, third)) + } +} + struct Delete(Vec); impl Message for Delete { @@ -281,6 +2543,7 @@ impl Handler for StorageManager { type Result = ::Result; fn handle(&mut self, Delete(key): Delete, _ctx: &mut Self::Context) -> Self::Result { + ensure_not_reserved_generic_storage_key(&key)?; self.backend .clone() .as_arc_dyn_storage() @@ -298,6 +2561,13 @@ impl Handler for StorageManager { type Result = ::Result; fn handle(&mut self, msg: Batch, _ctx: &mut Self::Context) -> Self::Result { + // Raw entries in a UTXO batch are used for versioned ChainState and migration records. + // Keep the snapshot-inventory namespaces exclusively owned by their generation handlers; + // otherwise an ordinary chain-state batch could overwrite or delete an active/staged + // inventory record while bypassing the single-key storage guards above. + for key in msg.0.raw_keys() { + ensure_not_reserved_inventory_key(key)?; + } self.backend.clone().as_arc_dyn_utxo_db().write(msg.0) } } @@ -318,7 +2588,7 @@ impl Handler for StorageManager { /// Helper trait to allow casting `Arc` to `Arc` and `Arc`. #[allow(missing_docs)] -pub trait NodeStorage { +pub(crate) trait NodeStorage { fn as_arc_dyn_storage(self: Arc) -> Arc; fn as_arc_dyn_utxo_db(self: Arc) -> Arc; fn as_arc_dyn_nodestorage(self: Arc) -> Arc; @@ -416,6 +2686,27 @@ impl StorageManagerAdapter { } } +#[cfg(test)] +struct StopStorageManager; + +#[cfg(test)] +impl Message for StopStorageManager { + type Result = (); +} + +#[cfg(test)] +impl Handler for StorageManagerAdapter { + type Result = ::Result; + + fn handle(&mut self, _message: StopStorageManager, ctx: &mut Self::Context) -> Self::Result { + // Tests that exercise a real RocksDB restart need to release the adapter-owned + // StorageManager before opening the same directory again. Production shutdown already + // tears down the actor system; this narrow test message makes that lifecycle explicit in + // an in-process restart test without changing the runtime storage API. + ctx.stop(); + } +} + impl Actor for StorageManagerAdapter { type Context = Context; @@ -461,3 +2752,1405 @@ where Box::pin(self.storage.send(msg).map(FlattenResult::flatten_result)) } } + +#[cfg(test)] +mod tests { + use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex, RwLock}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use witnet_data_structures::chain::{ + ChainInfo, OutputPointer, PublicKeyHash, ReputationEngine, SuperBlock, + ValueTransferOutput, + }; + use witnet_storage::storage::{StorageIterator, WriteBatchItem}; + + use super::*; + + /// Outcome injected into the next atomic backend batch. + /// + /// `ApplyThenFail` models the important lost-acknowledgement case: storage is already on the + /// new atomic image, but the actor receives an error and must let restart discover which view + /// became durable. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum InjectedWriteOutcome { + Succeed, + FailBeforeApply, + ApplyThenFail, + } + + #[derive(Default)] + struct FaultStorageState { + entries: RwLock, Vec>>, + write_outcomes: Mutex>, + } + + /// Shared, atomic in-memory backend with deterministic one-shot write outcomes. + /// + /// Clones model a process restart over the same durable bytes. Point reads and prefix scans do + /// not consume failpoints; only `write` and `write_sync` do, matching the persistence + /// boundaries exercised below. + #[derive(Clone, Default)] + struct FaultStorage { + state: Arc, + } + + impl FaultStorage { + fn queue_write_outcomes( + &self, + outcomes: impl IntoIterator, + ) { + self.state + .write_outcomes + .lock() + .unwrap() + .extend(outcomes); + } + + fn apply_batch(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { + let outcome = self + .state + .write_outcomes + .lock() + .unwrap() + .pop_front() + .unwrap_or(InjectedWriteOutcome::Succeed); + if outcome == InjectedWriteOutcome::FailBeforeApply { + return Err(anyhow::anyhow!("injected write failure before apply")); + } + + // Apply every operation under one lock so the fixture retains the atomic-batch + // property of the production HashMap and RocksDB backends. + let mut entries = self.state.entries.write().unwrap(); + for item in batch.batch { + match item { + WriteBatchItem::Put(key, value) => { + entries.insert(key, value); + } + WriteBatchItem::Delete(key) => { + entries.remove(&key); + } + WriteBatchItem::DeleteRange(start, end) => { + entries.retain(|key, _| { + key.as_slice() < start.as_slice() + || key.as_slice() >= end.as_slice() + }); + } + } + } + drop(entries); + + if outcome == InjectedWriteOutcome::ApplyThenFail { + Err(anyhow::anyhow!( + "injected lost acknowledgement after atomic apply" + )) + } else { + Ok(()) + } + } + } + + impl Storage for FaultStorage { + fn get(&self, key: &[u8]) -> Result>, anyhow::Error> { + Ok(self.state.entries.read().unwrap().get(key).cloned()) + } + + fn put(&self, key: Vec, value: Vec) -> Result<(), anyhow::Error> { + self.state.entries.write().unwrap().insert(key, value); + Ok(()) + } + + fn delete(&self, key: &[u8]) -> Result<(), anyhow::Error> { + self.state.entries.write().unwrap().remove(key); + Ok(()) + } + + fn prefix_iterator<'a, 'b: 'a>( + &'a self, + prefix: &'b [u8], + ) -> Result, anyhow::Error> { + // Return an owned point-in-time scan so a subsequent actor write cannot deadlock on a + // read guard retained by the iterator. + let mut entries = self + .state + .entries + .read() + .unwrap() + .iter() + .filter(|(key, _)| key.starts_with(prefix)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + entries.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + Ok(Box::new(entries.into_iter().map(Ok))) + } + + fn write(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { + self.apply_batch(batch) + } + + fn write_sync(&self, batch: WriteBatch) -> Result<(), anyhow::Error> { + self.apply_batch(batch) + } + } + + fn start_fault_storage_manager(storage: FaultStorage) -> Addr { + let backend: Arc = + Arc::new(UtxoDbWrapStorage(storage)); + SyncArbiter::start(1, move || StorageManager { + backend: Arc::clone(&backend), + }) + } + + #[test] + fn rocksdb_backend_reopens_current_chain_state_with_delegated_fields() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let db_path = std::env::temp_dir().join(format!( + "witnet-delegated-staking-rocksdb-restart-{}-{suffix}", + std::process::id() + )); + let mut config = Config::default(); + config.storage.backend = config::StorageBackend::RocksDB; + config.storage.db_path = db_path.clone(); + config.storage.utxos_in_memory = false; + + let key = b"delegated-staking-rocksdb-restart-fixture".to_vec(); + let mut chain_state = ChainState::default(); + chain_state.node_stats.block_mined_count = 0x0102_0304; + chain_state.delegation_state.activation_initialized = true; + let encoded = bincode::serialize(&chain_state).expect("current ChainState must encode"); + + { + let backend = create_appropriate_backend(&config.storage) + .expect("real RocksDB backend must open for the restart fixture"); + backend + .clone() + .as_arc_dyn_storage() + .put(key.clone(), encoded.clone()) + .expect("RocksDB must persist the current ChainState bytes"); + } + + let reopened = create_appropriate_backend(&config.storage) + .expect("real RocksDB backend must reopen the same durable directory"); + let recovered = reopened + .clone() + .as_arc_dyn_storage() + .get(&key) + .expect("RocksDB restart read must succeed") + .expect("restarted RocksDB must retain the ChainState record"); + assert_eq!(recovered, encoded); + let decoded: ChainState = + bincode::deserialize(&recovered).expect("restarted ChainState bytes must decode"); + assert_eq!(decoded.node_stats.block_mined_count, 0x0102_0304); + assert!(decoded.delegation_state.activation_initialized); + + drop(reopened); + let _ = std::fs::remove_dir_all(db_path); + } + + fn fixture_target(report_count: u64) -> SnapshotInventoryTarget { + let chain_info = ChainInfo::default(); + SnapshotInventoryTarget::new( + chain_info.consensus_constants.get_magic(), + chain_info.environment, + chain_info.highest_block_checkpoint, + chain_info.highest_superblock_checkpoint, + 0, + 0, + report_count, + ) + } + + fn fixture_superblock_notification(index: u32, discriminator: u8) -> SuperBlockNotify { + SuperBlockNotify { + superblock: SuperBlock::new( + 1, + Hash::SHA256([discriminator; 32]), + Hash::SHA256([discriminator.wrapping_add(1); 32]), + index, + Hash::SHA256([discriminator.wrapping_add(2); 32]), + Hash::SHA256([discriminator.wrapping_add(3); 32]), + Hash::SHA256([discriminator.wrapping_add(4); 32]), + ), + consolidated_block_hashes: vec![Hash::SHA256([ + discriminator.wrapping_add(5); + 32 + ])], + } + } + + fn fixture_superblock_target( + notifications: &[SuperBlockNotify], + ) -> SnapshotInventoryTarget { + let chain_info = ChainInfo::default(); + let tip = notifications + .last() + .expect("superblock target fixture requires a tip"); + SnapshotInventoryTarget::new( + chain_info.consensus_constants.get_magic(), + chain_info.environment, + chain_info.highest_block_checkpoint, + CheckpointBeacon { + checkpoint: tip.superblock.index, + hash_prev_block: tip.superblock.hash(), + }, + 0, + u64::try_from(notifications.len()).expect("fixture count fits u64"), + 0, + ) + } + + fn fixture_activation_context() -> node_migrations::Wit2ActivationChainContext { + let mut chain_state = ChainState::default(); + chain_state.chain_info = Some(ChainInfo::default()); + node_migrations::Wit2ActivationChainContext::from_chain_state(&chain_state).unwrap() + } + + fn fixture_commit_batch(value: &[u8]) -> UtxoWriteBatch { + let mut batch = UtxoWriteBatch::default(); + batch.put_raw(b"snapshot-generation-fixture-state".to_vec(), value.to_vec()); + batch + } + + fn fixture_ordinary_superblock_commit( + expected_view: InventoryView, + notification: SuperBlockNotify, + state_value: &[u8], + ) -> CommitChainStateWithWit2ActivationBatch { + CommitChainStateWithWit2ActivationBatch { + batch: fixture_commit_batch(state_value), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + ordinary_superblock: Some(OrdinarySuperblockInventoryCommit { + expected_view, + notification, + }), + } + } + + const FIXTURE_REPORT_KEY: &[u8] = b"DR-REPORT-snapshot-generation-fixture"; + const FIXTURE_STATE_KEY: &[u8] = b"snapshot-generation-fixture-state"; + + #[test] + fn historical_v7_inventory_target_survives_chain_state_v8_migration() { + let mut target = fixture_target(0); + target.chain_state_version = PRE_EXIT_CHAIN_STATE_VERSION; + let descriptor = InventoryGenerationDescriptor::new( + InventoryGeneration([7_u8; 32]), + target, + snapshot_superblock_commitment(&[]).unwrap(), + ); + let encoded = descriptor.encode().unwrap(); + let decoded = StorageManager::decode_inventory_descriptor(&encoded, "historical v7 fixture").unwrap(); + assert_eq!(decoded, descriptor); + assert_eq!(decoded.target.chain_state_version, PRE_EXIT_CHAIN_STATE_VERSION); + let mut invalid = descriptor; + invalid.target.chain_state_version = DELEGATION_CHAIN_STATE_VERSION; + assert!(invalid.validate().is_err()); + } + + async fn begin_and_stage_report( + addr: &Addr, + ) -> SnapshotInventoryToken { + let token = addr + .send(BeginSnapshotInventoryImport { + target: fixture_target(1), + superblock_commitment: snapshot_superblock_commitment(&[]) + .expect("commit empty superblock sequence"), + }) + .await + .expect("storage worker mailbox") + .expect("register inactive generation"); + addr.send(PutSnapshotInventoryBatch { + token: token.clone(), + items: vec![( + InventoryDomain::Report, + FIXTURE_REPORT_KEY.to_vec(), + b"opaque prevalidated report fixture".to_vec(), + )], + }) + .await + .expect("storage worker mailbox") + .expect("stage report in inactive generation"); + token + } + + #[test] + fn legacy_ordinary_superblock_commit_is_atomic_view_pinned_and_immutable() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let notification = fixture_superblock_notification(1, 7); + let logical_key = inventory_superblock_logical_key(1); + let physical_key = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Superblock, + &logical_key, + ) + .unwrap(); + + storage.queue_write_outcomes([InjectedWriteOutcome::FailBeforeApply]); + assert!( + addr.send(fixture_ordinary_superblock_commit( + InventoryView::Legacy, + notification.clone(), + b"must not commit", + )) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + assert!(storage.get(&physical_key).unwrap().is_none()); + + storage.queue_write_outcomes([InjectedWriteOutcome::ApplyThenFail]); + assert!( + addr.send(fixture_ordinary_superblock_commit( + InventoryView::Legacy, + notification.clone(), + b"committed atomically", + )) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"committed atomically".to_vec()) + ); + assert_eq!( + storage.get(&physical_key).unwrap(), + Some(serialize(¬ification).unwrap()) + ); + + addr.send(fixture_ordinary_superblock_commit( + InventoryView::Legacy, + notification.clone(), + b"committed atomically", + )) + .await + .expect("storage worker mailbox") + .expect("accept exact checkpoint retry after a lost acknowledgement"); + + let conflicting = fixture_superblock_notification(1, 8); + let error = addr + .send(fixture_ordinary_superblock_commit( + InventoryView::Legacy, + conflicting, + b"must not replace the checkpoint", + )) + .await + .expect("storage worker mailbox") + .expect_err("reject different bytes at the same legacy superblock index"); + assert!(error.to_string().contains("immutable superblock")); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"committed atomically".to_vec()) + ); + assert_eq!( + storage.get(&physical_key).unwrap(), + Some(serialize(¬ification).unwrap()) + ); + + let stale_generation = InventoryView::Generation { + generation: InventoryGeneration([0x55; 32]), + target: fixture_target(0), + }; + let stale_notification = fixture_superblock_notification(2, 9); + let stale_physical_key = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Superblock, + &inventory_superblock_logical_key(2), + ) + .unwrap(); + let error = addr + .send(fixture_ordinary_superblock_commit( + stale_generation, + stale_notification, + b"must not pass stale view CAS", + )) + .await + .expect("storage worker mailbox") + .expect_err("reject an expected view different from the worker's active view"); + assert!(error.to_string().contains("generation changed")); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"committed atomically".to_vec()) + ); + assert!(storage.get(&stale_physical_key).unwrap().is_none()); + }); + } + + #[test] + fn generation_ordinary_superblock_commit_rejects_a_missing_lower_index() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let generation = InventoryGeneration([0x56; 32]); + let target = fixture_target(0); + let existing = fixture_superblock_notification(3, 10); + let descriptor = InventoryGenerationDescriptor::new( + generation, + target, + snapshot_superblock_commitment(&[existing.clone()]).unwrap(), + ); + let descriptor_bytes = descriptor.encode().unwrap(); + storage + .put( + inventory_generation_descriptor_key(generation), + descriptor_bytes.clone(), + ) + .unwrap(); + storage + .put( + ACTIVE_INVENTORY_DESCRIPTOR_KEY.to_vec(), + descriptor_bytes, + ) + .unwrap(); + storage + .put( + inventory_physical_key( + descriptor.view(), + InventoryDomain::Superblock, + &inventory_superblock_logical_key(3), + ) + .unwrap(), + serialize(&existing).unwrap(), + ) + .unwrap(); + + let addr = start_fault_storage_manager(storage.clone()); + let missing_lower = fixture_superblock_notification(2, 11); + let missing_lower_key = inventory_physical_key( + descriptor.view(), + InventoryDomain::Superblock, + &inventory_superblock_logical_key(2), + ) + .unwrap(); + let error = addr + .send(PutActiveInventoryBatch { + expected_view: descriptor.view(), + items: vec![( + InventoryDomain::Superblock, + inventory_superblock_logical_key(2), + serialize(&missing_lower).unwrap(), + )], + }) + .await + .expect("storage worker mailbox") + .expect_err("a fresh ordinary append must advance the committed sequence tip"); + assert!(error.to_string().contains("below already committed superblock")); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + assert!(storage.get(&missing_lower_key).unwrap().is_none()); + + // The floor is the pre-batch durable maximum, not an ordering constraint on values + // entering together. A descending pair above that floor is one valid atomic append. + let later_five = fixture_superblock_notification(5, 14); + let later_four = fixture_superblock_notification(4, 13); + addr.send(PutActiveInventoryBatch { + expected_view: descriptor.view(), + items: vec![ + ( + InventoryDomain::Superblock, + inventory_superblock_logical_key(5), + serialize(&later_five).unwrap(), + ), + ( + InventoryDomain::Superblock, + inventory_superblock_logical_key(4), + serialize(&later_four).unwrap(), + ), + ], + }) + .await + .expect("storage worker mailbox") + .expect("accept unordered new indexes above the pre-batch committed floor"); + + let error = addr + .send(fixture_ordinary_superblock_commit( + descriptor.view(), + existing, + b"must not roll ChainState behind the active inventory tip", + )) + .await + .expect("storage worker mailbox") + .expect_err("combined checkpoint replay below the active inventory tip must fail"); + assert!(error.to_string().contains("already committed newer superblock")); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + }); + } + + #[test] + fn inventory_only_repair_is_bound_to_the_durable_chain_state() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let durable_notification = fixture_superblock_notification(1, 12); + let mut chain_info = ChainInfo::default(); + chain_info.consensus_constants.superblock_period = 1; + chain_info.highest_superblock_checkpoint = CheckpointBeacon { + checkpoint: 1, + hash_prev_block: durable_notification.superblock.hash(), + }; + let mut chain_state = ChainState::default(); + chain_state.chain_info = Some(chain_info); + chain_state + .block_chain + .insert(0, durable_notification.consolidated_block_hashes[0]); + let mut chain_state_bytes = CURRENT_CHAIN_STATE_VERSION.to_le_bytes().to_vec(); + chain_state_bytes.extend(serialize(&chain_state).unwrap()); + let chain_state_key = serialize(&"ordinary-repair-chain-state".to_string()).unwrap(); + storage + .put(chain_state_key.clone(), chain_state_bytes) + .unwrap(); + + let physical_key = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Superblock, + &inventory_superblock_logical_key(1), + ) + .unwrap(); + let addr = start_fault_storage_manager(storage.clone()); + let conflicting_same_index = fixture_superblock_notification(1, 13); + let error = addr + .send(RepairOrdinarySuperblockInventory { + chain_state_key: chain_state_key.clone(), + expected_view: InventoryView::Legacy, + notification: conflicting_same_index, + }) + .await + .expect("storage worker mailbox") + .expect_err("repair must authenticate the finalized body against durable state"); + assert!(error.to_string().contains("does not match persisted beacon")); + assert!(storage.get(&physical_key).unwrap().is_none()); + + addr.send(RepairOrdinarySuperblockInventory { + chain_state_key, + expected_view: InventoryView::Legacy, + notification: durable_notification.clone(), + }) + .await + .expect("storage worker mailbox") + .expect("repair notification matching the durable checkpoint"); + assert_eq!( + storage.get(&physical_key).unwrap(), + Some(serialize(&durable_notification).unwrap()) + ); + }); + } + + #[test] + fn inactive_snapshot_inventory_is_invisible_until_the_worker_commit() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let token = begin_and_stage_report(&addr).await; + let staged_report_key = inventory_physical_key( + token.view(), + InventoryDomain::Report, + FIXTURE_REPORT_KEY, + ) + .unwrap(); + + assert!(storage.get(&staged_report_key).unwrap().is_some()); + let legacy_report_key = inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Report, + FIXTURE_REPORT_KEY, + ) + .unwrap(); + assert!(storage.get(&legacy_report_key).unwrap().is_none()); + assert_eq!( + addr.send(GetInventoryView) + .await + .expect("storage worker mailbox") + .expect("load active view"), + InventoryView::Legacy + ); + + let committed_view = addr + .send(CommitSnapshotInventoryBatch { + token: token.clone(), + batch: fixture_commit_batch(b"committed"), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + }) + .await + .expect("storage worker mailbox") + .expect("commit snapshot generation"); + assert_eq!(committed_view, token.view()); + assert_eq!( + addr.send(GetInventoryView) + .await + .expect("storage worker mailbox") + .expect("load committed view"), + token.view() + ); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"committed".to_vec()) + ); + + // A writer that pinned the old active view before publication must not leak an item + // into either the retired legacy namespace or the new generation. + let stale_logical_key = b"DR-REPORT-stale-view-fixture".to_vec(); + assert!( + addr.send(PutActiveInventoryBatch { + expected_view: InventoryView::Legacy, + items: vec![( + InventoryDomain::Report, + stale_logical_key.clone(), + b"must remain unreachable".to_vec(), + )], + }) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert!(storage + .get( + &inventory_physical_key( + InventoryView::Legacy, + InventoryDomain::Report, + &stale_logical_key, + ) + .unwrap() + ) + .unwrap() + .is_none()); + assert!(storage + .get( + &inventory_physical_key( + token.view(), + InventoryDomain::Report, + &stale_logical_key, + ) + .unwrap() + ) + .unwrap() + .is_none()); + assert!( + addr.send(AbortSnapshotInventoryImport { token }) + .await + .expect("storage worker mailbox") + .is_err() + ); + }); + } + + #[test] + fn snapshot_staging_is_write_once_and_commit_revalidates_every_superblock() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let expected_first = fixture_superblock_notification(1, 11); + let expected_tip = fixture_superblock_notification(2, 12); + let overwritten_first = fixture_superblock_notification(1, 29); + assert_ne!( + expected_first.superblock.hash(), + overwritten_first.superblock.hash() + ); + let expected_sequence = vec![expected_first.clone(), expected_tip.clone()]; + + let token = addr + .send(BeginSnapshotInventoryImport { + target: fixture_superblock_target(&expected_sequence), + superblock_commitment: snapshot_superblock_commitment(&expected_sequence) + .expect("commit expected superblock sequence"), + }) + .await + .expect("storage worker mailbox") + .expect("register inactive generation"); + let first_logical_key = inventory_superblock_logical_key(1); + let tip_logical_key = inventory_superblock_logical_key(2); + addr.send(PutSnapshotInventoryBatch { + token: token.clone(), + items: vec![ + ( + InventoryDomain::Superblock, + first_logical_key.clone(), + serialize(&expected_first).unwrap(), + ), + ( + InventoryDomain::Superblock, + tip_logical_key, + serialize(&expected_tip).unwrap(), + ), + ], + }) + .await + .expect("storage worker mailbox") + .expect("stage preflighted superblock notification"); + + // Exact retry is the recovery path for a lost staging acknowledgement. A different + // value under the same generation key must not mutate the authenticated first write. + addr.send(PutSnapshotInventoryBatch { + token: token.clone(), + items: vec![( + InventoryDomain::Superblock, + first_logical_key.clone(), + serialize(&expected_first).unwrap(), + )], + }) + .await + .expect("storage worker mailbox") + .expect("accept exact idempotent staging retry"); + let overwrite_error = addr + .send(PutSnapshotInventoryBatch { + token: token.clone(), + items: vec![( + InventoryDomain::Superblock, + first_logical_key.clone(), + serialize(&overwritten_first).unwrap(), + )], + }) + .await + .expect("storage worker mailbox") + .expect_err("different-byte snapshot staging overwrite must be rejected"); + assert!(overwrite_error.to_string().contains("different bytes")); + + let first_physical_key = inventory_physical_key( + token.view(), + InventoryDomain::Superblock, + &first_logical_key, + ) + .unwrap(); + assert_eq!( + storage.get(&first_physical_key).unwrap(), + Some(serialize(&expected_first).unwrap()) + ); + + // Model corruption below the storage worker after preflight/staging. The advertised + // tip remains exact and the replacement is structurally valid under the same non-tip + // key, so only the full-sequence commitment can reject publication. + storage + .put( + first_physical_key.clone(), + serialize(&overwritten_first).unwrap(), + ) + .unwrap(); + + let error = addr + .send(CommitSnapshotInventoryBatch { + token: token.clone(), + batch: fixture_commit_batch(b"must not publish"), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + }) + .await + .expect("storage worker mailbox") + .expect_err( + "a count-complete generation with a changed non-tip superblock must be rejected", + ); + assert!(error.to_string().contains("sequence commitment")); + assert_eq!( + addr.send(GetInventoryView) + .await + .expect("storage worker mailbox") + .expect("load active view"), + InventoryView::Legacy + ); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + + // Repair the injected below-worker corruption. The inactive token was not published; + // restoring its exact preflighted value satisfies the final proof and publishes once. + storage + .put( + first_physical_key.clone(), + serialize(&expected_first).unwrap(), + ) + .unwrap(); + let committed_view = addr + .send(CommitSnapshotInventoryBatch { + token: token.clone(), + batch: fixture_commit_batch(b"published after exact restoration"), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + }) + .await + .expect("storage worker mailbox") + .expect("commit exact restored superblock generation"); + assert_eq!(committed_view, token.view()); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"published after exact restoration".to_vec()) + ); + + let appended = fixture_superblock_notification(3, 13); + storage.queue_write_outcomes([InjectedWriteOutcome::ApplyThenFail]); + assert!( + addr.send(fixture_ordinary_superblock_commit( + token.view(), + appended.clone(), + b"ordinary checkpoint committed with v3 inventory", + )) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"ordinary checkpoint committed with v3 inventory".to_vec()) + ); + + // ChainState, the item, and both advanced descriptor records were applied atomically + // before the acknowledgement was lost. An exact retry is idempotent, while a different + // value cannot replace any superblock already covered by the advanced commitment. + addr.send(fixture_ordinary_superblock_commit( + token.view(), + appended.clone(), + b"ordinary checkpoint committed with v3 inventory", + )) + .await + .expect("storage worker mailbox") + .expect("accept exact atomic checkpoint retry"); + let conflicting_append = fixture_superblock_notification(3, 77); + let overwrite_error = addr + .send(fixture_ordinary_superblock_commit( + token.view(), + conflicting_append, + b"must not replace committed v3 checkpoint", + )) + .await + .expect("storage worker mailbox") + .expect_err("active committed superblock overwrite must fail"); + assert!(overwrite_error.to_string().contains("immutable superblock")); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"ordinary checkpoint committed with v3 inventory".to_vec()) + ); + + let descriptor_bytes = storage + .get(&inventory_generation_descriptor_key(token.generation())) + .unwrap() + .expect("read committed generation descriptor"); + assert_eq!( + storage.get(ACTIVE_INVENTORY_DESCRIPTOR_KEY).unwrap(), + Some(descriptor_bytes.clone()) + ); + let durable_descriptor = StorageManager::decode_inventory_descriptor( + &descriptor_bytes, + "fixture", + ) + .expect("decode current descriptor"); + assert_eq!( + durable_descriptor.storage_version, + INVENTORY_DESCRIPTOR_STORAGE_VERSION + ); + assert_eq!( + durable_descriptor.superblock_commitment, + Some( + snapshot_superblock_commitment(&[ + expected_first.clone(), + expected_tip.clone(), + appended, + ]) + .expect("recompute expected sequence commitment") + ) + ); + assert_eq!( + addr.send(ValidateActiveInventoryNamespace) + .await + .expect("storage worker mailbox") + .expect("validate advanced active sequence"), + token.view() + ); + + let inactive_token = begin_and_stage_report(&addr).await; + let inactive_descriptor_key = + inventory_generation_descriptor_key(inactive_token.generation()); + let inactive_report_key = inventory_physical_key( + inactive_token.view(), + InventoryDomain::Report, + FIXTURE_REPORT_KEY, + ) + .unwrap(); + + // Publication must not discard the actor-preflighted commitment with the in-memory + // token. Corrupt the same non-tip record after publication, restart over the durable + // bytes, and prove the reload boundary rejects an otherwise count-complete and + // tip-correct namespace. + storage + .put(first_physical_key, serialize(&overwritten_first).unwrap()) + .unwrap(); + let cleanup_error = addr + .send(CleanupSnapshotInventoryGenerations) + .await + .expect("storage worker mailbox") + .expect_err("cleanup must authenticate active inventory before deletion"); + assert!(cleanup_error.to_string().contains("durable actor-preflighted")); + assert!(storage.get(&inactive_descriptor_key).unwrap().is_some()); + assert!(storage.get(&inactive_report_key).unwrap().is_some()); + drop(addr); + let restarted = start_fault_storage_manager(storage); + let restart_error = restarted + .send(ValidateActiveInventoryNamespace) + .await + .expect("restarted storage worker mailbox") + .expect_err("durable non-tip corruption must fail reload validation"); + assert!(restart_error.to_string().contains("durable actor-preflighted")); + }); + } + + #[test] + fn legacy_v2_active_descriptor_remains_structurally_loadable() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let generation = InventoryGeneration([0x42; 32]); + let legacy_notification = fixture_superblock_notification(1, 90); + let target = fixture_superblock_target(&[legacy_notification.clone()]); + let legacy_descriptor = InventoryGenerationDescriptorV2 { + magic: INVENTORY_DESCRIPTOR_MAGIC, + storage_version: LEGACY_INVENTORY_DESCRIPTOR_STORAGE_VERSION, + generation, + target, + }; + let descriptor_bytes = serialize(&legacy_descriptor).unwrap(); + storage + .put( + inventory_generation_descriptor_key(generation), + descriptor_bytes.clone(), + ) + .unwrap(); + storage + .put( + ACTIVE_INVENTORY_DESCRIPTOR_KEY.to_vec(), + descriptor_bytes, + ) + .unwrap(); + let legacy_view = InventoryView::Generation { generation, target }; + let legacy_superblock_key = inventory_physical_key( + legacy_view, + InventoryDomain::Superblock, + &inventory_superblock_logical_key(1), + ) + .unwrap(); + storage + .put( + legacy_superblock_key, + serialize(&legacy_notification).unwrap(), + ) + .unwrap(); + + let addr = start_fault_storage_manager(storage.clone()); + assert_eq!( + addr.send(ValidateActiveInventoryNamespace) + .await + .expect("storage worker mailbox") + .expect("validate historical descriptor namespace"), + legacy_view + ); + + let first_post_legacy_superblock = fixture_superblock_notification(2, 91); + addr.send(fixture_ordinary_superblock_commit( + legacy_view, + first_post_legacy_superblock.clone(), + b"ordinary checkpoint committed while upgrading v2", + )) + .await + .expect("storage worker mailbox") + .expect("atomically append to and upgrade historical active descriptor"); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"ordinary checkpoint committed while upgrading v2".to_vec()) + ); + let upgraded_bytes = storage + .get(&inventory_generation_descriptor_key(generation)) + .unwrap() + .expect("read upgraded descriptor"); + let upgraded = StorageManager::decode_inventory_descriptor( + &upgraded_bytes, + "upgraded fixture", + ) + .expect("decode upgraded descriptor"); + assert_eq!( + upgraded.storage_version, + INVENTORY_DESCRIPTOR_STORAGE_VERSION + ); + assert_eq!( + upgraded.superblock_commitment, + Some( + snapshot_superblock_commitment(&[ + legacy_notification, + first_post_legacy_superblock, + ]) + .expect("commit upgraded sequence") + ) + ); + + // A descriptor that advertises v3 while retaining the shorter v2 encoding is not a + // legacy descriptor. Accepting it would turn loss of the required commitment into a + // silent downgrade. + let missing_commitment = InventoryGenerationDescriptorV2 { + storage_version: INVENTORY_DESCRIPTOR_STORAGE_VERSION, + ..legacy_descriptor + }; + let missing_commitment_bytes = serialize(&missing_commitment).unwrap(); + storage + .put( + inventory_generation_descriptor_key(generation), + missing_commitment_bytes.clone(), + ) + .unwrap(); + storage + .put( + ACTIVE_INVENTORY_DESCRIPTOR_KEY.to_vec(), + missing_commitment_bytes, + ) + .unwrap(); + let error = addr + .send(ValidateActiveInventoryNamespace) + .await + .expect("storage worker mailbox") + .expect_err("v3 descriptor without a commitment must fail closed"); + assert!(error.to_string().contains("missing its superblock commitment")); + }); + } + + #[test] + fn inactive_snapshot_abort_is_complete_and_idempotent() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let token = begin_and_stage_report(&addr).await; + let descriptor_key = inventory_generation_descriptor_key(token.generation()); + let staged_report_key = inventory_physical_key( + token.view(), + InventoryDomain::Report, + FIXTURE_REPORT_KEY, + ) + .unwrap(); + + // Report deletion succeeds, then descriptor-last deletion becomes durable while its + // acknowledgement is lost. The caller cannot know whether abort completed. + storage.queue_write_outcomes([ + InjectedWriteOutcome::Succeed, + InjectedWriteOutcome::ApplyThenFail, + ]); + assert!( + addr.send(AbortSnapshotInventoryImport { + token: token.clone(), + }) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert!(storage.get(&staged_report_key).unwrap().is_none()); + assert!(storage.get(&descriptor_key).unwrap().is_none()); + assert_eq!( + addr.send(GetInventoryView) + .await + .expect("storage worker mailbox") + .expect("load active view"), + InventoryView::Legacy + ); + + // A retry after an acknowledgement loss must be harmless once descriptor-last + // cleanup has made the generation undiscoverable. + addr.send(AbortSnapshotInventoryImport { token }) + .await + .expect("storage worker mailbox") + .expect("idempotent inactive-generation abort"); + }); + } + + #[test] + fn lost_snapshot_commit_acknowledgement_is_resolved_from_durable_state_on_restart() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let token = begin_and_stage_report(&addr).await; + + storage.queue_write_outcomes([InjectedWriteOutcome::ApplyThenFail]); + assert!( + addr.send(CommitSnapshotInventoryBatch { + token: token.clone(), + batch: fixture_commit_batch(b"durable despite lost acknowledgement"), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + }) + .await + .expect("storage worker mailbox") + .is_err() + ); + + // Reopen the same durable bytes through a fresh storage worker after releasing the + // old address. The fixture does not observe OS-process termination, but no further + // request can be issued through the old handle. The fresh worker must discover the + // complete new image rather than treating the reported error as proof that the old + // view survived. + drop(addr); + let restarted = start_fault_storage_manager(storage.clone()); + assert_eq!( + restarted + .send(GetInventoryView) + .await + .expect("restarted storage worker mailbox") + .expect("recover active view"), + token.view() + ); + assert_eq!( + storage.get(FIXTURE_STATE_KEY).unwrap(), + Some(b"durable despite lost acknowledgement".to_vec()) + ); + assert_eq!( + restarted + .send(CleanupSnapshotInventoryGenerations) + .await + .expect("restarted storage worker mailbox") + .expect("cleanup inactive generations"), + 0 + ); + assert!(storage + .get(&inventory_generation_descriptor_key(token.generation())) + .unwrap() + .is_some()); + }); + } + + #[test] + fn failed_commit_and_interrupted_descriptor_last_cleanup_are_retryable() { + crate::utils::test_actix_system(|| async { + let storage = FaultStorage::default(); + let addr = start_fault_storage_manager(storage.clone()); + let token = begin_and_stage_report(&addr).await; + let descriptor_key = inventory_generation_descriptor_key(token.generation()); + let staged_report_key = inventory_physical_key( + token.view(), + InventoryDomain::Report, + FIXTURE_REPORT_KEY, + ) + .unwrap(); + + storage.queue_write_outcomes([InjectedWriteOutcome::FailBeforeApply]); + assert!( + addr.send(CommitSnapshotInventoryBatch { + token: token.clone(), + batch: fixture_commit_batch(b"must not publish"), + wit2_activation_context: fixture_activation_context(), + certificate: None, + protocol_info: None, + }) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert_eq!( + addr.send(GetInventoryView) + .await + .expect("storage worker mailbox") + .expect("load old active view"), + InventoryView::Legacy + ); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + + // The first cleanup write removes the only staged domain item. The injected second + // failure hits the synchronous descriptor-last deletion, leaving the empty generation + // discoverable for a later startup retry. + storage.queue_write_outcomes([ + InjectedWriteOutcome::Succeed, + InjectedWriteOutcome::FailBeforeApply, + ]); + assert!( + addr.send(CleanupSnapshotInventoryGenerations) + .await + .expect("storage worker mailbox") + .is_err() + ); + assert!(storage.get(&staged_report_key).unwrap().is_none()); + assert!(storage.get(&descriptor_key).unwrap().is_some()); + + addr.send(CleanupSnapshotInventoryGenerations) + .await + .expect("storage worker mailbox") + .expect("retry interrupted cleanup"); + assert!(storage.get(&descriptor_key).unwrap().is_none()); + assert!( + addr.send(PutSnapshotInventoryBatch { + token, + items: Vec::new(), + }) + .await + .expect("storage worker mailbox") + .is_err() + ); + }); + } + + /// Exercise the real snapshot-finalization boundary rather than a sentinel raw value. + /// + /// The public staging entry point validates and encodes the current ChainState, then reaches + /// `CommitSnapshotInventoryBatch`, whose one synchronous backend batch must replace the full + /// UTXO namespace and publish the inventory descriptor together with those v7 bytes. + #[test] + fn snapshot_commit_persists_v7_chain_state_and_replaces_the_complete_utxo_image() { + crate::utils::test_actix_system(|| async { + let mut conf = Config::default(); + conf.storage.backend = config::StorageBackend::HashMap; + let configured_magic = conf.consensus_constants.get_magic(); + let environment = conf.environment; + let consensus_constants = conf.consensus_constants.clone(); + config_mngr::start(Arc::new(conf)); + super::start(); + + // Seed a durable image that must be removed in its entirety by the final snapshot + // batch. Two keys make this stronger than proving one point replacement. + let old_pointer_a = OutputPointer { + transaction_id: Hash::with_first_u32(0x101), + output_index: 0, + }; + let old_pointer_b = OutputPointer { + transaction_id: Hash::with_first_u32(0x102), + output_index: 1, + }; + let old_output_a = ValueTransferOutput { + pkh: PublicKeyHash::from_bytes(&[0x11; 20]).unwrap(), + value: 11, + time_lock: 1, + }; + let old_output_b = ValueTransferOutput { + pkh: PublicKeyHash::from_bytes(&[0x12; 20]).unwrap(), + value: 12, + time_lock: 2, + }; + let backend = get_backend().await.expect("load configured storage backend"); + let mut old_batch = UtxoWriteBatch::default(); + old_batch.put(old_pointer_a, (old_output_a.clone(), 3)); + old_batch.put(old_pointer_b, (old_output_b.clone(), 4)); + backend + .clone() + .as_arc_dyn_utxo_db() + .write(old_batch) + .expect("seed old UTXO image"); + + let mut chain_state = ChainState::default(); + let bootstrap_hash = consensus_constants.bootstrap_hash; + let mut chain_info = ChainInfo { + environment, + consensus_constants, + ..ChainInfo::default() + }; + chain_info.highest_block_checkpoint.hash_prev_block = bootstrap_hash; + chain_info.highest_superblock_checkpoint.hash_prev_block = bootstrap_hash; + chain_info.highest_vrf_output.hash_prev_vrf = bootstrap_hash; + assert_eq!(chain_info.consensus_constants.get_magic(), configured_magic); + chain_state.data_request_pool.extra_rounds = + chain_info.consensus_constants.extra_rounds; + chain_state.chain_info = Some(chain_info.clone()); + chain_state.reputation_engine = Some(ReputationEngine::new( + chain_info.consensus_constants.activity_period as usize, + )); + // A non-default serialized field proves that the assertion below decoded this exact + // staged payload instead of accepting an arbitrary current-version value. + chain_state.node_stats.block_mined_count = 0x0102_0304; + + let target = SnapshotInventoryTarget::new( + configured_magic, + chain_info.environment, + chain_info.highest_block_checkpoint, + chain_info.highest_superblock_checkpoint, + 0, + 0, + 1, + ); + let token = begin_snapshot_inventory_import( + target, + snapshot_superblock_commitment(&[]) + .expect("commit empty superblock sequence"), + ) + .await + .expect("register snapshot inventory generation"); + put_snapshot_inventory_serialized_batch( + &token, + vec![( + InventoryDomain::Report, + FIXTURE_REPORT_KEY.to_vec(), + b"opaque prevalidated report fixture".to_vec(), + )], + ) + .await + .expect("stage snapshot report"); + + let new_pointer_a = OutputPointer { + transaction_id: Hash::with_first_u32(0x201), + output_index: 2, + }; + let new_pointer_b = OutputPointer { + transaction_id: Hash::with_first_u32(0x202), + output_index: 3, + }; + let new_output_a = ValueTransferOutput { + pkh: PublicKeyHash::from_bytes(&[0x21; 20]).unwrap(), + value: 21, + time_lock: 5, + }; + let new_output_b = ValueTransferOutput { + pkh: PublicKeyHash::from_bytes(&[0x22; 20]).unwrap(), + value: 22, + time_lock: 6, + }; + let mut replacement_batch = UtxoWriteBatch::default(); + replacement_batch.delete_all(); + replacement_batch.put(new_pointer_a, (new_output_a.clone(), 7)); + replacement_batch.put(new_pointer_b, (new_output_b.clone(), 8)); + + let expected_view = token.view(); + let chain_state_key = + crate::actors::storage_keys::chain_state_key(configured_magic); + let committed_view = put_snapshot_chain_state_in_batch_sync( + &chain_state_key, + &chain_state, + replacement_batch, + token, + None, + None, + ) + .await + .expect("commit versioned ChainState and replacement UTXO image"); + assert_eq!(committed_view, expected_view); + assert_eq!( + get_inventory_view().await.expect("load committed inventory view"), + expected_view + ); + + let utxo_db = backend.clone().as_arc_dyn_utxo_db(); + assert_eq!(utxo_db.get_utxo(&old_pointer_a).unwrap(), None); + assert_eq!(utxo_db.get_utxo(&old_pointer_b).unwrap(), None); + let mut actual_utxos = utxo_db + .utxo_iterator() + .unwrap() + .collect::, _>>() + .unwrap(); + actual_utxos.sort_unstable_by_key(|(pointer, _)| *pointer); + let mut expected_utxos = vec![ + (new_pointer_a, (new_output_a, 7)), + (new_pointer_b, (new_output_b, 8)), + ]; + expected_utxos.sort_unstable_by_key(|(pointer, _)| *pointer); + assert_eq!(actual_utxos, expected_utxos); + + let storage = backend.as_arc_dyn_storage(); + let chain_state_key_bytes = serialize(&chain_state_key).unwrap(); + let chain_state_bytes = storage + .get(&chain_state_key_bytes) + .unwrap() + .expect("read committed ChainState bytes"); + assert!(chain_state_bytes.len() >= 4); + assert_eq!( + u32::from_le_bytes(chain_state_bytes[..4].try_into().unwrap()), + CURRENT_CHAIN_STATE_VERSION + ); + let decoded_chain_state: ChainState = + bincode_compatibility::deserialize_exact(&chain_state_bytes[4..]).unwrap(); + assert_eq!(decoded_chain_state, chain_state); + assert!(storage.get(FIXTURE_STATE_KEY).unwrap().is_none()); + }); + } +} diff --git a/node/src/storage_mngr/node_migrations.rs b/node/src/storage_mngr/node_migrations.rs index 31319b1399..ad16ec74f6 100644 --- a/node/src/storage_mngr/node_migrations.rs +++ b/node/src/storage_mngr/node_migrations.rs @@ -1,10 +1,35 @@ +use std::{collections::BTreeMap, io::Read, sync::{LazyLock, RwLock}}; + +use serde::{Deserialize, Serialize}; +use witnet_util::timestamp::get_timestamp; use witnet_data_structures::{ - chain::{ChainState, tapi::TapiEngine}, - proto::versioning::ProtocolInfo, - staking::stakes::StakesTracker, - utxo_pool::UtxoWriteBatch, + chain::{ + AltKeys, Blockchain, ChainInfo, ChainState, ConsensusConstantsWit2, Epoch, EpochConstants, + Environment, Hash, Hashable, NodeStats, PublicKeyHash, ReputationEngine, + tapi::TapiEngine, + }, + data_request::DataRequestPool, + get_environment, get_protocol_info, get_protocol_runtime, + try_load_protocol_info_with_consensus_constants, + try_register_protocol_version_with_epoch_constants, + proto::versioning::{ProtocolInfo, ProtocolVersion}, + staking::{ + delegation::{ + DELEGATED_STAKING_MAXIMUM_NANOWITS, DELEGATED_STAKING_MINIMUM_NANOWITS, + CollateralDebit, CollateralReservation, CollateralReservationKey, DelegationPolicy, + DelegationState, LegacyActivationSettlement, ValidatorPolicy, + }, + helpers::StakeKey, + stakes::{QueryStakesKey, StakesTracker}, + }, + superblock::SuperBlockState, + transaction::CommitTransaction, + utxo_pool::{OldUnspentOutputsPool, OwnUnspentOutputsPool, UtxoWriteBatch}, + wit::Wit, }; +use crate::actors::{messages::SuperBlockNotify, storage_keys}; + use super::*; macro_rules! as_failure { @@ -13,6 +38,755 @@ macro_rules! as_failure { }; } +/// Current version of both persisted and portable `ChainState` encodings. +/// +/// Snapshot exporters include this value from v6 onward so an older node rejects a snapshot that +/// contains delegation state instead of accepting the known v5 prefix and ignoring trailing bytes. +/// Version 9 appends persistent activation-boundary recovery obligations. Earlier complete v6/v7 +/// values and the complete v8 value must decode through frozen layouts rather than borrowing the +/// new field. +pub const CURRENT_CHAIN_STATE_VERSION: u32 = 9; + +/// First database version whose `ChainState` contains `DelegationState`. +pub const DELEGATION_CHAIN_STATE_VERSION: u32 = 6; + +/// Last complete layout before persistent exit intent and commit-time reward entitlements. +pub const PRE_EXIT_CHAIN_STATE_VERSION: u32 = 7; + +/// Complete layout immediately before non-spendable activation-boundary obligations. +pub const PRE_RECOVERY_CHAIN_STATE_VERSION: u32 = 8; + +/// Exact local certificate written with every durable ChainState that contains a newly discovered +/// Wit/2.0 trigger. This is a storage companion, not part of the portable ChainState or consensus +/// wire schema. ChainManager finality still controls when its process-local rollback journal may +/// be discharged. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct Wit2ActivationCertificate { + format_version: u32, + trigger_epoch: Epoch, + trigger_hash: Hash, + activation_epoch: Epoch, + checkpoint_period: u16, +} + +/// Exact protocol schedule carried by a portable snapshot and persisted beside the imported +/// ChainState. This companion is deliberately independent from the Wit/2 activation certificate: +/// a snapshot can contain a complete schedule without containing a dynamically discovered trigger. +/// Keeping it outside the ChainState wire layout preserves legacy database/snapshot decoding while +/// allowing a restart to recover the same schedule that authenticated the imported history. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct PersistedProtocolSchedule { + format_version: u32, + environment: Environment, + network_magic: u16, + protocol_info: ProtocolInfo, +} + +impl PersistedProtocolSchedule { + const FORMAT_VERSION: u32 = 1; + + fn from_chain_info( + chain_info: &ChainInfo, + protocol_info: ProtocolInfo, + ) -> Result { + protocol_info + .validate_timing_topology(&chain_info.consensus_constants) + .map_err(|error| { + anyhow::format_err!( + "protocol schedule is incompatible with its ChainState consensus constants: {error}" + ) + })?; + Ok(Self { + format_version: Self::FORMAT_VERSION, + environment: chain_info.environment, + network_magic: chain_info.consensus_constants.get_magic(), + protocol_info, + }) + } + + pub(crate) fn from_chain_state( + chain_state: &ChainState, + protocol_info: ProtocolInfo, + ) -> Result { + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!( + "cannot persist a protocol schedule beside a ChainState without ChainInfo" + ) + })?; + Self::from_chain_info(chain_info, protocol_info) + } + + fn validate(&self, chain_info: &ChainInfo) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.format_version == Self::FORMAT_VERSION, + "unsupported persisted protocol schedule version {}", + self.format_version + ); + anyhow::ensure!( + self.environment == chain_info.environment, + "persisted protocol schedule environment {:?} does not match ChainState environment {:?}", + self.environment, + chain_info.environment + ); + anyhow::ensure!( + self.network_magic == chain_info.consensus_constants.get_magic(), + "persisted protocol schedule network magic {} does not match ChainState magic {}", + self.network_magic, + chain_info.consensus_constants.get_magic() + ); + self.protocol_info + .validate_timing_topology(&chain_info.consensus_constants) + .map_err(|error| { + anyhow::format_err!( + "persisted protocol schedule is incompatible with its ChainState consensus constants: {error}" + ) + })?; + Ok(()) + } + + pub(crate) fn protocol_info(&self) -> ProtocolInfo { + self.protocol_info.clone() + } +} + +fn protocol_schedule_matches(left: &ProtocolInfo, right: &ProtocolInfo) -> bool { + // `current_version` is a runtime projection of the current epoch, not historical schedule + // provenance. Compare only the immutable activation and period maps so a restart at a later + // epoch does not reject an otherwise identical persisted companion. + left.all_versions == right.all_versions + && left.all_checkpoints_periods == right.all_checkpoints_periods +} + +fn protocol_schedule_is_configured_prefix( + configured: &ProtocolInfo, + persisted: &ProtocolInfo, +) -> bool { + // A normal node starts with the operator/default schedule, which can legitimately stop at + // Wit/2.0 while a previously imported snapshot carries an authenticated Wit/2.1 extension. + // Require every configured activation and period to match, but permit the persisted schedule + // to add later versions. This preserves explicit configuration conflicts while allowing the + // durable snapshot companion to restore the schedule that authenticated its history. + configured.all_checkpoints_periods.iter().all(|(&version, period)| { + configured.all_versions.try_get_activation_epoch(version) + == persisted.all_versions.try_get_activation_epoch(version) + && persisted.all_checkpoints_periods.get(&version) == Some(period) + }) +} + +/// Reconcile a portable schedule with the node's already approved schedule before replacing state. +/// Historical entries absent locally may be supplied for canonical replay, but that replay cannot +/// authenticate an activation beyond its block tip. Such future entries require the exact local +/// activation and period already to be present. Preserve every local entry, including future ones, +/// so accepting an import cannot introduce a conflict that ordinary restart would reject. +/// +/// This is an authorization boundary, not proof of historical activation or finality. The importer +/// must still authenticate the supplied history, and historical snapshot trust remains explicit. +pub(crate) fn validate_snapshot_protocol_schedule( + local: &ProtocolInfo, + supplied: &ProtocolInfo, + imported_chain_info: &ChainInfo, +) -> Result<(), anyhow::Error> { + local + .validate_timing_topology(&imported_chain_info.consensus_constants) + .map_err(|error| anyhow::format_err!("local protocol schedule is invalid: {error}"))?; + supplied + .validate_timing_topology(&imported_chain_info.consensus_constants) + .map_err(|error| anyhow::format_err!("snapshot protocol schedule is invalid: {error}"))?; + anyhow::ensure!( + protocol_schedule_is_configured_prefix(local, supplied), + "snapshot protocol schedule changes or omits a locally approved activation or checkpoint period" + ); + + let imported_tip = imported_chain_info.highest_block_checkpoint.checkpoint; + let mut versions = supplied.all_checkpoints_periods.keys().copied().collect::>(); + versions.sort_by(|left, right| { + left.partial_cmp(right) + .expect("all protocol versions have a total canonical order") + }); + for version in versions { + let epoch = supplied.all_versions.try_get_activation_epoch(version).ok_or_else(|| { + anyhow::format_err!("snapshot protocol schedule has no activation for {version:?}") + })?; + anyhow::ensure!( + epoch <= imported_tip || local.all_versions.try_get_activation_epoch(version).is_some(), + "snapshot protocol schedule introduces unapproved future activation {version:?} at epoch {epoch}, beyond imported block tip {imported_tip}" + ); + } + Ok(()) +} + +fn registered_wit2_schedule(protocol_info: &ProtocolInfo) -> Option<(Epoch, u16)> { + protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .and_then(|epoch| { + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + .map(|period| (epoch, period)) + }) +} + +fn protocol_info_for_chain_state( + storage: &(dyn Storage + Send + Sync), + context: &Wit2ActivationChainContext, + supplied: Option<&ProtocolInfo>, +) -> Result { + if let Some(protocol_info) = supplied { + return Ok(protocol_info.clone()); + } + + let schedule_key = storage_keys::protocol_schedule_key( + context.chain_info.consensus_constants.get_magic(), + ); + let schedule_key_bytes = bincode::serialize(&schedule_key)?; + let Some(bytes) = storage.get(&schedule_key_bytes)? else { + return Ok(get_protocol_info()); + }; + let persisted = witnet_data_structures::bincode_compatibility::deserialize_exact::< + PersistedProtocolSchedule, + >(&bytes) + .map_err(|error| { + anyhow::format_err!( + "cannot resolve activation certificate beside malformed protocol schedule: {error}" + ) + })?; + persisted.validate(&context.chain_info)?; + Ok(persisted.protocol_info()) +} + +fn restore_persisted_protocol_schedule( + persisted: &PersistedProtocolSchedule, + chain_info: &ChainInfo, +) -> Result<(), anyhow::Error> { + persisted.validate(chain_info)?; + witnet_data_structures::proto::versioning::ensure_production_protocol_schedule_ready( + &persisted.protocol_info, + )?; + let (configured, _, _) = get_protocol_runtime(); + if configured.all_versions.is_empty() { + try_load_protocol_info_with_consensus_constants( + persisted.protocol_info(), + &chain_info.consensus_constants, + ) + .map_err(|error| { + anyhow::format_err!( + "cannot restore the protocol schedule authenticated by ChainState: {error}" + ) + })?; + } else if protocol_schedule_matches(&configured, &persisted.protocol_info) { + return Ok(()); + } else { + anyhow::ensure!( + protocol_schedule_is_configured_prefix(&configured, &persisted.protocol_info), + "persisted protocol schedule conflicts with the configured protocol schedule" + ); + // The persisted companion is the authoritative schedule for this already-imported + // ChainState. Publish the validated extension atomically with its matching epoch clock so + // subsequent delegated-staking replay uses the same Wit/2.1 activation it was exported + // under, even when the process started with the older default configuration. + try_load_protocol_info_with_consensus_constants( + persisted.protocol_info(), + &chain_info.consensus_constants, + ) + .map_err(|error| { + anyhow::format_err!( + "cannot restore the protocol schedule extension authenticated by ChainState: {error}" + ) + })?; + } + Ok(()) +} + +/// Rollback information reconstructed when a restart certificate belongs to a trigger that has +/// reached a durable replay/synchronization checkpoint but not a finalized superblock checkpoint. +#[derive(Clone, Debug)] +pub(crate) struct RestoredWit2ActivationRollback { + /// Protocol schedule configured before the dynamic Wit/2.0 entry was restored. + pub(crate) protocol_info: ProtocolInfo, + /// Epoch constants used before the dynamic Wit/2.0 entry was restored. + pub(crate) epoch_constants: EpochConstants, + /// Epoch of the block that crossed the activation threshold. + pub(crate) trigger_epoch: Epoch, + /// Exact canonical hash of the trigger block. + pub(crate) trigger_hash: Hash, + /// Deterministic Wit/2.0 activation epoch. + pub(crate) activation_epoch: Epoch, + /// Deterministic Wit/2.0 checkpoint period. + pub(crate) checkpoint_period: u16, +} + +static RESTORED_WIT2_ACTIVATION_ROLLBACK: LazyLock< + RwLock>, +> = LazyLock::new(|| RwLock::new(None)); + +fn install_restored_wit2_activation_rollback( + rollback: RestoredWit2ActivationRollback, +) -> Result<(), anyhow::Error> { + let mut journal = RESTORED_WIT2_ACTIVATION_ROLLBACK + .write() + .map_err(|_| anyhow::anyhow!("restored Wit/2 activation rollback lock is poisoned"))?; + *journal = Some(rollback); + Ok(()) +} + +/// Return the restart-reconstructed dynamic-activation rollback journal, if one is still pending. +pub(crate) fn restored_wit2_activation_rollback() -> + Result, anyhow::Error> +{ + RESTORED_WIT2_ACTIVATION_ROLLBACK + .read() + .map(|journal| journal.clone()) + .map_err(|_| anyhow::anyhow!("restored Wit/2 activation rollback lock is poisoned")) +} + +/// Remove and return the restart-reconstructed dynamic-activation rollback journal. +pub(crate) fn take_restored_wit2_activation_rollback() -> + Result, anyhow::Error> +{ + RESTORED_WIT2_ACTIVATION_ROLLBACK + .write() + .map(|mut journal| journal.take()) + .map_err(|_| anyhow::anyhow!("restored Wit/2 activation rollback lock is poisoned")) +} + +/// Discharge a restart-reconstructed journal only for the exact finalized trigger. +pub(crate) fn acknowledge_restored_wit2_activation( + trigger_epoch: Epoch, + trigger_hash: Hash, +) -> Result<(), anyhow::Error> { + let mut rollback = RESTORED_WIT2_ACTIVATION_ROLLBACK + .write() + .map_err(|_| anyhow::anyhow!("restored Wit/2 activation rollback lock is poisoned"))?; + if rollback.as_ref().is_some_and(|pending| { + pending.trigger_epoch == trigger_epoch && pending.trigger_hash == trigger_hash + }) { + *rollback = None; + } + Ok(()) +} + +impl Wit2ActivationCertificate { + const FORMAT_VERSION: u32 = 1; + + pub(crate) fn new( + trigger_epoch: Epoch, + trigger_hash: Hash, + activation_epoch: Epoch, + checkpoint_period: u16, + ) -> Self { + Self { + format_version: Self::FORMAT_VERSION, + trigger_epoch, + trigger_hash, + activation_epoch, + checkpoint_period, + } + } + + pub(crate) fn trigger(&self) -> (Epoch, Hash) { + (self.trigger_epoch, self.trigger_hash) + } + + pub(crate) fn validate(&self, chain_state: &ChainState) -> Result<(), anyhow::Error> { + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!( + "Wit/2 activation certificate cannot accompany ChainState without ChainInfo" + ) + })?; + self.validate_chain(chain_info, &chain_state.block_chain) + } + + fn validate_chain( + &self, + chain_info: &ChainInfo, + block_chain: &Blockchain, + ) -> Result<(), anyhow::Error> { + self.validate_parameters(chain_info)?; + anyhow::ensure!( + block_chain.get(&self.trigger_epoch) == Some(&self.trigger_hash), + "Wit/2 activation certificate trigger {} at epoch {} is not in the canonical ChainState index", + self.trigger_hash, + self.trigger_epoch + ); + Ok(()) + } + + fn validate_parameters(&self, chain_info: &ChainInfo) -> Result<(), anyhow::Error> { + anyhow::ensure!( + self.format_version == Self::FORMAT_VERSION, + "unsupported Wit/2 activation certificate version {}", + self.format_version + ); + anyhow::ensure!( + get_environment() == chain_info.environment, + "Wit/2 activation certificate environment {:?} does not match process environment {:?}", + chain_info.environment, + get_environment() + ); + + let superblock_period = u32::from(chain_info.consensus_constants.superblock_period); + anyhow::ensure!( + superblock_period != 0 && self.trigger_epoch.is_multiple_of(superblock_period), + "Wit/2 activation certificate trigger epoch {} is not a valid superblock boundary", + self.trigger_epoch + ); + + let wit2_constants = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: chain_info.consensus_constants.checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, + }; + let expected_activation_epoch = self + .trigger_epoch + .checked_add(wit2_constants.get_wit2_activation_delay_epochs()) + .ok_or_else(|| { + anyhow::format_err!( + "Wit/2 activation certificate trigger epoch overflows its activation delay" + ) + })?; + anyhow::ensure!( + self.activation_epoch == expected_activation_epoch, + "Wit/2 activation certificate schedules epoch {}, expected {} from trigger epoch {}", + self.activation_epoch, + expected_activation_epoch, + self.trigger_epoch + ); + anyhow::ensure!( + self.checkpoint_period == wit2_constants.get_checkpoints_period_wit2(), + "Wit/2 activation certificate checkpoint period {} does not match deterministic period {}", + self.checkpoint_period, + wit2_constants.get_checkpoints_period_wit2() + ); + + Ok(()) + } +} + +fn superblock_checkpoint_finalizes_epoch( + superblock_index: u32, + superblock_period: u16, + epoch: Epoch, +) -> Result { + anyhow::ensure!( + superblock_period != 0, + "cannot derive a finalized epoch boundary from a zero superblock period" + ); + let first_unfinalized_epoch = superblock_index + .checked_mul(u32::from(superblock_period)) + .ok_or_else(|| { + anyhow::format_err!( + "superblock #{superblock_index} overflows period {superblock_period} while deriving its finalized epoch boundary" + ) + })?; + Ok(first_unfinalized_epoch > epoch) +} + +/// Return the exact activation trigger only when the supplied superblock checkpoint finalizes its +/// epoch. Superblock checkpoints are indexes: checkpoint `N` covers epochs strictly below +/// `N * period`, so direct index/epoch comparisons are invalid. +pub(crate) fn finalized_wit2_activation_trigger( + certificate: &Wit2ActivationCertificate, + superblock_index: u32, + superblock_period: u16, +) -> Result, anyhow::Error> { + superblock_checkpoint_finalizes_epoch( + superblock_index, + superblock_period, + certificate.trigger_epoch, + ) + .map(|finalized| finalized.then(|| certificate.trigger())) +} + +/// Frozen positional layout of a version-5 `ChainState`. +/// +/// Bincode serializes structs by field order. Decoding this exact type is the only sound way to +/// distinguish a complete v5 payload from a malformed or truncated current payload: appending a +/// serialized default field before decoding would allow those appended bytes to complete an +/// otherwise incomplete value. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +struct ChainStateV5 { + chain_info: Option, + unspent_outputs_pool_old_migration_db: OldUnspentOutputsPool, + data_request_pool: DataRequestPool, + block_chain: Blockchain, + own_utxos: OwnUnspentOutputsPool, + reputation_engine: Option, + node_stats: NodeStats, + alt_keys: AltKeys, + superblock_state: SuperBlockState, + tapi_engine: TapiEngine, + stakes: StakesTracker, +} + +/// Frozen complete v6/v7 layouts. In particular, the nested reservation must not reuse the current +/// type: v8 adds reward weights to every reservation, not merely a field at the end of ChainState. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CollateralReservationV7 { + total: Wit, + debits: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct DelegationStateV7 { + policy: DelegationPolicy, + activation_initialized: bool, + validators: BTreeMap, + collateral_reservations: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +struct ChainStateV7 { + chain_info: Option, + unspent_outputs_pool_old_migration_db: OldUnspentOutputsPool, + data_request_pool: DataRequestPool, + block_chain: Blockchain, + own_utxos: OwnUnspentOutputsPool, + reputation_engine: Option, + node_stats: NodeStats, + alt_keys: AltKeys, + superblock_state: SuperBlockState, + tapi_engine: TapiEngine, + stakes: StakesTracker, + delegation_state: DelegationStateV7, +} + +impl ChainStateV7 { + fn into_current(self) -> Result { + if self.delegation_state.activation_initialized { + return Err(Box::new(bincode::ErrorKind::Custom( + "activated v6/v7 delegated-staking state requires replay from before activation; commit-time reward entitlements cannot be inferred from current balances".to_string(), + ))); + } + let delegation_state = DelegationState { + policy: self.delegation_state.policy, + activation_initialized: false, + validators: self.delegation_state.validators, + collateral_reservations: self.delegation_state.collateral_reservations + .into_iter() + .map(|(key, reservation)| (key, CollateralReservation { + total: reservation.total, + debits: reservation.debits, + // Before activation every sidecar is legacy owner-only accounting. No native + // reward entitlement is fabricated by this conversion. + reward_weights: Vec::new(), + })) + .collect(), + legacy_activation_obligations: Default::default(), + exit_intents: Default::default(), + }; + Ok(ChainState { + chain_info: self.chain_info, + unspent_outputs_pool_old_migration_db: self.unspent_outputs_pool_old_migration_db, + data_request_pool: self.data_request_pool, + block_chain: self.block_chain, + own_utxos: self.own_utxos, + reputation_engine: self.reputation_engine, + node_stats: self.node_stats, + alt_keys: self.alt_keys, + superblock_state: self.superblock_state, + tapi_engine: self.tapi_engine, + stakes: self.stakes, + delegation_state, + unspent_outputs_pool: Default::default(), + rad_hashes_index: Default::default(), + }) + } +} + +#[cfg(test)] +impl From<&ChainState> for ChainStateV7 { + fn from(current: &ChainState) -> Self { + Self { + chain_info: current.chain_info.clone(), + unspent_outputs_pool_old_migration_db: current.unspent_outputs_pool_old_migration_db.clone(), + data_request_pool: current.data_request_pool.clone(), + block_chain: current.block_chain.clone(), + own_utxos: current.own_utxos.clone(), + reputation_engine: current.reputation_engine.clone(), + node_stats: current.node_stats.clone(), + alt_keys: current.alt_keys.clone(), + superblock_state: current.superblock_state.clone(), + tapi_engine: current.tapi_engine.clone(), + stakes: current.stakes.clone(), + delegation_state: DelegationStateV7 { + policy: current.delegation_state.policy, + activation_initialized: current.delegation_state.activation_initialized, + validators: current.delegation_state.validators.clone(), + collateral_reservations: current.delegation_state.collateral_reservations.iter() + .map(|(&key, reservation)| (key, CollateralReservationV7 { + total: reservation.total, + debits: reservation.debits.clone(), + })) + .collect(), + }, + } + } +} + +/// Frozen complete v8 layout. Version 9 adds activation-boundary obligations to the nested +/// delegation state, so even the otherwise unchanged v8 payload must not decode through the +/// current `ChainState` type. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +struct CollateralReservationV8 { + total: Wit, + debits: Vec, + reward_weights: Vec<(StakeKey, Wit)>, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +struct DelegationStateV8 { + policy: DelegationPolicy, + activation_initialized: bool, + validators: BTreeMap, + collateral_reservations: BTreeMap, + exit_intents: std::collections::BTreeSet>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +struct ChainStateV8 { + chain_info: Option, + unspent_outputs_pool_old_migration_db: OldUnspentOutputsPool, + data_request_pool: DataRequestPool, + block_chain: Blockchain, + own_utxos: OwnUnspentOutputsPool, + reputation_engine: Option, + node_stats: NodeStats, + alt_keys: AltKeys, + superblock_state: SuperBlockState, + tapi_engine: TapiEngine, + stakes: StakesTracker, + delegation_state: DelegationStateV8, +} + +impl ChainStateV8 { + fn into_current(self) -> ChainState { + let delegation_state = DelegationState { + policy: self.delegation_state.policy, + activation_initialized: self.delegation_state.activation_initialized, + validators: self.delegation_state.validators, + collateral_reservations: self + .delegation_state + .collateral_reservations + .into_iter() + .map(|(key, reservation)| { + ( + key, + CollateralReservation { + total: reservation.total, + debits: reservation.debits, + reward_weights: reservation.reward_weights, + }, + ) + }) + .collect(), + legacy_activation_obligations: Default::default(), + exit_intents: self.delegation_state.exit_intents, + }; + ChainState { + chain_info: self.chain_info, + unspent_outputs_pool_old_migration_db: self.unspent_outputs_pool_old_migration_db, + data_request_pool: self.data_request_pool, + block_chain: self.block_chain, + own_utxos: self.own_utxos, + reputation_engine: self.reputation_engine, + node_stats: self.node_stats, + alt_keys: self.alt_keys, + superblock_state: self.superblock_state, + tapi_engine: self.tapi_engine, + stakes: self.stakes, + delegation_state, + unspent_outputs_pool: Default::default(), + rad_hashes_index: Default::default(), + } + } +} + +#[cfg(test)] +impl From<&ChainState> for ChainStateV8 { + fn from(current: &ChainState) -> Self { + Self { + chain_info: current.chain_info.clone(), + unspent_outputs_pool_old_migration_db: current + .unspent_outputs_pool_old_migration_db + .clone(), + data_request_pool: current.data_request_pool.clone(), + block_chain: current.block_chain.clone(), + own_utxos: current.own_utxos.clone(), + reputation_engine: current.reputation_engine.clone(), + node_stats: current.node_stats.clone(), + alt_keys: current.alt_keys.clone(), + superblock_state: current.superblock_state.clone(), + tapi_engine: current.tapi_engine.clone(), + stakes: current.stakes.clone(), + delegation_state: DelegationStateV8 { + policy: current.delegation_state.policy, + activation_initialized: current.delegation_state.activation_initialized, + validators: current.delegation_state.validators.clone(), + collateral_reservations: current + .delegation_state + .collateral_reservations + .iter() + .map(|(&key, reservation)| { + ( + key, + CollateralReservationV8 { + total: reservation.total, + debits: reservation.debits.clone(), + reward_weights: reservation.reward_weights.clone(), + }, + ) + }) + .collect(), + exit_intents: current.delegation_state.exit_intents.clone(), + }, + } + } +} + +impl From for ChainState { + fn from(legacy: ChainStateV5) -> Self { + Self { + chain_info: legacy.chain_info, + unspent_outputs_pool_old_migration_db: legacy + .unspent_outputs_pool_old_migration_db, + data_request_pool: legacy.data_request_pool, + block_chain: legacy.block_chain, + own_utxos: legacy.own_utxos, + reputation_engine: legacy.reputation_engine, + node_stats: legacy.node_stats, + alt_keys: legacy.alt_keys, + superblock_state: legacy.superblock_state, + tapi_engine: legacy.tapi_engine, + stakes: legacy.stakes, + delegation_state: DelegationState::default(), + unspent_outputs_pool: Default::default(), + rad_hashes_index: Default::default(), + } + } +} + +#[cfg(test)] +impl From<&ChainState> for ChainStateV5 { + fn from(current: &ChainState) -> Self { + Self { + chain_info: current.chain_info.clone(), + unspent_outputs_pool_old_migration_db: current + .unspent_outputs_pool_old_migration_db + .clone(), + data_request_pool: current.data_request_pool.clone(), + block_chain: current.block_chain.clone(), + own_utxos: current.own_utxos.clone(), + reputation_engine: current.reputation_engine.clone(), + node_stats: current.node_stats.clone(), + alt_keys: current.alt_keys.clone(), + superblock_state: current.superblock_state.clone(), + tapi_engine: current.tapi_engine.clone(), + stakes: current.stakes.clone(), + } + } +} + /// Return the version of the `ChainState` serialization. Returns error on end of file. fn check_chain_state_version(chain_state_bytes: &[u8]) -> Result { if chain_state_bytes.is_empty() { @@ -40,15 +814,16 @@ fn check_chain_state_version(chain_state_bytes: &[u8]) -> Result { // TODO: change signature to &mut Vec and edit the bytes in place? // The input is assumed to be the serialization of a v0 ChainState -fn migrate_chain_state_v0_to_v2(old_chain_state_bytes: &[u8]) -> Vec { +fn migrate_chain_state_v0_to_v2(old_chain_state_bytes: &[u8]) -> Result, anyhow::Error> { let db_version: u32 = 2; let db_version_bytes = db_version.to_le_bytes(); // Extra fields in ChainState v2: let tapi = TapiEngine::default(); - let tapi_bytes = bincode::serialize(&tapi).unwrap(); + let tapi_bytes = bincode::serialize(&tapi) + .map_err(|error| anyhow::format_err!("Cannot serialize ChainState v2 TapiEngine: {error}"))?; - [&db_version_bytes, old_chain_state_bytes, &tapi_bytes].concat() + Ok([&db_version_bytes, old_chain_state_bytes, &tapi_bytes].concat()) } // This only needs to update the db_version field @@ -58,198 +833,2077 @@ fn migrate_chain_state_v2_to_v3(chain_state_bytes: &mut [u8]) { chain_state_bytes[0..4].copy_from_slice(&db_version_bytes); } -fn migrate_chain_state_v3_to_v4(old_chain_state_bytes: &[u8]) -> Vec { +fn migrate_chain_state_v3_to_v4( + old_chain_state_bytes: &[u8], +) -> Result, anyhow::Error> { + anyhow::ensure!( + old_chain_state_bytes.len() >= 5, + "Cannot migrate ChainState v3: payload is shorter than its version and option tag" + ); + let db_version: u32 = 4; let db_version_bytes = db_version.to_le_bytes(); // Extra fields in ChainState v4: let protocol_info = ProtocolInfo::default(); - let protocol_info_bytes = serialize(&protocol_info).unwrap(); + let protocol_info_bytes = serialize(&protocol_info) + .map_err(|error| anyhow::format_err!("Cannot serialize ChainState v4 ProtocolInfo: {error}"))?; let stakes = StakesTracker::default(); - let stakes_bytes = serialize(&stakes).unwrap(); + let stakes_bytes = serialize(&stakes) + .map_err(|error| anyhow::format_err!("Cannot serialize ChainState v4 StakesTracker: {error}"))?; - [ + Ok([ &db_version_bytes, &old_chain_state_bytes[4..5], &protocol_info_bytes, &old_chain_state_bytes[5..], &stakes_bytes, ] - .concat() + .concat()) } -fn migrate_chain_state_v4_to_v5(old_chain_state_bytes: &[u8]) -> Vec { +fn migrate_chain_state_v4_to_v5( + old_chain_state_bytes: &[u8], +) -> Result, anyhow::Error> { let db_version: u32 = 5; let db_version_bytes = db_version.to_le_bytes(); // Removal of fields in ChainState v5: let protocol_info = ProtocolInfo::default(); - let protocol_info_bytes = serialize(&protocol_info).unwrap(); + let protocol_info_bytes = serialize(&protocol_info) + .map_err(|error| anyhow::format_err!("Cannot serialize ChainState v5 ProtocolInfo: {error}"))?; + let payload_after_protocol_info = 5_usize + .checked_add(protocol_info_bytes.len()) + .ok_or_else(|| anyhow::format_err!("Cannot migrate ChainState v4: field offset overflow"))?; + anyhow::ensure!( + old_chain_state_bytes.len() >= payload_after_protocol_info, + "Cannot migrate ChainState v4: payload is shorter than its serialized ProtocolInfo field" + ); - [ + Ok([ &db_version_bytes, &old_chain_state_bytes[4..5], - &old_chain_state_bytes[5 + protocol_info_bytes.len()..], + &old_chain_state_bytes[payload_after_protocol_info..], ] - .concat() + .concat()) } -fn migrate_chain_state(mut bytes: Vec) -> Result { - loop { - let version = check_chain_state_version(&bytes); - log::info!("Chain state version as read from storage is {version:?}"); +fn migrate_chain_state_v5_to_v6( + old_chain_state_bytes: &[u8], +) -> Result, anyhow::Error> { + anyhow::ensure!( + old_chain_state_bytes.len() >= 4, + "Cannot migrate ChainState v5: payload is shorter than its version tag" + ); + let mut version_bytes = [0_u8; 4]; + version_bytes.copy_from_slice(&old_chain_state_bytes[..4]); + let version = u32::from_le_bytes(version_bytes); + anyhow::ensure!( + version == 5, + "Cannot migrate ChainState v5: found version {version}" + ); + deserialize_chain_state_v5_exact(&old_chain_state_bytes[4..]) + .map_err(|error| as_failure!(error))?; - match version { - Ok(0) => { - // Migrate from v0 to v2 - bytes = migrate_chain_state_v0_to_v2(&bytes); - log::info!("Successfully migrated ChainState v0 to v2"); - } - Ok(2) => { - // Migrate from v2 to v3 - // Actually v2 and v3 have the same serialization, the difference is that in v2 the - // UTXOs are stored inside the ChainState, while in v3 that data structure is empty - // and the UTXOs are stored in separate keys. But that operation is done in the - // ChainManager on initialization, here we just update the db_version field. - migrate_chain_state_v2_to_v3(&mut bytes); - log::info!("Successfully migrated ChainState v2 to v3"); - } - Ok(3) => { - // Migrate from v3 to v4 - bytes = migrate_chain_state_v3_to_v4(&bytes); - log::info!("Successfully migrated ChainState v3 to v4"); - } - Ok(4) => { - // Migrate from v3 to v4 - bytes = migrate_chain_state_v4_to_v5(&bytes); - log::info!("Successfully migrated ChainState v4 to v5"); - } - Ok(5) => { - // Latest version - // Skip the first 4 bytes because they are used to encode db_version - return match deserialize(&bytes[4..]) { - Ok(v) => Ok(v), - Err(e) => Err(as_failure!(e)), - }; - } - Ok(unknown_version) => { - return Err(anyhow::format_err!( - "Error when reading ChainState from database: version {} not supported", - unknown_version - )); - } - Err(()) => { - // Error reading version (end of file?) - return Err(anyhow::format_err!( - "Error when reading ChainState version from database: unexpected end of file" - )); - } - } - } + let db_version_bytes = DELEGATION_CHAIN_STATE_VERSION.to_le_bytes(); + + // Keep this historical intermediate layout frozen even after v8 adds more delegation fields. + // The next migration exact-decodes v7 and converts to the current layout explicitly. + let delegation_state = DelegationStateV7::default(); + let delegation_state_bytes = serialize(&delegation_state).map_err(|error| { + anyhow::format_err!("Cannot serialize default DelegationState during ChainState v5 migration: {error}") + })?; + + Ok([ + &db_version_bytes, + &old_chain_state_bytes[4..], + &delegation_state_bytes, + ] + .concat()) } -/// Get value associated to key, with migrations support -fn get_versioned( - key: &K, - migration_fn: F, -) -> impl Future, anyhow::Error>> + use -where - K: serde::Serialize, - F: FnOnce(Vec) -> Result, -{ - let addr = StorageManagerAdapter::from_registry(); +/// Version 7 introduces a persistence-compatibility boundary without changing the complete v6 +/// `ChainState` payload. Validate the whole v6 value before changing only its four-byte tag. +fn migrate_chain_state_v6_to_v7(chain_state_bytes: &mut [u8]) -> Result<(), anyhow::Error> { + anyhow::ensure!( + chain_state_bytes.len() >= 4, + "Cannot migrate ChainState v6: payload is shorter than its version tag" + ); + let mut version_bytes = [0_u8; 4]; + version_bytes.copy_from_slice(&chain_state_bytes[..4]); + let version = u32::from_le_bytes(version_bytes); + anyhow::ensure!( + version == DELEGATION_CHAIN_STATE_VERSION, + "Cannot migrate ChainState v6: found version {version}" + ); + witnet_data_structures::bincode_compatibility::deserialize_exact::( + &chain_state_bytes[4..], + ) + .map_err(|error| as_failure!(error))?; - let key_bytes = match serialize(key) { - Ok(x) => x, - Err(e) => return futures::future::Either::Left(future::ready(Err(e.into()))), + chain_state_bytes[..4].copy_from_slice(&PRE_EXIT_CHAIN_STATE_VERSION.to_le_bytes()); + Ok(()) +} + +fn migrate_chain_state_v7_to_v8(bytes: &[u8]) -> Result, anyhow::Error> { + anyhow::ensure!(bytes.len() >= 4, "ChainState v7 migration input is shorter than its version tag"); + let version = u32::from_le_bytes(bytes[..4].try_into().expect("checked version tag length")); + anyhow::ensure!( + version == PRE_EXIT_CHAIN_STATE_VERSION, + "expected ChainState v7 migration input" + ); + let legacy = witnet_data_structures::bincode_compatibility::deserialize_exact::( + &bytes[4..], + ) + .map_err(|error| as_failure!(error))?; + if legacy.delegation_state.activation_initialized { + return Err(anyhow::anyhow!( + "activated v6/v7 delegated-staking state requires replay from before activation; commit-time reward entitlements cannot be inferred from current balances" + )); + } + let frozen = ChainStateV8 { + chain_info: legacy.chain_info, + unspent_outputs_pool_old_migration_db: legacy.unspent_outputs_pool_old_migration_db, + data_request_pool: legacy.data_request_pool, + block_chain: legacy.block_chain, + own_utxos: legacy.own_utxos, + reputation_engine: legacy.reputation_engine, + node_stats: legacy.node_stats, + alt_keys: legacy.alt_keys, + superblock_state: legacy.superblock_state, + tapi_engine: legacy.tapi_engine, + stakes: legacy.stakes, + delegation_state: DelegationStateV8 { + policy: legacy.delegation_state.policy, + activation_initialized: legacy.delegation_state.activation_initialized, + validators: legacy.delegation_state.validators, + collateral_reservations: legacy + .delegation_state + .collateral_reservations + .into_iter() + .map(|(key, reservation)| { + ( + key, + CollateralReservationV8 { + total: reservation.total, + debits: reservation.debits, + reward_weights: Vec::new(), + }, + ) + }) + .collect(), + exit_intents: Default::default(), + }, }; + Ok(bincode::serialize(&(PRE_RECOVERY_CHAIN_STATE_VERSION, frozen))?) +} - let fut = async move { - let opt = addr.send(Get(key_bytes)).await??; +fn migrate_chain_state_v8_to_v9(bytes: &[u8]) -> Result, anyhow::Error> { + anyhow::ensure!(bytes.len() >= 4, "ChainState v8 migration input is shorter than its version tag"); + let version = u32::from_le_bytes(bytes[..4].try_into().expect("checked version tag length")); + anyhow::ensure!( + version == PRE_RECOVERY_CHAIN_STATE_VERSION, + "expected ChainState v8 migration input" + ); + let frozen = witnet_data_structures::bincode_compatibility::deserialize_exact::( + &bytes[4..], + ) + .map_err(|error| as_failure!(error))?; + Ok(bincode::serialize(&(CURRENT_CHAIN_STATE_VERSION, frozen.into_current()))?) +} - match opt { - Some(bytes) => migration_fn(bytes).map(Some), - None => Ok(None), +/// Exact, version-dispatched portable decoding from a verified file or byte reader. V6/v7 share +/// the frozen pre-exit layout; v8 has its own frozen layout; v9 uses the current type. Reject +/// trailing bytes before conversion. +pub fn deserialize_versioned_chain_state_snapshot_reader( + reader: &mut R, +) -> Result<(u32, ChainState), bincode::Error> { + let version: u32 = bincode::deserialize_from(&mut *reader)?; + let state = match version { + DELEGATION_CHAIN_STATE_VERSION | PRE_EXIT_CHAIN_STATE_VERSION => { + let legacy: ChainStateV7 = bincode::deserialize_from(&mut *reader)?; + require_chain_state_eof(reader)?; + legacy.into_current()? + } + PRE_RECOVERY_CHAIN_STATE_VERSION => { + let frozen: ChainStateV8 = bincode::deserialize_from(&mut *reader)?; + require_chain_state_eof(reader)?; + frozen.into_current() + } + CURRENT_CHAIN_STATE_VERSION => { + let current: ChainState = bincode::deserialize_from(&mut *reader)?; + require_chain_state_eof(reader)?; + current } + _ => return Err(Box::new(bincode::ErrorKind::Custom(format!( + "unsupported portable ChainState version {version}" + )))), }; + Ok((version, state)) +} - futures::future::Either::Right(fut) +fn require_chain_state_eof(reader: &mut R) -> Result<(), bincode::Error> { + let mut trailing = [0_u8; 1]; + let read = reader.read(&mut trailing).map_err(|error| Box::new(bincode::ErrorKind::Io(error)))?; + if read != 0 { + return Err(Box::new(bincode::ErrorKind::Custom( + "ChainState payload contains trailing bytes".to_string(), + ))); + } + Ok(()) } -/// Get value associated to key -pub fn get_chain_state(key: K) -> impl Future, anyhow::Error>> -where - K: serde::Serialize, -{ - get_versioned(&key, migrate_chain_state) +/// Exact raw v6/v7 compatibility input, distinct from the current raw layout and frozen raw v5. +pub fn deserialize_chain_state_v7_snapshot(bytes: &[u8]) -> Result { + witnet_data_structures::bincode_compatibility::deserialize_exact::(bytes)? + .into_current() } -/// Put a value associated to the key into the storage, preceded by a 4-byte version tag -fn put_versioned_to_batch( - key: &K, - value: &ChainState, - db_version: u32, - batch: &mut UtxoWriteBatch, -) -> Result<(), anyhow::Error> -where - K: serde::Serialize, -{ - let key_bytes = match serialize(key) { - Ok(x) => x, - Err(e) => { - return Err(e.into()); - } - }; +/// Distinguish exact raw legacy layouts without turning a valid-but-unmigratable v7 image into a +/// v5 fallback. In particular, retain the explicit replay-required error for activated v7 state. +pub fn deserialize_legacy_chain_state_snapshot(bytes: &[u8]) -> Result { + match witnet_data_structures::bincode_compatibility::deserialize_exact::(bytes) { + Ok(legacy) => legacy.into_current(), + Err(_) => deserialize_chain_state_v5_exact(bytes).map(ChainState::from), + } +} - let mut buf = db_version.to_le_bytes().to_vec(); - let value_bytes = match bincode::serialize_into(&mut buf, value) { - Ok(()) => buf, - Err(e) => { - return Err(e.into()); - } - }; +fn deserialize_chain_state_exact(payload: &[u8]) -> Result { + let mut reader = std::io::Cursor::new(payload); + let chain_state: ChainState = bincode::deserialize_from(&mut reader)?; + let payload_len = u64::try_from(payload.len()).map_err(|_| { + Box::new(bincode::ErrorKind::Custom( + "ChainState payload length does not fit in u64".to_string(), + )) + })?; - batch.put_raw(key_bytes, value_bytes); + if reader.position() != payload_len { + return Err(Box::new(bincode::ErrorKind::Custom(format!( + "ChainState payload has {} trailing bytes", + payload_len.saturating_sub(reader.position()) + )))); + } - Ok(()) + Ok(chain_state) } -/// Put a value associated to the key into the storage. -/// The value will be atomically written along with the contents of the batch: either it will all -/// succeed or it will all fail. -// TODO: how to ensure that we don't accidentally persist the chain state using put instead of put_chain_state? -pub fn put_chain_state_in_batch( - key: &K, - chain_state: &ChainState, - mut batch: UtxoWriteBatch, -) -> impl Future> + 'static -where - K: serde::Serialize + 'static, -{ - let db_version: u32 = 5; - // The first byte of the ChainState db_version must never be 0 or 1, - // because that can be confused with version 0. - assert!(db_version.to_le_bytes()[0] >= 2); +fn deserialize_chain_state_v5_exact(payload: &[u8]) -> Result { + let mut reader = std::io::Cursor::new(payload); + let chain_state: ChainStateV5 = bincode::deserialize_from(&mut reader)?; + let payload_len = u64::try_from(payload.len()).map_err(|_| { + Box::new(bincode::ErrorKind::Custom( + "ChainState v5 payload length does not fit in u64".to_string(), + )) + })?; - let res = put_versioned_to_batch(key, chain_state, db_version, &mut batch); + if reader.position() != payload_len { + return Err(Box::new(bincode::ErrorKind::Custom(format!( + "ChainState v5 payload has {} trailing bytes", + payload_len.saturating_sub(reader.position()) + )))); + } - let addr = StorageManagerAdapter::from_registry(); + Ok(chain_state) +} - async move { - res?; - addr.send(Batch(batch)).await? +/// Compare a live ChainState with the explicit pristine reset marker without reading poisoned +/// stake-entry locks through the infallible derived `PartialEq` implementation. Storage and +/// snapshot boundaries may receive an in-memory state after an earlier lock failure; those paths +/// must return the represented persistence error rather than panic while deciding whether the +/// value is the replaceable default marker. +pub(crate) fn is_pristine_chain_state(chain_state: &ChainState) -> Result { + // `try_clone_checked` intentionally rebuilds stake secondary indexes from the canonical + // primary map. Once delegated staking is authoritative, do not let this migration/reset + // probe turn malformed omitted, duplicated, detached, or mis-owned rows into a pristine + // projection before the caller can reject the persisted state. Legacy states retain the + // historical compatibility behavior. + if chain_state.delegation_state.activation_initialized { + chain_state + .stakes + .validate_validator_index_checked() + .map_err(|error| { + anyhow::format_err!("cannot inspect delegated stake indexes: {error}") + })?; } + let checked = chain_state + .try_clone_checked() + .map_err(|error| { + anyhow::format_err!("cannot inspect ChainState pristine marker: {error}") + })?; + Ok(checked == ChainState::default()) } -#[cfg(test)] -mod tests { - use serde::{Deserialize, Serialize}; +/// Reject persisted state whose consensus-derived runtime copies or one-time delegated-staking +/// marker disagree with their authoritative inputs. A false marker after the first Wit/2.1 block +/// would permit a late owner and zero-commission rebuild; a true marker before Wit/2.1 would skip +/// canonical activation entirely. A divergent data-request round allowance would make replay use a +/// different stage schedule from the one committed in `ChainInfo`. +/// +/// `block_chain` distinguishes the pre-first-block checkpoint-zero state from a real epoch-zero +/// block. A nonempty index must begin at the configured genesis, and its last entry must agree +/// with the checkpoint stored in `ChainInfo`. +/// +/// The companion certificate is the preferred proof for a dynamically discovered Wit/2.0 +/// activation. This separate provenance check covers the legacy/no-certificate case: once a +/// persisted delegation policy, activation marker, or Wit/2 collateral sidecar exists, an empty +/// or incomplete protocol schedule cannot safely reinterpret the same block history as pre-Wit/2. +/// Callers importing a portable state with a supplied schedule should invoke the public helper +/// below before replay; ordinary database persistence obtains the schedule from the process-wide +/// registry here. +pub fn validate_delegated_staking_persistence( + chain_state: &ChainState, +) -> Result<(), anyhow::Error> { + let protocol_info = get_protocol_info(); + validate_delegated_staking_schedule_provenance(chain_state, &protocol_info)?; + validate_delegated_staking_persistence_with(chain_state, |epoch| { + // Keep provenance and every epoch-specific decision on the same immutable schedule + // snapshot. Re-reading the process-global registry here could mix two protocol eras if a + // schedule publication races this persistence check. + protocol_info.all_versions.version_for_epoch(epoch) + }) +} - use witnet_data_structures::chain::ChainInfo; +/// Require a registered Wit/2.0 schedule whenever persisted state contains delegated-staking +/// history that would be interpreted differently under the legacy protocol. +/// +/// A pre-Wit/2 snapshot with the default [`DelegationState`] remains valid without a schedule. +/// Conversely, a nonempty policy map, activation marker, or collateral sidecar is evidence that +/// this state has crossed (or is carrying an outstanding request from) the Wit/2 regime. Without +/// a registered V2.0 activation epoch and checkpoint period, replay would silently fall back to +/// the legacy version map and could accept the wrong transaction hashes, stake transitions, or +/// activation semantics. This guard is deliberately provenance-only: it does not claim the +/// schedule proves the historical threshold, which remains the reason legacy databases and +/// portable snapshots still need operator configuration or a separately authenticated replay. +pub fn validate_delegated_staking_schedule_provenance( + chain_state: &ChainState, + protocol_info: &ProtocolInfo, +) -> Result<(), anyhow::Error> { + let delegation_state = &chain_state.delegation_state; + let carries_delegated_history = delegation_state.activation_initialized + || !delegation_state.validators.is_empty() + || !delegation_state.collateral_reservations.is_empty() + || !delegation_state.legacy_activation_obligations.is_empty(); + if !carries_delegated_history { + return Ok(()); + } - use super::*; + let activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoint_period = protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied(); + anyhow::ensure!( + activation_epoch.is_some() && checkpoint_period.is_some_and(|period| period > 0), + "delegated-staking state requires a registered Wit/2.0 activation schedule or an authenticated activation certificate" + ); - #[test] + Ok(()) +} + +/// Validate persisted delegated-staking state against an explicitly supplied protocol schedule. +/// +/// Portable snapshot manifests carry the schedule that authenticated their historical blocks, so +/// importing one must not consult whichever schedule happens to be installed in the process-global +/// runtime. Keep the schedule-aware path beside the global wrapper to make that distinction explicit +/// at every ChainState decode boundary. +pub fn validate_delegated_staking_persistence_with_protocol_info( + chain_state: &ChainState, + protocol_info: &ProtocolInfo, +) -> Result<(), anyhow::Error> { + // Preserve the ordinary persistence boundary's explicit pristine reset marker. There is no + // consensus context against which to validate a schedule for that value, but it carries no + // delegated history either and is rejected later by snapshot identity checks if encountered in + // a manifest-bound import. + let Some(chain_info) = chain_state.chain_info.as_ref() else { + return validate_delegated_staking_persistence_with(chain_state, |epoch| { + protocol_info.all_versions.version_for_epoch(epoch) + }); + }; + protocol_info + .validate_timing_topology(&chain_info.consensus_constants) + .map_err(|error| { + anyhow::format_err!( + "supplied protocol schedule is incompatible with its ChainState consensus constants: {error}" + ) + })?; + validate_delegated_staking_schedule_provenance(chain_state, protocol_info)?; + validate_delegated_staking_persistence_with(chain_state, |epoch| { + protocol_info.all_versions.version_for_epoch(epoch) + }) +} + +fn validate_delegated_staking_persistence_with( + chain_state: &ChainState, + mut protocol_at: F, +) -> Result<(), anyhow::Error> +where + F: FnMut(Epoch) -> ProtocolVersion, +{ + // `AltKeys` is deserialized independently of its insertion helper. Validate it before any + // ChainState image (including the pristine reset marker) can be accepted or re-persisted, so a + // malformed BN256 entry cannot later be used as the authoritative key for vote admission. + chain_state + .alt_keys + .validate() + .map_err(|error| { + anyhow::format_err!("ChainState contains invalid alternate key: {error}") + })?; + + // The pristine default is the explicit reset marker used by ChainManager before rebuilding a + // genesis state. Any other state missing either required runtime component is a partial or + // interrupted durable write and must not be accepted for persistence/import. Without this + // gate, a malformed value could be written back and later interpreted as a fresh chain, + // discarding delegated-staking policy, reservations, and stake balances. + if chain_state.chain_info.is_none() || chain_state.reputation_engine.is_none() { + anyhow::ensure!( + is_pristine_chain_state(chain_state)?, + "ChainState is incomplete: ChainInfo and ReputationEngine must be present" + ); + return Ok(()); + } + + let chain_info = chain_state + .chain_info + .as_ref() + .ok_or_else(|| anyhow::format_err!("initialized ChainState has no ChainInfo"))?; + + // DataRequestPool persists a copy of the consensus round allowance and uses it directly when + // advancing active requests during replay. It must therefore agree exactly with ChainInfo; + // otherwise two nodes loading the same indexed chain could derive different request stages. + // The terminal round is `extra_rounds + 1`, so the maximum u16 value is not representable and + // must be rejected here as well as at configured-node startup. + anyhow::ensure!( + chain_info + .consensus_constants + .extra_rounds + .checked_add(1) + .is_some(), + "ChainState consensus extra_rounds {} has no representable terminal round", + chain_info.consensus_constants.extra_rounds + ); + anyhow::ensure!( + chain_state.data_request_pool.extra_rounds + == chain_info.consensus_constants.extra_rounds, + "ChainState data-request pool extra_rounds {} disagrees with consensus extra_rounds {}", + chain_state.data_request_pool.extra_rounds, + chain_info.consensus_constants.extra_rounds + ); + + // The activation marker is meaningful only for a complete canonical index. A suffix that + // starts after genesis can have a perfectly matching tip and marker while omitting the block + // that performed the one-time owner/policy transition. Ordinary persistence and portable + // snapshots therefore share the same unpruned-origin contract. + if let Some((first_epoch, first_hash)) = chain_state.block_chain.iter().next() { + anyhow::ensure!( + *first_epoch == 0, + "ChainState block index starts at epoch {first_epoch} instead of genesis epoch 0" + ); + anyhow::ensure!( + *first_hash == chain_info.consensus_constants.genesis_hash, + "ChainState epoch-zero block hash {:?} disagrees with configured genesis hash {:?}", + first_hash, + chain_info.consensus_constants.genesis_hash + ); + } + + let last_consolidated = chain_state + .block_chain + .iter() + .next_back() + .map(|(epoch, hash)| (*epoch, *hash)); + if let Some((last_epoch, last_hash)) = last_consolidated { + let checkpoint = chain_info.highest_block_checkpoint; + anyhow::ensure!( + checkpoint.checkpoint == last_epoch, + "ChainState highest block checkpoint {} disagrees with block index tip {}", + checkpoint.checkpoint, + last_epoch + ); + anyhow::ensure!( + checkpoint.hash_prev_block == last_hash, + "ChainState highest block hash {:?} disagrees with block index tip {:?}", + checkpoint.hash_prev_block, + last_hash + ); + } else { + anyhow::ensure!( + chain_info.highest_block_checkpoint.checkpoint == 0, + "ChainState has no indexed blocks but its highest checkpoint is {}", + chain_info.highest_block_checkpoint.checkpoint + ); + anyhow::ensure!( + chain_info.highest_block_checkpoint.hash_prev_block + == chain_info.consensus_constants.bootstrap_hash, + "ChainState has no indexed blocks but its checkpoint-zero block beacon does not use the bootstrap hash" + ); + } + + if chain_info.highest_superblock_checkpoint.checkpoint == 0 { + anyhow::ensure!( + chain_info.highest_superblock_checkpoint.hash_prev_block + == chain_info.consensus_constants.bootstrap_hash, + "ChainState checkpoint-zero superblock beacon does not use the bootstrap hash" + ); + } + + let last_consolidated_epoch = last_consolidated.map(|(epoch, _)| epoch); + let marker_required = last_consolidated_epoch + .is_some_and(|epoch| protocol_at(epoch) >= ProtocolVersion::V2_1); + anyhow::ensure!( + chain_state.delegation_state.activation_initialized == marker_required, + "Delegated-staking activation marker {} disagrees with consolidated tip {:?} (expected {})", + chain_state.delegation_state.activation_initialized, + last_consolidated_epoch, + marker_required + ); + anyhow::ensure!( + marker_required || chain_state.delegation_state.exit_intents.is_empty(), + "preactivation ChainState cannot contain native withdrawer exit intent" + ); + + // Active Wit/2 commits remain settlement- and activation-relevant even before delegated + // staking initializes and even when raw-zero Wit/2.0 collateral has no reservation sidecar. + // Authenticate their persisted addressing independently of the post-marker reservation gate. + validate_persisted_wit2_commit_identities(chain_state, &mut protocol_at)?; + + if marker_required { + // Every live/reservation validator must retain the policy created at activation or first + // registration. Validate the decoded records exactly; never repair malformed state on load. + let current_epoch = last_consolidated_epoch.ok_or_else(|| { + anyhow::format_err!("delegated-staking marker requires a consolidated tip") + })?; + // This boundary can validate a portable state against a supplied protocol schedule before + // that schedule is process-global. The delegated bounds are immutable protocol constants; + // deriving them through the epoch-gated runtime accessors would instead consult whichever + // unrelated schedule is currently published and could turn the 10,000 WIT minimum into + // zero while importing an otherwise post-Wit/2.1 state. + let minimum_stake = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let maximum_validator_stake = + Wit::from_nanowits(DELEGATED_STAKING_MAXIMUM_NANOWITS); + chain_state + .delegation_state + .validate_persisted_invariants( + &chain_state.stakes, + current_epoch, + minimum_stake, + maximum_validator_stake, + )?; + validate_persisted_delegation_reservations( + chain_state, + &mut protocol_at, + chain_info.consensus_constants.collateral_minimum, + )?; + } + + Ok(()) +} + +/// Validate the relationship between persisted collateral reservations and the authoritative +/// active data-request pool. Structural reservation checks cannot tell a native Wit/2.1 debit +/// from a legacy Wit/2.0 sidecar, nor can they detect a reservation left behind after its tally. +/// Once activation is persisted, every reservation must therefore identify an outstanding +/// Wit/2.0-or-later commit, match that request's effective collateral (including the Wit/2.1 +/// zero-value default), and retain the immutable owner-only debit semantics for legacy requests. +/// Conversely, every outstanding positive-collateral commit must have one matching reservation; +/// otherwise a later refund/slash would discover an incomplete persisted state only after loading. +fn validate_persisted_delegation_reservations( + chain_state: &ChainState, + protocol_at: &mut F, + collateral_minimum: u64, +) -> Result<(), anyhow::Error> +where + F: FnMut(Epoch) -> ProtocolVersion, +{ + // Activation-boundary obligations are not escrow. Pending records must still bind to the + // active canonical request and exact raw terms; terminal records may remain after the request + // leaves the active pool so their non-spendable audit history survives restart. + for (obligation_key, obligation) in &chain_state.delegation_state.legacy_activation_obligations { + if matches!(obligation.settlement, LegacyActivationSettlement::Pending) { + let data_request_state = chain_state + .data_request_pool + .data_request_state(&obligation_key.data_request) + .ok_or_else(|| { + anyhow::format_err!( + "pending activation obligation references inactive data request {}", + obligation_key.data_request + ) + })?; + let data_request_protocol = protocol_at(data_request_state.epoch); + anyhow::ensure!( + data_request_protocol == ProtocolVersion::V2_0, + "pending activation obligation references non-Wit/2.0 request {}", + obligation_key.data_request + ); + anyhow::ensure!( + data_request_state.info.commits.contains_key(&obligation_key.validator), + "pending activation obligation for {} has no outstanding commit by validator {}", + obligation_key.data_request, + obligation_key.validator + ); + anyhow::ensure!( + obligation.raw_collateral.nanowits() == data_request_state.data_request.collateral + && obligation.witness_reward.nanowits() + == data_request_state.data_request.witness_reward, + "pending activation obligation for {} does not match its request terms", + obligation_key.data_request + ); + } + } + + for (reservation_key, reservation) in &chain_state.delegation_state.collateral_reservations { + let data_request_state = chain_state + .data_request_pool + .data_request_state(&reservation_key.data_request) + .ok_or_else(|| { + anyhow::format_err!( + "delegated collateral reservation references inactive data request {}", + reservation_key.data_request + ) + })?; + let data_request_protocol = protocol_at(data_request_state.epoch); + anyhow::ensure!( + data_request_protocol >= ProtocolVersion::V2_0, + "delegated collateral reservation references a pre-Wit/2.0 data request {}", + reservation_key.data_request + ); + let expected_collateral = if data_request_protocol >= ProtocolVersion::V2_1 + && data_request_state.data_request.collateral == 0 + { + collateral_minimum + } else { + data_request_state.data_request.collateral + }; + anyhow::ensure!( + data_request_state.info.commits.contains_key(&reservation_key.validator), + "delegated collateral reservation for {} has no outstanding commit by validator {}", + reservation_key.data_request, + reservation_key.validator + ); + anyhow::ensure!( + reservation.total.nanowits() == expected_collateral, + "delegated collateral reservation for {} has total inconsistent with its data request", + reservation_key.data_request + ); + anyhow::ensure!( + (data_request_protocol >= ProtocolVersion::V2_1) != reservation.reward_weights.is_empty(), + "delegated collateral reservation for {} has reward weights incompatible with request protocol {:?}", + reservation_key.data_request, + data_request_protocol + ); + + if data_request_protocol == ProtocolVersion::V2_0 { + chain_state + .delegation_state + .validate_active_legacy_collateral_owner(*reservation_key)?; + } + } + + // Validate reservation coverage in the other direction as well. A zero Wit/2.0 collateral + // is a historical no-op and intentionally has no sidecar, but its committer still needs a + // live immutable owner for legacy settlement. Every other Wit/2.0-or-later commitment must + // retain the exact reservation created when its collateral was debited. This function is + // post-marker only and does not impose a new owner requirement on preactivation history. + for (data_request, data_request_state) in &chain_state.data_request_pool.data_request_pool { + let data_request_protocol = protocol_at(data_request_state.epoch); + if data_request_protocol < ProtocolVersion::V2_0 { + continue; + } + let expected_collateral = if data_request_protocol >= ProtocolVersion::V2_1 + && data_request_state.data_request.collateral == 0 + { + collateral_minimum + } else { + data_request_state.data_request.collateral + }; + for validator in data_request_state.info.commits.keys() { + let obligation_key = CollateralReservationKey { + data_request: *data_request, + validator: *validator, + }; + let has_obligation = chain_state + .delegation_state + .legacy_activation_obligations + .get(&obligation_key) + .is_some_and(|obligation| { + obligation.raw_collateral.nanowits() == data_request_state.data_request.collateral + }); + if data_request_protocol == ProtocolVersion::V2_0 && !has_obligation { + let owner_key = chain_state.delegation_state.owner_key(*validator)?; + let owner_entries = chain_state.stakes.query_stakes_primary_checked( + QueryStakesKey::Key(owner_key), + )?; + anyhow::ensure!( + !owner_entries.is_empty(), + "outstanding legacy commitment for {} by validator {} has no live immutable owner", + data_request, + validator + ); + } + if expected_collateral == 0 { + continue; + } + if has_obligation { + continue; + } + let reservation_key = CollateralReservationKey { + data_request: *data_request, + validator: *validator, + }; + anyhow::ensure!( + chain_state + .delegation_state + .collateral_reservations + .get(&reservation_key) + .is_some_and(|reservation| reservation.total.nanowits() == expected_collateral), + "outstanding delegated collateral for {} by validator {} has no matching reservation", + data_request, + validator + ); + } + } + + Ok(()) +} + +/// Authenticate every active Wit/2 commitment before any reservation membership is trusted. +fn validate_persisted_wit2_commit_identities( + chain_state: &ChainState, + protocol_at: &mut F, +) -> Result<(), anyhow::Error> +where + F: FnMut(Epoch) -> ProtocolVersion, +{ + for (data_request, data_request_state) in &chain_state.data_request_pool.data_request_pool { + if protocol_at(data_request_state.epoch) < ProtocolVersion::V2_0 { + continue; + } + for (validator, commit) in &data_request_state.info.commits { + validate_persisted_wit2_commit_identity(*data_request, *validator, commit)?; + } + } + + Ok(()) +} + +/// Bind an active persisted Wit/2 commit to the identities under which the data-request pool and +/// delegated collateral state address it. `DataRequestPool::process_commit` indexes a validated +/// transaction by its sole signature PKH after validation has already required that signer to equal +/// the VRF proof identity. A decoded database or portable snapshot bypasses that construction path, +/// so trusting only `commits.contains_key(validator)` would let a request/reservation for one +/// validator be backed by a transaction for another request or identity. +fn validate_persisted_wit2_commit_identity( + data_request: Hash, + validator: PublicKeyHash, + commit: &CommitTransaction, +) -> Result<(), anyhow::Error> { + anyhow::ensure!( + commit.body.dr_pointer == data_request, + "persisted Wit/2 commit keyed under data request {data_request} points to {}", + commit.body.dr_pointer + ); + anyhow::ensure!( + commit.body.proof.proof.pkh() == validator, + "persisted Wit/2 commit for {data_request} is keyed by validator {validator} but carries a different VRF proof identity" + ); + let [signature] = commit.signatures.as_slice() else { + return Err(anyhow::format_err!( + "persisted Wit/2 commit for {data_request} by validator {validator} does not have exactly one signature" + )); + }; + anyhow::ensure!( + signature.public_key.pkh() == validator, + "persisted Wit/2 commit for {data_request} is keyed by validator {validator} but carries a different signature identity" + ); + + Ok(()) +} + +/// Deserialize the raw, unversioned `ChainState` payload written by a v5 snapshot exporter. +/// +/// Database values already carry a four-byte version prefix and use `migrate_chain_state` below. +/// Portable snapshots historically did not. Decode the frozen v5 positional type to exact EOF and +/// then convert it explicitly, adding the v6 default delegation state. Without the frozen decoder, +/// bytes appended by the migrator could complete a malformed current payload and make it appear to +/// be a valid legacy value. +pub fn deserialize_chain_state_v5_snapshot( + old_chain_state_bytes: Vec, +) -> Result { + deserialize_chain_state_v5_exact(&old_chain_state_bytes).map(ChainState::from) +} + +fn migrate_chain_state(bytes: Vec) -> Result { + let chain_state = migrate_chain_state_unvalidated(bytes)?; + validate_delegated_staking_persistence(&chain_state)?; + Ok(chain_state) +} + +/// Decode and structurally migrate a database ChainState before protocol-schedule-dependent +/// invariants are evaluated. The database load path uses this narrow split to authenticate and +/// register a companion Wit/2.0 activation certificate first; every other caller uses the +/// validated wrapper above. +fn migrate_chain_state_unvalidated(mut bytes: Vec) -> Result { + loop { + let version = check_chain_state_version(&bytes); + log::info!("Chain state version as read from storage is {version:?}"); + + match version { + Ok(0) => { + // Migrate from v0 to v2 + bytes = migrate_chain_state_v0_to_v2(&bytes)?; + log::info!("Successfully migrated ChainState v0 to v2"); + } + Ok(2) => { + // Migrate from v2 to v3 + // Actually v2 and v3 have the same serialization, the difference is that in v2 the + // UTXOs are stored inside the ChainState, while in v3 that data structure is empty + // and the UTXOs are stored in separate keys. But that operation is done in the + // ChainManager on initialization, here we just update the db_version field. + migrate_chain_state_v2_to_v3(&mut bytes); + log::info!("Successfully migrated ChainState v2 to v3"); + } + Ok(3) => { + // Migrate from v3 to v4 + bytes = migrate_chain_state_v3_to_v4(&bytes)?; + log::info!("Successfully migrated ChainState v3 to v4"); + } + Ok(4) => { + // Migrate from v4 to v5 + bytes = migrate_chain_state_v4_to_v5(&bytes)?; + log::info!("Successfully migrated ChainState v4 to v5"); + } + Ok(5) => { + // Migrate from v5 to v6 + bytes = migrate_chain_state_v5_to_v6(&bytes)?; + log::info!("Successfully migrated ChainState v5 to v6"); + } + Ok(DELEGATION_CHAIN_STATE_VERSION) => { + // Version 7 retains the complete v6 payload and changes only its version tag. + migrate_chain_state_v6_to_v7(&mut bytes)?; + log::info!("Successfully migrated ChainState v6 to v7"); + } + Ok(PRE_EXIT_CHAIN_STATE_VERSION) => { + bytes = migrate_chain_state_v7_to_v8(&bytes)?; + log::info!("Successfully migrated ChainState v7 to v8"); + } + Ok(PRE_RECOVERY_CHAIN_STATE_VERSION) => { + bytes = migrate_chain_state_v8_to_v9(&bytes)?; + log::info!("Successfully migrated ChainState v8 to v9"); + } + Ok(CURRENT_CHAIN_STATE_VERSION) => { + // Latest version + // Skip the first 4 bytes because they are used to encode db_version + let chain_state = deserialize_chain_state_exact(&bytes[4..]) + .map_err(|error| as_failure!(error))?; + return Ok(chain_state); + } + Ok(unknown_version) => { + return Err(anyhow::format_err!( + "Error when reading ChainState from database: version {} not supported", + unknown_version + )); + } + Err(()) => { + // Error reading version (end of file?) + return Err(anyhow::format_err!( + "Error when reading ChainState version from database: unexpected end of file" + )); + } + } + } +} + +/// Load the versioned ChainState and its optional activation-certificate and protocol-schedule +/// companions. Both companions are read in the same storage-manager turn; the schedule is +/// restored before protocol-dependent persistence invariants, and the certificate is authenticated +/// against the structurally decoded canonical index before it is registered. +pub fn get_chain_state( + key: String, +) -> impl Future, anyhow::Error>> + 'static { + let key_bytes = bincode::serialize(&key).map_err(anyhow::Error::from); + let certificate_key = + storage_keys::wit2_activation_certificate_key_for_chain_state(&key); + let certificate_key_bytes = + bincode::serialize(&certificate_key).map_err(anyhow::Error::from); + let protocol_schedule_key = + storage_keys::protocol_schedule_key_for_chain_state(&key); + let protocol_schedule_key_bytes = + bincode::serialize(&protocol_schedule_key).map_err(anyhow::Error::from); + let addr = StorageManagerAdapter::from_registry(); + + async move { + let key_bytes = key_bytes?; + let certificate_key_bytes = certificate_key_bytes?; + let protocol_schedule_key_bytes = protocol_schedule_key_bytes?; + // All three entries are committed in one `Batch`; read them in one storage-manager actor + // turn so another batch cannot be processed between the ChainState and either companion. + let (chain_state_bytes, certificate_bytes, protocol_schedule_bytes) = addr + .send(GetTriple( + key_bytes, + certificate_key_bytes, + protocol_schedule_key_bytes, + )) + .await??; + let certificate: Option = certificate_bytes + .map(|bytes| { + witnet_data_structures::bincode_compatibility::deserialize_exact(&bytes).map_err( + |error| { + anyhow::format_err!( + "invalid persisted Wit/2 activation certificate: {error}" + ) + }, + ) + }) + .transpose()?; + let persisted_protocol_schedule = protocol_schedule_bytes + .map(|bytes| { + witnet_data_structures::bincode_compatibility::deserialize_exact::< + PersistedProtocolSchedule, + >(&bytes) + .map_err(|error| { + anyhow::format_err!( + "invalid persisted protocol schedule companion: {error}" + ) + }) + }) + .transpose()?; + + let Some(chain_state_bytes) = chain_state_bytes else { + anyhow::ensure!( + certificate.is_none(), + "found an orphan Wit/2 activation certificate without its ChainState" + ); + anyhow::ensure!( + persisted_protocol_schedule.is_none(), + "found an orphan persisted protocol schedule without its ChainState" + ); + return Ok(None); + }; + + // Decode the state before protocol-dependent validation. A snapshot-import schedule is + // authenticated by its companion and must be restored before the normal persistence gate + // resolves historical block epochs. + let decoded_chain_state = migrate_chain_state_unvalidated(chain_state_bytes)?; + if let Some(persisted_protocol_schedule) = persisted_protocol_schedule.as_ref() { + let chain_info = decoded_chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!( + "persisted protocol schedule cannot accompany a ChainState without ChainInfo" + ) + })?; + restore_persisted_protocol_schedule(persisted_protocol_schedule, chain_info)?; + } + + let chain_state = if let Some(certificate) = certificate { + let chain_state = decoded_chain_state; + let indexed_tip = chain_state + .block_chain + .iter() + .next_back() + .map(|(epoch, _)| *epoch); + if is_pristine_chain_state(&chain_state)? { + // The pristine reset marker is the sole ChainState representation without a + // ChainInfo context. Its retained companion cannot be parameter-checked until + // replay has reconstructed the network constants, so leave it inert here. Any + // already-configured schedule can still be compared to the self-contained tuple. + let configured = get_protocol_info(); + if let Some(configured_epoch) = configured + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + { + anyhow::ensure!( + configured_epoch == certificate.activation_epoch + && configured + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + == Some(&certificate.checkpoint_period), + "retained Wit/2 activation certificate conflicts with the configured schedule at the pristine reset marker" + ); + } + // The companion itself is the authenticated provenance for this replay prefix; + // it may legitimately carry legacy collateral sidecars before the certified + // trigger is indexed, while the process-wide schedule has not yet been restored. + // Use the structural validator directly and defer schedule provenance to the + // certificate branch below. + validate_delegated_staking_persistence_with(&chain_state, |epoch| { + configured.all_versions.version_for_epoch(epoch) + })?; + return Ok(Some(chain_state)); + } + if indexed_tip.map_or(true, |tip| tip < certificate.trigger_epoch) { + // A reset marker or replay checkpoint before the certified trigger has no + // canonical block against which the retained certificate can be authenticated. + // The non-default prefix does have ChainInfo, so authenticate every deterministic + // parameter now and defer only the unavailable trigger-hash check. Ignore (but do + // not delete or register) the companion while replay reconstructs that prefix; + // once the indexed tip reaches the trigger, the exact hash becomes mandatory. + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!( + "non-default ChainState prefix has no ChainInfo for its retained Wit/2 activation certificate" + ) + })?; + certificate.validate_parameters(chain_info)?; + let configured = get_protocol_info(); + if let Some(configured_epoch) = configured + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + { + anyhow::ensure!( + configured_epoch == certificate.activation_epoch + && configured + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + == Some(&certificate.checkpoint_period), + "retained Wit/2 activation certificate conflicts with the configured schedule before its trigger is indexed" + ); + } + // As above, the persisted certificate—not the currently configured schedule—is + // the provenance for a pre-trigger replay prefix. Do not reject its legitimate + // sidecars merely because certificate restoration has not yet registered V2.0. + validate_delegated_staking_persistence_with(&chain_state, |epoch| { + configured.all_versions.version_for_epoch(epoch) + })?; + return Ok(Some(chain_state)); + } + + certificate.validate(&chain_state)?; + let (configured_protocol_info, configured_epoch_constants, _) = + get_protocol_runtime(); + let chain_info = chain_state + .chain_info + .as_ref() + .ok_or_else(|| { + anyhow::format_err!( + "validated Wit/2 activation certificate lost its ChainInfo context" + ) + })?; + let trigger_is_finalized = finalized_wit2_activation_trigger( + &certificate, + chain_info.highest_superblock_checkpoint.checkpoint, + chain_info.consensus_constants.superblock_period, + )? + .is_some(); + // A certificate can accompany an intermediate synchronization/replay checkpoint. If + // it restores a schedule that was not present in configuration, retain the exact + // pre-registration schedule and epoch constants until a finalized ChainState + // acknowledges the trigger. This recreates, rather than fabricates, the process-local + // journal that existed when the certificate was written. + let needs_restored_rollback = configured_protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .is_none() + && !trigger_is_finalized; + let restored_rollback = if needs_restored_rollback { + let epoch_constants = match configured_epoch_constants { + Some(constants) => constants, + None if configured_protocol_info.all_versions.is_empty() => EpochConstants { + checkpoint_zero_timestamp: chain_info + .consensus_constants + .checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2: u16::MAX, + }, + None => { + return Err(anyhow::format_err!( + "cannot restore a Wit/2 activation rollback from an unpaired configured protocol schedule" + )); + } + }; + Some(RestoredWit2ActivationRollback { + protocol_info: configured_protocol_info.clone(), + epoch_constants, + trigger_epoch: certificate.trigger_epoch, + trigger_hash: certificate.trigger_hash, + activation_epoch: certificate.activation_epoch, + checkpoint_period: certificate.checkpoint_period, + }) + } else { + None + }; + + let mut protocol_info = configured_protocol_info; + protocol_info + .try_register( + certificate.activation_epoch, + ProtocolVersion::V2_0, + certificate.checkpoint_period, + ) + .map_err(|error| { + anyhow::format_err!( + "stored Wit/2 activation certificate conflicts with configured protocol schedule: {error}" + ) + })?; + validate_delegated_staking_persistence_with(&chain_state, |epoch| { + protocol_info.all_versions.version_for_epoch(epoch) + })?; + let restored_epoch_constants = EpochConstants::try_new( + chain_info.consensus_constants.checkpoint_zero_timestamp, + chain_info.consensus_constants.checkpoints_period, + Some(certificate.activation_epoch), + certificate.checkpoint_period, + ) + .map_err(|error| { + anyhow::format_err!( + "stored Wit/2 activation certificate has an unrepresentable epoch clock: {error}" + ) + })?; + try_register_protocol_version_with_epoch_constants( + ProtocolVersion::V2_0, + certificate.activation_epoch, + certificate.checkpoint_period, + restored_epoch_constants, + restored_epoch_constants + .epoch_at(get_timestamp()) + .map_err(|error| { + anyhow::format_err!( + "stored Wit/2 activation certificate cannot derive the current epoch: {error}" + ) + })?, + ) + .map_err(|error| { + anyhow::format_err!( + "stored Wit/2 activation certificate could not be published to the current protocol schedule: {error}" + ) + })?; + if let Some(restored_rollback) = restored_rollback { + install_restored_wit2_activation_rollback(restored_rollback)?; + } else if trigger_is_finalized { + acknowledge_restored_wit2_activation( + certificate.trigger_epoch, + certificate.trigger_hash, + )?; + } + chain_state + } else { + validate_delegated_staking_persistence(&decoded_chain_state)?; + decoded_chain_state + }; + + Ok(Some(chain_state)) + } +} + +/// Put a value associated to the key into the storage, preceded by a 4-byte version tag +fn put_versioned_to_batch( + key: &K, + value: &ChainState, + db_version: u32, + batch: &mut UtxoWriteBatch, +) -> Result<(), anyhow::Error> +where + K: serde::Serialize, +{ + let key_bytes = match serialize(key) { + Ok(x) => x, + Err(e) => { + return Err(e.into()); + } + }; + + let mut buf = db_version.to_le_bytes().to_vec(); + let value_bytes = match bincode::serialize_into(&mut buf, value) { + Ok(()) => buf, + Err(e) => { + return Err(e.into()); + } + }; + + batch.put_raw(key_bytes, value_bytes); + + Ok(()) +} + +// Validate and serialize the current ChainState into the caller's atomic UTXO/storage batch. Both +// asynchronous and durable persistence entry points must share this exact staging path. +fn stage_validated_chain_state_in_batch( + key: &K, + chain_state: &ChainState, + mut batch: UtxoWriteBatch, + protocol_info: Option<&ProtocolInfo>, +) -> Result +where + K: serde::Serialize, +{ + let db_version = CURRENT_CHAIN_STATE_VERSION; + // The first byte of the ChainState db_version must never be 0 or 1, + // because that can be confused with version 0. + assert!(db_version.to_le_bytes()[0] >= 2); + + if let Some(protocol_info) = protocol_info { + validate_delegated_staking_persistence_with(chain_state, |epoch| { + protocol_info.all_versions.version_for_epoch(epoch) + })?; + } else { + validate_delegated_staking_persistence(chain_state)?; + } + put_versioned_to_batch(key, chain_state, db_version, &mut batch)?; + + Ok(batch) +} + +/// Bind an ordinary finalized-superblock inventory record to the exact ChainState bytes that will +/// share its backend batch. +/// +/// The beacon binds index and the complete superblock body through its hash, and the canonical block +/// index binds the notification's ordered consolidated-block list. The transient current-superblock +/// candidate is deliberately not part of this invariant: synchronization can construct the next +/// candidate before its finalized predecessor's checkpoint reaches storage. +/// Performing this check before serializing either owned value prevents an internally mismatched +/// notification from becoming atomically durable merely because its two records share a write. +fn validate_ordinary_superblock_inventory_binding( + chain_state: &ChainState, + notification: &SuperBlockNotify, +) -> Result<(), anyhow::Error> { + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!( + "ordinary finalized-superblock checkpoint has no ChainInfo for inventory binding" + ) + })?; + let superblock = ¬ification.superblock; + anyhow::ensure!( + superblock.index != 0, + "bootstrap superblock #0 has no ordinary inventory notification" + ); + let superblock_hash = superblock.hash(); + let persisted_beacon = chain_info.highest_superblock_checkpoint; + anyhow::ensure!( + persisted_beacon.checkpoint == superblock.index + && persisted_beacon.hash_prev_block == superblock_hash, + "ordinary superblock notification #{} {} does not match persisted beacon #{} {}", + superblock.index, + superblock_hash, + persisted_beacon.checkpoint, + persisted_beacon.hash_prev_block + ); + let superblock_period = u32::from(chain_info.consensus_constants.superblock_period); + anyhow::ensure!( + superblock_period != 0, + "ordinary superblock inventory binding requires a nonzero superblock period" + ); + let final_epoch = superblock + .index + .checked_mul(superblock_period) + .ok_or_else(|| anyhow::format_err!("ordinary superblock inventory epoch range overflow"))?; + let initial_epoch = final_epoch.checked_sub(superblock_period).ok_or_else(|| { + anyhow::format_err!("ordinary superblock inventory epoch range underflow") + })?; + let expected_block_hashes = chain_state + .block_chain + .range(initial_epoch..final_epoch) + .map(|(_epoch, hash)| *hash) + .collect::>(); + anyhow::ensure!( + notification.consolidated_block_hashes == expected_block_hashes, + "ordinary superblock notification block list does not match its persisted canonical interval" + ); + + Ok(()) +} + +/// Authenticate an inventory-only repair against the exact durable ChainState observed by the +/// storage worker. Structural migration is sufficient here: the repair neither replaces state nor +/// changes protocol activation, and the binding below reads only its finalized beacon and canonical +/// block index. Normal ChainState loading remains responsible for companion-certificate validation. +pub(super) fn validate_persisted_ordinary_superblock_inventory_binding( + chain_state_bytes: Vec, + notification: &SuperBlockNotify, +) -> Result<(), anyhow::Error> { + let chain_state = migrate_chain_state_unvalidated(chain_state_bytes)?; + validate_ordinary_superblock_inventory_binding(&chain_state, notification) +} + +/// Minimal canonical-chain context carried into storage commit handlers so an activation +/// certificate can be resolved at the same linearization point as ChainState replacement. +pub(super) struct Wit2ActivationChainContext { + chain_info: ChainInfo, + block_chain: Blockchain, +} + +impl Wit2ActivationChainContext { + /// Capture the immutable certificate-resolution inputs from a ChainState. + pub(super) fn from_chain_state(chain_state: &ChainState) -> Result { + let chain_info = chain_state.chain_info.clone().ok_or_else(|| { + anyhow::format_err!( + "snapshot ChainState has no ChainInfo for activation-certificate binding" + ) + })?; + Ok(Self { + chain_info, + block_chain: chain_state.block_chain.clone(), + }) + } +} + +/// Exact-revalidate and re-stage an existing activation certificate while replacing ChainState. +/// A stale, malformed, or prefix-unverifiable companion rejects the snapshot commit before any +/// ChainState or active-inventory descriptor changes become durable. +pub(super) fn bind_existing_wit2_activation_certificate_to_snapshot( + storage: &(dyn Storage + Send + Sync), + context: &Wit2ActivationChainContext, + protocol_info: Option<&ProtocolInfo>, + batch: &mut UtxoWriteBatch, +) -> Result<(), anyhow::Error> { + let certificate_key = storage_keys::wit2_activation_certificate_key( + context.chain_info.consensus_constants.get_magic(), + ); + let certificate_key_bytes = bincode::serialize(&certificate_key)?; + anyhow::ensure!( + !batch + .raw_keys() + .any(|key| key == certificate_key_bytes.as_slice()), + "snapshot caller batch must not mutate the Wit/2 activation-certificate companion" + ); + let Some(certificate_bytes) = storage.get(&certificate_key_bytes)? else { + return Ok(()); + }; + let certificate: Wit2ActivationCertificate = + witnet_data_structures::bincode_compatibility::deserialize_exact(&certificate_bytes) + .map_err(|error| { + anyhow::format_err!( + "cannot replace ChainState while its Wit/2 activation certificate is malformed: {error}" + ) + })?; + certificate + .validate_chain(&context.chain_info, &context.block_chain) + .map_err(|error| { + anyhow::format_err!( + "snapshot ChainState is incompatible with the existing Wit/2 activation certificate: {error}" + ) + })?; + + let protocol_info = protocol_info.cloned().unwrap_or_else(get_protocol_info); + anyhow::ensure!( + protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + == Some(certificate.activation_epoch) + && protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + == Some(&certificate.checkpoint_period), + "snapshot-compatible Wit/2 activation certificate conflicts with the selected protocol schedule" + ); + + // Re-stage the exact bytes rather than merely leaving the old key untouched. This binds the + // companion and replacement ChainState into the same synchronous atomic snapshot commit. + batch.put_raw(certificate_key_bytes, certificate_bytes); + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExistingWit2ActivationCertificateAction { + Preserve, + Delete, +} + +fn existing_wit2_activation_certificate_action( + context: &Wit2ActivationChainContext, + existing: &Wit2ActivationCertificate, + registered_v2: Option<(Epoch, u16)>, +) -> Result { + existing.validate_parameters(&context.chain_info)?; + let indexed_tip = context + .block_chain + .iter() + .next_back() + .map(|(epoch, _)| *epoch); + if indexed_tip.map_or(true, |tip| tip < existing.trigger_epoch) { + anyhow::ensure!( + registered_v2.is_none() + || registered_v2 + == Some((existing.activation_epoch, existing.checkpoint_period)), + "refusing to preserve a pre-trigger Wit/2 activation certificate beside a conflicting live V2_0 schedule" + ); + return Ok(ExistingWit2ActivationCertificateAction::Preserve); + } + + if context.block_chain.get(&existing.trigger_epoch) == Some(&existing.trigger_hash) { + anyhow::ensure!( + registered_v2 == Some((existing.activation_epoch, existing.checkpoint_period)), + "ChainState indexes the certified Wit/2 trigger but no matching live protocol schedule or new certificate exists" + ); + return Ok(ExistingWit2ActivationCertificateAction::Preserve); + } + + anyhow::ensure!( + finalized_wit2_activation_trigger( + existing, + context.chain_info.highest_superblock_checkpoint.checkpoint, + context.chain_info.consensus_constants.superblock_period, + )? + .is_none(), + "refusing to discard a Wit/2 activation certificate finalized by the persisted superblock checkpoint" + ); + anyhow::ensure!( + registered_v2.is_none(), + "refusing to discard a Wit/2 activation certificate while a V2_0 schedule remains registered" + ); + + Ok(ExistingWit2ActivationCertificateAction::Delete) +} + +fn validate_wit2_activation_certificate_replacement( + context: &Wit2ActivationChainContext, + existing: &Wit2ActivationCertificate, + replacement: &Wit2ActivationCertificate, +) -> Result<(), anyhow::Error> { + if existing == replacement { + return Ok(()); + } + + // A different certificate may replace an unfinalized trigger after a rollback, but it must + // never erase durable evidence that the current state still indexes or has already finalized. + existing.validate_parameters(&context.chain_info)?; + anyhow::ensure!( + context.block_chain.get(&existing.trigger_epoch) != Some(&existing.trigger_hash), + "refusing to replace a Wit/2 activation certificate whose trigger remains canonical" + ); + anyhow::ensure!( + finalized_wit2_activation_trigger( + existing, + context.chain_info.highest_superblock_checkpoint.checkpoint, + context.chain_info.consensus_constants.superblock_period, + )? + .is_none(), + "refusing to replace a Wit/2 activation certificate finalized by the persisted superblock checkpoint" + ); + + Ok(()) +} + +/// Resolve the activation companion while atomically replacing ordinary ChainState. +/// +/// A new exact certificate may overwrite only an old, non-canonical, unfinalized companion. +/// Without a new certificate, replay prefixes before the old trigger retain it; an alternate +/// unfinalized branch that has reached that epoch deletes it only after proving that no Wit/2 +/// schedule remains registered. A finalized or still-registered certificate is never silently +/// discarded. +pub(super) fn resolve_wit2_activation_certificate_for_chain_state( + storage: &(dyn Storage + Send + Sync), + context: &Wit2ActivationChainContext, + certificate: Option<&Wit2ActivationCertificate>, + protocol_info: Option<&ProtocolInfo>, + batch: &mut UtxoWriteBatch, +) -> Result<(), anyhow::Error> { + let certificate_key = storage_keys::wit2_activation_certificate_key( + context.chain_info.consensus_constants.get_magic(), + ); + let certificate_key_bytes = bincode::serialize(&certificate_key)?; + anyhow::ensure!( + !batch + .raw_keys() + .any(|key| key == certificate_key_bytes.as_slice()), + "ChainState caller batch must not mutate the Wit/2 activation-certificate companion" + ); + let existing = storage + .get(&certificate_key_bytes)? + .map(|existing_bytes| { + witnet_data_structures::bincode_compatibility::deserialize_exact::< + Wit2ActivationCertificate, + >(&existing_bytes) + .map_err(|error| { + anyhow::format_err!( + "cannot resolve malformed persisted Wit/2 activation certificate: {error}" + ) + }) + }) + .transpose()?; + + if let Some(certificate) = certificate { + certificate.validate_chain(&context.chain_info, &context.block_chain)?; + let protocol_info = protocol_info_for_chain_state(storage, context, protocol_info)?; + anyhow::ensure!( + protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + == Some(certificate.activation_epoch), + "Wit/2 activation certificate epoch does not match the selected protocol schedule" + ); + anyhow::ensure!( + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + == Some(&certificate.checkpoint_period), + "Wit/2 activation certificate period does not match the selected protocol schedule" + ); + if let Some(existing) = existing.as_ref() { + validate_wit2_activation_certificate_replacement(context, existing, certificate)?; + } + batch.put_raw(certificate_key_bytes, bincode::serialize(certificate)?); + return Ok(()); + } + + let Some(existing) = existing else { + return Ok(()); + }; + // Ordinary ChainState writes do not carry a new manifest schedule. Prefer the durable + // schedule companion that was authenticated with the currently stored ChainState over the + // mutable process-global registry when deciding whether an old activation certificate may be + // retired. A restart or configuration update must not make certificate cleanup depend on a + // different schedule than the one that authenticated this state. Snapshot imports still pass + // an explicit schedule through `protocol_info` and therefore never need this fallback. + let selected_protocol_info = + protocol_info_for_chain_state(storage, context, protocol_info)?; + let registered_v2 = registered_wit2_schedule(&selected_protocol_info); + match existing_wit2_activation_certificate_action(context, &existing, registered_v2)? { + ExistingWit2ActivationCertificateAction::Preserve => {} + ExistingWit2ActivationCertificateAction::Delete => { + batch.delete_raw(certificate_key_bytes); + } + } + Ok(()) +} + +/// Resolve the optional protocol-schedule companion while atomically replacing ChainState. +/// +/// Snapshot imports may supply a complete schedule authenticated by their manifest. Ordinary +/// ChainState writes pass `None` and re-stage the existing companion byte-for-byte, preserving that +/// schedule across every later superblock/checkpoint write. A supplied schedule may create the +/// companion only when it preserves both the current local approvals and any already durable +/// schedule. Recheck at the storage-worker boundary so a changed runtime after actor preflight +/// cannot make an unapproved future activation durable. +pub(super) fn resolve_protocol_schedule_for_chain_state( + storage: &(dyn Storage + Send + Sync), + context: &Wit2ActivationChainContext, + protocol_info: Option<&ProtocolInfo>, + batch: &mut UtxoWriteBatch, +) -> Result<(), anyhow::Error> { + let schedule_key = storage_keys::protocol_schedule_key( + context.chain_info.consensus_constants.get_magic(), + ); + let schedule_key_bytes = bincode::serialize(&schedule_key)?; + anyhow::ensure!( + !batch + .raw_keys() + .any(|key| key == schedule_key_bytes.as_slice()), + "ChainState caller batch must not mutate the protocol-schedule companion" + ); + + let existing = storage + .get(&schedule_key_bytes)? + .map(|bytes| { + let persisted = witnet_data_structures::bincode_compatibility::deserialize_exact::< + PersistedProtocolSchedule, + >(&bytes) + .map_err(|error| { + anyhow::format_err!( + "cannot resolve malformed persisted protocol schedule companion: {error}" + ) + })?; + Ok::<_, anyhow::Error>((bytes, persisted)) + }) + .transpose()?; + + if let Some(protocol_info) = protocol_info { + witnet_data_structures::proto::versioning::ensure_production_protocol_schedule_ready( + protocol_info, + )?; + validate_snapshot_protocol_schedule( + &get_protocol_info(), + protocol_info, + &context.chain_info, + )?; + let replacement = PersistedProtocolSchedule::from_chain_info( + &context.chain_info, + protocol_info.clone(), + )?; + if let Some((_existing_bytes, existing)) = existing.as_ref() { + existing.validate(&context.chain_info)?; + anyhow::ensure!( + protocol_schedule_matches(&existing.protocol_info, &replacement.protocol_info), + "imported protocol schedule conflicts with the durable protocol-schedule companion" + ); + } + batch.put_raw(schedule_key_bytes, bincode::serialize(&replacement)?); + return Ok(()); + } + + if let Some((existing_bytes, existing)) = existing { + existing.validate(&context.chain_info)?; + witnet_data_structures::proto::versioning::ensure_production_protocol_schedule_ready( + &existing.protocol_info, + )?; + // Re-stage the exact bytes so the schedule and replacement ChainState share one atomic + // visibility boundary rather than relying on an untouched key outside the caller batch. + batch.put_raw(schedule_key_bytes, existing_bytes); + } + + Ok(()) +} + +/// Require the immutable snapshot-generation metadata to describe the exact `ChainState` that is +/// about to share its durable commit. The token is exact-revalidated again by the storage worker; +/// performing these semantic checks before serialization therefore binds that durable descriptor +/// to the bytes staged below without exposing a separate marker-write API. +/// +/// Superblock and report record counts are deliberately not inferred from chain tips: snapshot +/// preflight accepts strictly increasing retained history with gaps and separately binds each +/// descriptor count to the decoded list staged in its inventory domain. +fn validate_snapshot_inventory_target( + chain_state: &ChainState, + target: SnapshotInventoryTarget, +) -> Result<(), anyhow::Error> { + anyhow::ensure!( + target.chain_state_version() == CURRENT_CHAIN_STATE_VERSION, + "snapshot inventory targets ChainState version {}, expected {}", + target.chain_state_version(), + CURRENT_CHAIN_STATE_VERSION + ); + + let chain_info = chain_state.chain_info.as_ref().ok_or_else(|| { + anyhow::format_err!("snapshot ChainState has no ChainInfo to bind its inventory target") + })?; + anyhow::ensure!( + target.network_magic() == chain_info.consensus_constants.get_magic(), + "snapshot inventory network magic does not match its ChainState" + ); + anyhow::ensure!( + target.environment() == chain_info.environment, + "snapshot inventory environment does not match its ChainState" + ); + anyhow::ensure!( + target.highest_block_checkpoint() == chain_info.highest_block_checkpoint, + "snapshot inventory block tip does not match its ChainState" + ); + anyhow::ensure!( + target.highest_superblock_checkpoint() == chain_info.highest_superblock_checkpoint, + "snapshot inventory superblock tip does not match its ChainState" + ); + + let block_count = u64::try_from(chain_state.block_chain.len()).map_err(|_| { + anyhow::format_err!("snapshot ChainState block count does not fit in u64") + })?; + anyhow::ensure!( + target.block_count() == block_count, + "snapshot inventory block count {} does not match its ChainState block count {}", + target.block_count(), + block_count + ); + + Ok(()) +} + +/// Put a value associated to the key into the storage. +/// The value will be atomically written along with the contents of the batch: either it will all +/// succeed or it will all fail. Non-pristine states go through the companion-aware storage-worker +/// boundary so an imported protocol schedule is preserved during compatibility migrations. +/// Typed `put` and `put_batch` reject `ChainState`; callers that need to persist it must use this +/// validated path so delegation invariants and the versioned layout are applied before enqueueing +/// the batch. +pub(crate) fn put_chain_state_in_batch( + key: &K, + chain_state: &ChainState, + batch: UtxoWriteBatch, +) -> impl Future> + 'static +where + K: serde::Serialize + 'static, +{ + let staged_batch = (|| { + let batch = stage_validated_chain_state_in_batch(key, chain_state, batch, None)?; + // The pristine reset marker intentionally has no ChainInfo. Every real ChainState must + // use the companion-aware commit boundary so a manifest-authenticated schedule survives + // UTXO migration and other compatibility writes that do not carry an activation cert. + let activation_context = if is_pristine_chain_state(chain_state)? { + None + } else { + Some(Wit2ActivationChainContext::from_chain_state(chain_state)?) + }; + Ok::<_, anyhow::Error>((batch, activation_context)) + })(); + let addr = StorageManagerAdapter::from_registry(); + + async move { + let (batch, activation_context) = staged_batch?; + if let Some(activation_context) = activation_context { + commit_chain_state_with_wit2_activation_batch( + batch, + activation_context, + None, + None, + None, + ) + .await + } else { + addr.send(Batch(batch)).await? + } + } +} + +/// Atomically persist a validated ChainState, its UTXO changes, an exact Wit/2.0 activation +/// certificate whenever that state contains a newly discovered trigger, and an optional matching +/// ordinary finalized-superblock inventory record. Synchronization and replay checkpoints use this +/// path too, so a crash cannot retain the trigger or notification while losing its canonical state. +/// The storage worker also preserves an old certificate below its trigger, overwrites it with a new +/// exact certificate, or retires it atomically after an unfinalized alternate branch reaches that +/// epoch without a V2 schedule. Only a separately verified finality acknowledgement may discharge +/// the ChainManager rollback journal. +pub(crate) fn put_chain_state_with_wit2_activation_in_batch( + key: &K, + chain_state: &ChainState, + batch: UtxoWriteBatch, + certificate: Option, + protocol_info: Option, + superblock_notification: Option, +) -> impl Future> + 'static +where + K: serde::Serialize + 'static, +{ + let staged_batch = (|| { + let wit2_activation_context = Wit2ActivationChainContext::from_chain_state(chain_state)?; + if let Some(notification) = superblock_notification.as_ref() { + validate_ordinary_superblock_inventory_binding(chain_state, notification)?; + } + let batch = stage_validated_chain_state_in_batch( + key, + chain_state, + batch, + protocol_info.as_ref(), + )?; + Ok::<_, anyhow::Error>((batch, wit2_activation_context)) + })(); + + async move { + let (batch, wit2_activation_context) = staged_batch?; + let ordinary_superblock = match superblock_notification { + Some(notification) => Some(OrdinarySuperblockInventoryCommit { + expected_view: get_inventory_view().await?, + notification, + }), + None => None, + }; + commit_chain_state_with_wit2_activation_batch( + batch, + wit2_activation_context, + certificate, + protocol_info, + ordinary_superblock, + ) + .await + } +} + +/// Atomically persist a validated, versioned `ChainState`, the caller's batch, and one registered +/// snapshot inventory generation. The descriptor target must match that exact state's chain +/// identity, tips, and indexed block count. At the final storage-worker linearization point, an +/// pending Wit/2 activation certificate supplied by ChainManager must authenticate against the +/// imported canonical index and is resolved in the same batch. Without a pending certificate, the +/// existing companion is re-staged byte-for-byte as before; stale or unverifiable companions reject +/// replacement. The worker then revalidates the token and makes the write durable before returning +/// the newly active pinned view. +pub(crate) fn put_snapshot_chain_state_in_batch_sync( + key: &K, + chain_state: &ChainState, + batch: UtxoWriteBatch, + token: SnapshotInventoryToken, + certificate: Option, + protocol_info: Option, +) -> impl Future> + 'static +where + K: serde::Serialize + 'static, +{ + let staged_batch = validate_snapshot_inventory_target(chain_state, token.target()).and_then( + |()| { + let wit2_activation_context = + Wit2ActivationChainContext::from_chain_state(chain_state)?; + let batch = stage_validated_chain_state_in_batch( + key, + chain_state, + batch, + protocol_info.as_ref(), + )?; + Ok((batch, wit2_activation_context)) + }, + ); + + async move { + let (batch, wit2_activation_context) = staged_batch?; + commit_snapshot_inventory_batch( + token, + batch, + wit2_activation_context, + certificate, + protocol_info, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use witnet_data_structures::{ + chain::{ + ChainInfo, DataRequestOutput, DataRequestState, Environment, Hash, KeyedSignature, + PublicKey, PublicKeyHash, SuperBlock, + }, + transaction::CommitTransaction, + staking::{ + delegation::{ + CollateralDebit, CollateralReservation, CollateralReservationKey, + PendingCommission, + }, + helpers::StakeKey, + }, + wit::Wit, + }; + + use super::*; + + fn snapshot_schedule_fixture(wit21_epoch: Option) -> (ChainInfo, ProtocolInfo) { + let mut chain_info = ChainInfo::default(); + chain_info.highest_block_checkpoint.checkpoint = 15; + chain_info.consensus_constants.checkpoints_period = 15; + let mut protocol_info = ProtocolInfo::default(); + protocol_info + .try_register(1, ProtocolVersion::V1_8, chain_info.consensus_constants.checkpoints_period) + .unwrap(); + protocol_info.try_register(10, ProtocolVersion::V2_0, 15).unwrap(); + if let Some(epoch) = wit21_epoch { + protocol_info.try_register(epoch, ProtocolVersion::V2_1, 15).unwrap(); + } + (chain_info, protocol_info) + } + + #[test] + fn snapshot_schedule_cannot_authorize_a_future_activation() { + let (mut chain_info, local) = snapshot_schedule_fixture(None); + let (_, supplied) = snapshot_schedule_fixture(Some(20)); + + let error = validate_snapshot_protocol_schedule(&local, &supplied, &chain_info) + .unwrap_err(); + assert!(error.to_string().contains("unapproved future activation")); + // The same historical prefix may be portable once the canonical replay includes the + // activation. This policy check is still followed by actual block/state replay in import. + chain_info.highest_block_checkpoint.checkpoint = 20; + assert!(validate_snapshot_protocol_schedule(&local, &supplied, &chain_info).is_ok()); + assert!(protocol_schedule_is_configured_prefix(&local, &supplied)); + + // Clock time is irrelevant: a snapshot ending before the activation cannot prove it, + // even if the importing node's current_version projection already names a later era. + chain_info.highest_block_checkpoint.checkpoint = 19; + let mut projected_local = local; + projected_local.current_version = ProtocolVersion::V2_1; + assert!( + validate_snapshot_protocol_schedule(&projected_local, &supplied, &chain_info).is_err() + ); + } + + #[test] + fn snapshot_schedule_preserves_explicit_future_approvals_for_restart() { + let (chain_info, approved) = snapshot_schedule_fixture(Some(20)); + let (_, changed_epoch) = snapshot_schedule_fixture(Some(30)); + let (_, omitted) = snapshot_schedule_fixture(None); + assert!(validate_snapshot_protocol_schedule(&approved, &approved, &chain_info).is_ok()); + assert!(validate_snapshot_protocol_schedule(&approved, &changed_epoch, &chain_info).is_err()); + assert!(validate_snapshot_protocol_schedule(&approved, &omitted, &chain_info).is_err()); + + // Both schedules satisfy the two-clock topology; changing the periods still changes an + // approved activation decision and would conflict on restart. + let mut changed_period = approved.clone(); + changed_period.all_checkpoints_periods.insert(ProtocolVersion::V2_0, 10); + changed_period.all_checkpoints_periods.insert(ProtocolVersion::V2_1, 10); + assert!( + validate_snapshot_protocol_schedule(&approved, &changed_period, &chain_info).is_err() + ); + + let mut later_projection = approved.clone(); + later_projection.current_version = ProtocolVersion::V2_1; + assert!( + validate_snapshot_protocol_schedule(&approved, &later_projection, &chain_info).is_ok() + ); + assert!(protocol_schedule_matches(&approved, &later_projection)); + } + + #[test] + fn snapshot_schedule_requires_approval_again_after_local_schedule_changes() { + let (mut chain_info, original_local) = snapshot_schedule_fixture(None); + let (_, supplied) = snapshot_schedule_fixture(Some(20)); + chain_info.highest_block_checkpoint.checkpoint = 25; + assert!( + validate_snapshot_protocol_schedule(&original_local, &supplied, &chain_info).is_ok() + ); + let (_, changed_local) = snapshot_schedule_fixture(Some(30)); + // Actor preflight approved historical portability, but the storage-worker recheck must + // reject replacement if the local schedule was changed during asynchronous import work. + assert!( + validate_snapshot_protocol_schedule(&changed_local, &supplied, &chain_info).is_err() + ); + } + + #[test] + fn ordinary_superblock_inventory_is_bound_to_beacon_and_canonical_interval() { + let first_block = Hash::SHA256([0x11; 32]); + let second_block = Hash::SHA256([0x12; 32]); + let superblock = SuperBlock::new( + 1, + Hash::SHA256([0x21; 32]), + Hash::SHA256([0x22; 32]), + 1, + Hash::SHA256([0x23; 32]), + Hash::SHA256([0x24; 32]), + Hash::SHA256([0x25; 32]), + ); + let notification = SuperBlockNotify { + superblock: superblock.clone(), + consolidated_block_hashes: vec![first_block, second_block], + }; + let mut chain_info = ChainInfo::default(); + chain_info.consensus_constants.superblock_period = 2; + chain_info.highest_superblock_checkpoint = witnet_data_structures::chain::CheckpointBeacon { + checkpoint: superblock.index, + hash_prev_block: superblock.hash(), + }; + let mut chain_state = ChainState::default(); + chain_state.chain_info = Some(chain_info); + chain_state.block_chain.insert(0, first_block); + chain_state.block_chain.insert(1, second_block); + + assert!( + validate_ordinary_superblock_inventory_binding(&chain_state, ¬ification).is_ok() + ); + + // Synchronization may already have installed the next candidate in this transient field. + // It is intentionally irrelevant to the durable finalized-beacon binding. + let _ = chain_state + .superblock_state + .set_current_superblock(SuperBlock::new( + 1, + Hash::SHA256([0x31; 32]), + Hash::SHA256([0x32; 32]), + 2, + Hash::SHA256([0x33; 32]), + Hash::SHA256([0x34; 32]), + Hash::SHA256([0x35; 32]), + )); + assert!( + validate_ordinary_superblock_inventory_binding(&chain_state, ¬ification).is_ok() + ); + + let mut wrong_beacon = chain_state.clone(); + wrong_beacon + .chain_info + .as_mut() + .unwrap() + .highest_superblock_checkpoint + .hash_prev_block = Hash::SHA256([0x41; 32]); + assert!( + validate_ordinary_superblock_inventory_binding(&wrong_beacon, ¬ification).is_err() + ); + + let mut wrong_interval = notification.clone(); + wrong_interval.consolidated_block_hashes.swap(0, 1); + assert!( + validate_ordinary_superblock_inventory_binding(&chain_state, &wrong_interval).is_err() + ); + } + + /// The final snapshot commit capability must not be usable to publish a descriptor whose + /// immutable chain identity or advertised coverage describes a different `ChainState`. + #[test] + fn snapshot_inventory_target_matches_committed_chain_state() { + let mut chain_state = ChainState::default(); + let chain_info = ChainInfo::default(); + let target = SnapshotInventoryTarget::new( + chain_info.consensus_constants.get_magic(), + chain_info.environment, + chain_info.highest_block_checkpoint, + chain_info.highest_superblock_checkpoint, + 0, + 0, + 0, + ); + chain_state.chain_info = Some(chain_info); + + assert!(validate_snapshot_inventory_target(&chain_state, target).is_ok()); + + let wrong_magic = SnapshotInventoryTarget::new( + target.network_magic() ^ 1, + target.environment(), + target.highest_block_checkpoint(), + target.highest_superblock_checkpoint(), + target.block_count(), + target.superblock_count(), + target.report_count(), + ); + assert!(validate_snapshot_inventory_target(&chain_state, wrong_magic).is_err()); + + let wrong_environment = match target.environment() { + Environment::Mainnet => Environment::Testnet, + Environment::Testnet => Environment::Development, + Environment::Development => Environment::Mainnet, + }; + let wrong_environment = SnapshotInventoryTarget::new( + target.network_magic(), + wrong_environment, + target.highest_block_checkpoint(), + target.highest_superblock_checkpoint(), + target.block_count(), + target.superblock_count(), + target.report_count(), + ); + assert!(validate_snapshot_inventory_target(&chain_state, wrong_environment).is_err()); + + let mut wrong_block_tip = target.highest_block_checkpoint(); + wrong_block_tip.checkpoint = wrong_block_tip.checkpoint.saturating_add(1); + let wrong_block_tip = SnapshotInventoryTarget::new( + target.network_magic(), + target.environment(), + wrong_block_tip, + target.highest_superblock_checkpoint(), + target.block_count(), + target.superblock_count(), + target.report_count(), + ); + assert!(validate_snapshot_inventory_target(&chain_state, wrong_block_tip).is_err()); + + let mut wrong_superblock_tip = target.highest_superblock_checkpoint(); + wrong_superblock_tip.checkpoint = wrong_superblock_tip.checkpoint.saturating_add(1); + let wrong_superblock_tip = SnapshotInventoryTarget::new( + target.network_magic(), + target.environment(), + target.highest_block_checkpoint(), + wrong_superblock_tip, + target.block_count(), + target.superblock_count(), + target.report_count(), + ); + assert!(validate_snapshot_inventory_target(&chain_state, wrong_superblock_tip).is_err()); + + let wrong_block_count = SnapshotInventoryTarget::new( + target.network_magic(), + target.environment(), + target.highest_block_checkpoint(), + target.highest_superblock_checkpoint(), + 1, + target.superblock_count(), + target.report_count(), + ); + assert!(validate_snapshot_inventory_target(&chain_state, wrong_block_count).is_err()); + + let wrong_report_count = SnapshotInventoryTarget::new( + target.network_magic(), + target.environment(), + target.highest_block_checkpoint(), + target.highest_superblock_checkpoint(), + target.block_count(), + target.superblock_count(), + target.report_count().saturating_add(1), + ); + // The report count is not a ChainState field, but it is part of the immutable inventory + // descriptor and is checked by the storage worker against the staged report namespace. + assert_ne!(target.report_count(), wrong_report_count.report_count()); + + let mut wrong_version = target; + wrong_version.chain_state_version = CURRENT_CHAIN_STATE_VERSION - 1; + assert!(validate_snapshot_inventory_target(&chain_state, wrong_version).is_err()); + + chain_state.chain_info = None; + assert!(validate_snapshot_inventory_target(&chain_state, target).is_err()); + } + + #[test] fn bincode_version() { #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] struct TestString0 { @@ -263,113 +2917,1675 @@ mod tests { extra: String, } - let t0 = TestString0 { - data: "data".to_string(), + let t0 = TestString0 { + data: "data".to_string(), + }; + + let t0_bytes = bincode::serialize(&t0).unwrap(); + + let t1 = TestString1 { + version: 1, + data: "data".to_string(), + extra: "extra".to_string(), + }; + + let t1_bytes = bincode::serialize(&t1).unwrap(); + + let version: u32 = 1; + let version_bytes = bincode::serialize(&version).unwrap(); + let field = "extra".to_string(); + let field_bytes = bincode::serialize(&field).unwrap(); + let t0_bytes_migrated = [&version_bytes[..], &t0_bytes[..], &field_bytes[..]].concat(); + assert_eq!(t0_bytes_migrated, t1_bytes); + + let migrated_t1: TestString1 = bincode::deserialize(&t0_bytes_migrated).unwrap(); + assert_eq!(migrated_t1, t1); + } + + #[derive(Debug, Deserialize, Serialize, PartialEq)] + struct OldChainState { + /// Blockchain information data structure + pub chain_info: Option, + } + + #[derive(Debug, Deserialize, Serialize, PartialEq)] + struct NewChainState { + /// Blockchain information data structure + pub chain_info: Option, + /// TAPI + pub tapi: TapiEngine, + } + + #[test] + fn bincode_chainstate_option() { + // The first field of the old ChainState is an Option<_>, so the first byte of the + // serialization will be either 0 or 1. + + let t0 = OldChainState { chain_info: None }; + let t0_bytes = bincode::serialize(&t0).unwrap(); + // An option set to None is serialized as one byte: 0 + assert_eq!(t0_bytes, vec![0]); + // This is detected as version 0 + assert_eq!(check_chain_state_version(&t0_bytes), Ok(0)); + + let default_chain_info = ChainInfo::default(); + + let t1 = OldChainState { + chain_info: Some(default_chain_info.clone()), + }; + let t1_bytes = bincode::serialize(&t1).unwrap(); + // An option set to Some is serialized as one byte: 1, followed by the serialization of + // the field + assert_eq!(t1_bytes[0], 1); + + let chain_info_bytes = bincode::serialize(&default_chain_info).unwrap(); + assert_eq!(t1_bytes[1..], chain_info_bytes); + + // This is also detected as version 0 + assert_eq!(check_chain_state_version(&t1_bytes), Ok(0)); + } + + #[test] + fn bincode_chainstate_migration() { + let t0 = OldChainState { chain_info: None }; + let t0_bytes = bincode::serialize(&t0).unwrap(); + + let t0_migrated_bytes = migrate_chain_state_v0_to_v2(&t0_bytes).unwrap(); + let t0_migrated: NewChainState = bincode::deserialize(&t0_migrated_bytes[4..]).unwrap(); + assert_eq!( + t0_migrated, + NewChainState { + chain_info: None, + tapi: TapiEngine::default(), + } + ); + + let default_chain_info = ChainInfo::default(); + let t1 = OldChainState { + chain_info: Some(default_chain_info.clone()), + }; + let t1_bytes = bincode::serialize(&t1).unwrap(); + let t1_migrated_bytes = migrate_chain_state_v0_to_v2(&t1_bytes).unwrap(); + let t1_migrated: NewChainState = bincode::deserialize(&t1_migrated_bytes[4..]).unwrap(); + assert_eq!( + t1_migrated, + NewChainState { + chain_info: Some(default_chain_info), + tapi: TapiEngine::default(), + } + ); + } + + #[test] + fn bincode_chainstate_migration_multiple_steps() { + // This test ensures that there are no accidental infinite loops in migrate_chain_state + + // An empty ChainState v0 is 241 bytes + let chain_state_v0_bytes = vec![0; 241]; + let migrated_chain_state = migrate_chain_state(chain_state_v0_bytes); + migrated_chain_state.unwrap(); + } + + /// A corrupt version tag must produce an ordinary migration error, not reach an unchecked + /// field splice and panic while storage is being opened. + #[test] + fn bincode_chainstate_short_versioned_payloads_are_rejected() { + assert!(migrate_chain_state(3_u32.to_le_bytes().to_vec()).is_err()); + assert!(migrate_chain_state(4_u32.to_le_bytes().to_vec()).is_err()); + assert!(migrate_chain_state(5_u32.to_le_bytes().to_vec()).is_err()); + assert!( + migrate_chain_state(DELEGATION_CHAIN_STATE_VERSION.to_le_bytes().to_vec()).is_err() + ); + + let mut truncated_v4 = 4_u32.to_le_bytes().to_vec(); + truncated_v4.push(0); + assert!(migrate_chain_state(truncated_v4).is_err()); + } + + /// Source-only compatibility fixture for the v5-to-v6 append, v6-to-v7 retag, and v8/v9 conversion. + /// + /// Serializing the frozen v5 type independently locks its complete field order. The v6 + /// migration appends exactly one default `DelegationState`; v7 changes only the version tag. + #[test] + fn bincode_chainstate_v5_to_v9_is_exact_and_preserves_legacy_payload() { + let mut expected = ChainState::default(); + expected.chain_info = Some(ChainInfo::default()); + expected.reputation_engine = Some(ReputationEngine::new(1000)); + expected.node_stats.block_mined_count = 0x0102_0304; + expected.node_stats.commits_count = 0x0506_0708; + + let v5_payload = bincode::serialize(&ChainStateV5::from(&expected)).unwrap(); + let current_payload = bincode::serialize(&expected).unwrap(); + let frozen = ChainStateV7::from(&expected); + let frozen_v8 = ChainStateV8::from(&expected); + let v7_payload = bincode::serialize(&frozen).unwrap(); + let delegation_state_bytes = bincode::serialize(&frozen.delegation_state).unwrap(); + let mut expected_default_delegation_bytes = vec![15_u8, 70_u8, 0_u8]; + expected_default_delegation_bytes.extend_from_slice(&0_u64.to_le_bytes()); + expected_default_delegation_bytes.extend_from_slice(&0_u64.to_le_bytes()); + assert_eq!(delegation_state_bytes, expected_default_delegation_bytes); + assert_eq!( + v7_payload, + [&v5_payload[..], &delegation_state_bytes[..]].concat() + ); + + let version_v5 = 5_u32.to_le_bytes(); + let versioned_v5 = [&version_v5[..], &v5_payload[..]].concat(); + let migrated_v6 = migrate_chain_state_v5_to_v6(&versioned_v5).unwrap(); + let version_v6 = DELEGATION_CHAIN_STATE_VERSION.to_le_bytes(); + let expected_versioned_v6 = [&version_v6[..], &v7_payload[..]].concat(); + + assert_eq!(migrated_v6, expected_versioned_v6); + assert_eq!( + deserialize_chain_state_v7_snapshot(&migrated_v6[4..]).unwrap(), + expected + ); + let portable_v6 = + bincode::serialize(&(DELEGATION_CHAIN_STATE_VERSION, &frozen)).unwrap(); + assert_eq!(portable_v6, expected_versioned_v6); + + let mut trailing_v5 = versioned_v5.clone(); + trailing_v5.push(0); + assert!(migrate_chain_state_v5_to_v6(&trailing_v5).is_err()); + let truncated_v5 = versioned_v5[..versioned_v5.len() - 1].to_vec(); + assert!(migrate_chain_state_v5_to_v6(&truncated_v5).is_err()); + + let mut migrated_v7 = migrated_v6.clone(); + migrate_chain_state_v6_to_v7(&mut migrated_v7).unwrap(); + let version_v7 = PRE_EXIT_CHAIN_STATE_VERSION.to_le_bytes(); + let expected_versioned_v7 = [&version_v7[..], &v7_payload[..]].concat(); + assert_eq!(migrated_v7, expected_versioned_v7); + assert_eq!(&migrated_v7[4..], &migrated_v6[4..]); + assert_eq!(migrate_chain_state(versioned_v5).unwrap(), expected); + + let portable_v7 = + bincode::serialize(&(PRE_EXIT_CHAIN_STATE_VERSION, &frozen)).unwrap(); + assert_eq!(portable_v7, expected_versioned_v7); + let migrated_v8 = migrate_chain_state_v7_to_v8(&migrated_v7).unwrap(); + assert_eq!(&migrated_v8[..4], &PRE_RECOVERY_CHAIN_STATE_VERSION.to_le_bytes()); + assert_eq!( + &migrated_v8[4..], + &bincode::serialize(&frozen_v8).unwrap() + ); + let migrated_v9 = migrate_chain_state_v8_to_v9(&migrated_v8).unwrap(); + assert_eq!(&migrated_v9[..4], &CURRENT_CHAIN_STATE_VERSION.to_le_bytes()); + assert_eq!(&migrated_v9[4..], ¤t_payload); + assert_eq!(migrate_chain_state(migrated_v9).unwrap(), expected); + } + + #[test] + fn bincode_historical_release_v5_payload_migrates_exactly() { + // This payload was produced by the archived pre-delegated-staking release's actual + // ChainState type; it is intentionally distinct from the locally defined frozen types + // used by the structural migration fixture above. + let historical_payload = hex::decode( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/historical/chainstate_v5_complete_from_release_33c42f0e.hex" + )) + .trim(), + ) + .expect("decode historical ChainState fixture"); + assert_eq!(historical_payload.len(), 655); + + let versioned = [ + 5_u32.to_le_bytes().as_slice(), + historical_payload.as_slice(), + ] + .concat(); + let migrated = migrate_chain_state(versioned).expect("migrate historical v5 payload"); + + assert_eq!(migrated.node_stats.block_proposed_count, 0x1122_3344); + assert_eq!(migrated.node_stats.block_mined_count, 0x5566_7788); + assert_eq!(migrated.node_stats.commits_count, 0x99aa_bbcc); + assert_eq!( + bincode::serialize(&ChainStateV5::from(&migrated)).unwrap(), + historical_payload + ); + } + + #[test] + fn historical_release_v5_payload_migrates_through_rocksdb_and_survives_reopen() { + // Seed a real RocksDB with the historical database representation, load it through the + // production StorageManager path, persist the migrated v9 state through the normal + // validated write boundary, and then prove that a second storage-manager startup reads + // the durable current representation. This intentionally complements the exact byte-level + // migration test above: a frozen decoder passing alone would not exercise backend wiring, + // actor configuration, or restart persistence. + let historical_payload = hex::decode( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/historical/chainstate_v5_complete_from_release_33c42f0e.hex" + )) + .trim(), + ) + .expect("decode historical ChainState fixture"); + let historical_chain_state = deserialize_chain_state_v5_snapshot(historical_payload) + .expect("historical ChainState fixture must decode"); + let magic = historical_chain_state + .chain_info + .as_ref() + .expect("historical fixture must carry ChainInfo") + .consensus_constants + .get_magic(); + let key = storage_keys::chain_state_key(magic); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let db_path = std::env::temp_dir().join(format!( + "witnet-historical-chainstate-rocksdb-{}-{suffix}", + std::process::id() + )); + let mut config = witnet_config::config::Config::default(); + config.storage.backend = witnet_config::config::StorageBackend::RocksDB; + config.storage.db_path = db_path.clone(); + config.storage.utxos_in_memory = false; + + let mut historical_bytes = 5_u32.to_le_bytes().to_vec(); + historical_bytes.extend_from_slice( + &bincode::serialize(&ChainStateV5::from(&historical_chain_state)) + .expect("historical ChainState must re-encode as v5"), + ); + let raw_key = bincode::serialize(&key).expect("ChainState key must encode"); + + { + let backend = create_appropriate_backend(&config.storage) + .expect("real RocksDB backend must open for the historical fixture"); + backend + .clone() + .as_arc_dyn_storage() + .put(raw_key.clone(), historical_bytes) + .expect("historical ChainState must be seedable in RocksDB"); + } + + let first_config = config.clone(); + let first_key = key.clone(); + crate::utils::test_actix_system(|| async move { + let addr = StorageManagerAdapter::from_config(first_config).start(); + actix::SystemRegistry::set(addr); + + let migrated = get_chain_state(first_key.clone()) + .await + .expect("current storage path must migrate historical ChainState") + .expect("seeded historical ChainState must be present"); + assert_eq!(migrated.node_stats.block_proposed_count, 0x1122_3344); + assert_eq!(migrated.node_stats.block_mined_count, 0x5566_7788); + assert_eq!(migrated.node_stats.commits_count, 0x99aa_bbcc); + + put_chain_state_in_batch(&first_key, &migrated, UtxoWriteBatch::default()) + .await + .expect("migrated ChainState must persist through the validated write boundary"); + + let addr = StorageManagerAdapter::from_registry(); + addr.send(StopStorageManager) + .await + .expect("storage manager adapter must stop for the restart check"); + }); + + // StorageManager::drop contains a short RocksDB shutdown grace period to avoid the + // backend's known pure-virtual-method race while the SyncArbiter releases its handle. + std::thread::sleep(std::time::Duration::from_millis(700)); + + { + let reopened = create_appropriate_backend(&config.storage) + .expect("RocksDB must reopen after the storage manager shuts down"); + let current_bytes = reopened + .clone() + .as_arc_dyn_storage() + .get(&raw_key) + .expect("reopened RocksDB read must succeed") + .expect("migrated ChainState must remain durable"); + assert_eq!( + ¤t_bytes[..4], + &CURRENT_CHAIN_STATE_VERSION.to_le_bytes(), + "validated persistence must write the current ChainState version" + ); + let current = migrate_chain_state(current_bytes) + .expect("durable current ChainState must pass the migration validator"); + assert_eq!(current.node_stats.block_proposed_count, 0x1122_3344); + assert_eq!(current.node_stats.block_mined_count, 0x5566_7788); + assert_eq!(current.node_stats.commits_count, 0x99aa_bbcc); + } + + let second_config = config.clone(); + let second_key = key.clone(); + crate::utils::test_actix_system(|| async move { + let addr = StorageManagerAdapter::from_config(second_config).start(); + actix::SystemRegistry::set(addr); + + let reopened = get_chain_state(second_key) + .await + .expect("second storage-manager startup must read the reopened database") + .expect("current ChainState must survive the second startup"); + assert_eq!(reopened.node_stats.block_proposed_count, 0x1122_3344); + assert_eq!(reopened.node_stats.block_mined_count, 0x5566_7788); + assert_eq!(reopened.node_stats.commits_count, 0x99aa_bbcc); + }); + + let _ = std::fs::remove_dir_all(db_path); + } + + /// Portable v5 snapshots have no database-version prefix. They must remain importable, while + /// current, trailing, or partially appended payloads must never be accepted by the frozen v5 + /// decoder and downgraded to the default delegation state. + #[test] + fn bincode_chainstate_v5_snapshot_fallback_is_exact() { + let mut expected = ChainState::default(); + expected.node_stats.block_proposed_count = 0x1122_3344; + + let v5_payload = bincode::serialize(&ChainStateV5::from(&expected)).unwrap(); + let current_payload = bincode::serialize(&expected).unwrap(); + let delegation_state_bytes = bincode::serialize(&expected.delegation_state).unwrap(); + assert_eq!( + current_payload, + [&v5_payload[..], &delegation_state_bytes[..]].concat() + ); + + assert_eq!( + deserialize_chain_state_v5_snapshot(v5_payload.clone()).unwrap(), + expected + ); + assert!(deserialize_chain_state_v5_snapshot(current_payload.clone()).is_err()); + + let mut trailing_v5 = v5_payload; + trailing_v5.push(0); + assert!(deserialize_chain_state_v5_snapshot(trailing_v5).is_err()); + + let truncated_current = current_payload[..current_payload.len() - 1].to_vec(); + assert!(deserialize_chain_state_v5_snapshot(truncated_current).is_err()); + } + + /// The portable v9 envelope must preserve non-default consensus-critical delegation state, + /// including its one-time activation marker and validator commission policy. + #[test] + fn bincode_chainstate_v9_snapshot_preserves_delegation_state() { + let mut expected = ChainState::default(); + let mut chain_info = ChainInfo::default(); + let genesis_hash = chain_info.consensus_constants.genesis_hash; + let tip_hash = Hash::with_first_u32(10); + chain_info.highest_block_checkpoint.checkpoint = 10; + chain_info.highest_block_checkpoint.hash_prev_block = tip_hash; + expected.chain_info = Some(chain_info); + expected.reputation_engine = Some(ReputationEngine::new(1000)); + expected.block_chain.insert(0, genesis_hash); + expected.block_chain.insert(10, tip_hash); + let data_request_pointer = Hash::default(); + let (validator, commit) = structurally_bound_commit(data_request_pointer); + let owner_key = StakeKey { + validator, + withdrawer: validator, }; + let minimum_stake = Wit::from_nanowits(10_000_000_000_000); + expected + .stakes + .add_stake(owner_key.clone(), minimum_stake, 0, true, minimum_stake) + .unwrap(); + expected + .delegation_state + .register_validator_if_absent(validator, validator, 15) + .unwrap(); + let validator_policy = expected + .delegation_state + .validators + .get_mut(&validator) + .unwrap(); + validator_policy.commission_nonce = 4; + validator_policy.pending_commission = Some(PendingCommission { + commission_percent: 3, + effective_epoch: 77, + nonce: 5, + }); + let reservation_key = CollateralReservationKey { + data_request: data_request_pointer, + validator, + }; + let mut data_request = DataRequestOutput::default(); + data_request.collateral = 123_456; + let mut data_request_state = DataRequestState::new(data_request, validator, 9, None); + data_request_state + .info + .commits + .insert(validator, commit); + expected + .data_request_pool + .data_request_pool + .insert(reservation_key.data_request, data_request_state); + expected.delegation_state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + total: Wit::from_nanowits(123_456), + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: owner_key, + amount: Wit::from_nanowits(123_456), + }], + }, + ); + expected.delegation_state.activation_initialized = true; - let t0_bytes = bincode::serialize(&t0).unwrap(); + let encoded = bincode::serialize(&(CURRENT_CHAIN_STATE_VERSION, &expected)).unwrap(); + assert_eq!( + &encoded[..4], + &CURRENT_CHAIN_STATE_VERSION.to_le_bytes() + ); + let (version, decoded): (u32, ChainState) = bincode::deserialize(&encoded).unwrap(); - let t1 = TestString1 { - version: 1, - data: "data".to_string(), - extra: "extra".to_string(), + assert_eq!(version, CURRENT_CHAIN_STATE_VERSION); + assert_eq!(decoded, expected); + assert!(decoded.delegation_state.activation_initialized); + assert!( + validate_delegated_staking_persistence_with( + &decoded, + protocol_across_delegation_boundary, + ) + .is_ok() + ); + assert_eq!(decoded.delegation_state.collateral_reservations.len(), 1); + let mut native = decoded.clone(); + native.data_request_pool.data_request_pool.get_mut(&data_request_pointer).unwrap().epoch = 10; + assert!(validate_delegated_staking_persistence_with( + &native, protocol_across_delegation_boundary, + ).is_err()); + native.delegation_state.collateral_reservations.get_mut(&reservation_key).unwrap() + .reward_weights = vec![( + StakeKey { validator, withdrawer: validator }, + Wit::from_nanowits(minimum_stake.nanowits() + 123_456), + )]; + assert!(validate_delegated_staking_persistence_with( + &native, protocol_across_delegation_boundary, + ).is_ok()); + native.data_request_pool.data_request_pool.get_mut(&data_request_pointer).unwrap().epoch = 9; + assert!(validate_delegated_staking_persistence_with( + &native, protocol_across_delegation_boundary, + ).is_err()); + assert_eq!( + decoded + .delegation_state + .validator_policy(validator) + .unwrap() + .pending_commission + .unwrap(), + PendingCommission { + commission_percent: 3, + effective_epoch: 77, + nonce: 5, + } + ); + } + + /// Database version dispatch must not let a valid current state hide trailing corruption, and + /// it must reject a future version rather than interpreting its payload as the current layout. + #[test] + fn bincode_chainstate_v9_database_decode_and_v6_retag_are_exact() { + let payload = bincode::serialize(&ChainState::default()).unwrap(); + let frozen_payload = bincode::serialize(&ChainStateV7::from(&ChainState::default())).unwrap(); + let frozen_v8_payload = bincode::serialize(&ChainStateV8::from(&ChainState::default())).unwrap(); + + let mut versioned_v6 = DELEGATION_CHAIN_STATE_VERSION.to_le_bytes().to_vec(); + versioned_v6.extend_from_slice(&frozen_payload); + let v6_payload = versioned_v6[4..].to_vec(); + migrate_chain_state_v6_to_v7(&mut versioned_v6).unwrap(); + assert_eq!( + &versioned_v6[..4], + &PRE_EXIT_CHAIN_STATE_VERSION.to_le_bytes() + ); + assert_eq!(&versioned_v6[4..], &v6_payload); + assert!(migrate_chain_state(versioned_v6).is_ok()); + + let mut versioned_v8 = PRE_RECOVERY_CHAIN_STATE_VERSION.to_le_bytes().to_vec(); + versioned_v8.extend_from_slice(&frozen_v8_payload); + let decoded_v8 = deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(versioned_v8.clone()), + ) + .unwrap(); + assert_eq!(decoded_v8.0, PRE_RECOVERY_CHAIN_STATE_VERSION); + assert_eq!(decoded_v8.1, ChainState::default()); + let migrated_v9 = migrate_chain_state_v8_to_v9(&versioned_v8).unwrap(); + assert_eq!(&migrated_v9[..4], &CURRENT_CHAIN_STATE_VERSION.to_le_bytes()); + assert_eq!(migrate_chain_state(migrated_v9).unwrap(), ChainState::default()); + + let mut corrupt_v6 = DELEGATION_CHAIN_STATE_VERSION.to_le_bytes().to_vec(); + corrupt_v6.extend_from_slice(&frozen_payload); + corrupt_v6.push(0); + assert!(migrate_chain_state_v6_to_v7(&mut corrupt_v6).is_err()); + + let mut current = CURRENT_CHAIN_STATE_VERSION.to_le_bytes().to_vec(); + current.extend_from_slice(&payload); + assert!(migrate_chain_state(current.clone()).is_ok()); + + current.push(0); + assert!(migrate_chain_state(current).is_err()); + + let mut future = (CURRENT_CHAIN_STATE_VERSION + 1).to_le_bytes().to_vec(); + future.extend_from_slice(&payload); + assert!(migrate_chain_state(future).is_err()); + } + + #[test] + fn v7_migration_preserves_legacy_sidecars_and_refuses_activated_entitlement_guessing() { + let key = StakeKey { + validator: PublicKeyHash::default(), + withdrawer: PublicKeyHash::default(), + }; + let reservation_key = CollateralReservationKey { + data_request: Hash::with_first_u32(7), + validator: key.validator, }; + let mut legacy = ChainStateV7::from(&ChainState::default()); + legacy.delegation_state.collateral_reservations.insert(reservation_key, CollateralReservationV7 { + total: Wit::from_nanowits(123), + debits: vec![CollateralDebit { key: key.clone(), amount: Wit::from_nanowits(123) }], + }); + let raw = bincode::serialize(&legacy).unwrap(); + let migrated = deserialize_chain_state_v7_snapshot(&raw).unwrap(); + assert!(migrated.delegation_state.exit_intents.is_empty()); + let reservation = &migrated.delegation_state.collateral_reservations[&reservation_key]; + assert_eq!(reservation.total, Wit::from_nanowits(123)); + assert_eq!(reservation.debits[0].key, key); + assert!(reservation.reward_weights.is_empty()); + assert!(deserialize_chain_state_v5_snapshot(raw.clone()).is_err()); + let mut trailing = raw.clone(); + trailing.push(0); + assert!(deserialize_chain_state_v7_snapshot(&trailing).is_err()); + assert!(deserialize_chain_state_v7_snapshot(&raw[..raw.len() - 1]).is_err()); - let t1_bytes = bincode::serialize(&t1).unwrap(); + legacy.delegation_state.activation_initialized = true; + let activated_raw = bincode::serialize(&legacy).unwrap(); + let error = deserialize_legacy_chain_state_snapshot(&activated_raw).unwrap_err(); + assert!(error.to_string().contains("replay from before activation")); + for version in [DELEGATION_CHAIN_STATE_VERSION, PRE_EXIT_CHAIN_STATE_VERSION] { + let versioned = bincode::serialize(&(version, &legacy)).unwrap(); + let error = deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(versioned.clone()), + ).unwrap_err(); + assert!(error.to_string().contains("reward entitlements")); + assert!(migrate_chain_state_unvalidated(versioned).is_err()); + } + } - let version: u32 = 1; - let version_bytes = bincode::serialize(&version).unwrap(); - let field = "extra".to_string(); - let field_bytes = bincode::serialize(&field).unwrap(); - let t0_bytes_migrated = [&version_bytes[..], &t0_bytes[..], &field_bytes[..]].concat(); - assert_eq!(t0_bytes_migrated, t1_bytes); + #[test] + fn v9_snapshot_exactly_preserves_exit_intent_and_committed_reward_weights() { + let mut current = ChainState::default(); + let key = StakeKey { + validator: PublicKeyHash::default(), + withdrawer: PublicKeyHash::default(), + }; + current.delegation_state.activation_initialized = true; + current.delegation_state.exit_intents.insert(key.clone()); + current.delegation_state.collateral_reservations.insert( + CollateralReservationKey { data_request: Hash::with_first_u32(8), validator: key.validator }, + CollateralReservation { + total: Wit::from_nanowits(123), + debits: vec![CollateralDebit { key: key.clone(), amount: Wit::from_nanowits(123) }], + reward_weights: vec![(key.clone(), Wit::from_nanowits(10_000_000_000_123))], + }, + ); + current + .delegation_state + .legacy_activation_obligations + .insert( + CollateralReservationKey { + data_request: Hash::with_first_u32(9), + validator: key.validator, + }, + witnet_data_structures::staking::delegation::LegacyActivationObligation { + raw_collateral: Wit::from_nanowits(456), + witness_reward: Wit::from_nanowits(789), + canonical_live_owner: None, + settlement: LegacyActivationSettlement::Deferred { + collateral: Wit::from_nanowits(456), + witness_reward: Wit::from_nanowits(789), + }, + }, + ); + // This is an encoding fixture; the production importer separately validates full chain + // identity and accounting and the closed release gate refuses production activation. + let encoded = bincode::serialize(&(CURRENT_CHAIN_STATE_VERSION, ¤t)).unwrap(); + let (version, decoded) = deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(&encoded), + ).unwrap(); + assert_eq!(version, CURRENT_CHAIN_STATE_VERSION); + assert_eq!(decoded, current); + assert!(deserialize_chain_state_v7_snapshot(&encoded[4..]).is_err()); + let mut trailing = encoded.clone(); + trailing.push(0); + assert!(deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(trailing), + ).is_err()); + assert!(deserialize_versioned_chain_state_snapshot_reader( + &mut std::io::Cursor::new(&encoded[..encoded.len() - 1]), + ).is_err()); + } - let migrated_t1: TestString1 = bincode::deserialize(&t0_bytes_migrated).unwrap(); - assert_eq!(migrated_t1, t1); + fn chain_state_at_tip(tip: Option, activation_initialized: bool) -> ChainState { + let mut chain_state = ChainState::default(); + chain_state.delegation_state.activation_initialized = activation_initialized; + if let Some(epoch) = tip { + let mut chain_info = ChainInfo::default(); + let genesis_hash = chain_info.consensus_constants.genesis_hash; + let tip_hash = if epoch == 0 { + genesis_hash + } else { + Hash::with_first_u32(epoch) + }; + chain_info.highest_block_checkpoint.checkpoint = epoch; + chain_info.highest_block_checkpoint.hash_prev_block = tip_hash; + chain_state.chain_info = Some(chain_info); + chain_state.reputation_engine = Some(ReputationEngine::new(1000)); + chain_state.block_chain.insert(0, genesis_hash); + if epoch != 0 { + chain_state.block_chain.insert(epoch, tip_hash); + } + } + chain_state } - #[derive(Debug, Deserialize, Serialize, PartialEq)] - struct OldChainState { - /// Blockchain information data structure - pub chain_info: Option, + fn structurally_bound_commit(data_request: Hash) -> (PublicKeyHash, CommitTransaction) { + let public_key = PublicKey::default(); + let validator = public_key.pkh(); + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = data_request; + commit.signatures.push(KeyedSignature { + public_key, + ..KeyedSignature::default() + }); + assert_eq!(commit.body.proof.proof.pkh(), validator); + + (validator, commit) } - #[derive(Debug, Deserialize, Serialize, PartialEq)] - struct NewChainState { - /// Blockchain information data structure - pub chain_info: Option, - /// TAPI - pub tapi: TapiEngine, + #[test] + fn persisted_wit2_commit_identity_is_exact() { + let data_request = Hash::with_first_u32(77); + let (validator, commit) = structurally_bound_commit(data_request); + assert!( + validate_persisted_wit2_commit_identity(data_request, validator, &commit).is_ok() + ); + + let mut wrong_pointer = commit.clone(); + wrong_pointer.body.dr_pointer = Hash::with_first_u32(78); + assert!( + validate_persisted_wit2_commit_identity(data_request, validator, &wrong_pointer) + .is_err() + ); + + let different_public_key = PublicKey { + compressed: 2, + bytes: [1_u8; 32], + }; + let different_validator = different_public_key.pkh(); + assert_ne!(different_validator, validator); + let mut different_signer = commit.clone(); + different_signer.signatures[0].public_key = different_public_key; + // With the original map key, the proof matches but the sole signer does not. + assert!( + validate_persisted_wit2_commit_identity(data_request, validator, &different_signer) + .is_err() + ); + // With the signer's map key, the signer matches but the VRF proof does not. + assert!( + validate_persisted_wit2_commit_identity( + data_request, + different_validator, + &different_signer, + ) + .is_err() + ); + + let mut missing_signature = commit.clone(); + missing_signature.signatures.clear(); + assert!( + validate_persisted_wit2_commit_identity( + data_request, + validator, + &missing_signature, + ) + .is_err() + ); + let mut extra_signature = commit; + extra_signature.signatures.push(KeyedSignature::default()); + assert!( + validate_persisted_wit2_commit_identity( + data_request, + validator, + &extra_signature, + ) + .is_err() + ); + + // Raw-zero Wit/2.0 collateral has no reservation sidecar, but its active commit still + // participates in activation and later settlement and must cross the same identity gate. + let mut zero_collateral_state = chain_state_at_tip(Some(9), false); + let mut request = DataRequestOutput::default(); + request.collateral = 0; + let mut request_state = DataRequestState::new(request, validator, 9, None); + request_state.info.commits.insert(validator, structurally_bound_commit(data_request).1); + zero_collateral_state + .data_request_pool + .data_request_pool + .insert(data_request, request_state); + assert!( + validate_delegated_staking_persistence_with( + &zero_collateral_state, + protocol_across_delegation_boundary, + ) + .is_ok() + ); + zero_collateral_state + .data_request_pool + .data_request_pool + .get_mut(&data_request) + .unwrap() + .info + .commits + .get_mut(&validator) + .unwrap() + .body + .dr_pointer = Hash::with_first_u32(79); + assert!( + validate_delegated_staking_persistence_with( + &zero_collateral_state, + protocol_across_delegation_boundary, + ) + .is_err() + ); } #[test] - fn bincode_chainstate_option() { - // The first field of the old ChainState is an Option<_>, so the first byte of the - // serialization will be either 0 or 1. + fn postactivation_zero_collateral_legacy_commitment_requires_live_immutable_owner() { + let pointer = Hash::with_first_u32(0x7a); + let (validator, commit) = structurally_bound_commit(pointer); + let owner = PublicKeyHash::from_bytes(&[0x7b; 20]).unwrap(); + let mut request = DataRequestOutput::default(); + request.collateral = 0; + let mut request_state = DataRequestState::new(request, validator, 9, None); + request_state.info.commits.insert(validator, commit); + let mut legacy = chain_state_at_tip(Some(9), false); + legacy.data_request_pool.data_request_pool.insert(pointer, request_state); + assert!(validate_delegated_staking_persistence_with( + &legacy, protocol_across_delegation_boundary, + ).is_ok()); - let t0 = OldChainState { chain_info: None }; - let t0_bytes = bincode::serialize(&t0).unwrap(); - // An option set to None is serialized as one byte: 0 - assert_eq!(t0_bytes, vec![0]); - // This is detected as version 0 - assert_eq!(check_chain_state_version(&t0_bytes), Ok(0)); + let mut activated = chain_state_at_tip(Some(10), true); + activated.data_request_pool = legacy.data_request_pool.clone(); + activated.delegation_state.register_validator_if_absent(validator, owner, 0).unwrap(); + assert!(activated.delegation_state.collateral_reservations.is_empty()); + assert!(validate_delegated_staking_persistence_with( + &activated, protocol_across_delegation_boundary, + ).is_err()); + let owner_key = StakeKey { validator, withdrawer: owner }; + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + activated.stakes.add_stake(owner_key.clone(), minimum, 7, true, minimum).unwrap(); + activated.delegation_state.request_exit(&activated.stakes, owner_key, 7).unwrap(); + assert!(validate_delegated_staking_persistence_with( + &activated, protocol_across_delegation_boundary, + ).is_ok()); + // Draining is valid while settlement waits; removing that exact owner is not. Once the + // request has settled, an owner policy without a live entry is a valid dormant validator. + activated.stakes = StakesTracker::default(); + activated.delegation_state.exit_intents.clear(); + assert!(validate_delegated_staking_persistence_with( + &activated, protocol_across_delegation_boundary, + ).is_err()); + activated.data_request_pool.data_request_pool.clear(); + assert!(validate_delegated_staking_persistence_with( + &activated, protocol_across_delegation_boundary, + ).is_ok()); + } - let default_chain_info = ChainInfo::default(); + fn activation_certificate_for( + chain_info: &ChainInfo, + trigger_epoch: Epoch, + trigger_hash: Hash, + ) -> Wit2ActivationCertificate { + let wit2_constants = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: chain_info.consensus_constants.checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, + }; + Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + trigger_epoch + .checked_add(wit2_constants.get_wit2_activation_delay_epochs()) + .unwrap(), + wit2_constants.get_checkpoints_period_wit2(), + ) + } - let t1 = OldChainState { - chain_info: Some(default_chain_info.clone()), + fn protocol_across_delegation_boundary(epoch: Epoch) -> ProtocolVersion { + if epoch >= 10 { + ProtocolVersion::V2_1 + } else { + ProtocolVersion::V2_0 + } + } + + #[test] + fn wit2_activation_finality_uses_superblock_index_boundaries() { + // Superblock #1 with period 10 finalizes epochs 0..=9. A trigger at epoch 10 is first + // finalized by superblock #2, whose exclusive upper boundary is epoch 20. + let epoch_10 = Wit2ActivationCertificate::new(10, Hash::default(), 0, 1); + let epoch_0 = Wit2ActivationCertificate::new(0, Hash::default(), 0, 1); + assert!(finalized_wit2_activation_trigger(&epoch_0, 1, 10) + .unwrap() + .is_some()); + assert!(finalized_wit2_activation_trigger(&epoch_10, 1, 10) + .unwrap() + .is_none()); + assert!(finalized_wit2_activation_trigger(&epoch_10, 2, 10) + .unwrap() + .is_some()); + assert!(finalized_wit2_activation_trigger(&epoch_0, 0, 10) + .unwrap() + .is_none()); + assert!( + finalized_wit2_activation_trigger(&epoch_0, u32::MAX, u16::MAX).is_err() + ); + assert!(finalized_wit2_activation_trigger(&epoch_0, 1, 0).is_err()); + } + + #[test] + fn alternate_unfinalized_trigger_retires_stale_activation_certificate() { + let trigger_epoch = 10; + let mut certified_state = chain_state_at_tip(Some(trigger_epoch), false); + certified_state + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = 10; + let chain_info = certified_state.chain_info.as_ref().unwrap(); + let wit2_constants = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: chain_info.consensus_constants.checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, }; - let t1_bytes = bincode::serialize(&t1).unwrap(); - // An option set to Some is serialized as one byte: 1, followed by the serialization of - // the field - assert_eq!(t1_bytes[0], 1); + let certificate = Wit2ActivationCertificate::new( + trigger_epoch, + certified_state.block_chain[&trigger_epoch], + trigger_epoch + .checked_add(wit2_constants.get_wit2_activation_delay_epochs()) + .unwrap(), + wit2_constants.get_checkpoints_period_wit2(), + ); + let registered = Some((certificate.activation_epoch, certificate.checkpoint_period)); + let certified_context = + Wit2ActivationChainContext::from_chain_state(&certified_state).unwrap(); + assert_eq!( + existing_wit2_activation_certificate_action( + &certified_context, + &certificate, + registered, + ) + .unwrap(), + ExistingWit2ActivationCertificateAction::Preserve + ); - let chain_info_bytes = bincode::serialize(&default_chain_info).unwrap(); - assert_eq!(t1_bytes[1..], chain_info_bytes); + let mut prefix = chain_state_at_tip(Some(trigger_epoch - 1), false); + prefix + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = 10; + let prefix_context = Wit2ActivationChainContext::from_chain_state(&prefix).unwrap(); + assert_eq!( + existing_wit2_activation_certificate_action(&prefix_context, &certificate, None) + .unwrap(), + ExistingWit2ActivationCertificateAction::Preserve + ); + assert_eq!( + existing_wit2_activation_certificate_action( + &prefix_context, + &certificate, + registered, + ) + .unwrap(), + ExistingWit2ActivationCertificateAction::Preserve + ); + assert!( + existing_wit2_activation_certificate_action( + &prefix_context, + &certificate, + Some(( + certificate.activation_epoch.saturating_add(1), + certificate.checkpoint_period, + )), + ) + .is_err() + ); - // This is also detected as version 0 - assert_eq!(check_chain_state_version(&t1_bytes), Ok(0)); + let mut alternate = certified_state.clone(); + alternate.block_chain.insert( + trigger_epoch, + Hash::with_first_u32(trigger_epoch.saturating_add(1)), + ); + let alternate_hash = alternate.block_chain[&trigger_epoch]; + alternate + .chain_info + .as_mut() + .unwrap() + .highest_block_checkpoint + .hash_prev_block = alternate_hash; + alternate + .chain_info + .as_mut() + .unwrap() + .highest_superblock_checkpoint + .checkpoint = 1; + let alternate_context = + Wit2ActivationChainContext::from_chain_state(&alternate).unwrap(); + assert_eq!( + existing_wit2_activation_certificate_action(&alternate_context, &certificate, None) + .unwrap(), + ExistingWit2ActivationCertificateAction::Delete + ); + assert!( + existing_wit2_activation_certificate_action( + &alternate_context, + &certificate, + registered, + ) + .is_err() + ); + + alternate + .chain_info + .as_mut() + .unwrap() + .highest_superblock_checkpoint + .checkpoint = 2; + let finalized_alternate_context = + Wit2ActivationChainContext::from_chain_state(&alternate).unwrap(); + assert!( + existing_wit2_activation_certificate_action( + &finalized_alternate_context, + &certificate, + None, + ) + .is_err() + ); } #[test] - fn bincode_chainstate_migration() { - let t0 = OldChainState { chain_info: None }; - let t0_bytes = bincode::serialize(&t0).unwrap(); + fn replacement_cannot_erase_canonical_or_finalized_activation_certificate() { + let mut old_state = chain_state_at_tip(Some(10), false); + old_state + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = 10; + let old_certificate = activation_certificate_for( + old_state.chain_info.as_ref().unwrap(), + 10, + old_state.block_chain[&10], + ); - let t0_migrated_bytes = migrate_chain_state_v0_to_v2(&t0_bytes); - let t0_migrated: NewChainState = bincode::deserialize(&t0_migrated_bytes[4..]).unwrap(); + let mut replacement_state = chain_state_at_tip(Some(20), false); + replacement_state + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = 10; + let replacement_certificate = activation_certificate_for( + replacement_state.chain_info.as_ref().unwrap(), + 20, + replacement_state.block_chain[&20], + ); + + // An alternate, not-yet-finalized branch may atomically replace a provisional companion. + let replacement_context = + Wit2ActivationChainContext::from_chain_state(&replacement_state).unwrap(); + assert!( + validate_wit2_activation_certificate_replacement( + &replacement_context, + &old_certificate, + &replacement_certificate, + ) + .is_ok() + ); + + // Keeping the old trigger canonical makes a different replacement contradictory. + replacement_state + .block_chain + .insert(10, old_certificate.trigger_hash); + let canonical_old_context = + Wit2ActivationChainContext::from_chain_state(&replacement_state).unwrap(); + assert!( + validate_wit2_activation_certificate_replacement( + &canonical_old_context, + &old_certificate, + &replacement_certificate, + ) + .is_err() + ); + + // Even on an alternate branch, superblock #2 with period 10 has finalized epoch 10. + replacement_state.block_chain.remove(&10); + replacement_state + .chain_info + .as_mut() + .unwrap() + .highest_superblock_checkpoint + .checkpoint = 2; + let finalized_old_context = + Wit2ActivationChainContext::from_chain_state(&replacement_state).unwrap(); + assert!( + validate_wit2_activation_certificate_replacement( + &finalized_old_context, + &old_certificate, + &replacement_certificate, + ) + .is_err() + ); + + // Restaging the exact same certificate remains idempotent regardless of finality. + assert!( + validate_wit2_activation_certificate_replacement( + &finalized_old_context, + &old_certificate, + &old_certificate, + ) + .is_ok() + ); + } + + #[test] + fn wit2_activation_certificate_is_bound_to_canonical_trigger_and_parameters() { + let trigger_epoch = 10; + let mut chain_state = chain_state_at_tip(Some(trigger_epoch), false); + chain_state + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = u16::try_from(trigger_epoch).unwrap(); + let chain_info = chain_state.chain_info.as_ref().unwrap(); + let trigger_hash = chain_state.block_chain[&trigger_epoch]; + let wit2_constants = ConsensusConstantsWit2 { + checkpoint_zero_timestamp: chain_info.consensus_constants.checkpoint_zero_timestamp, + checkpoints_period: chain_info.consensus_constants.checkpoints_period, + }; + let activation_epoch = trigger_epoch + .checked_add(wit2_constants.get_wit2_activation_delay_epochs()) + .unwrap(); + let certificate = Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + activation_epoch, + wit2_constants.get_checkpoints_period_wit2(), + ); + + assert!(certificate.validate(&chain_state).is_ok()); + let encoded = bincode::serialize(&certificate).unwrap(); assert_eq!( - t0_migrated, - NewChainState { - chain_info: None, - tapi: TapiEngine::default(), - } + witnet_data_structures::bincode_compatibility::deserialize_exact::< + Wit2ActivationCertificate, + >(&encoded) + .unwrap(), + certificate + ); + let mut trailing = encoded; + trailing.push(0); + assert!( + witnet_data_structures::bincode_compatibility::deserialize_exact::< + Wit2ActivationCertificate, + >(&trailing) + .is_err() ); - let default_chain_info = ChainInfo::default(); - let t1 = OldChainState { - chain_info: Some(default_chain_info.clone()), + let mut future_format = certificate.clone(); + future_format.format_version = Wit2ActivationCertificate::FORMAT_VERSION + 1; + assert!(future_format.validate(&chain_state).is_err()); + + // A replay prefix cannot authenticate the future trigger hash yet, but it can and must + // reject malformed deterministic parameters using its already-restored ChainInfo. + let mut prefix = chain_state_at_tip(Some(trigger_epoch - 1), false); + prefix + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .superblock_period = u16::try_from(trigger_epoch).unwrap(); + let prefix_chain_info = prefix.chain_info.as_ref().unwrap(); + assert!(certificate.validate_parameters(prefix_chain_info).is_ok()); + assert!(future_format.validate_parameters(prefix_chain_info).is_err()); + + let wrong_hash = Wit2ActivationCertificate::new( + trigger_epoch, + Hash::with_first_u32(trigger_epoch.saturating_add(1)), + activation_epoch, + wit2_constants.get_checkpoints_period_wit2(), + ); + assert!(wrong_hash.validate(&chain_state).is_err()); + + let wrong_epoch = Wit2ActivationCertificate::new( + trigger_epoch, + trigger_hash, + activation_epoch.saturating_add(1), + wit2_constants.get_checkpoints_period_wit2(), + ); + assert!(wrong_epoch.validate(&chain_state).is_err()); + + chain_state.block_chain.remove(&trigger_epoch); + assert!(certificate.validate(&chain_state).is_err()); + } + + /// The persisted one-time marker is tied to the actual consolidated tip, including the + /// otherwise ambiguous checkpoint-zero state before the first block is indexed. + #[test] + fn delegated_staking_persistence_marker_matches_consolidated_tip() { + assert!( + validate_delegated_staking_persistence_with( + &chain_state_at_tip(None, false), + protocol_across_delegation_boundary, + ) + .is_ok() + ); + assert!( + validate_delegated_staking_persistence_with( + &chain_state_at_tip(None, true), + protocol_across_delegation_boundary, + ) + .is_err() + ); + assert!( + validate_delegated_staking_persistence_with( + &chain_state_at_tip(Some(9), false), + protocol_across_delegation_boundary, + ) + .is_ok() + ); + assert!( + validate_delegated_staking_persistence_with( + &chain_state_at_tip(Some(9), true), + protocol_across_delegation_boundary, + ) + .is_err() + ); + let mut activated = chain_state_at_tip(Some(10), true); + let validator = PublicKeyHash::default(); + let owner_key = StakeKey { + validator, + withdrawer: validator, }; - let t1_bytes = bincode::serialize(&t1).unwrap(); - let t1_migrated_bytes = migrate_chain_state_v0_to_v2(&t1_bytes); - let t1_migrated: NewChainState = bincode::deserialize(&t1_migrated_bytes[4..]).unwrap(); + activated + .stakes + .add_stake( + owner_key, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + 0, + true, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ) + .unwrap(); + activated + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .unwrap(); + assert!(validate_delegated_staking_persistence_with( + &activated, + protocol_across_delegation_boundary, + ) + .is_ok()); + assert!( + validate_delegated_staking_persistence_with( + &chain_state_at_tip(Some(10), false), + protocol_across_delegation_boundary, + ) + .is_err() + ); + } + + #[test] + fn delegated_state_requires_a_wit2_schedule_without_a_certificate() { + let mut chain_state = chain_state_at_tip(Some(9), false); + let validator = PublicKeyHash::default(); + chain_state + .delegation_state + .collateral_reservations + .insert( + CollateralReservationKey { + data_request: Hash::default(), + validator, + }, + CollateralReservation { + total: Wit::from_nanowits(1), + debits: Vec::new(), + reward_weights: Vec::new(), + }, + ); + + assert!(validate_delegated_staking_schedule_provenance( + &chain_state, + &ProtocolInfo::default(), + ) + .is_err()); + + let mut protocol_info = ProtocolInfo::default(); + protocol_info + .try_register(10, ProtocolVersion::V2_0, 20) + .unwrap(); + assert!(validate_delegated_staking_schedule_provenance( + &chain_state, + &protocol_info, + ) + .is_ok()); + + let activated = chain_state_at_tip(Some(10), true); + assert!(validate_delegated_staking_schedule_provenance( + &activated, + &ProtocolInfo::default(), + ) + .is_err()); + } + + #[test] + fn persisted_exit_intent_cannot_predate_activation() { + let mut state = chain_state_at_tip(Some(9), false); + state.delegation_state.exit_intents.insert(StakeKey { + validator: PublicKeyHash::default(), + withdrawer: PublicKeyHash::default(), + }); + let error = validate_delegated_staking_persistence_with( + &state, protocol_across_delegation_boundary, + ).unwrap_err(); + assert!(error.to_string().contains("preactivation ChainState")); + } + + #[test] + fn pristine_state_remains_schedule_independent() { + assert!(validate_delegated_staking_schedule_provenance( + &chain_state_at_tip(Some(9), false), + &ProtocolInfo::default(), + ) + .is_ok()); + } + + #[test] + fn delegated_staking_persistence_authenticates_data_request_round_configuration() { + let mut matched = chain_state_at_tip(Some(9), false); + matched + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .extra_rounds = 1; + matched.data_request_pool.extra_rounds = 1; + assert!( + validate_delegated_staking_persistence_with( + &matched, + protocol_across_delegation_boundary, + ) + .is_ok() + ); + + let mut mismatched = matched.clone(); + mismatched.data_request_pool.extra_rounds = 2; + assert!( + validate_delegated_staking_persistence_with( + &mismatched, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut unrepresentable = matched; + unrepresentable + .chain_info + .as_mut() + .unwrap() + .consensus_constants + .extra_rounds = u16::MAX; + unrepresentable.data_request_pool.extra_rounds = u16::MAX; + assert!( + validate_delegated_staking_persistence_with( + &unrepresentable, + protocol_across_delegation_boundary, + ) + .is_err() + ); + } + + #[test] + fn delegated_staking_persistence_rejects_partial_chain_state_but_allows_reset_marker() { + // The explicit reset marker is the only incomplete value accepted by the persistence + // boundary. A partially decoded state must fail rather than being persisted and later + // mistaken for a fresh chain during startup. + assert!(validate_delegated_staking_persistence(&ChainState::default()).is_ok()); + + let mut partial = ChainState::default(); + partial.reputation_engine = Some(ReputationEngine::new(1000)); + assert!(validate_delegated_staking_persistence(&partial).is_err()); + + let mut other_partial = ChainState::default(); + other_partial.chain_info = Some(ChainInfo::default()); + assert!(validate_delegated_staking_persistence(&other_partial).is_err()); + } + + #[test] + fn delegated_staking_persistence_rejects_corrupt_validator_policy() { + let mut chain_state = chain_state_at_tip(Some(10), true); + let validator = PublicKeyHash::default(); + chain_state + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .unwrap(); + chain_state + .delegation_state + .validators + .get_mut(&validator) + .unwrap() + .commission_percent = 16; + + assert!( + validate_delegated_staking_persistence_with( + &chain_state, + protocol_across_delegation_boundary, + ) + .is_err() + ); + } + + #[test] + fn delegated_staking_persistence_uses_fixed_bounds_with_a_supplied_protocol() { + let validator = PublicKeyHash::default(); + let owner_key = StakeKey { + validator, + withdrawer: validator, + }; + let state_with_owner_stake = |amount| { + let mut chain_state = chain_state_at_tip(Some(10), true); + // Build the malformed historical image without applying the future protocol minimum; + // the persistence boundary below is responsible for rejecting it. + chain_state + .stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(amount), + 0, + true, + Wit::from_nanowits(0), + ) + .unwrap(); + chain_state + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .unwrap(); + chain_state + }; + + // `protocol_across_delegation_boundary` is the authoritative snapshot schedule for this + // validation. Native admission still enforces the fixed bounds, while a below-minimum + // owner already present at the activation boundary is retained as settlement-only legacy + // state and must remain loadable. + let below_minimum = + state_with_owner_stake(DELEGATED_STAKING_MINIMUM_NANOWITS.saturating_sub(1)); + assert!( + validate_delegated_staking_persistence_with( + &below_minimum, + protocol_across_delegation_boundary, + ) + .is_ok() + ); + + let exact_minimum = state_with_owner_stake(DELEGATED_STAKING_MINIMUM_NANOWITS); + assert!( + validate_delegated_staking_persistence_with( + &exact_minimum, + protocol_across_delegation_boundary, + ) + .is_ok() + ); assert_eq!( - t1_migrated, - NewChainState { - chain_info: Some(default_chain_info), - tapi: TapiEngine::default(), + DELEGATED_STAKING_MAXIMUM_NANOWITS, + 10_000_000_u64 * 1_000_000_000 + ); + } + + #[test] + fn delegated_staking_persistence_rejects_orphaned_and_misowned_reservations() { + let data_request_pointer = Hash::with_first_u32(77); + let (validator, commit) = structurally_bound_commit(data_request_pointer); + let owner_key = StakeKey { + validator, + withdrawer: validator, + }; + let minimum_stake = Wit::from_nanowits(10_000_000_000_000); + let collateral = Wit::from_nanowits(1_000_000_000); + let reservation_key = CollateralReservationKey { + data_request: data_request_pointer, + validator, + }; + + let mut orphaned = chain_state_at_tip(Some(10), true); + orphaned + .stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(20_000_000_000_000), + 0, + true, + minimum_stake, + ) + .unwrap(); + orphaned + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .unwrap(); + orphaned.delegation_state.collateral_reservations.insert( + reservation_key, + CollateralReservation { + total: collateral, + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: owner_key.clone(), + amount: collateral, + }], + }, + ); + assert!( + validate_delegated_staking_persistence_with( + &orphaned, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let delegator = PublicKeyHash::from_bytes(&[1_u8; 20]).unwrap(); + let mut misowned = orphaned; + misowned.data_request_pool.data_request_pool.clear(); + misowned + .stakes + .add_stake( + StakeKey { + validator, + withdrawer: delegator, + }, + Wit::from_nanowits(20_000_000_000_000), + 0, + true, + minimum_stake, + ) + .unwrap(); + let mut data_request = DataRequestOutput::default(); + data_request.collateral = collateral.nanowits(); + let mut data_request_state = DataRequestState::new(data_request, validator, 9, None); + data_request_state + .info + .commits + .insert(validator, commit.clone()); + misowned + .data_request_pool + .data_request_pool + .insert(reservation_key.data_request, data_request_state); + misowned + .delegation_state + .collateral_reservations + .get_mut(&reservation_key) + .unwrap() + .debits[0] + .key = StakeKey { + validator, + withdrawer: delegator, + }; + assert!( + validate_delegated_staking_persistence_with( + &misowned, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut mismatched_total = misowned.clone(); + let reservation = mismatched_total + .delegation_state + .collateral_reservations + .get_mut(&reservation_key) + .unwrap(); + let incorrect_total = Wit::from_nanowits(collateral.nanowits() + 1); + reservation.total = incorrect_total; + reservation.debits[0].key = owner_key.clone(); + reservation.debits[0].amount = incorrect_total; + assert!( + validate_delegated_staking_persistence_with( + &mismatched_total, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + // Reservations are only meaningful for Wit/2.0 and later requests. A forged sidecar + // attached to a pre-Wit/2.0 request must not survive loading merely because it has an + // active commit and an otherwise matching total. + let mut pre_wit2 = chain_state_at_tip(Some(10), true); + pre_wit2 + .stakes + .add_stake( + owner_key.clone(), + Wit::from_nanowits(20_000_000_000_000), + 0, + true, + minimum_stake, + ) + .unwrap(); + pre_wit2 + .delegation_state + .register_validator_if_absent(validator, validator, 0) + .unwrap(); + let mut pre_wit2_request = DataRequestOutput::default(); + pre_wit2_request.collateral = collateral.nanowits(); + let mut pre_wit2_state = DataRequestState::new(pre_wit2_request, validator, 1, None); + pre_wit2_state + .info + .commits + .insert(validator, commit); + pre_wit2 + .data_request_pool + .data_request_pool + .insert(reservation_key.data_request, pre_wit2_state); + pre_wit2 + .delegation_state + .collateral_reservations + .insert( + reservation_key, + CollateralReservation { + total: collateral, + reward_weights: Vec::new(), + debits: vec![CollateralDebit { + key: owner_key, + amount: collateral, + }], + }, + ); + let protocol_with_pre_wit2 = |epoch: Epoch| { + if epoch >= 10 { + ProtocolVersion::V2_1 + } else if epoch >= 5 { + ProtocolVersion::V2_0 + } else { + ProtocolVersion::V1_8 } + }; + assert!( + validate_delegated_staking_persistence_with(&pre_wit2, protocol_with_pre_wit2) + .is_err() ); } + /// A marker check must never infer activation from a checkpoint that disagrees with the + /// persisted block index, because either side could otherwise select different fork rules. #[test] - fn bincode_chainstate_migration_multiple_steps() { - // This test ensures that there are no accidental infinite loops in migrate_chain_state + fn delegated_staking_persistence_rejects_checkpoint_tip_mismatch() { + let mut chain_state = chain_state_at_tip(Some(10), true); + chain_state + .chain_info + .as_mut() + .unwrap() + .highest_block_checkpoint + .checkpoint = 9; - // An empty ChainState v0 is 241 bytes - let chain_state_v0_bytes = vec![0; 241]; - let migrated_chain_state = migrate_chain_state(chain_state_v0_bytes); - migrated_chain_state.unwrap(); + assert!( + validate_delegated_staking_persistence_with( + &chain_state, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut hash_mismatch = chain_state_at_tip(Some(10), true); + hash_mismatch + .chain_info + .as_mut() + .unwrap() + .highest_block_checkpoint + .hash_prev_block = Hash::SHA256([1_u8; 32]); + assert!( + validate_delegated_staking_persistence_with( + &hash_mismatch, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut missing_genesis = chain_state_at_tip(Some(10), true); + missing_genesis.block_chain.remove(&0); + assert!( + validate_delegated_staking_persistence_with( + &missing_genesis, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut wrong_genesis = chain_state_at_tip(Some(10), true); + wrong_genesis + .block_chain + .insert(0, Hash::SHA256([1_u8; 32])); + assert!( + validate_delegated_staking_persistence_with( + &wrong_genesis, + protocol_across_delegation_boundary, + ) + .is_err() + ); + + let mut missing_index = ChainState::default(); + let mut chain_info = ChainInfo::default(); + chain_info.highest_block_checkpoint.checkpoint = 10; + chain_info.highest_block_checkpoint.hash_prev_block = Hash::with_first_u32(10); + missing_index.chain_info = Some(chain_info); + missing_index.reputation_engine = Some(ReputationEngine::new(1000)); + assert!( + validate_delegated_staking_persistence_with( + &missing_index, + protocol_across_delegation_boundary, + ) + .is_err() + ); } #[test] diff --git a/node/src/utils.rs b/node/src/utils.rs index 6987bc7dc7..b126c88e38 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -4,7 +4,7 @@ use std::{ fs::File, future::Future, hash::Hash, - io::BufReader, + io::{BufReader, Write}, path::{Path, PathBuf}, pin::Pin, sync::{ @@ -13,6 +13,20 @@ use std::{ }, }; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; + +#[cfg(test)] +static TEST_PROTOCOL_RUNTIME_LOCK: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn test_protocol_runtime_guard() -> MutexGuard<'static, ()> { + TEST_PROTOCOL_RUNTIME_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("protocol runtime test lock must not be poisoned") +} + /// Given a list of elements, return the most common one. In case of tie, return `None`. pub fn mode_consensus(pb: I, threshold: usize) -> Option where @@ -54,6 +68,13 @@ pub fn stop_system_if_panicking(actor_name: &str) { /// Helper function used to test actors. /// This should use the same code that the node uses to start the actor system. pub fn test_actix_system Fut, Fut: Future>(test_function: F) { + // Protocol schedules and their epoch clocks are process-wide. Keep actor tests that use the + // shared runtime from racing with direct protocol-registration tests in the same test + // binary; otherwise a valid admission can observe an unrelated schedule half-way through + // its asynchronous verification and fail as ChainNotReady. + #[cfg(test)] + let _protocol_runtime_guard = test_protocol_runtime_guard(); + // Use this flag to ensure that the test has been run, because you can never trust // asynchronous code let done = Arc::new(AtomicBool::new(false)); @@ -242,10 +263,29 @@ pub fn serialize_to_file(data: &D, path: &Path) -> Result<(), anyhow::Error> where D: serde::Serialize, { - // Create file, serialize and write - let file = witnet_util::files::create_file(path)?; - let writer = std::io::BufWriter::new(file); - bincode::serialize_into(writer, data)?; + // Keep the prior complete snapshot component in place until the replacement is fully encoded + // and durable. A crash or serialization error may leave a `.part` file, but never exposes a + // truncated payload at the final path. + let mut temporary_path = path.as_os_str().to_os_string(); + temporary_path.push(".part"); + let temporary_path = PathBuf::from(temporary_path); + let file = witnet_util::files::create_file(&temporary_path)?; + let mut writer = std::io::BufWriter::new(file); + bincode::serialize_into(&mut writer, data)?; + writer.flush()?; + writer.get_ref().sync_all()?; + drop(writer); + std::fs::rename(temporary_path, path)?; + // On Unix, persisting the file contents is not enough to make the renamed directory entry + // crash-durable. Sync the parent after publication; a bare relative filename uses `.`. + #[cfg(unix)] + { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + File::open(parent)?.sync_all()?; + } Ok(()) } diff --git a/schemas/witnet/witnet.proto b/schemas/witnet/witnet.proto index fc0d7c0445..9f4bd915f9 100644 --- a/schemas/witnet/witnet.proto +++ b/schemas/witnet/witnet.proto @@ -297,6 +297,30 @@ message StakeOutput { uint64 value = 1; StakeKey key = 2; KeyedSignature authorization = 3; + // Present only on Wit/2.1 delegated stake transactions. The existing + // `authorization` field signs this payload instead of the legacy + // zero-padded withdrawer address. + StakeAuthorization authorization_terms = 4; +} + +// Versioned, network-bound payload signed by the action-specific authorizer: +// the validator for actions 0, 1, and 2 or the withdrawer for action 3. Scalar +// fields intentionally use fixed-width types where their byte width is part of +// the authorization-code format. +message StakeAuthorization { + fixed32 version = 1; + fixed32 network = 2; + PublicKeyHash validator = 3; + PublicKeyHash withdrawer = 4; + fixed32 expiration_epoch = 5; + fixed32 commission_percent = 6; + // 0 = delegate or top up, 1 = update validator-wide commission, + // 2 = register the immutable owner, 3 = request an exact-entry exit. + fixed32 action = 7; + // Zero for delegation/top-up and owner-registration actions; monotonically + // increasing per validator for commission updates; the exact live-entry + // nonce for withdrawer-signed exit requests. + fixed64 nonce = 8; } message StakeTransactionBody { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 519cecd977..f869b117d0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,7 +6,10 @@ use terminal_size as term; use env_logger::TimestampPrecision; use witnet_config as config; -use witnet_data_structures::register_protocol_version; +use witnet_data_structures::{ + proto::versioning::ProtocolInfo, + try_load_protocol_info_with_consensus_constants, +}; mod node; mod wallet; @@ -58,11 +61,19 @@ pub fn exec( witnet_data_structures::set_environment(config.environment); log::debug!("{config:#?}"); + // Build and validate the complete configured schedule before publishing any of it globally. + // Structural contradictions and periods that cannot fit the runtime's pre-/post-Wit/2 timing + // regimes are startup errors without leaving a partially registered process-global prefix. + let mut protocol_info = ProtocolInfo::default(); for (version, epoch_period) in config.protocol.iter() { if let Some((epoch, period)) = epoch_period { - register_protocol_version(version, epoch, period); + protocol_info + .try_register(epoch, version, period) + .map_err(|error| anyhow::anyhow!("Invalid protocol schedule: {error}"))?; } } + try_load_protocol_info_with_consensus_constants(protocol_info, &config.consensus_constants) + .map_err(|error| anyhow::anyhow!("Invalid protocol schedule: {error}"))?; exec_cmd(cmd, config_path, config) } diff --git a/src/cli/node/json_rpc_client.rs b/src/cli/node/json_rpc_client.rs index 0aa34e9667..e6e88f89a8 100644 --- a/src/cli/node/json_rpc_client.rs +++ b/src/cli/node/json_rpc_client.rs @@ -28,8 +28,10 @@ use witnet_data_structures::{ capabilities::Capability, chain::{ Block, ConsensusConstants, DataRequestInfo, DataRequestOutput, Environment, Epoch, - Hashable, KeyedSignature, NodeStats, OutputPointer, PublicKey, PublicKeyHash, StateMachine, - SupplyInfo, SupplyInfo2, SyncStatus, ValueTransferOutput, + Hashable, KeyedSignature, NodeStats, OutputPointer, PublicKey, PublicKeyHash, + StakeAuthorizationCode, StateMachine, SupplyInfo, SupplyInfo2, SyncStatus, + STAKE_AUTHORIZATION_ACTION_DELEGATE, STAKE_AUTHORIZATION_VERSION_V1, + ValueTransferOutput, priority::{PrioritiesEstimate, Priority, PriorityEstimate, TimeToBlock}, tapi::{ActiveWips, current_active_wips}, }, @@ -39,7 +41,7 @@ use witnet_data_structures::{ ProtobufConvert, versioning::{ProtocolInfo, ProtocolVersion}, }, - staking::prelude::StakeEntry, + staking::prelude::{ActivationBoundaryReport, ActivationPreflightReport, StakeEntry}, transaction::{ DRTransaction, StakeTransaction, Transaction, UnstakeTransaction, VTTransaction, }, @@ -54,9 +56,11 @@ use witnet_node::actors::{ AddrType, GetBlockChainParams, GetTransactionOutput, PeersResult, QueryStakingPowersRecord, }, messages::{ - AuthorizeStake, BuildDrt, BuildStakeParams, BuildStakeResponse, BuildUnstakeParams, - BuildVtt, GetBalanceTarget, GetReputationResult, MagicEither, QueryStakes, - QueryStakesFilter, QueryStakingPowers, SignalingInfo, StakeAuthorization, + AuthorizeStake, BuildDrt, BuildStakeParams, BuildStakeResponse, + BuildUnstakeByDebitParams, BuildUnstakeParams, BuildVtt, GetBalanceTarget, + GetReputationResult, MagicEither, QueryStakes, + QueryStakesFilter, QueryStakingPowers, QueryValidatorPolicy, SignalingInfo, + StakeAuthorization, ValidatorPolicyInfo, }, }; use witnet_rad::types::RadonTypes; @@ -928,7 +932,11 @@ pub fn send_st( sorted_bigger: Option, requires_confirmation: Option, dry_run: bool, + validate_admission: bool, ) -> Result<(), anyhow::Error> { + if validate_admission && !dry_run { + bail!("--validate-admission requires --dry-run"); + } let mut stream = start_client(addr)?; let mut id = SequentialId::initialize(1u8); @@ -943,31 +951,33 @@ pub fn send_st( let mut build_stake_params = BuildStakeParams { authorization: MagicEither::Left(authorization.clone()), + authorization_terms: None, withdrawer, value, fee, utxo_strategy, dry_run, + validate_admission, }; // If no fee was specified, we first need to do a dry run for each of the priority tiers to // find out the actual transaction weight (as different priorities will affect the number // of inputs being used, and thus also the weight). if let Some(PrioritiesEstimate { - vtt_stinky, - vtt_low, - vtt_medium, - vtt_high, - vtt_opulent, + st_stinky, + st_low, + st_medium, + st_high, + st_opulent, .. }) = estimate { let priorities = vec![ - (vtt_stinky, "Stinky"), - (vtt_low, "Low"), - (vtt_medium, "Medium"), - (vtt_high, "High"), - (vtt_opulent, "Opulent"), + (st_stinky, "Stinky"), + (st_low, "Low"), + (st_medium, "Medium"), + (st_high, "High"), + (st_opulent, "Opulent"), ]; let mut estimates = vec![]; let mut fee; @@ -997,6 +1007,11 @@ pub fn send_st( let dry_params = BuildStakeParams { fee, dry_run: true, + // These internal probes discover construction weight for every advertised + // tier. Local fee-floor/pressure admission applies only to the user's final + // explicit preview; otherwise one intentionally cheap tier could prevent the + // CLI from offering the later tiers. + validate_admission: false, ..build_stake_params.clone() }; let (bsr, ..): (BuildStakeResponse, _) = @@ -1023,13 +1038,20 @@ pub fn send_st( dry_run: true, ..build_stake_params.clone() }; - let (dry, _): (BuildStakeResponse, _) = + let (dry, (dry_request, dry_response)): (BuildStakeResponse, _) = issue_method("stake", Some(params), &mut stream, id.next())?; - let validator_address = { - let pkh_bytes = - hex::decode(authorization.clone().chars().take(40).collect::()).unwrap(); - PublicKeyHash::from_bytes(pkh_bytes.as_slice())? + // Keep CLI presentation compatibility with the SDK's HexString helpers. The RPC and portable + // decoder accept the optional prefix, so derive the local validator from the same normalized + // code rather than misclassifying a prefixed legacy code as a V2 authorization. + let normalized_authorization = authorization + .strip_prefix("0x") + .or_else(|| authorization.strip_prefix("0X")) + .unwrap_or(&authorization); + let validator_address = if normalized_authorization.len() == 170 { + PublicKeyHash::from_hex(&normalized_authorization[..40])? + } else { + StakeAuthorizationCode::from_hex(normalized_authorization)?.terms.validator }; if validator_address != dry.validator { bail!( @@ -1042,6 +1064,18 @@ pub fn send_st( ); } + // The RPC deliberately returns `BuildStakeResponse` for a dry run and a bare + // `StakeTransaction` only for a broadcast. Reuse the exact request that produced the + // validated preview and stop here: issuing a second dry-run request while decoding it as a + // transaction makes `stake --dry-run` fail deterministically and could misleadingly reach the + // broadcast-success wording below. + if dry_run { + println!("> {dry_request}"); + println!("< {dry_response}"); + println!("Stake transaction dry run completed successfully; no transaction was broadcast."); + return Ok(()); + } + let confirmation = if requires_confirmation.unwrap_or(true) { // Exactly what it says: shows all the facts about the staking transaction, and expects confirmation through // user input @@ -1056,7 +1090,8 @@ pub fn send_st( if let Some(dry) = confirmation { // Finally ask the node to create the transaction with the chosen fee. - build_stake_params.dry_run = dry_run; + build_stake_params.dry_run = false; + build_stake_params.validate_admission = false; let (st, (request, response)): (StakeTransaction, _) = issue_method("stake", Some(build_stake_params), &mut stream, id.next())?; @@ -1085,27 +1120,123 @@ pub fn send_st( Ok(()) } -pub fn authorize_st(addr: SocketAddr, withdrawer: Option) -> Result<(), anyhow::Error> { +pub fn authorize_st( + addr: SocketAddr, + validator: Option, + withdrawer: Option, + version: Option, + expiration_epoch: Option, + commission_percent: u32, + action: u32, + nonce: u64, +) -> Result<(), anyhow::Error> { let mut stream = start_client(addr)?; let mut id = SequentialId::initialize(1u8); - let params = AuthorizeStake { withdrawer }; + // Keep an independently decoded copy of the operator's contract before moving the request + // into the JSON-RPC call. A valid node signature proves what the node authorized, not that a + // buggy or mismatched response authorized the terms this CLI asked for. + let requested_validator = validator.as_ref() + .map(|address| PublicKeyHash::from_bech32(get_environment(), address)) + .transpose()?; + let requested_withdrawer = withdrawer + .as_ref() + .map(|address| PublicKeyHash::from_bech32(get_environment(), address)) + .transpose()?; + let requested_version = version; + let requested_expiration_epoch = expiration_epoch; + let requested_commission_percent = commission_percent; + let requested_action = action; + let requested_nonce = nonce; + + // `commission_percent` is a CLI convenience default for the active V2 format. Do not + // serialize that implicit zero as a present V2-only field when the operator explicitly + // requests the legacy V1 format; the node correctly rejects present V2 terms on V1. + if requested_version == Some(STAKE_AUTHORIZATION_VERSION_V1) + && (requested_commission_percent != 0 + || requested_expiration_epoch.is_some() + || requested_action != STAKE_AUTHORIZATION_ACTION_DELEGATE + || requested_nonce != 0) + { + bail!("legacy authorization version 1 cannot include Wit/2.1 authorization terms"); + } + + let params = AuthorizeStake { + validator, + withdrawer, + version, + expiration_epoch, + commission_percent: (version != Some(STAKE_AUTHORIZATION_VERSION_V1)) + .then_some(commission_percent), + action, + nonce, + }; let (authorization, (_, _response)): (StakeAuthorization, _) = issue_method("authorizeStake", Some(params), &mut stream, id.next())?; - let message = authorization.withdrawer.as_secp256k1_msg(); + let StakeAuthorization { + withdrawer, + signature, + authorization_terms, + code, + } = authorization; + + let expected_withdrawer = requested_withdrawer.unwrap_or_else(|| signature.public_key.pkh()); + if expected_withdrawer != withdrawer { + bail!("authorizeStake returned a different withdrawer than requested"); + } - let auth_string = { - let validator_bytes: [u8; 20] = authorization - .signature - .public_key - .pkh() - .as_ref() - .try_into()?; - let signature_bytes: [u8; 65] = authorization - .signature - .to_recoverable_bytes(&message) - .unwrap(); + let auth_string = if let Some(code) = code { + let decoded = StakeAuthorizationCode::from_hex(&code)?; + let response_terms = match authorization_terms { + Some(terms) => terms, + None => bail!( + "authorizeStake returned a portable Wit/2.1 code without its structured authorization terms" + ), + }; + if decoded.terms.version != response_terms.version + || decoded.terms.network != response_terms.network + || decoded.terms.validator != response_terms.validator + || decoded.terms.withdrawer != response_terms.withdrawer + || decoded.terms.expiration_epoch != response_terms.expiration_epoch + || decoded.terms.commission_percent != response_terms.commission_percent + || decoded.terms.action != response_terms.action + || decoded.terms.nonce != response_terms.nonce + || decoded.terms.withdrawer != withdrawer + || decoded.signature != signature + { + bail!( + "authorizeStake returned inconsistent portable code, signature, or structured terms" + ); + } + if requested_version.is_some_and(|expected| expected != decoded.terms.version) + || requested_validator.is_some_and(|expected| expected != decoded.terms.validator) + || requested_expiration_epoch != Some(decoded.terms.expiration_epoch) + || requested_commission_percent != decoded.terms.commission_percent + || requested_action != decoded.terms.action + || requested_nonce != decoded.terms.nonce + { + bail!("authorizeStake returned portable terms that differ from the request"); + } + code + } else { + if authorization_terms.is_some() { + bail!( + "authorizeStake returned Wit/2.1 authorization terms without the required portable code" + ); + } + if requested_version.is_some_and(|expected| expected != STAKE_AUTHORIZATION_VERSION_V1) + || requested_validator.is_some_and(|expected| expected != signature.public_key.pkh()) + || requested_expiration_epoch.is_some() + || requested_commission_percent != 0 + || requested_action != STAKE_AUTHORIZATION_ACTION_DELEGATE + || requested_nonce != 0 + { + bail!("authorizeStake returned a legacy authorization for a Wit/2.1-shaped request"); + } + let message = withdrawer.as_secp256k1_msg(); + let validator_bytes: [u8; 20] = signature.public_key.pkh().as_ref().try_into()?; + let signature_bytes: [u8; 65] = signature.to_recoverable_bytes(&message)?; hex::encode([&validator_bytes[..], &signature_bytes[..]].concat()) }; @@ -1126,28 +1257,83 @@ pub fn authorize_st(addr: SocketAddr, withdrawer: Option) -> Result<(), pub fn send_ut( addr: SocketAddr, operator: MagicEither, - value: u64, + value: Option, + debit: Option, + all: bool, fee: u64, dry_run: bool, + validate_admission: bool, ) -> Result<(), anyhow::Error> { + if validate_admission && !dry_run { + bail!("--validate-admission requires --dry-run"); + } + match (value, debit, all) { + (Some(_), None, false) | (None, None, true) => {} + (None, Some(debit), false) if debit > fee => {} + (None, Some(0), false) => bail!("--debit must be positive"), + (None, Some(_), false) => bail!("--debit must exceed --fee"), + _ => bail!("exactly one of --value, --debit, or --all must be selected"), + } let mut stream = start_client(addr)?; let mut id = SequentialId::initialize(1u8); - let build_unstake_params = BuildUnstakeParams { - operator, - value, - fee, - dry_run, + // Keep the legacy net-output method byte-for-byte isolated from the new gross-debit method. + // In particular, never retry `unstakeByDebit` as `unstake` when talking to an older node: an + // unknown-field downgrade could otherwise turn the intended gross debit into a zero value. + let (request, response) = match (value, debit, all) { + (Some(value), None, false) => { + let params = BuildUnstakeParams { + operator, + value, + fee, + dry_run, + validate_admission, + }; + let (_, request_and_response): (UnstakeTransaction, _) = issue_method( + "unstake", + Some(params), + &mut stream, + id.next(), + )?; + request_and_response + } + (None, Some(debit), false) => { + let params = BuildUnstakeByDebitParams { + operator, + debit, + all: false, + fee, + dry_run, + validate_admission, + }; + let (_, request_and_response): (UnstakeTransaction, _) = issue_method( + "unstakeByDebit", + Some(params), + &mut stream, + id.next(), + )?; + request_and_response + } + (None, None, true) => { + let params = BuildUnstakeByDebitParams { + operator, + debit: 0, + all: true, + fee, + dry_run, + validate_admission, + }; + let (_, request_and_response): (UnstakeTransaction, _) = issue_method( + "unstakeByDebit", + Some(params), + &mut stream, + id.next(), + )?; + request_and_response + } + _ => bail!("exactly one of --value, --debit, or --all must be selected"), }; - // Finally ask the node to create the transaction. - let (_, (request, response)): (UnstakeTransaction, _) = issue_method( - "unstake", - Some(build_unstake_params), - &mut stream, - id.next(), - )?; - // On dry run mode, print the request, otherwise, print the response. // This is kept like this strictly for backwards compatibility. // TODO: wouldn't it be better to always print the response or both? @@ -1415,9 +1601,11 @@ pub fn data_request_report( let mut dr_output = dr_tx.body.dr_output; let dr_creator_pkh = dr_tx.signatures[0].public_key.pkh(); - // When collateral is set to 0, it is actually the default collateral - // Get the consensus constants from to node to find out what is the default collateral - if dr_output.collateral == 0 { + // Serialized zero collateral is protocol-specific. Historical Wit/2.0 requests preserve + // their raw zero owner-only economics even when their report is fetched after activation; + // pre-Wit/2.0 and native Wit/2.1 requests display the configured minimum. Keep the CLI's + // reward breakdown aligned with consensus validation and cross-fork tally settlement. + if dr_output.collateral == 0 && version_at_epoch != ProtocolVersion::V2_0 { let request = r#"{"jsonrpc": "2.0","method": "getConsensusConstants", "id": "1"}"#; let response = send_request(&mut stream, request)?; let consensus_constants: ConsensusConstants = parse_response(&response)?; @@ -1778,6 +1966,61 @@ pub fn get_protocol(addr: SocketAddr) -> Result<(), anyhow::Error> { Ok(()) } +pub fn delegated_staking_activation_preflight( + addr: SocketAddr, +) -> Result<(), anyhow::Error> { + let mut stream = start_client(addr)?; + let request = r#"{"jsonrpc": "2.0","method": "delegatedStakingActivationPreflight", "id": "1"}"#; + let response = send_request(&mut stream, request)?; + let report: ActivationPreflightReport = parse_response(&response)?; + + println!("{}", serde_json::to_string_pretty(&report)?); + if !report.safe { + bail!("Wit/2.1 delegated-staking activation preflight is unsafe"); + } + + Ok(()) +} + +/// Print canonical diagnostics as lossless JSON, without interpreting them as a readiness verdict. +pub fn delegated_staking_activation_classification( + addr: SocketAddr, +) -> Result<(), anyhow::Error> { + let mut stream = start_client(addr)?; + let request = r#"{"jsonrpc": "2.0","method": "delegatedStakingActivationClassification", "id": "1"}"#; + let response = send_request(&mut stream, request)?; + let report: ActivationBoundaryReport = parse_response(&response)?; + if report.schema_version != 1 || !report.diagnostic_only { + bail!("unsupported or non-diagnostic activation classification response"); + } + eprintln!( + "Diagnostic only: this report is not activation approval, migration authorization, or proof of historical collateral debit." + ); + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +/// Query one validator's persistent Wit/2.1 owner, commission, and delegated-staking state. +pub fn query_validator_policy( + addr: SocketAddr, + validator: String, +) -> Result<(), anyhow::Error> { + let mut stream = start_client(addr)?; + let params = QueryValidatorPolicy { + validator: MagicEither::Left(validator), + }; + let response = send_request( + &mut stream, + &format!( + r#"{{"jsonrpc": "2.0","method": "validatorPolicy", "params": {}, "id": 1}}"#, + serde_json::to_string(¶ms)? + ), + )?; + let policy: ValidatorPolicyInfo = parse_response(&response)?; + println!("{}", serde_json::to_string_pretty(&policy)?); + Ok(()) +} + pub fn add_peers(addr: SocketAddr, peers: Vec) -> Result<(), anyhow::Error> { let mut stream = start_client(addr)?; if peers.is_empty() { @@ -1885,8 +2128,7 @@ pub fn rewind(addr: SocketAddr, epoch: Epoch) -> Result<(), anyhow::Error> { let response = send_request(&mut stream, &request)?; let response: bool = parse_response(&response)?; if response { - println!("Started rewind process up to epoch {}.", params.0); - println!("Use the nodeStats command to check the progress."); + println!("Rewind completed successfully through epoch {}.", params.0); } else { bail!("Failed to rewind chain"); } @@ -2423,6 +2665,67 @@ fn prompt_user_for_stake_confirmation(data: &BuildStakeResponse) -> Result = parse_response(""); diff --git a/src/cli/node/with_node.rs b/src/cli/node/with_node.rs index 517cb8b655..dabe107907 100644 --- a/src/cli/node/with_node.rs +++ b/src/cli/node/with_node.rs @@ -271,6 +271,16 @@ pub fn exec_cmd( Command::SignalingInfo { node } => rpc::signaling_info(node.unwrap_or(default_jsonrpc)), Command::Priority { node, json } => rpc::priority(node.unwrap_or(default_jsonrpc), json), Command::ProtocolInfo { node } => rpc::get_protocol(node.unwrap_or(default_jsonrpc)), + Command::DelegatedStakingActivationPreflight { node } => { + rpc::delegated_staking_activation_preflight(node.unwrap_or(default_jsonrpc)) + } + Command::DelegatedStakingActivationClassification { node } => { + rpc::delegated_staking_activation_classification(node.unwrap_or(default_jsonrpc)) + } + Command::QueryValidatorPolicy { node, validator } => rpc::query_validator_policy( + node.unwrap_or(default_jsonrpc), + validator, + ), Command::Stake { node, value, @@ -279,6 +289,7 @@ pub fn exec_cmd( fee, require_confirmation, dry_run, + validate_admission, } => rpc::send_st( node.unwrap_or(default_jsonrpc), value, @@ -288,10 +299,27 @@ pub fn exec_cmd( None, require_confirmation, dry_run, + validate_admission, + ), + Command::AuthorizeStake { + node, + validator, + withdrawer, + version, + expiration_epoch, + commission_percent, + action, + nonce, + } => rpc::authorize_st( + node.unwrap_or(default_jsonrpc), + validator, + withdrawer, + version, + expiration_epoch, + commission_percent, + action, + nonce, ), - Command::AuthorizeStake { node, withdrawer } => { - rpc::authorize_st(node.unwrap_or(default_jsonrpc), withdrawer) - } Command::QueryStakes { node, validator, @@ -307,14 +335,20 @@ pub fn exec_cmd( node, operator, value, + debit, + all, fee, dry_run, + validate_admission, } => rpc::send_ut( node.unwrap_or(default_jsonrpc), MagicEither::Left(operator), value, + debit, + all, fee, dry_run, + validate_admission, ), } } @@ -526,6 +560,39 @@ pub enum Command { #[structopt(short = "n", long = "node")] node: Option, }, + #[structopt( + name = "delegatedStakingActivationPreflight", + aliases = &["activationPreflight", "delegated-staking-activation-preflight"], + about = "Read-only Wit/2.1 delegated-staking activation safety report; exits nonzero when unsafe" + )] + DelegatedStakingActivationPreflight { + /// Socket address of the Witnet node to query + #[structopt(short = "n", long = "node")] + node: Option, + }, + #[structopt( + name = "delegatedStakingActivationClassification", + aliases = &["activationClassification", "delegated-staking-activation-classification"], + about = "Read-only canonical legacy-state classification as JSON; never activation approval" + )] + DelegatedStakingActivationClassification { + /// Socket address of the Witnet node to query + #[structopt(short = "n", long = "node")] + node: Option, + }, + #[structopt( + name = "validatorPolicy", + aliases = &["queryValidatorPolicy", "validator-policy"], + about = "Query persistent owner, commission, and delegated-staking state for a validator" + )] + QueryValidatorPolicy { + /// Socket address of the Witnet node to query + #[structopt(short = "n", long = "node")] + node: Option, + /// Validator/operator public-key hash + #[structopt(long = "validator", alias = "address", alias = "pkh")] + validator: String, + }, #[structopt(name = "output", about = "Find an output of a transaction")] Output { /// Socket address of the Witnet node to query @@ -822,15 +889,37 @@ pub enum Command { /// Print the request that would be sent to the node and exit without doing anything #[structopt(long = "dry-run")] dry_run: bool, + /// Assert that the signed dry-run preview is admissible under the node's current + /// consensus state, pending-action indexes, fee floors, and mempool weight pressure. + #[structopt(long = "validate-admission", requires = "dry-run")] + validate_admission: bool, }, #[structopt(name = "authorizeStake", about = "Create an stake authorization")] AuthorizeStake { /// Socket address of the Witnet node to query #[structopt(short = "n", long = "node")] node: Option, + /// Target validator for withdrawer-signed exit (otherwise the local validator) + #[structopt(long = "validator")] + validator: Option, /// Withdrawer address #[structopt(long = "withdrawer")] withdrawer: Option, + /// Authorization format (1 for legacy, 2 for Wit/2.1) + #[structopt(long = "version")] + version: Option, + /// Exclusive expiration epoch for a Wit/2.1 authorization + #[structopt(long = "expiration-epoch")] + expiration_epoch: Option, + /// Whole-number validator commission percentage (0 through 15) + #[structopt(long = "commission-percent", default_value = "0")] + commission_percent: u32, + /// Authorization action (0 delegates, 1 updates commission, 2 registers owner, 3 exits) + #[structopt(long = "action", default_value = "0")] + action: u32, + /// Commission-update nonce or exact current stake-entry nonce for exit + #[structopt(long = "nonce", default_value = "0")] + nonce: u64, }, #[structopt( name = "queryStakes", @@ -871,15 +960,25 @@ pub enum Command { /// Node address operating the staked coins #[structopt(long = "operator")] operator: String, - /// Value - #[structopt(long = "value")] - value: u64, - /// Fee + /// Legacy net withdrawal value; the fee is debited from stake in addition to this amount + #[structopt(long = "value", conflicts_with_all = &["debit", "all"])] + value: Option, + /// Gross stake debit, including the fee + #[structopt(long = "debit", conflicts_with_all = &["value", "all"])] + debit: Option, + /// Debit the exact local withdrawer's live entry balance observed during construction + #[structopt(long = "all", conflicts_with_all = &["value", "debit"])] + all: bool, + /// Fee included in --debit/--all, but charged in addition to legacy --value #[structopt(long = "fee")] fee: u64, /// Print the request that would be sent to the node and exit without doing anything #[structopt(long = "dry-run")] dry_run: bool, + /// Assert that the signed dry-run preview is admissible under the node's current + /// consensus state, pending-action indexes, fee floors, and mempool weight pressure. + #[structopt(long = "validate-admission", requires = "dry-run")] + validate_admission: bool, }, } diff --git a/storage/src/backends/hashmap.rs b/storage/src/backends/hashmap.rs index cdd1779267..48f0d90645 100644 --- a/storage/src/backends/hashmap.rs +++ b/storage/src/backends/hashmap.rs @@ -45,6 +45,11 @@ impl Storage for Backend { WriteBatchItem::Delete(key) => { map.remove(&key); } + WriteBatchItem::DeleteRange(start, end) => { + map.retain(|key, _| { + key.as_slice() < start.as_slice() || key.as_slice() >= end.as_slice() + }); + } } } @@ -59,7 +64,7 @@ struct DBIterator<'a, 'b> { } impl Iterator for DBIterator<'_, '_> { - type Item = (Vec, Vec); + type Item = Result<(Vec, Vec)>; fn next(&mut self) -> Option { // TODO: is this correct? Add tests @@ -78,7 +83,7 @@ impl Iterator for DBIterator<'_, '_> { }) .next(); self.skip = skip; - res + res.map(Ok) } } @@ -97,6 +102,12 @@ mod tests { assert_eq!(None, storage.get(b"name").unwrap()); storage.put(b"name".to_vec(), b"john".to_vec()).unwrap(); assert_eq!(Some("john".into()), storage.get(b"name").unwrap()); + let entries = storage + .prefix_iterator(b"na") + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(entries, vec![(b"name".to_vec(), b"john".to_vec())]); storage.delete(b"name").unwrap(); assert_eq!(None, storage.get(b"name").unwrap()); } diff --git a/storage/src/backends/rocks.rs b/storage/src/backends/rocks.rs index 614347a1a9..7852ea3349 100644 --- a/storage/src/backends/rocks.rs +++ b/storage/src/backends/rocks.rs @@ -15,6 +15,26 @@ pub type Options = rocksdb::Options; #[error("RocksDB error: {0}")] struct Error(rocksdb::Error); +fn into_rocksdb_batch(batch: WriteBatch) -> rocksdb::WriteBatch { + let mut rocksdb_batch = rocksdb::WriteBatch::default(); + + for item in batch.batch { + match item { + WriteBatchItem::Put(key, value) => { + rocksdb_batch.put(key, value); + } + WriteBatchItem::Delete(key) => { + rocksdb_batch.delete(key); + } + WriteBatchItem::DeleteRange(start, end) => { + rocksdb_batch.delete_range(start, end); + } + } + } + + rocksdb_batch +} + impl Storage for Backend { fn get(&self, key: &[u8]) -> Result>> { let result = Backend::get(self, key) @@ -35,32 +55,34 @@ impl Storage for Backend { fn prefix_iterator<'a, 'b: 'a>(&'a self, prefix: &'b [u8]) -> Result> { let iterator = Backend::prefix_iterator(self, prefix) - .filter_map(|result| { + .map(|result| { result - .ok() .map(|(k, v)| (Vec::::from(k), Vec::::from(v))) + .map_err(|error| anyhow::Error::new(Error(error))) }) - .take_while(|(k, _v)| k.starts_with(prefix)); + // An entry error has no key with which to test the boundary. Yield it so callers fail + // closed; a subsequent successful entry can still establish the end of the prefix. + .take_while(|result| match result { + Ok((key, _value)) => key.starts_with(prefix), + Err(_) => true, + }); Ok(Box::new(iterator)) } /// Atomically write a batch of operations fn write(&self, batch: WriteBatch) -> Result<()> { - let mut rocksdb_batch = rocksdb::WriteBatch::default(); - - for item in batch.batch { - match item { - WriteBatchItem::Put(key, value) => { - rocksdb_batch.put(key, value); - } - WriteBatchItem::Delete(key) => { - rocksdb_batch.delete(key); - } - } - } + Backend::write(self, into_rocksdb_batch(batch)).map_err(Error)?; - self.write(rocksdb_batch)?; + Ok(()) + } + + /// Atomically write a batch and synchronize its RocksDB write-ahead log before returning. + fn write_sync(&self, batch: WriteBatch) -> Result<()> { + let mut options = rocksdb::WriteOptions::default(); + options.set_sync(true); + Backend::write_opt(self, into_rocksdb_batch(batch), &options) + .map_err(Error)?; Ok(()) } diff --git a/storage/src/storage.rs b/storage/src/storage.rs index 92977d827c..1252f4d2be 100644 --- a/storage/src/storage.rs +++ b/storage/src/storage.rs @@ -21,15 +21,32 @@ pub trait Storage { /// Delete a value from the storage fn delete(&self, key: &[u8]) -> Result<()>; - /// Create an iterator over all the keys that start with the given prefix + /// Create an iterator over all the keys that start with the given prefix. + /// + /// Failure to create the iterator is returned here; failures discovered while reading entries + /// are returned by the iterator itself. fn prefix_iterator<'a, 'b: 'a>(&'a self, prefix: &'b [u8]) -> Result>; /// Atomically write a batch of operations fn write(&self, batch: WriteBatch) -> Result<()>; + + /// Atomically write a batch and request synchronous durability from the backend. + /// + /// Backends without a stronger durability primitive retain the ordinary atomic-write + /// semantics. Persistent backends should override this method when they can acknowledge only + /// after the write-ahead log or equivalent durable record has been synchronized. + fn write_sync(&self, batch: WriteBatch) -> Result<()> { + self.write(batch) + } } -/// Iterator over key-value pairs -pub type StorageIterator<'a> = Box, Vec)> + 'a>; +/// Iterator over key-value pairs. +/// +/// Backends may discover read failures only after iteration starts, so every entry carries its +/// own result. Consumers must handle those failures instead of accidentally treating a damaged or +/// unreadable entry as if it did not exist. +pub type StorageIterator<'a> = + Box, Vec)>> + 'a>; #[derive(Default)] /// An atomic batch of operations. To persist the changes, use `Storage::write`. @@ -47,6 +64,14 @@ impl WriteBatch { pub fn delete(&mut self, key: Vec) { self.batch.push(WriteBatchItem::Delete(key)); } + /// Add an exclusive-end key-range deletion to the batch. + /// + /// The operation removes every key `key` such that `start <= key < end`. Callers must provide + /// a non-empty, lexicographically ordered range. Backends apply the range atomically with the + /// other operations in this batch. + pub fn delete_range(&mut self, start: Vec, end: Vec) { + self.batch.push(WriteBatchItem::DeleteRange(start, end)); + } } /// WriteBatch item @@ -55,4 +80,6 @@ pub enum WriteBatchItem { Put(Vec, Vec), /// Delete Delete(Vec), + /// Delete every key in the exclusive-end range `[start, end)`. + DeleteRange(Vec, Vec), } diff --git a/validations/src/eligibility/current.rs b/validations/src/eligibility/current.rs index e8a0673603..924d81b2d8 100644 --- a/validations/src/eligibility/current.rs +++ b/validations/src/eligibility/current.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeSet, fmt::{Debug, Display}, iter::Sum, ops::{Add, AddAssign, Div, Mul, Rem, Sub}, @@ -55,6 +56,48 @@ where where ISK: Into
; + /// Mining eligibility with a consensus-supplied validator activity filter. + /// + /// Wit/2.1 uses this to exclude validators whose cold owner does not retain + /// the mandatory self-stake from both ranking and replication-factor math. + fn mining_eligibility_filtered( + &self, + validator: ISK, + epoch: Epoch, + rf: u16, + _validator_is_active: F, + ) -> StakesResult + where + ISK: Into
, + F: FnMut(&Address) -> bool, + { + // Keep downstream Eligibility implementations source-compatible. The concrete Stakes + // implementation below overrides this with the validator-scoped filtering required by + // Wit/2.1; older external implementations retain their historical eligibility behavior. + self.mining_eligibility(validator, epoch, rf) + } + + /// Fallible mining eligibility with a consensus-supplied validator activity filter. + /// + /// The concrete `Stakes` implementation overrides this method and propagates policy/ledger + /// errors. The default exists for downstream `Eligibility` implementations that predate + /// delegated staking; those implementations retain their historical best-effort behavior. + fn mining_eligibility_filtered_checked( + &self, + validator: ISK, + epoch: Epoch, + rf: u16, + mut validator_is_active: F, + ) -> StakesResult + where + ISK: Into
, + F: FnMut(&Address) -> StakesResult, + { + self.mining_eligibility_filtered(validator, epoch, rf, |candidate| { + validator_is_active(candidate).unwrap_or(false) + }) + } + /// Tells whether a VRF proof meets the requirements to become eligible for mining. Because this function returns a /// simple `bool`, it is best-effort: both lack of eligibility and any error cases are mapped to `false`. fn mining_eligibility_bool(&self, validator: ISK, epoch: Epoch, rf: u16) -> bool @@ -81,6 +124,56 @@ where where ISK: Into
; + /// Witnessing eligibility with a consensus-supplied validator activity filter. + /// + /// The filter applies to the candidate and to every member of the power + /// ranking used for Wit/2.1 quantile and threshold calculations. + fn witnessing_eligibility_filtered( + &self, + validator: ISK, + epoch: Epoch, + witnesses: u16, + round: u16, + max_rounds: u16, + _validator_is_active: F, + ) -> StakesResult<(Eligible, Hash, f64), Address, Coins, Epoch> + where + ISK: Into
, + F: FnMut(&Address) -> bool, + { + // As with mining, retain the historical behavior for external implementations that do + // not provide a delegated-staking-aware override. + self.witnessing_eligibility(validator, epoch, witnesses, round, max_rounds) + } + + /// Fallible witnessing eligibility with a consensus-supplied validator activity filter. + /// + /// The concrete `Stakes` implementation overrides this method and propagates policy/ledger + /// errors. The default exists for downstream implementations that predate delegated staking; + /// their compatibility path maps activity-callback errors to ordinary ineligibility. + fn witnessing_eligibility_filtered_checked( + &self, + validator: ISK, + epoch: Epoch, + witnesses: u16, + round: u16, + max_rounds: u16, + mut validator_is_active: F, + ) -> StakesResult<(Eligible, Hash, f64), Address, Coins, Epoch> + where + ISK: Into
, + F: FnMut(&Address) -> StakesResult, + { + self.witnessing_eligibility_filtered( + validator, + epoch, + witnesses, + round, + max_rounds, + |candidate| validator_is_active(candidate).unwrap_or(false), + ) + } + /// Tells whether a VRF proof meets the requirements to become eligible for witnessing. Because this function /// returns a simple `bool`, it is best-effort: both lack of eligibility and any error cases are mapped to `false`. fn witnessing_eligibility_bool( @@ -154,6 +247,7 @@ where + 'static, Power: Copy + Default + + num_traits::CheckedAdd + Ord + Add + Sub @@ -163,7 +257,11 @@ where + Serialize + Sum + Display, - u64: From + From + Mul + Div, + u64: From + + From + + From + + Mul + + Div, { fn mining_eligibility( &self, @@ -176,8 +274,9 @@ where { let validator: Address = validator.into(); - // Cap replication factor to 2/3rds of total stake entries count - let max_replication_factor = u16::try_from(self.stakes_count() * 2 / 3).unwrap_or(u16::MAX); + // Preserve the pre-Wit/2.1 entry-scoped ranking and release arithmetic exactly. The + // filtered method below is the validator-aggregate Wit/2.1 path. + let max_replication_factor = legacy_max_replication_factor(self.stakes_count()); let replication_factor = replication_factor.min(max_replication_factor); Ok( @@ -192,6 +291,92 @@ where ) } + fn mining_eligibility_filtered( + &self, + validator: ISK, + epoch: Epoch, + replication_factor: u16, + validator_is_active: F, + ) -> StakesResult + where + ISK: Into
, + F: FnMut(&Address) -> bool, + { + let validator: Address = validator.into(); + + // One validator may have multiple withdrawer entries in Wit/2.1, but it + // remains one eligibility identity. Rank and replication limits are + // therefore validator-scoped. This is equivalent to the legacy logic + // while the 1:1 validator/withdrawer invariant holds. + let mut ranking = self + .by_validator_rank_checked_filtered( + Capability::Mining, + epoch, + validator_is_active, + )? + .collect::>(); + let max_replication_factor = + u16::try_from(ranking.len().saturating_mul(2) / 3).unwrap_or(u16::MAX); + let replication_factor = replication_factor.min(max_replication_factor); + + // Preserve the legacy reverse-order validator tie break exactly. + ranking.sort_by(|(validator_1, power_1), (validator_2, power_2)| { + power_2 + .cmp(power_1) + .then_with(|| validator_2.cmp(validator_1)) + }); + + Ok( + match ranking + .into_iter() + .take(replication_factor as usize) + .find(|(ranked_validator, _)| ranked_validator == &validator) + { + Some(_) => Eligible::Yes, + None => IneligibilityReason::InsufficientPower.into(), + }, + ) + } + + fn mining_eligibility_filtered_checked( + &self, + validator: ISK, + epoch: Epoch, + replication_factor: u16, + mut validator_is_active: F, + ) -> StakesResult + where + ISK: Into
, + F: FnMut(&Address) -> StakesResult, + { + // The shared filtered implementation can report ordinary ineligibility when the + // candidate activity predicate never sees a validator omitted from a malformed secondary + // index. This fallible public boundary must reject the complete maintained projection + // before evaluating that predicate; callers that intentionally reuse a checked + // participation projection retain their own earlier validation contract. + self.validate_validator_index_checked()?; + let validator: Address = validator.into(); + + // Resolve the activity predicate through its fallible form before invoking the shared + // boolean-filter implementation. This keeps ranking arithmetic single-sourced while + // ensuring a malformed delegated-staking policy cannot be treated as an inactive validator. + let active_validators = self + .validator_addresses() + .try_fold(BTreeSet::new(), |mut active, candidate| { + if validator_is_active(candidate)? { + active.insert(candidate.clone()); + } + Ok::, StakesError>(active) + })?; + + self.mining_eligibility_filtered( + validator, + epoch, + replication_factor, + |candidate| active_validators.contains(candidate), + ) + } + fn witnessing_eligibility( &self, key: ISK, @@ -203,7 +388,104 @@ where where ISK: Into
, { - let power = match self.query_power(key, Capability::Witnessing, epoch) { + let power = match self.query_power_legacy(key, Capability::Witnessing, epoch) { + Ok(p) => p, + Err(e) => { + return match e { + StakesError::ValidatorNotFound { .. } => { + Ok((IneligibilityReason::NotStaking.into(), Hash::min(), 0.0)) + } + e => Err(e), + }; + } + }; + + if power == Power::from(0) { + return Ok(( + IneligibilityReason::InsufficientPower.into(), + Hash::min(), + 0.0, + )); + } + + let mut rank = self.by_rank(Capability::Witnessing, epoch); + let (_, max_power) = rank.next().unwrap_or_default(); + + // Round and witness counts are consensus-provided values. Keep the legacy ranking + // semantics for valid inputs, but fail with a structured staking error instead of + // panicking if the replication factor cannot be represented. A rank offset below two is + // handled explicitly below to preserve the legacy no-threshold result. + let rf = 2usize + .checked_pow(u32::from(round)) + .and_then(|factor| factor.checked_mul(usize::from(witnesses))) + .ok_or(StakesError::ArithmeticOverflow)?; + // The historical release build wrapped `rf - 2`; for `rf < 2`, `nth` therefore + // returned `None` and the threshold defaulted to zero. Preserve that compatibility + // result explicitly while avoiding the debug-build underflow panic. + let threshold_power = rf + .checked_sub(2) + .and_then(|index| rank.nth(index)) + .map(|(_, power)| power) + .unwrap_or_default(); + if power <= threshold_power { + return Ok(( + IneligibilityReason::InsufficientPower.into(), + Hash::min(), + 0.0, + )); + } + + let dividend = Power::from(u64::from(max_rounds)) + * Power::from((u64::BITS - u64::from(power).leading_zeros()).into()); + let divisor = u32::from(round) + .saturating_mul(u64::BITS - u64::from(threshold_power).leading_zeros()) + .saturating_add( + (u64::BITS - u64::from(max_power).leading_zeros()) + .saturating_mul(max_rounds.saturating_sub(round).into()), + ); + let (target_hash, probability) = if divisor == 0 { + (Hash::with_first_u32(u32::MAX), 1_f64) + } else { + let first_u32 = u32::try_from( + ((u64::MAX / Power::from(u64::from(divisor))) + .saturating_mul(u64::from(dividend))) + >> 32, + ) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let hash = Hash::with_first_u32(first_u32); + + #[allow(clippy::cast_precision_loss, clippy::cast_lossless)] + let probability: f64 = u64::from(dividend) as f64 / divisor as f64; + + (hash, probability) + }; + + Ok((Eligible::Yes, target_hash, probability)) + } + + fn witnessing_eligibility_filtered( + &self, + key: ISK, + epoch: Epoch, + witnesses: u16, + round: u16, + max_rounds: u16, + mut validator_is_active: F, + ) -> StakesResult<(Eligible, Hash, f64), Address, Coins, Epoch> + where + ISK: Into
, + F: FnMut(&Address) -> bool, + { + let validator: Address = key.into(); + if !validator_is_active(&validator) { + return Ok(( + IneligibilityReason::InsufficientPower.into(), + Hash::min(), + 0.0, + )); + } + + let power = match self.query_power(validator.clone(), Capability::Witnessing, epoch) { Ok(p) => p, Err(e) => { // Early exit if the stake key does not exist @@ -225,14 +507,37 @@ where )); } - let mut rank = self.by_rank(Capability::Witnessing, epoch); + let mut ranking = self + .by_validator_rank_checked_filtered( + Capability::Witnessing, + epoch, + validator_is_active, + )? + .collect::>(); + ranking.sort_by(|(validator_1, power_1), (validator_2, power_2)| { + power_2 + .cmp(power_1) + .then_with(|| validator_2.cmp(validator_1)) + }); + let mut rank = ranking.into_iter(); let (_, max_power) = rank.next().unwrap_or_default(); // Requirement no. 2 from the WIP: // "the witnessing power of the block proposer is in the `rf / stakers`th quantile among the witnessing powers // of all the stakers" - let rf = 2usize.pow(u32::from(round)) * witnesses as usize; - let (_, threshold_power) = rank.nth(rf - 2).unwrap_or_default(); + let replication_factor = 2usize + .checked_pow(u32::from(round)) + .and_then(|factor| factor.checked_mul(usize::from(witnesses))) + .ok_or(StakesError::ArithmeticOverflow)?; + // Match the unfiltered legacy path: a replication factor below two has no lower-ranked + // threshold after the maximum-power entry is consumed, so its threshold is zero. Using + // `saturating_sub(2)` would incorrectly select the first remaining ranked entry and make + // a valid one-witness round-zero request ineligible. + let threshold_power = replication_factor + .checked_sub(2) + .and_then(|index| rank.nth(index)) + .map(|(_, power)| power) + .unwrap_or_default(); if power <= threshold_power { return Ok(( IneligibilityReason::InsufficientPower.into(), @@ -250,18 +555,18 @@ where .saturating_mul(u64::BITS - u64::from(threshold_power).leading_zeros()) .saturating_add( (u64::BITS - u64::from(max_power).leading_zeros()) - .saturating_mul((max_rounds - round).into()), + .saturating_mul(max_rounds.saturating_sub(round).into()), ); let (target_hash, probability) = if divisor == 0 { (Hash::with_first_u32(u32::MAX), 1_f64) } else { - let hash = Hash::with_first_u32( - (((u64::MAX / Power::from(u64::from(divisor))) + let first_u32 = u32::try_from( + ((u64::MAX / Power::from(u64::from(divisor))) .saturating_mul(u64::from(dividend))) - >> 32) - .try_into() - .unwrap(), - ); + >> 32, + ) + .map_err(|_| StakesError::ArithmeticOverflow)?; + let hash = Hash::with_first_u32(first_u32); #[allow(clippy::cast_precision_loss, clippy::cast_lossless)] let probability: f64 = u64::from(dividend) as f64 / divisor as f64; @@ -271,6 +576,61 @@ where Ok((Eligible::Yes, target_hash, probability)) } + + fn witnessing_eligibility_filtered_checked( + &self, + key: ISK, + epoch: Epoch, + witnesses: u16, + round: u16, + max_rounds: u16, + mut validator_is_active: F, + ) -> StakesResult<(Eligible, Hash, f64), Address, Coins, Epoch> + where + ISK: Into
, + F: FnMut(&Address) -> StakesResult, + { + // The shared filtered implementation checks the complete validator projection only + // after the candidate activity predicate has accepted the requested validator. A + // malformed secondary index can therefore omit that validator and be reported as + // ordinary ineligibility before the ranking pass runs. This fallible public boundary + // must reject the malformed image up front; callers that intentionally reuse a checked + // participation projection still retain their own earlier validation contract. + self.validate_validator_index_checked()?; + let validator: Address = key.into(); + + // The legacy filtered method accepts only a boolean callback. Resolve every validator + // through the fallible callback first, then pass an immutable membership predicate to the + // shared eligibility arithmetic. This prevents a policy/read error from being converted + // into ordinary ineligibility while keeping the ranking implementation single-sourced. + let active_validators = self + .validator_addresses() + .try_fold(BTreeSet::new(), |mut active, candidate| { + if validator_is_active(candidate)? { + active.insert(candidate.clone()); + } + Ok::, StakesError>(active) + })?; + + self.witnessing_eligibility_filtered( + validator, + epoch, + witnesses, + round, + max_rounds, + |candidate| active_validators.contains(candidate), + ) + } +} + +/// Compute the legacy two-thirds replication cap without allowing a malformed/imported entry +/// count to overflow before the conversion to the protocol's bounded `u16` representation. +/// +/// Valid ledgers are far below this limit, so the saturating multiplication is observationally +/// identical to the historical `count * 2 / 3` formula while making the compatibility boundary +/// deterministic for hostile or corrupted snapshots. +fn legacy_max_replication_factor(stakes_count: usize) -> u16 { + u16::try_from(stakes_count.saturating_mul(2) / 3).unwrap_or(u16::MAX) } #[cfg(test)] @@ -279,6 +639,13 @@ mod tests { const MIN_STAKE_NANOWITS: u64 = 10_000_000_000; + #[test] + fn legacy_replication_cap_saturates_entry_count_before_conversion() { + assert_eq!(legacy_max_replication_factor(0), 0); + assert_eq!(legacy_max_replication_factor(3), 2); + assert_eq!(legacy_max_replication_factor(usize::MAX), u16::MAX); + } + #[test] fn test_mining_eligibility_no_stakers() { let stakes = StakesTester::default(); @@ -398,4 +765,33 @@ mod tests { assert!(!stakes.witnessing_eligibility_bool(isk_1, 0, 10, 0, 4)); assert!(stakes.witnessing_eligibility_bool(isk_1, 100, 10, 0, 4)); } + + #[test] + fn legacy_witnessing_eligibility_handles_one_witness_round_zero() { + let mut stakes = StakesTester::default(); + stakes + .add_stake("validator", 10_000_000_000, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + // The legacy implementation previously evaluated `rf - 2` with rf == 1, which could + // panic before returning an eligibility result. The compatibility path should retain + // deterministic eligibility semantics for this valid minimum witness count. + assert!(matches!( + stakes.witnessing_eligibility("validator", 100, 1, 0, 4), + Ok((Eligible::Yes, _, _)) + )); + } + + #[test] + fn legacy_witnessing_eligibility_rejects_replication_factor_overflow() { + let mut stakes = StakesTester::default(); + stakes + .add_stake("validator", 10_000_000_000, 0, true, MIN_STAKE_NANOWITS) + .unwrap(); + + assert_eq!( + stakes.witnessing_eligibility("validator", 100, 1, u16::MAX, 4), + Err(StakesError::ArithmeticOverflow) + ); + } } diff --git a/validations/src/eligibility/legacy.rs b/validations/src/eligibility/legacy.rs index 1119a095c1..9b0a164935 100644 --- a/validations/src/eligibility/legacy.rs +++ b/validations/src/eligibility/legacy.rs @@ -36,6 +36,12 @@ pub fn calculate_reppoe_threshold( minimum_difficulty: u32, active_wips: &ActiveWips, ) -> (Hash, f64) { + // Match the legacy RandPoE threshold's defensive authority handling. A zero + // configured minimum is malformed but can still be encountered while replaying + // an imported or partially initialized chain; clamping it prevents the + // third-hard-fork branch below from dividing by zero and taking the node down. + let minimum_difficulty = std::cmp::max(1, minimum_difficulty); + // Set minimum total_active_reputation to 1 to avoid division by zero let total_active_rep = std::cmp::max(rep_eng.total_active_reputation(), 1); // Add 1 to reputation because otherwise a node with 0 reputation would diff --git a/validations/src/tests/mod.rs b/validations/src/tests/mod.rs index 3f16598525..b8ddf08804 100644 --- a/validations/src/tests/mod.rs +++ b/validations/src/tests/mod.rs @@ -1,7 +1,10 @@ use std::{ collections::HashSet, convert::TryFrom, - sync::atomic::{AtomicU32, Ordering}, + sync::{ + atomic::{AtomicU32, Ordering}, + Mutex, MutexGuard, OnceLock, + }, }; use itertools::Itertools; @@ -24,8 +27,8 @@ use witnet_data_structures::{ proto::versioning::{ProtocolVersion, VersionedHashable}, radon_error::RadonError, radon_report::{RadonReport, ReportContext, TypeLike}, - register_protocol_version, staking::{ + delegation::DelegationState, helpers::StakeKey, stakes::{StakesTracker, process_stake_transactions}, }, @@ -34,6 +37,7 @@ use witnet_data_structures::{ transaction_factory::transaction_outputs_sum, utxo_pool::{UnspentOutputsPool, UtxoDiff}, vrf::{BlockEligibilityClaim, DataRequestEligibilityClaim, VrfCtx}, + wit::Wit, }; use witnet_protected::Protected; use witnet_rad::{ @@ -74,6 +78,24 @@ static DEFAULT_INPUT_VALUE: u64 = 2 * ONE_WIT; // Block epoch used in tally tests const E: Epoch = LAST_EPOCH_WITH_WIP_ACTIVATED; +/// Validation fixtures select one protocol at epoch zero at a time. Keep that test-only behavior +/// explicit instead of relying on production registration to overwrite a conflicting schedule. +fn register_protocol_version(protocol: ProtocolVersion, epoch: Epoch, checkpoint_period: u16) { + clear_protocol_info(); + witnet_data_structures::register_protocol_version(protocol, epoch, checkpoint_period); +} + +// These compatibility tests intentionally mutate the process-global protocol schedule. Serialize +// them so a parallel test cannot change the schedule between registration and validation. +static PROTOCOL_RUNTIME_TEST_LOCK: OnceLock> = OnceLock::new(); + +fn protocol_runtime_test_guard() -> MutexGuard<'static, ()> { + PROTOCOL_RUNTIME_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("protocol runtime test lock must not be poisoned") +} + const CONSENSUS_CONSTANTS_FOR_TALLY: ConsensusConstants = ConsensusConstants { checkpoint_zero_timestamp: 0, collateral_minimum: 1_000_000_000, @@ -321,14 +343,13 @@ fn mint_mismatched_reward() { let mint_tx = MintTransaction::new(epoch, vec![output]); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 45); - - let x = validate_mint_transaction( + let x = validate_mint_transaction_for_protocol( &mint_tx, total_fees, epoch, INITIAL_BLOCK_REWARD, HALVING_PERIOD, + protocol, ); if protocol >= ProtocolVersion::V2_0 { // Error: mint transactions should be empty under V2.0 @@ -348,7 +369,6 @@ fn mint_mismatched_reward() { ); } } - clear_protocol_info(); } #[test] @@ -403,14 +423,13 @@ fn mint_multiple_split() { let mint_tx = MintTransaction::new(epoch, vec![output1, output2, output3]); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 45); - - let x = validate_mint_transaction( + let x = validate_mint_transaction_for_protocol( &mint_tx, total_fees, epoch, INITIAL_BLOCK_REWARD, HALVING_PERIOD, + protocol, ); if protocol >= ProtocolVersion::V2_0 { // Error: mint transactions should be empty under V2.0 @@ -426,7 +445,6 @@ fn mint_multiple_split() { ); } } - clear_protocol_info(); } #[test] @@ -447,14 +465,13 @@ fn mint_split_valid() { let mint_tx = MintTransaction::new(epoch, vec![output1, output2]); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 45); - - let x = validate_mint_transaction( + let x = validate_mint_transaction_for_protocol( &mint_tx, total_fees, epoch, INITIAL_BLOCK_REWARD, HALVING_PERIOD, + protocol, ); if protocol >= ProtocolVersion::V2_0 { // Error: mint transactions should be empty under V2.0 @@ -467,7 +484,6 @@ fn mint_split_valid() { x.unwrap(); } } - clear_protocol_info(); } #[test] @@ -483,14 +499,13 @@ fn mint_valid() { let mint_tx = MintTransaction::new(epoch, vec![output]); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 45); - - let x = validate_mint_transaction( + let x = validate_mint_transaction_for_protocol( &mint_tx, total_fees, epoch, INITIAL_BLOCK_REWARD, HALVING_PERIOD, + protocol, ); if protocol >= ProtocolVersion::V2_0 { // Error: mint transactions should be empty under V2.0 @@ -503,7 +518,6 @@ fn mint_valid() { x.unwrap(); } } - clear_protocol_info(); } #[test] @@ -3142,6 +3156,7 @@ fn test_empty_commit(c_tx: &CommitTransaction) -> Result<(), anyhow::Error> { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -3204,6 +3219,7 @@ fn test_commit_with_dr_and_utxo_set( superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, )?; @@ -3300,6 +3316,7 @@ fn test_commit_difficult_proof() { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -3384,6 +3401,7 @@ fn test_commit_with_collateral( superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -3637,6 +3655,7 @@ fn commitment_invalid_proof() { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -3723,6 +3742,7 @@ fn commitment_dr_in_reveal_stage() { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ); @@ -4036,7 +4056,7 @@ fn commitment_collateral_zero_is_minimum() { let inputs = vec![Input::new(output1), Input::new(output2)]; let change_output = ValueTransferOutput { pkh: MY_PKH_1.parse().unwrap(), - value: ONE_WIT + 1, + value: ONE_WIT, time_lock: 0, }; @@ -4115,6 +4135,7 @@ fn commitment_collateral_zero_is_minimum() { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -4122,9 +4143,9 @@ fn commitment_collateral_zero_is_minimum() { }; assert_eq!( x.unwrap_err().downcast::().unwrap(), - TransactionError::NegativeCollateral { - input_value: ONE_WIT, - output_value: ONE_WIT + 1 + TransactionError::IncorrectCollateral { + expected: 1, + found: 0, } ); } @@ -4216,6 +4237,7 @@ fn commitment_timelock() { superblock_period, ProtocolVersion::V1_7, &stakes, + &DelegationState::default(), MAX_ROUNDS, MIN_STAKE_NANOWITS, ) @@ -9814,13 +9836,25 @@ fn setup_stakes_tracker( let stake_tx = StakeTransaction::new(stake_tx_body, vec![]); let mut stakes = StakesTracker::default(); - let _ = process_stake_transactions(&mut stakes, [stake_tx].iter(), 0, 10_000_000_000); + let _ = process_stake_transactions( + &mut stakes, + [stake_tx].iter(), + 0, + 10_000_000_000, + ); (validator_pkh, withdrawer_pkh, stakes) } +// These fixtures exercise the legacy stake payload. Wit/2.1 requires signed authorization terms +// and needs action-specific fixtures rather than silently changing the expected legacy error. +fn legacy_stake_protocols() -> impl Iterator { + ProtocolVersion::iter().filter(|protocol| *protocol < ProtocolVersion::V2_1) +} + #[test] fn st_not_allowed() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); let utxo_set = UnspentOutputsPool::default(); let block_number = 0; let utxo_diff = UtxoDiff::new(&utxo_set, block_number); @@ -9844,6 +9878,8 @@ fn st_not_allowed() { EpochConstants::default(), &mut vec![], &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -9856,6 +9892,7 @@ fn st_not_allowed() { #[test] fn st_no_inputs() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); let utxo_set = UnspentOutputsPool::default(); let block_number = 0; let utxo_diff = UtxoDiff::new(&utxo_set, block_number); @@ -9870,7 +9907,7 @@ fn st_no_inputs() { let st_body = StakeTransactionBody::new(vec![], st_output, None); let st_tx = StakeTransaction::new(st_body, vec![]); - for protocol in ProtocolVersion::iter() { + for protocol in legacy_stake_protocols() { register_protocol_version(protocol, 0, 10); let x = validate_stake_transaction( @@ -9880,6 +9917,8 @@ fn st_no_inputs() { EpochConstants::default(), &mut vec![], &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -9902,6 +9941,7 @@ fn st_no_inputs() { #[test] fn st_one_input_but_no_signature() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); let mut signatures_to_verify = vec![]; let utxo_set = UnspentOutputsPool::default(); let block_number = 0; @@ -9922,7 +9962,7 @@ fn st_one_input_but_no_signature() { let stake_tx_body = StakeTransactionBody::new(vec![vti], stake_output, None); let stake_tx = StakeTransaction::new(stake_tx_body, vec![]); - for protocol in ProtocolVersion::iter() { + for protocol in legacy_stake_protocols() { register_protocol_version(protocol, 0, 10); let x = validate_stake_transaction( @@ -9932,6 +9972,8 @@ fn st_one_input_but_no_signature() { EpochConstants::default(), &mut signatures_to_verify, &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -9955,6 +9997,7 @@ fn st_one_input_but_no_signature() { #[test] fn st_below_min_stake() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); let mut signatures_to_verify = vec![]; let utxo_set = UnspentOutputsPool::default(); let block_number = 0; @@ -9975,7 +10018,7 @@ fn st_below_min_stake() { let stake_tx_body = StakeTransactionBody::new(vec![vti], stake_output, None); let stake_tx = StakeTransaction::new(stake_tx_body, vec![]); - for protocol in ProtocolVersion::iter() { + for protocol in legacy_stake_protocols() { register_protocol_version(protocol, 0, 10); let x = validate_stake_transaction( @@ -9985,6 +10028,8 @@ fn st_below_min_stake() { EpochConstants::default(), &mut signatures_to_verify, &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -10008,6 +10053,7 @@ fn st_below_min_stake() { #[test] fn st_above_max_stake() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); // Setup stakes tracker with a (validator, validator) pair let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MAX_STAKE_NANOWITS, PRIV_KEY_1, PRIV_KEY_2); @@ -10030,7 +10076,7 @@ fn st_above_max_stake() { let stake_tx_body = StakeTransactionBody::new(vec![vti], stake_output, None); let stake_tx = StakeTransaction::new(stake_tx_body, vec![]); - for protocol in ProtocolVersion::iter() { + for protocol in legacy_stake_protocols() { register_protocol_version(protocol, 0, 10); let x = validate_stake_transaction( @@ -10040,6 +10086,8 @@ fn st_above_max_stake() { EpochConstants::default(), &mut signatures_to_verify, &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -10070,7 +10118,7 @@ fn st_above_max_stake() { let stake_tx_body = StakeTransactionBody::new(vec![vti], stake_output, None); let stake_tx = StakeTransaction::new(stake_tx_body, vec![]); - for protocol in ProtocolVersion::iter() { + for protocol in legacy_stake_protocols() { register_protocol_version(protocol, 0, 10); let x = validate_stake_transaction( @@ -10080,6 +10128,8 @@ fn st_above_max_stake() { EpochConstants::default(), &mut signatures_to_verify, &stakes, + &DelegationState::default(), + 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, ); @@ -10101,11 +10151,115 @@ fn st_above_max_stake() { clear_protocol_info(); } +// These isolated Unstake fixtures have no outstanding requests. Construct explicit pool evidence +// instead of introducing a permissive empty native lock default. +fn empty_request_reward_locks() -> LegacyRewardLocks { + LegacyRewardLocks::from_pool( + &DataRequestPool::default(), &DelegationState::default(), + &witnet_data_structures::proto::versioning::ProtocolInfo::default(), + ).unwrap() +} + +fn activated_delegation_state( + validator: PublicKeyHash, + owner_withdrawer: PublicKeyHash, +) -> DelegationState { + let mut state = DelegationState { + activation_initialized: true, + ..DelegationState::default() + }; + state + .register_validator_if_absent(validator, owner_withdrawer, 0) + .unwrap(); + state +} + +fn unstake_epoch_constants() -> EpochConstants { + EpochConstants { + // Existing fixtures encode the lock as a relative duration. Use a realistic positive + // genesis timestamp so Wit/2.1 classifies that wire value exactly as production does. + checkpoint_zero_timestamp: i64::try_from(UNSTAKING_DELAY_SECONDS) + .expect("test unstaking delay fits i64") + + 1, + ..EpochConstants::default() + } +} + +#[test] +fn wit21_unstake_absolute_lock_is_measured_from_inclusion_epoch() { + let epoch = Epoch::from(2_u32); + let delay = 100; + let constants = EpochConstants { + checkpoint_zero_timestamp: 1_000_000_000, + checkpoints_period: 10, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2: 10, + }; + let absolute_minimum = 1_000_000_000 + 20 + delay; + let transaction_with_lock = |time_lock| { + UnstakeTransaction::new( + UnstakeTransactionBody::new( + PublicKeyHash::default(), + ValueTransferOutput { + time_lock, + ..ValueTransferOutput::default() + }, + 0, + 0, + ), + KeyedSignature::default(), + ) + }; + + assert!(validate_unstake_timelock_for_protocol( + &transaction_with_lock(delay), + epoch, + constants, + delay, + ProtocolVersion::V2_1, + ) + .is_ok()); + for invalid in [constants.checkpoint_zero_timestamp as u64, absolute_minimum - 1] { + assert!(matches!( + validate_unstake_timelock_for_protocol( + &transaction_with_lock(invalid), + epoch, + constants, + delay, + ProtocolVersion::V2_1, + ) + .unwrap_err() + .downcast::() + .unwrap(), + TransactionError::InvalidAbsoluteUnstakeTimelock { .. } + )); + } + assert!(validate_unstake_timelock_for_protocol( + &transaction_with_lock(absolute_minimum), + epoch, + constants, + delay, + ProtocolVersion::V2_1, + ) + .is_ok()); + + // Wit/2.0 retains its historical raw-duration check for replay compatibility. + assert!(validate_unstake_timelock_for_protocol( + &transaction_with_lock(constants.checkpoint_zero_timestamp as u64), + epoch, + constants, + delay, + ProtocolVersion::V2_0, + ) + .is_ok()); +} + #[test] fn unstake_success() { // Setup stakes tracker with a (validator, validator) pair let (validator_pkh, _, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS + 1000, PRIV_KEY_1, PRIV_KEY_1); + let delegation_state = activated_delegation_state(validator_pkh, validator_pkh); let vto = ValueTransferOutput { pkh: validator_pkh, @@ -10118,14 +10272,16 @@ fn unstake_success() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert!(x.is_ok()); @@ -10136,11 +10292,11 @@ fn unstake_success() { ); } } - clear_protocol_info(); // Setup stakes tracker with a (validator, withdrawer) pair let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS + 1000, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); // Try unstaking up to the MIN_STAKE_NANOWITS balance let vto = ValueTransferOutput { @@ -10154,14 +10310,16 @@ fn unstake_success() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert!(x.is_ok()); @@ -10172,7 +10330,6 @@ fn unstake_success() { ); } } - clear_protocol_info(); // Note that we did not process above unstake transaction, so the stakes structure did not change // Try unstaking the full balance @@ -10187,14 +10344,16 @@ fn unstake_success() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert!(x.is_ok()); @@ -10205,11 +10364,11 @@ fn unstake_success() { ); } } - clear_protocol_info(); } #[test] fn unstake_not_allowed() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); register_protocol_version(ProtocolVersion::V2_0, 1, 10); let stakes = StakesTracker::default(); @@ -10223,7 +10382,10 @@ fn unstake_not_allowed() { let x = validate_unstake_transaction( &unstake_tx, Epoch::default(), + unstake_epoch_constants(), &stakes, + &DelegationState::default(), + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, ); @@ -10238,6 +10400,7 @@ fn unstake_not_allowed() { fn unstake_more_than_staked() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); // Unstaking 1 nanowit more than what was staked (using a zero nanowit fee) let vto = ValueTransferOutput { @@ -10250,14 +10413,16 @@ fn unstake_more_than_staked() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::default(), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10274,7 +10439,6 @@ fn unstake_more_than_staked() { ); } } - clear_protocol_info(); // Unstaking what was staked, but adding a 1 nanowit fee resulting in unstaking too much let vto = ValueTransferOutput { @@ -10287,14 +10451,16 @@ fn unstake_more_than_staked() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::default(), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10311,13 +10477,13 @@ fn unstake_more_than_staked() { ); } } - clear_protocol_info(); } #[test] fn unstake_invalid_nonce() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); let vto = ValueTransferOutput { pkh: withdrawer_pkh, @@ -10329,14 +10495,16 @@ fn unstake_invalid_nonce() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10353,13 +10521,13 @@ fn unstake_invalid_nonce() { ); } } - clear_protocol_info(); } #[test] fn unstake_wrong_withdrawer() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); let vto = ValueTransferOutput { pkh: withdrawer_pkh, @@ -10372,14 +10540,16 @@ fn unstake_wrong_withdrawer() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10396,13 +10566,13 @@ fn unstake_wrong_withdrawer() { ); } } - clear_protocol_info(); } #[test] fn unstake_below_stake_minimum() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS + 1000, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); let vto = ValueTransferOutput { pkh: withdrawer_pkh, @@ -10414,14 +10584,16 @@ fn unstake_below_stake_minimum() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10438,13 +10610,13 @@ fn unstake_below_stake_minimum() { ); } } - clear_protocol_info(); } #[test] fn unstake_timelock() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); let vto = ValueTransferOutput { pkh: withdrawer_pkh, @@ -10456,14 +10628,16 @@ fn unstake_timelock() { let unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10480,13 +10654,13 @@ fn unstake_timelock() { ); } } - clear_protocol_info(); } #[test] fn unstake_signature() { let (validator_pkh, withdrawer_pkh, stakes) = setup_stakes_tracker(MIN_STAKE_NANOWITS + 1000, PRIV_KEY_1, PRIV_KEY_2); + let delegation_state = activated_delegation_state(validator_pkh, withdrawer_pkh); let vto = ValueTransferOutput { pkh: withdrawer_pkh, @@ -10498,14 +10672,16 @@ fn unstake_signature() { let mut unstake_tx = UnstakeTransaction::new(unstake_tx_body, signature); for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert!(x.is_ok()); @@ -10516,19 +10692,20 @@ fn unstake_signature() { ); } } - clear_protocol_info(); unstake_tx.body.withdrawal.value = 999; for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 10); - - let x = validate_unstake_transaction( + let x = validate_unstake_transaction_for_protocol( &unstake_tx, Epoch::from(20001_u32), + unstake_epoch_constants(), &stakes, + &delegation_state, + &empty_request_reward_locks(), MIN_STAKE_NANOWITS, UNSTAKING_DELAY_SECONDS, + protocol, ); if protocol >= ProtocolVersion::V2_0 { assert_eq!( @@ -10545,7 +10722,6 @@ fn unstake_signature() { ); } } - clear_protocol_info(); } static LAST_VRF_INPUT: &str = "4da71b67e7e50ae4ad06a71e505244f8b490da55fc58c50386c908f7146d2239"; @@ -10553,8 +10729,6 @@ static LAST_VRF_INPUT: &str = "4da71b67e7e50ae4ad06a71e505244f8b490da55fc58c5038 #[test] fn block_signatures() { for protocol in ProtocolVersion::iter() { - register_protocol_version(protocol, 0, 45); - let mut b = Block::new(Default::default(), Default::default(), Default::default()); // Add valid vrf proof let vrf = &mut VrfCtx::secp256k1().unwrap(); @@ -10574,7 +10748,7 @@ fn block_signatures() { let f = |mut b: Block, ks| -> Result<_, anyhow::Error> { b.block_sig = ks; let mut signatures_to_verify = vec![]; - validate_block_signature(&b, &mut signatures_to_verify)?; + validate_block_signature_for_protocol(&b, &mut signatures_to_verify, protocol)?; verify_signatures_test(signatures_to_verify)?; Ok(()) }; @@ -10643,7 +10817,6 @@ fn block_signatures() { } ); } - clear_protocol_info(); } static ONE_WIT_OUTPUT: &str = "0f0f000000000000000000000000000000000000000000000000000000000000:0"; @@ -10791,6 +10964,8 @@ fn test_block_with_drpool_and_utxo_set bool>( &consensus_constants, &active_wips, &stakes, + &DelegationState::default(), + 0, protocol_version, REPLICATION_FACTOR, )?; @@ -10811,12 +10986,217 @@ fn test_block_with_drpool_and_utxo_set bool>( &active_wips, None, &stakes, + &DelegationState::default(), + 0, )?; verify_signatures_test(signatures_to_verify)?; Ok(()) } +#[test] +fn wit21_first_stake_requires_explicit_owner_registration() { + let secret_key = Secp256k1_SecretKey::from_slice(&PRIV_KEY_1).unwrap(); + let public_key = PublicKey::from(Secp256k1_PublicKey::from_secret_key_global(&secret_key)); + let validator = public_key.pkh(); + let owner = PublicKeyHash::from_bytes(&[71; 20]).unwrap(); + let input_pointer: OutputPointer = ONE_WIT_OUTPUT.parse().unwrap(); + let mut utxo_set = UnspentOutputsPool::default(); + utxo_set.insert(input_pointer, ValueTransferOutput { + pkh: validator, + value: MIN_STAKE_NANOWITS + 1, + time_lock: 0, + }, 0); + let utxo_diff = UtxoDiff::new(&utxo_set, 0); + let stakes = StakesTracker::default(); + let mut delegation_state = DelegationState::default(); + delegation_state.activation_initialized = true; + + let transaction_for_terms = |terms: StakeAuthorization| { + let secret_key = Secp256k1_SecretKey::from_slice(&PRIV_KEY_1).unwrap(); + let authorization = KeyedSignature { + signature: Signature::from(sign(secret_key, &terms.signing_digest().unwrap()).unwrap()), + public_key: public_key.clone(), + }; + let body = StakeTransactionBody::new( + vec![Input::new(input_pointer)], + StakeOutput { + authorization, + authorization_terms: Some(terms), + key: StakeKey::from((validator, owner)), + value: MIN_STAKE_NANOWITS, + }, + None, + ); + let signature = sign_tx(PRIV_KEY_1, &body, Some(ProtocolVersion::V2_1)); + StakeTransaction::new(body, vec![signature]) + }; + let delegation = transaction_for_terms(StakeAuthorization::delegation( + 0, validator, owner, Epoch::MAX, 15, + )); + let error = validate_stake_transaction_for_protocol( + &delegation, &utxo_diff, 1, EpochConstants::default(), &mut vec![], + &stakes, &delegation_state, 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, + ProtocolVersion::V2_1, + ).unwrap_err(); + assert!(matches!( + error.downcast_ref::>(), + Some(witnet_data_structures::staking::errors::StakesError::ValidatorRegistrationRequired { + validator: rejected, + }) if *rejected == validator + )); + + let registration = transaction_for_terms(StakeAuthorization::register_owner( + 0, validator, owner, Epoch::MAX, 15, + )); + let mut signatures = vec![]; + let (_, output, fee, _, _) = validate_stake_transaction_for_protocol( + ®istration, &utxo_diff, 1, EpochConstants::default(), &mut signatures, + &stakes, &delegation_state, 0, MIN_STAKE_NANOWITS, MAX_STAKE_NANOWITS, + ProtocolVersion::V2_1, + ).unwrap(); + assert_eq!(output.key.withdrawer, owner); + assert_eq!(fee, 1); + verify_signatures_test(signatures).unwrap(); + // Admission must not install the policy; only successful ordered consolidation does that. + assert!(delegation_state.validators.is_empty()); +} + +#[test] +fn wit21_rejects_deposit_and_unstake_for_the_same_exact_entry() { + let epoch = Epoch::from(1_u32); + // An offline schedule keeps this native conflict fixture independent of the dormant runtime. + let mut protocol_info = witnet_data_structures::proto::versioning::ProtocolInfo::default(); + protocol_info.try_register(0, ProtocolVersion::V2_0, 10).unwrap(); + protocol_info.try_register(epoch, ProtocolVersion::V2_1, 10).unwrap(); + let validator = BlockEligibilityClaim::default().proof.pkh(); + assert_eq!(validator, KeyedSignature::default().public_key.pkh()); + + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + StakeKey::from((validator, validator)), + Wit::from_nanowits(MIN_STAKE_NANOWITS), + 0, + true, + Wit::from_nanowits(MIN_STAKE_NANOWITS), + ) + .expect("owner stake fixture should be valid"); + + let input_pointer: OutputPointer = ONE_WIT_OUTPUT.parse().unwrap(); + let mut utxo_set = UnspentOutputsPool::default(); + utxo_set.insert( + input_pointer, + ValueTransferOutput { + pkh: validator, + value: 1, + time_lock: 0, + }, + 0, + ); + let epoch_constants = EpochConstants { + checkpoint_zero_timestamp: 1_000_000_000, + checkpoints_period: 10, + checkpoint_zero_timestamp_wit2: i64::MAX, + checkpoints_period_wit2: 10, + }; + let consensus_constants = ConsensusConstants { + checkpoint_zero_timestamp: epoch_constants.checkpoint_zero_timestamp, + ..CONSENSUS_CONSTANTS_FOR_TALLY.clone() + }; + + let validate_for_terms = |terms: StakeAuthorization| { + let stake = StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::new(input_pointer)], + StakeOutput { + authorization: KeyedSignature::default(), + authorization_terms: Some(terms), + key: StakeKey::from((validator, validator)), + value: 0, + }, + Some(ValueTransferOutput { + pkh: validator, + value: 1, + time_lock: 0, + }), + ), + vec![KeyedSignature::default()], + ); + let unstake = UnstakeTransaction::new( + UnstakeTransactionBody::new( + validator, + ValueTransferOutput { + pkh: validator, + value: 1, + time_lock: UNSTAKING_DELAY_SECONDS, + }, + 0, + 0, + ), + KeyedSignature::default(), + ); + let mut block = Block::default(); + block.block_header.beacon.checkpoint = epoch; + block.txns.stake_txns = vec![stake]; + block.txns.unstake_txns = vec![unstake]; + + validate_block_transactions_for_protocol( + &utxo_set, + &mut DataRequestPool::default(), + &block, + CheckpointVRF::default(), + &mut vec![], + &ReputationEngine::new(100), + epoch_constants, + 0, + &consensus_constants, + &ConsensusConstantsWit2::default(), + &all_wips_active(), + None, + &stakes, + &DelegationState::default(), + 0, + &protocol_info, + ProtocolVersion::V2_1, + ) + }; + + let error = validate_for_terms(StakeAuthorization::delegation( + 0, + validator, + validator, + Epoch::MAX, + 0, + )) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!( + error, + BlockError::ConflictingStakeAndUnstakeEntry { + validator, + withdrawer: validator, + } + ); + + let error = validate_for_terms(StakeAuthorization::commission_update( + 0, + validator, + validator, + Epoch::MAX, + 0, + 1, + )) + .unwrap_err() + .downcast::() + .unwrap(); + assert!(matches!( + error, + TransactionError::InvalidStakeAuthorization { .. } + )); +} + /////////////////////////////////////////////////////////////////////////////// // Block tests: one block /////////////////////////////////////////////////////////////////////////////// @@ -11074,6 +11454,8 @@ fn block_difficult_proof() { &consensus_constants, ¤t_active_wips(), &stakes, + &DelegationState::default(), + 0, protocol_version, REPLICATION_FACTOR, )?; @@ -11094,6 +11476,8 @@ fn block_difficult_proof() { ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, )?; verify_signatures_test(signatures_to_verify)?; @@ -11809,6 +12193,8 @@ fn test_blocks_with_limits( &consensus_constants, ¤t_active_wips(), &stakes, + &DelegationState::default(), + 0, protocol_version, REPLICATION_FACTOR, )?; @@ -11830,6 +12216,8 @@ fn test_blocks_with_limits( ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, )?; verify_signatures_test(signatures_to_verify)?; @@ -12323,6 +12711,8 @@ fn genesis_block_after_not_bootstrap_hash() { &consensus_constants, ¤t_active_wips(), &StakesTracker::default(), + &DelegationState::default(), + 0, ProtocolVersion::V1_7, REPLICATION_FACTOR, ); @@ -12409,6 +12799,8 @@ fn genesis_block_value_overflow() { &consensus_constants, ¤t_active_wips(), &stakes, + &DelegationState::default(), + 0, protocol_version, REPLICATION_FACTOR, ) @@ -12431,6 +12823,8 @@ fn genesis_block_value_overflow() { ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, ); assert_eq!(signatures_to_verify, vec![]); assert_eq!( @@ -12501,6 +12895,8 @@ fn genesis_block_full_validate() { &consensus_constants, ¤t_active_wips(), &stakes, + &DelegationState::default(), + 0, protocol_version, REPLICATION_FACTOR, ) @@ -12523,6 +12919,8 @@ fn genesis_block_full_validate() { ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, ) .unwrap(); assert_eq!(signatures_to_verify, vec![]); @@ -12617,6 +13015,8 @@ fn validate_block_transactions_uses_block_number_in_utxo_diff() { ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, ) .unwrap() }; @@ -12787,6 +13187,8 @@ fn validate_commit_transactions_included_in_utxo_diff() { ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, ) .unwrap() }; @@ -12862,7 +13264,7 @@ fn validate_update_utxo_diff() { |inputs: &[Input], outputs: &[ValueTransferOutput], block_numbers: &[u32]| { let mut utxo_diff = UtxoDiff::new(&utxo_set, block_number); - update_utxo_diff( + try_update_utxo_diff( &mut utxo_diff, inputs, outputs, @@ -12870,7 +13272,8 @@ fn validate_update_utxo_diff() { 0, EpochConstants::default(), 0, - ); + ) + .expect("test UTXO diff update should succeed"); let diff = utxo_diff.take_diff(); @@ -12947,6 +13350,8 @@ fn validate_required_tally_not_found() { let stakes = StakesTracker::default(); let b = Block::default(); + let mut consensus_constants = ConsensusConstants::default(); + consensus_constants.halving_period = HALVING_PERIOD; let e = validate_block_transactions( &UnspentOutputsPool::default(), @@ -12957,11 +13362,13 @@ fn validate_required_tally_not_found() { &ReputationEngine::new(1000), EpochConstants::default(), 100, - &ConsensusConstants::default(), + &consensus_constants, &ConsensusConstantsWit2::default(), ¤t_active_wips(), None, &stakes, + &DelegationState::default(), + 0, ) .unwrap_err(); @@ -12974,6 +13381,89 @@ fn validate_required_tally_not_found() { ); } +#[test] +fn validate_block_transactions_does_not_leak_immediate_too_many_request_on_failure() { + let _protocol_runtime_guard = protocol_runtime_test_guard(); + clear_protocol_info(); + register_protocol_version(ProtocolVersion::V2_0, 0, 10); + + let input_pointer = ONE_WIT_OUTPUT.parse().unwrap(); + let mut utxo_set = UnspentOutputsPool::default(); + utxo_set.insert( + input_pointer, + ValueTransferOutput { + pkh: MY_PKH_1.parse().unwrap(), + value: ONE_WIT, + time_lock: 0, + }, + 0, + ); + + // With no validators, this otherwise-valid request needs an immediate TooManyWitnesses tally. + // Omitting that tally makes validation fail only after the request has been materialized in the + // validation-local pool. + let dr_body = DRTransactionBody::new( + vec![Input::new(input_pointer)], + example_data_request_output(1, DEFAULT_WITNESS_REWARD, 0), + vec![], + ); + let dr_signature = sign_tx(PRIV_KEY_1, &dr_body, Some(ProtocolVersion::V2_0)); + let dr_tx = DRTransaction::new(dr_body, vec![dr_signature]); + let epoch = 1; + let block = Block::new( + BlockHeader { + beacon: CheckpointBeacon { + checkpoint: epoch, + ..Default::default() + }, + ..Default::default() + }, + KeyedSignature::default(), + BlockTransactions { + data_request_txns: vec![dr_tx], + ..Default::default() + }, + ); + + let mut dr_pool = DataRequestPool::default(); + let dr_pool_before = dr_pool.clone(); + let consensus_constants = ConsensusConstants { + collateral_minimum: DEFAULT_COLLATERAL, + genesis_hash: GENESIS_BLOCK_HASH.parse().unwrap(), + max_dr_weight: MAX_DR_WEIGHT, + halving_period: HALVING_PERIOD, + ..Default::default() + }; + let error = validate_block_transactions( + &utxo_set, + &mut dr_pool, + &block, + CheckpointVRF::default(), + &mut vec![], + &ReputationEngine::new(1000), + EpochConstants::default(), + 1, + &consensus_constants, + &ConsensusConstantsWit2::default(), + ¤t_active_wips(), + None, + &StakesTracker::default(), + &DelegationState::default(), + 0, + ) + .unwrap_err(); + clear_protocol_info(); + + assert_eq!( + error.downcast::().unwrap(), + BlockError::MissingExpectedTallies { + count: 1, + block_hash: block.versioned_hash(ProtocolVersion::V2_0), + }, + ); + assert_eq!(dr_pool, dr_pool_before); +} + #[test] fn validate_vt_weight_overflow() { let t0 = { diff --git a/validations/src/tests/reppoe.rs b/validations/src/tests/reppoe.rs index 4cdaff7138..46522b06c1 100644 --- a/validations/src/tests/reppoe.rs +++ b/validations/src/tests/reppoe.rs @@ -1010,8 +1010,13 @@ fn reppoe_worst_case() { &witnet_config::defaults::Mainnet, ); let max_dr_weight = consensus_constants.max_dr_weight; - // Need to use extra_rounds + 1 because this variable represents the additional rounds - let max_commit_rounds = consensus_constants.extra_rounds + 1; + // Need to use extra_rounds + 1 because this variable represents the additional rounds. + // Production rejects an unrepresentable terminal round; keep the fixture on that same + // explicit boundary instead of relying on debug/release overflow behavior. + let max_commit_rounds = consensus_constants + .extra_rounds + .checked_add(1) + .expect("fixture extra_rounds must leave room for the terminal round"); let minimum_difficulty = consensus_constants.minimum_difficulty; let max_witnesses = max_num_witnesses_for_dr_weigth(max_dr_weight); diff --git a/validations/src/validations.rs b/validations/src/validations.rs index d52a01a21f..ae027df309 100644 --- a/validations/src/validations.rs +++ b/validations/src/validations.rs @@ -1,6 +1,6 @@ use std::{ cmp::Ordering, - collections::{HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, convert::{TryFrom, TryInto}, panic, }; @@ -14,23 +14,36 @@ use witnet_crypto::{ signature::{PublicKey, Signature, verify}, }; use witnet_data_structures::{ + capabilities::Capability, chain::{ Block, BlockMerkleRoots, CheckpointBeacon, CheckpointVRF, ConsensusConstants, ConsensusConstantsWit2, DataRequestOutput, DataRequestStage, DataRequestState, Epoch, EpochConstants, Hash, Hashable, Input, KeyedSignature, OutputPointer, PublicKeyHash, RADRequest, RADTally, RADType, Reputation, ReputationEngine, SignaturesToVerify, + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, STAKE_AUTHORIZATION_ACTION_DELEGATE, + STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, StakeOutput, ValueTransferOutput, tapi::ActiveWips, }, data_request::{ DataRequestPool, calculate_reward_collateral_ratio, calculate_tally_change, - calculate_witness_reward, calculate_witness_reward_before_second_hard_fork, create_tally, - data_request_has_too_many_witnesses, + calculate_tally_change_checked, calculate_witness_reward, + calculate_witness_reward_before_second_hard_fork_checked, + calculate_witness_reward_before_second_hard_fork_legacy_checked, + calculate_witness_reward_checked, classify_too_many_witnesses_tally_for_protocols, + create_tally_checked, data_request_has_too_many_witnesses_for_protocol, + TooManyWitnessesTallyMode, }, error::{BlockError, DataRequestError, TransactionError}, - get_protocol_version, get_protocol_version_activation_epoch, - proto::versioning::{ProtocolVersion, VersionedHashable}, + get_protocol_info, get_protocol_version, get_protocol_version_activation_epoch, + proto::versioning::{ProtocolInfo, ProtocolVersion, VersionedHashable}, radon_report::{RadonReport, ReportContext}, - staking::prelude::{Power, QueryStakesKey, StakeKey, StakesTracker}, + staking::prelude::{ + commission_effective_epoch, ActivationPreflightReport, CollateralReservationKey, + DelegationResult, DelegationState, Power, QueryStakesKey, StakeKey, StakesError, + StakesTracker, ValidatorPolicy, + DELEGATED_STAKING_MAXIMUM_NANOWITS, DELEGATED_STAKING_MINIMUM_NANOWITS, + MAX_COMMISSION_PERCENT, + }, transaction::{ CommitTransaction, DRTransaction, MintTransaction, RevealTransaction, StakeTransaction, TallyTransaction, Transaction, UnstakeTransaction, VTTransaction, @@ -39,7 +52,7 @@ use witnet_data_structures::{ types::visitor::Visitor, utxo_pool::{Diff, UnspentOutputsPool, UtxoDiff}, vrf::{BlockEligibilityClaim, DataRequestEligibilityClaim, VrfCtx}, - wit::NANOWITS_PER_WIT, + wit::{NANOWITS_PER_WIT, Wit}, }; use witnet_rad::{ conditions::{ @@ -53,10 +66,7 @@ use witnet_rad::{ }; use crate::eligibility::{ - current::{ - Eligibility, Eligible, - IneligibilityReason::{InsufficientPower, NotStaking}, - }, + current::{Eligibility, Eligible}, legacy::*, }; @@ -114,6 +124,27 @@ pub fn st_transaction_fee( utxo_diff: &UtxoDiff<'_>, epoch: Epoch, epoch_constants: EpochConstants, +) -> Result { + st_transaction_fee_for_protocol( + st_tx, + utxo_diff, + epoch, + epoch_constants, + get_protocol_version(Some(epoch)), + ) +} + +/// Returns the fee of a stake transaction using an explicitly selected protocol version. +/// +/// Historical snapshot replay uses this entry point so wrapping Wit/2.0 or checked Wit/2.1 output +/// arithmetic is selected from its pinned protocol schedule instead of mutable process-global +/// state. +pub fn st_transaction_fee_for_protocol( + st_tx: &StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + protocol_version: ProtocolVersion, ) -> Result { let in_value = transaction_inputs_sum(&st_tx.body.inputs, utxo_diff, epoch, epoch_constants)?; let out_value = st_tx.body.output.value; @@ -122,13 +153,90 @@ pub fn st_transaction_fee( None => 0, }; - if out_value + change_value > in_value { + let total_output_value = if protocol_version >= ProtocolVersion::V2_1 { + out_value + .checked_add(change_value) + .ok_or(TransactionError::OutputValueOverflow)? + } else { + // Wit/2.0 used release-mode u64 arithmetic here. Preserve that historical validity rule; + // strict output accounting begins with Wit/2.1. + out_value.wrapping_add(change_value) + }; + if total_output_value > in_value { Err(TransactionError::NegativeFee.into()) + } else if protocol_version >= ProtocolVersion::V2_1 { + Ok(in_value - total_output_value) } else { - Ok(in_value - out_value - change_value) + Ok(in_value + .wrapping_sub(out_value) + .wrapping_sub(change_value)) } } +/// Return the legacy tally remainder that is charged as a transaction fee. +/// +/// Pre-Wit/2 Mint accounting includes this value in addition to the VT, Commit, Reveal, data +/// request, and Stake fees. Keep the arithmetic in the validation crate so portable replay and +/// production tally validation share the same hard-fork and release-wrapping rules. The caller +/// supplies the request's canonical participant counts because a snapshot verifier intentionally +/// does not execute RAD or rebuild an entire live `DataRequestPool`. +pub fn tally_extra_fee_for_protocol( + commits_count: usize, + reveals_count: usize, + liars_count: usize, + errors_count: usize, + witness_reward: u64, + collateral: u64, + protocol_version: ProtocolVersion, + active_wips: &ActiveWips, +) -> Result { + let (_, extra_fee) = if active_wips.wips_0009_0011_0012() { + if protocol_version >= ProtocolVersion::V2_1 { + calculate_witness_reward_checked( + commits_count, + liars_count, + errors_count, + witness_reward, + collateral, + active_wips.wip0023(), + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward( + commits_count, + liars_count, + errors_count, + witness_reward, + collateral, + active_wips.wip0023(), + ) + } + } else { + if protocol_version >= ProtocolVersion::V2_1 { + calculate_witness_reward_before_second_hard_fork_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + witness_reward, + collateral, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + witness_reward, + collateral, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } + }; + Ok(extra_fee) +} + /// Returns the fee of a unstake transaction. /// /// The fee is suplied as part of the unstake transaction @@ -137,17 +245,50 @@ pub fn st_transaction_fee( pub fn ut_transaction_fee( ut_tx: &UnstakeTransaction, staked_amount: u64, + protocol_version: ProtocolVersion, ) -> Result { let out_value = ut_tx.body.value(); let fee_value = ut_tx.body.fee; - if out_value + fee_value > staked_amount { + let requested = add_value_for_protocol(out_value, fee_value, protocol_version)?; + if requested > staked_amount { Err(TransactionError::NegativeFee.into()) } else { Ok(fee_value) } } +/// Ensure a caller cannot substitute mutable/runtime values for the native Wit/2.1 stake bounds. +/// +/// The legacy validation APIs keep these arguments because Wit/1.8 and Wit/2.0 historically +/// supplied their epoch-derived limits. Once a native delegated-staking action is being checked, +/// however, the 10,000 WIT minimum and 10,000,000 WIT admission cap are protocol authorities and +/// must be identical at every consensus boundary. +fn validate_v21_minimum_stake( + min_stake_nanowits: u64, +) -> Result<(), StakesError> { + if min_stake_nanowits != DELEGATED_STAKING_MINIMUM_NANOWITS { + return Err(StakesError::InvalidDelegationState { + reason: "Wit/2.1 stake minimum must be the fixed 10,000 WIT minimum", + }); + } + Ok(()) +} + +/// Validate the fixed Wit/2.1 minimum stake and admission cap. It delegates the minimum check to validate_v21_minimum_stake and rejects mutable limits before consensus validation continues. +fn validate_v21_stake_limits( + min_stake_nanowits: u64, + max_stake_nanowits: u64, +) -> Result<(), anyhow::Error> { + validate_v21_minimum_stake(min_stake_nanowits)?; + if max_stake_nanowits != DELEGATED_STAKING_MAXIMUM_NANOWITS { + return Err(anyhow::anyhow!( + "Wit/2.1 stake cap must be the fixed 10,000,000 WIT maximum" + )); + } + Ok(()) +} + /// Returns the fee of a data request transaction. /// /// The fee is the difference between the outputs (with the data request value) @@ -166,25 +307,148 @@ pub fn validate_commit_collateral( superblock_period: u16, protocol_version: ProtocolVersion, stakes: &StakesTracker, + delegation_state: &DelegationState, + min_stake: u64, +) -> Result<(), anyhow::Error> { + // Keep the historical public helper source-compatible for callers that only have an epoch. + // Consensus block validation uses the explicit-context helper below so cross-fork requests do + // not accidentally consult the mutable process-global protocol schedule. + validate_commit_collateral_for_block_protocol( + co_tx, + utxo_diff, + epoch, + epoch_constants, + required_collateral, + block_number, + collateral_age, + superblock_period, + get_protocol_version(Some(epoch)), + protocol_version, + stakes, + delegation_state, + min_stake, + ) +} + +/// Validate commit collateral against the protocol regime of the containing block. It keeps legacy and native delegated-staking collateral rules aligned with the UTXO and request projections. +#[allow(clippy::too_many_arguments)] +fn validate_commit_collateral_for_block_protocol( + co_tx: &CommitTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + required_collateral: u64, + block_number: u32, + collateral_age: u32, + superblock_period: u16, + block_protocol_version: ProtocolVersion, + protocol_version: ProtocolVersion, + stakes: &StakesTracker, + delegation_state: &DelegationState, min_stake: u64, ) -> Result<(), anyhow::Error> { + validate_request_protocol_for_block( + block_protocol_version, + protocol_version, + )?; let block_number_limit = block_number.saturating_sub(collateral_age); let commit_pkh = co_tx.body.proof.proof.pkh(); + if block_protocol_version >= ProtocolVersion::V2_1 + && protocol_version >= ProtocolVersion::V2_0 + && !co_tx.body.collateral.is_empty() + { + // Wit/2.0 requests that cross into a Wit/2.1 block retain their owner-only economics, + // but their collateral is now reserved from the delegated stake ledger just like a + // native Wit/2.1 request. Reject the entire legacy collateral vector, rather than only + // checking its net input/output difference: an equal change output must not turn a + // UTXO-backed commit into a valid zero-difference stake reservation. Pre-Wit/2.0 + // requests remain on the historical UTXO-collateral path. + return Err(TransactionError::NativeCommitWithUtxoCollateral.into()); + } + if block_protocol_version >= ProtocolVersion::V2_1 + && protocol_version >= ProtocolVersion::V2_0 + && !delegation_state.activation_initialized + { + // Wit/2.0 requests crossing into a Wit/2.1 block use the same canonical owner-only + // reservation boundary as native requests. Do not let this lower-level collateral + // validator accept a pre-activation policy projection when called directly, even though + // the complete Commit validator also checks the marker through eligibility resolution. + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native Commit collateral validation requires the activation marker", + } + .into()); + } let mut in_value: u64 = 0; let mut seen_output_pointers = HashSet::with_capacity(co_tx.body.collateral.len()); let qualification_requirement = 100 * NANOWITS_PER_WIT; // Validate commit collateral value in wit/2 - if protocol_version >= ProtocolVersion::V2_0 { - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let validator_balance: u64 = stakes - .query_stakes(QueryStakesKey::Validator(commit_pkh)) - .unwrap_or_default() + if protocol_version >= ProtocolVersion::V2_1 { + validate_v21_minimum_stake(min_stake)?; + // The planner applies the deterministic 70/30 owner/delegator split, + // respects each entry's minimum remainder, and verifies exact aggregate + // capacity without mutating consensus state during validation. + delegation_state.plan_collateral_reservation( + stakes, + commit_pkh, + Wit::from_nanowits(required_collateral), + Wit::from_nanowits(min_stake), + )?; + } else if protocol_version >= ProtocolVersion::V2_0 { + // A Wit/2.0 data request may still be committed after V2.1 has + // introduced multiple entries. Read the stable owner entry rather + // than relying on entry ordering; this is identical under V2.0's 1:1 + // invariant. + if block_protocol_version >= ProtocolVersion::V2_1 { + // Cross-fork Wit/2.0 commitments retain their owner-only economics, but their live + // owner still has to preserve the hard post-activation minimum. Do not let a stale + // runtime argument weaken that boundary merely because the request itself is V2.0. + validate_v21_minimum_stake(min_stake)?; + } + let owner = if block_protocol_version >= ProtocolVersion::V2_1 { + delegation_state.owner_withdrawer_or_legacy(stakes, commit_pkh)? + } else { + delegation_state.legacy_live_withdrawer(stakes, commit_pkh)? + }; + if block_protocol_version >= ProtocolVersion::V2_1 { + delegation_state.ensure_entry_accepts_deposit(&StakeKey { + validator: commit_pkh, withdrawer: owner, + })?; + } + let owner_key = QueryStakesKey::Key(StakeKey { + validator: commit_pkh, + withdrawer: owner, + }); + // A Wit/2.0 request can settle after Wit/2.1 activation. Once the strict delegated + // protocol is active, use the fallible stake read so a poisoned/corrupt owner entry + // rejects the block instead of reaching the legacy `read_value().unwrap()` boundary. + // Pre-activation validation retains the historical lookup behavior for compatibility. + let owner_stake = if block_protocol_version >= ProtocolVersion::V2_1 { + stakes.query_stakes_checked(owner_key.clone())? + } else { + stakes.query_stakes(owner_key.clone()).map_err(anyhow::Error::from)? + }; + let validator_balance: u64 = owner_stake .first() .map(|stake| stake.value.coins) - .unwrap() + .ok_or(TransactionError::NoStakeFound { + validator: commit_pkh, + withdrawer: owner, + })? .into(); - if validator_balance < min_stake + required_collateral { + let minimum_validator_balance = + if block_protocol_version >= ProtocolVersion::V2_1 { + min_stake + .checked_add(required_collateral) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + // Wit/2.0 release builds performed this addition directly. Preserve the + // resulting release-mode wrap until the containing block activates strict + // Wit/2.1 arithmetic, even though this helper may validate a Wit/2.0 request + // that remains in flight across the fork. + min_stake.wrapping_add(required_collateral) + }; + if validator_balance < minimum_validator_balance { return Err(TransactionError::CollateralBelowMinimumStake { collateral: required_collateral, validator: commit_pkh, @@ -194,7 +458,7 @@ pub fn validate_commit_collateral( } for input in &co_tx.body.collateral { - let vt_output = utxo_diff.get(input.output_pointer()).ok_or_else(|| { + let vt_output = utxo_diff.try_get(input.output_pointer())?.ok_or_else(|| { TransactionError::OutputNotFound { output: *input.output_pointer(), } @@ -206,19 +470,29 @@ pub fn validate_commit_collateral( if protocol_version < ProtocolVersion::V2_0 && epoch > 2_245_000 { let committer = vt_output.pkh; let mut balance = 0; - utxo_diff.get_utxo_set().visit_with_pkh( + let mut maturity_error = None; + utxo_diff.get_utxo_set().try_visit_with_pkh( committer, |_| (), |(output_pointer, (vto, _))| { - if let Some(utxo_block_number) = - utxo_diff.included_in_block_number(output_pointer) - && utxo_block_number - < block_number.saturating_sub((2 * superblock_period).into()) - { - balance += vto.value + if maturity_error.is_some() { + return; + } + match utxo_diff.try_included_in_block_number(output_pointer) { + Ok(Some(utxo_block_number)) + if utxo_block_number + < block_number.saturating_sub((2 * superblock_period).into()) => + { + balance += vto.value; + } + Ok(Some(_)) | Ok(None) => {} + Err(error) => maturity_error = Some(error), } }, - ); + )?; + if let Some(error) = maturity_error { + return Err(error); + } if balance < qualification_requirement { return Err(TransactionError::UnqualifiedCommitter { @@ -256,14 +530,24 @@ pub fn validate_commit_collateral( // Outputs to be spent in commitment inputs need to be older than `block_number_limit`. // All outputs from the genesis block are fulfill this requirement because // `block_number_limit` can't go lower than `0`. + // A collateral output can be present in the value view while its maturity metadata is + // missing after a partial/imported UTXO reconstruction. Treat that as an invalid output + // instead of panicking inside consensus validation; accepted state always carries the + // inclusion height and therefore keeps the valid path unchanged. let included_in_block_number = utxo_diff - .included_in_block_number(input.output_pointer()) - .unwrap(); + .try_included_in_block_number(input.output_pointer())? + .ok_or(TransactionError::OutputNotFound { + output: *input.output_pointer(), + })?; if included_in_block_number > block_number_limit { + // A malformed/imported UTXO view can advertise an inclusion height newer than the + // block currently being validated. The maturity check is already rejecting that + // metadata; keep construction of the structured error non-panicking as well instead + // of subtracting in the wrong direction at a consensus boundary. return Err(TransactionError::CollateralNotMature { output: *input.output_pointer(), must_be_older_than: collateral_age, - found: block_number - included_in_block_number, + found: block_number.saturating_sub(included_in_block_number), } .into()); } @@ -289,9 +573,28 @@ pub fn validate_commit_collateral( output_value: out_value, } .into()) + } else if block_protocol_version >= ProtocolVersion::V2_1 + && protocol_version >= ProtocolVersion::V2_0 + { + // Once a Wit/2.1 block is active, both native Wit/2.1 and crossing Wit/2.0 collateral are + // reserved from the delegated stake ledger above. Commit transactions must therefore + // carry no UTXO collateral; accepting an exact `required_collateral` difference here + // would double-lock the committer's funds, while accepting a nonzero arbitrary difference + // would create a second, untracked collateral source. Pre-Wit/2.0 requests retain their + // historical UTXO collateral even when included after activation. + let found_collateral = in_value - out_value; + if found_collateral == 0 { + Ok(()) + } else { + Err(TransactionError::IncorrectCollateral { + expected: 0, + found: found_collateral, + } + .into()) + } } else if in_value - out_value != required_collateral { let found_collateral = in_value - out_value; - if protocol_version != ProtocolVersion::V2_0 { + if protocol_version < ProtocolVersion::V2_0 { Err(TransactionError::IncorrectCollateral { expected: required_collateral, found: found_collateral, @@ -311,6 +614,39 @@ pub fn validate_commit_collateral( } } +/// A data request can only be included in a block governed by the same or a newer protocol. +/// +/// Protocol schedules are monotonic and a request is created no later than the block that +/// contains its Commit, so a request-era version newer than the containing-block version is an +/// impossible context. Reject it at shared direct helpers instead of allowing callers that pass +/// an inconsistent pair to select native collateral rules before the canonical schedule has had +/// an opportunity to fail. +pub fn validate_request_protocol_for_block( + block_protocol_version: ProtocolVersion, + request_protocol_version: ProtocolVersion, +) -> Result<(), anyhow::Error> { + if request_protocol_version > block_protocol_version { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "request protocol is newer than its containing block protocol", + } + .into()); + } + Ok(()) +} + +/// Validate that a request's protocol regime is compatible with delegated-staking replay. It prevents a block from applying native economics to a request created under another fork. +fn validate_request_protocol_for_delegation( + block_protocol_version: ProtocolVersion, + request_protocol_version: ProtocolVersion, +) -> DelegationResult<()> { + if request_protocol_version > block_protocol_version { + return Err(StakesError::InvalidDelegationState { + reason: "request protocol is newer than its containing block protocol", + }); + } + Ok(()) +} + /// Function to validate a mint transaction pub fn validate_mint_transaction( mint_tx: &MintTransaction, @@ -318,6 +654,28 @@ pub fn validate_mint_transaction( block_epoch: Epoch, initial_block_reward: u64, halving_period: u32, +) -> Result<(), anyhow::Error> { + validate_mint_transaction_for_protocol( + mint_tx, + total_fees, + block_epoch, + initial_block_reward, + halving_period, + ProtocolVersion::from_epoch(block_epoch), + ) +} + +/// Validate a Mint transaction using an explicitly selected containing-block protocol. +/// +/// Block replay uses this entry point so the legacy/V2 mint shape cannot change when the +/// process-global protocol schedule is updated during historical validation. +pub fn validate_mint_transaction_for_protocol( + mint_tx: &MintTransaction, + total_fees: u64, + block_epoch: Epoch, + initial_block_reward: u64, + halving_period: u32, + protocol_version: ProtocolVersion, ) -> Result<(), anyhow::Error> { // Mint epoch must be equal to block epoch if mint_tx.epoch != block_epoch { @@ -328,11 +686,12 @@ pub fn validate_mint_transaction( .into()); } - if ProtocolVersion::from_epoch(block_epoch) < ProtocolVersion::V2_0 { + if protocol_version < ProtocolVersion::V2_0 { let mint_value = transaction_outputs_sum(&mint_tx.outputs)?; - let block_reward_value = block_reward(mint_tx.epoch, initial_block_reward, halving_period); + let block_reward_value = try_block_reward(mint_tx.epoch, initial_block_reward, halving_period)?; // Mint value must be equal to block_reward + transaction fees - if mint_value != total_fees + block_reward_value { + let expected_mint_value = total_fees.wrapping_add(block_reward_value); + if mint_value != expected_mint_value { return Err(BlockError::MismatchedMintValue { mint_value, fees_value: total_fees, @@ -488,7 +847,7 @@ pub fn validate_genesis_vt_transaction( // Genesis VTTs should have 0 signatures if !vt_tx.signatures.is_empty() { return Err(TransactionError::MismatchingSignaturesNumber { - signatures_n: u8::try_from(vt_tx.signatures.len()).unwrap(), + signatures_n: u8::try_from(vt_tx.signatures.len()).unwrap_or(u8::MAX), inputs_n: 0, }); } @@ -572,9 +931,12 @@ pub fn validate_dr_transaction<'a>( } // The output must have the same pkh as the first input + let first_input_pointer = dr_tx.body.inputs[0].output_pointer(); let first_input = utxo_diff - .get(dr_tx.body.inputs[0].output_pointer()) - .unwrap(); + .try_get(first_input_pointer)? + .ok_or(TransactionError::OutputNotFound { + output: *first_input_pointer, + })?; let expected_pkh = first_input.pkh; if dr_output.pkh != expected_pkh { @@ -680,124 +1042,544 @@ pub fn validate_commit_transaction( superblock_period: u16, protocol_version: ProtocolVersion, stakes: &StakesTracker, + delegation_state: &DelegationState, max_rounds: u16, min_stake: u64, ) -> Result<(Hash, u16, u64), anyhow::Error> { - // Get DataRequest information - let dr_pointer = co_tx.body.dr_pointer; - let dr_state = dr_pool - .data_request_state(&dr_pointer) - .ok_or(TransactionError::DataRequestNotFound { hash: dr_pointer })?; - if dr_state.stage != DataRequestStage::COMMIT { - return Err(DataRequestError::NotCommitStage.into()); - } + validate_commit_transaction_inner( + co_tx, + dr_pool, + vrf_input, + signatures_to_verify, + rep_eng, + epoch, + epoch_constants, + utxo_diff, + collateral_minimum, + collateral_age, + block_number, + minimum_reppoe_difficulty, + active_wips, + superblock_period, + get_protocol_version(Some(epoch)), + protocol_version, + stakes, + delegation_state, + max_rounds, + min_stake, + None, + ) +} - let dr_output = &dr_state.data_request; - let proof_pkh = co_tx.body.proof.proof.pkh(); +/// Opaque evidence that the activation-appropriate owner population and maintained +/// validator-index integrity were checked against an authoritative source ledger. +/// +/// The proof may be transferred to the matching `StakesTracker::try_clone_checked` and +/// `DelegationState::clone` pair created immediately from that source, and to matching checked-clone +/// descendants of that pair. It may then be reused only across projected transitions implemented by +/// the checked staking and delegation APIs: activation initialization, collateral +/// reservation/refund/settlement, reward distribution, age resets, validated stake +/// deposits/commission changes, and validated unstake removals. Substituting an unrelated or mixed +/// state pair, decoding new state, or mutating internals outside those APIs requires a new proof. +/// The proof is an optimization contract for the frozen active-validator snapshot; it is not a +/// state-identity token and never replaces the authoritative policy/index validation performed at +/// each public transaction boundary. Standalone validators intentionally construct no proof and +/// remain fail-closed on every call. +#[derive(Debug)] +pub struct PrevalidatedDelegationInvariants { + _private: (), +} - // Check if the commit transaction is from an eligible validator - let (target_hash_wit2, withdrawer) = if protocol_version >= ProtocolVersion::V2_0 { - match stakes.witnessing_eligibility( - proof_pkh, - epoch, - dr_state.data_request.witnesses, - dr_state.info.current_commit_round, - max_rounds, - ) { - Ok((eligibility, target_hash, _)) => { - if matches!(eligibility, Eligible::No(_)) { - return Err(TransactionError::ValidatorNotEligible { - validator: proof_pkh, - } - .into()); - } +/// Owner-active validator identities frozen before a block's commitment and reward phases. +/// +/// On a valid ledger, checked collateral and tally transitions preserve the minimum live balance or +/// settle already-debited escrow, rewards and refunds credit existing entries, and age resets change +/// only age. This set may therefore be reused through those phases, but only with the same minimum +/// self-stake used to construct it. It must not be used after any stake or unstake action because +/// those actions can change membership. +#[derive(Debug)] +pub struct FrozenActiveValidatorSet { + active_validators: BTreeSet, + minimum_self_stake: Wit, +} - // Retrieve all stake entries in which we are the validator - let stake_entries = stakes - .query_stakes(QueryStakesKey::Validator(proof_pkh)) - .unwrap_or_default(); - // Retrieve the withdrawer address of the first stake entry in which we are the validator - let withdrawer = stake_entries.first().map(|stake| stake.key.withdrawer); +impl FrozenActiveValidatorSet { + /// Return the number of owner-active validators in the frozen pre-action snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.active_validators.len() + } - (target_hash, withdrawer) - } - Err(e) => return Err(e.into()), - } - } else { - (Hash::min(), None) - }; + /// Return whether the frozen pre-action snapshot has no owner-active validators. + #[must_use] + pub fn is_empty(&self) -> bool { + self.active_validators.is_empty() + } - // Commitment's output is only for change propose, so it only has to be one output and the - // address has to be the same than the address which creates the commitment - if co_tx.body.outputs.len() > 1 { - return Err(TransactionError::SeveralCommitOutputs.into()); + fn contains(&self, validator: &PublicKeyHash) -> bool { + self.active_validators.contains(validator) } - if let Some(output) = &co_tx.body.outputs.first() { - if output.value == 0 { - return Err(TransactionError::ZeroValueOutput { - tx_hash: co_tx.hash(), - output_id: 0, - } - .into()); - } - if output.pkh != proof_pkh { - return Err(TransactionError::PublicKeyHashMismatch { - expected_pkh: proof_pkh, - signature_pkh: output.pkh, - } - .into()); + + fn validate_minimum_self_stake( + &self, + minimum_self_stake: Wit, + ) -> DelegationResult<()> { + let canonical_minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + if self.minimum_self_stake != canonical_minimum + || minimum_self_stake != canonical_minimum + || self.minimum_self_stake != minimum_self_stake + { + return Err(StakesError::InvalidDelegationState { + reason: "frozen active-validator set does not use the fixed minimum self-stake", + }); } - } - // Check that collateral has correct amount and age - let mut required_collateral = dr_output.collateral; - if required_collateral == 0 { - required_collateral = collateral_minimum; + Ok(()) } - validate_commit_collateral( - co_tx, - utxo_diff, - epoch, - epoch_constants, - required_collateral, - block_number, - collateral_age, - superblock_period, - protocol_version, +} + +/// Validate one authoritative delegated-staking snapshot and freeze its pre-action active set. +/// +/// Call this before cloning: `StakesTracker::try_clone_checked` intentionally rebuilds secondary +/// indexes from the canonical primary map and would otherwise hide an omission or duplicate in the +/// source validator index. The returned invariant proof and frozen set have the distinct mutation +/// contracts documented on their types. +pub fn prevalidate_delegation_snapshot( + stakes: &StakesTracker, + delegation_state: &DelegationState, + minimum_self_stake: Wit, +) -> DelegationResult<( + PrevalidatedDelegationInvariants, + FrozenActiveValidatorSet, +)> { + // This helper is the public source-snapshot boundary for native Wit/2.1 callers. Reject a + // divergent compatibility argument instead of silently normalizing it: otherwise a direct + // caller could believe it proved one threshold while the frozen set actually used another. + validate_v21_minimum_stake(minimum_self_stake.nanowits())?; + // The native active set is governed by one immutable 10,000 WIT minimum. Keep the retained + // compatibility parameter out of the calculation after the authority check, so the frozen + // proof always carries the immutable threshold. + let minimum_self_stake = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let active_validators = delegation_state.active_validator_set_checked( stakes, - min_stake, + minimum_self_stake, )?; + Ok(( + PrevalidatedDelegationInvariants { _private: () }, + FrozenActiveValidatorSet { + active_validators, + minimum_self_stake, + }, + )) +} - // commit time_lock was disabled in the first hard fork - if !active_wips.wip_0008() { - // Verify that commits are only accepted after the time lock expired - let (epoch_timestamp, _) = epoch_constants.epoch_timestamp(epoch)?; - let dr_time_lock = i64::try_from(dr_output.data_request.time_lock)?; - if dr_time_lock > epoch_timestamp { - return Err(TransactionError::TimeLock { - expected: dr_time_lock, - current: epoch_timestamp, - } - .into()); +/// Reject a Wit/2 block whose signer is outside the stake-ranked proposer set. +/// +/// The caller must supply the immutable block-opening stake snapshot and the replication factor +/// for this block. Wit/2.1 additionally ranks only owner-active validators. Normal admission lets +/// this helper derive that set after checking the authoritative delegation projection; historical +/// snapshot replay can pass the already frozen pre-action set it also uses for Commit eligibility. +/// This is deliberately separate from the block VRF target: Wit/2 proves proposer rank here and +/// verifies the VRF cryptography against `Hash::max()`. +#[allow(clippy::too_many_arguments)] +pub fn validate_wit2_block_mining_eligibility( + validator: PublicKeyHash, + protocol_version: ProtocolVersion, + block_epoch: Epoch, + replication_factor: u16, + stakes: &StakesTracker, + delegation_state: &DelegationState, + minimum_self_stake: u64, + frozen_active_validators: Option<&FrozenActiveValidatorSet>, +) -> Result<(), anyhow::Error> { + if protocol_version < ProtocolVersion::V2_0 { + return Err(StakesError::::InvalidDelegationState { + reason: "stake-ranked block eligibility requires Wit/2", } + .into()); } - let commit_signature = - validate_commit_reveal_signature(co_tx.hash(), &co_tx.signatures, signatures_to_verify)?; - - let sign_pkh = commit_signature.public_key.pkh(); - if proof_pkh != sign_pkh { - return Err(TransactionError::PublicKeyHashMismatch { - expected_pkh: proof_pkh, - signature_pkh: sign_pkh, + if protocol_version >= ProtocolVersion::V2_1 { + // The minimum is a native Wit/2.1 authority, not a caller-configurable + // compatibility parameter. Frozen snapshots validate this below, but + // direct callers without a snapshot must be rejected here as well so + // the public boundary cannot silently accept a divergent policy. + validate_v21_minimum_stake(minimum_self_stake)?; + if !delegation_state.activation_initialized { + return Err(StakesError::::InvalidDelegationState { + reason: "native block eligibility requires the activation marker", + } + .into()); } - .into()); + // A frozen active-validator set is an optimization view, not a state-identity + // capability. Re-prove the authoritative policy/index projection even when the caller + // supplies that view, so a stale or cross-state set cannot make malformed delegation + // metadata look eligible. + if frozen_active_validators.is_some() { + delegation_state.validate_authoritative_owner_policies(stakes)?; + } + // A frozen active-validator set authenticates membership for this block-opening view, + // but it does not carry the validator-policy timeline. Reject a persisted pending + // commission whose effective epoch has arrived before using either the supplied frozen + // set or a freshly derived one. Normal block projection materializes due changes first; + // this guard closes the standalone and replay helper boundary when that projection is + // absent or malformed. + delegation_state.validate_pending_commissions_at_epoch(block_epoch)?; } - let pkh = proof_pkh; - let backup_witnesses = dr_state.backup_witnesses(); - let num_witnesses = dr_output.witnesses + backup_witnesses; + if let Some(frozen) = frozen_active_validators { + frozen.validate_minimum_self_stake(Wit::from_nanowits(minimum_self_stake))?; + } + let computed_active_validators = + if protocol_version >= ProtocolVersion::V2_1 && frozen_active_validators.is_none() { + Some(delegation_state.active_validator_set_checked( + stakes, + Wit::from_nanowits(minimum_self_stake), + )?) + } else { + None + }; + let active_validators = frozen_active_validators + .map(|frozen| &frozen.active_validators) + .or(computed_active_validators.as_ref()); + + let eligibility = if protocol_version >= ProtocolVersion::V2_1 { + let active_validators = active_validators.ok_or_else(|| { + anyhow::anyhow!("Wit/2.1 block eligibility did not receive an active-validator set") + })?; + delegation_state.participating_stakes_checked(stakes)?.mining_eligibility_filtered( + validator, + block_epoch, + replication_factor, + |ranked_validator| active_validators.contains(ranked_validator), + ) + } else { + stakes.mining_eligibility(validator, block_epoch, replication_factor) + }; + + match eligibility? { + Eligible::Yes => Ok(()), + Eligible::No(_) => Err(BlockError::ValidatorNotEligible { validator }.into()), + } +} + +/// Resolve the exact stake-ranked eligibility target and Wit/2 owner salt for one Commit. +/// +/// Both block validation and Wit/2.1 candidate construction must call this against the stake and +/// delegation projection immediately before the Commit's collateral is reserved. In particular, +/// an earlier reservation can change aggregate witnessing power and therefore the later Commit's +/// rank or target. The request protocol selects native delegated or cross-fork owner-only +/// semantics, while the containing block protocol decides whether the delegated state is active. +/// +/// This helper deliberately stops before verifying the VRF proof. Validation queues that proof for +/// batched verification; candidate construction verifies it synchronously before admitting the +/// complete request group. Both consumers use the returned target and owner salt. +#[allow(clippy::too_many_arguments)] +pub fn resolve_commit_stake_eligibility( + proof_pkh: PublicKeyHash, + block_protocol_version: ProtocolVersion, + request_protocol_version: ProtocolVersion, + epoch: Epoch, + witnesses: u16, + current_commit_round: u16, + max_rounds: u16, + stakes: &StakesTracker, + delegation_state: &DelegationState, + min_stake: u64, + frozen_active_validators: Option<&FrozenActiveValidatorSet>, +) -> Result<(Hash, Option), anyhow::Error> { + validate_request_protocol_for_block( + block_protocol_version, + request_protocol_version, + )?; + if request_protocol_version < ProtocolVersion::V2_0 { + return Err(StakesError::::InvalidDelegationState { + reason: "stake-ranked Commit eligibility requires a Wit/2 request", + } + .into()); + } + + let delegated_staking_active = block_protocol_version >= ProtocolVersion::V2_1; + let native_delegated_request = + delegated_staking_active && request_protocol_version >= ProtocolVersion::V2_1; + if delegated_staking_active { + // Keep the standalone/replay helper on the same immutable 10,000 WIT + // authority as the frozen-snapshot path below. Without this guard a + // future change to active-set derivation could turn the retained + // compatibility argument into a consensus knob. + validate_v21_minimum_stake(min_stake)?; + if !delegation_state.activation_initialized { + return Err(StakesError::::InvalidDelegationState { + reason: "native Commit eligibility requires the activation marker", + } + .into()); + } + // The optional frozen set carries only owner-active membership. It does not prove that + // the supplied stake/policy pair is the one from which it was derived, so every public + // Commit-eligibility boundary must still validate the authoritative projection. + if frozen_active_validators.is_some() { + delegation_state.validate_authoritative_owner_policies(stakes)?; + } + // Commit eligibility may use a caller-supplied frozen active set, so checking only the + // set would leave the commission timeline unauthenticated. Accepted-block projection + // materializes due changes before this helper; reject stale persisted metadata for direct + // admission and historical replay callers that do not perform that step. + delegation_state.validate_pending_commissions_at_epoch(epoch)?; + } + if let Some(frozen) = frozen_active_validators { + frozen.validate_minimum_self_stake(Wit::from_nanowits(min_stake))?; + } + let computed_active_validators = + if native_delegated_request && frozen_active_validators.is_none() { + // Standalone admission remains fail-closed: resolve the delegated owner-active + // population only after validating the complete policy/index projection for this + // exact snapshot. + Some(delegation_state.active_validator_set_checked( + stakes, + Wit::from_nanowits(min_stake), + )?) + } else { + None + }; + if delegated_staking_active + && !native_delegated_request + && frozen_active_validators.is_none() + { + // Cross-fork Wit/2.0 requests still use the legacy owner-only view, but standalone + // validation must prove policy/index integrity before that view can be projected. + delegation_state.validate_authoritative_owner_policies(stakes)?; + } + let active_validators = if native_delegated_request { + Some( + frozen_active_validators + .map(|frozen| &frozen.active_validators) + .or(computed_active_validators.as_ref()) + .ok_or_else(|| { + anyhow::anyhow!( + "delegated Commit eligibility did not receive an active-validator set" + ) + })?, + ) + } else { + None + }; + let legacy_owner_stakes = if delegated_staking_active + && request_protocol_version < ProtocolVersion::V2_1 + { + Some(delegation_state.legacy_owner_view_checked(stakes)?) + } else { + None + }; + + let eligibility = if delegated_staking_active { + if request_protocol_version < ProtocolVersion::V2_1 { + let legacy_owner_stakes = legacy_owner_stakes.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "legacy owner eligibility view must be available after activation" + ) + })?; + legacy_owner_stakes.witnessing_eligibility( + proof_pkh, + epoch, + witnesses, + current_commit_round, + max_rounds, + ) + } else { + delegation_state.participating_stakes_checked(stakes)?.witnessing_eligibility_filtered( + proof_pkh, + epoch, + witnesses, + current_commit_round, + max_rounds, + |validator| { + active_validators + .as_ref() + .is_some_and(|active| active.contains(validator)) + }, + ) + } + } else { + stakes.witnessing_eligibility( + proof_pkh, + epoch, + witnesses, + current_commit_round, + max_rounds, + ) + }; + let target_hash = match eligibility { + Ok((Eligible::Yes, target_hash, _)) => target_hash, + Ok((Eligible::No(_), _, _)) => { + return Err(TransactionError::ValidatorNotEligible { + validator: proof_pkh, + } + .into()); + } + Err(error) => return Err(error.into()), + }; + + // Wit/2.0 has exactly one withdrawer, while delegated validators use the same deterministic + // persistent/lazy cold owner. This keeps the VRF salt stable across the V2.1 transition. + let withdrawer = Some(if delegated_staking_active { + delegation_state.owner_withdrawer_or_legacy(stakes, proof_pkh)? + } else { + delegation_state.legacy_live_withdrawer(stakes, proof_pkh)? + }); + + Ok((target_hash, withdrawer)) +} + +/// Validate one commit transaction against the block's pinned protocol and stake state. It checks collateral, signatures, request chronology, and delegated owner policy before admission. +#[allow(clippy::too_many_arguments)] +fn validate_commit_transaction_inner( + co_tx: &CommitTransaction, + dr_pool: &DataRequestPool, + vrf_input: CheckpointVRF, + signatures_to_verify: &mut Vec, + rep_eng: &ReputationEngine, + epoch: Epoch, + epoch_constants: EpochConstants, + utxo_diff: &UtxoDiff<'_>, + collateral_minimum: u64, + collateral_age: u32, + block_number: u32, + minimum_reppoe_difficulty: u32, + active_wips: &ActiveWips, + superblock_period: u16, + block_protocol_version: ProtocolVersion, + protocol_version: ProtocolVersion, + stakes: &StakesTracker, + delegation_state: &DelegationState, + max_rounds: u16, + min_stake: u64, + frozen_active_validators: Option<&FrozenActiveValidatorSet>, +) -> Result<(Hash, u16, u64), anyhow::Error> { + // Get DataRequest information + let dr_pointer = co_tx.body.dr_pointer; + let dr_state = dr_pool + .data_request_state(&dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { hash: dr_pointer })?; + if dr_state.stage != DataRequestStage::COMMIT { + return Err(DataRequestError::NotCommitStage.into()); + } + + let dr_output = &dr_state.data_request; + let proof_pkh = co_tx.body.proof.proof.pkh(); + // Check if the commit transaction is from an eligible validator + let (target_hash_wit2, withdrawer) = if protocol_version >= ProtocolVersion::V2_0 { + resolve_commit_stake_eligibility( + proof_pkh, + block_protocol_version, + protocol_version, + epoch, + dr_state.data_request.witnesses, + dr_state.info.current_commit_round, + max_rounds, + stakes, + delegation_state, + min_stake, + frozen_active_validators, + )? + } else { + // Preserve the post-activation fail-closed policy/index proof for an in-flight Wit/1 + // request. Its reputation target and V1 VRF message do not consume stake, but the previous + // validation path still rejected a malformed delegated snapshot before queuing the proof. + if let Some(frozen) = frozen_active_validators { + frozen.validate_minimum_self_stake(Wit::from_nanowits(min_stake))?; + } + if block_protocol_version >= ProtocolVersion::V2_1 { + // A frozen active-validator set is not a state-identity capability. Preserve the + // authoritative policy/index proof for in-flight Wit/1 requests even when the caller + // supplies the optimized block-opening view. + delegation_state.validate_authoritative_owner_policies(stakes)?; + let _legacy_owner_stakes = delegation_state.legacy_owner_view_checked(stakes)?; + } + (Hash::min(), None) + }; + + // Commitment's output is only for change propose, so it only has to be one output and the + // address has to be the same than the address which creates the commitment + if co_tx.body.outputs.len() > 1 { + return Err(TransactionError::SeveralCommitOutputs.into()); + } + if let Some(output) = &co_tx.body.outputs.first() { + if output.value == 0 { + return Err(TransactionError::ZeroValueOutput { + tx_hash: co_tx.hash(), + output_id: 0, + } + .into()); + } + if output.pkh != proof_pkh { + return Err(TransactionError::PublicKeyHashMismatch { + expected_pkh: proof_pkh, + signature_pkh: output.pkh, + } + .into()); + } + } + + // A serialized zero means the network minimum for the historical UTXO-collateral path and + // native Wit/2.1, but remains a raw zero owner-only no-op for Wit/2.0 requests. This must use + // the request protocol (not the containing block protocol) and match mining, tally validation, + // DataRequestPool accounting, and consolidation across the V2.1 activation boundary. + let required_collateral = if dr_output.collateral == 0 + && protocol_version != ProtocolVersion::V2_0 + { + collateral_minimum + } else { + dr_output.collateral + }; + validate_commit_collateral_for_block_protocol( + co_tx, + utxo_diff, + epoch, + epoch_constants, + required_collateral, + block_number, + collateral_age, + superblock_period, + block_protocol_version, + protocol_version, + stakes, + delegation_state, + min_stake, + )?; + + // commit time_lock was disabled in the first hard fork + if !active_wips.wip_0008() { + // Verify that commits are only accepted after the time lock expired + let (epoch_timestamp, _) = epoch_constants.epoch_timestamp(epoch)?; + let dr_time_lock = i64::try_from(dr_output.data_request.time_lock)?; + if dr_time_lock > epoch_timestamp { + return Err(TransactionError::TimeLock { + expected: dr_time_lock, + current: epoch_timestamp, + } + .into()); + } + } + + let commit_signature = + validate_commit_reveal_signature(co_tx.hash(), &co_tx.signatures, signatures_to_verify)?; + + let sign_pkh = commit_signature.public_key.pkh(); + if proof_pkh != sign_pkh { + return Err(TransactionError::PublicKeyHashMismatch { + expected_pkh: proof_pkh, + signature_pkh: sign_pkh, + } + .into()); + } + + let pkh = proof_pkh; let target_hash = if protocol_version < ProtocolVersion::V2_0 { + // Pre-Wit/2 release binaries wrapped this u16 addition. Consensus-valid requests are + // bounded well below the overflow point by DR weight, but retain the exact legacy rule. + let num_witnesses = dr_output + .witnesses + .wrapping_add(dr_state.backup_witnesses()); let (target_hash, _) = calculate_reppoe_threshold( rep_eng, &pkh, @@ -810,14 +1592,14 @@ pub fn validate_commit_transaction( } else { target_hash_wit2 }; - add_dr_vrf_signature_to_verify( + add_dr_vrf_signature_to_verify_for_protocol( signatures_to_verify, &co_tx.body.proof, vrf_input, co_tx.body.dr_pointer, target_hash, withdrawer, - Some(dr_state.epoch), + protocol_version, ); // The commit fee here is the fee to include one commit @@ -874,6 +1656,26 @@ pub fn validate_reveal_transaction( Ok(dr_state.data_request.commit_and_reveal_fee) } +/// Validate a reveal while keeping its request-era protocol below the containing block protocol. +/// +/// The compatibility helper above intentionally has no protocol context because older callers +/// only needed stage, signature, and commitment checks. Block admission and standalone admission +/// do have a pinned containing protocol, however, and must reject an impossible request-newer-than- +/// block pair before accepting the reveal fee or signature as canonical. +pub fn validate_reveal_transaction_for_protocol( + re_tx: &RevealTransaction, + dr_pool: &DataRequestPool, + signatures_to_verify: &mut Vec, + request_protocol_version: ProtocolVersion, + containing_protocol_version: ProtocolVersion, +) -> Result { + validate_request_protocol_for_block( + containing_protocol_version, + request_protocol_version, + )?; + validate_reveal_transaction(re_tx, dr_pool, signatures_to_verify) +} + /// Execute RADON tally given a list of reveals. /// If this function panics internally, the result will be set to RadError::Unknown. pub fn run_tally_panic_safe( @@ -960,6 +1762,7 @@ pub fn run_tally( report } +/// Build the tally transaction expected from a validated data-request state. It derives outputs and reward terms used by live validation and snapshot replay comparisons. fn create_expected_tally_transaction( ta_tx: &TallyTransaction, dr_pool: &DataRequestPool, @@ -980,8 +1783,12 @@ fn create_expected_tally_transaction( let dr_output = &dr_state.data_request; - // The unwrap is safe because we know that the data request exists - let reveal_txns = dr_pool.get_reveals(&dr_pointer, active_wips).unwrap(); + // Keep the lookup fail-closed even if a malformed/inconsistent pool reports the request on + // one index but cannot produce its reveal set. A consensus validation path must return the + // structured absence error rather than panic while constructing the expected tally. + let reveal_txns = dr_pool + .get_reveals(&dr_pointer, active_wips) + .ok_or(TransactionError::DataRequestNotFound { hash: dr_pointer })?; let non_error_min = f64::from(dr_output.min_consensus_percentage) / 100.0; let committers = dr_state .info @@ -999,7 +1806,7 @@ fn create_expected_tally_transaction( active_wips, too_many_witnesses, ); - let ta_tx = create_tally( + let ta_tx = create_tally_checked( dr_pointer, dr_output, dr_state.pkh, @@ -1010,7 +1817,7 @@ fn create_expected_tally_transaction( tally_bytes_on_encode_error(), active_wips, protocol_version, - ); + )?; Ok((ta_tx, dr_state.clone())) } @@ -1019,7 +1826,11 @@ fn create_expected_tally_transaction( pub fn tally_bytes_on_encode_error() -> Vec { let radon_report_unknown_error: RadonReport = RadonReport::from_result(Err(RadError::Unknown), &ReportContext::default()); - Vec::try_from(&radon_report_unknown_error).unwrap() + // This helper is used at the boundary that constructs an infallible fallback tally. The + // Unknown error currently has a deterministic, always-encodable representation, but keep a + // malformed/future encoder from unwinding consensus code: an empty placeholder is not a + // valid Radon tally and is rejected by the later expected-tally validation path. + Vec::try_from(&radon_report_unknown_error).unwrap_or_default() } /// Return (number_of_lies, number_of_errors) in `TallyTransaction` @@ -1029,9 +1840,9 @@ pub fn calculate_liars_and_errors_count_from_tally(tally_tx: &TallyTransaction) .iter() .fold((0, 0), |(l_count, e_count), x| { if tally_tx.error_committers.contains(x) { - (l_count, e_count + 1) + (l_count, e_count.saturating_add(1)) } else { - (l_count + 1, e_count) + (l_count.saturating_add(1), e_count) } }) } @@ -1047,36 +1858,68 @@ pub fn validate_tally_transaction<'a>( validator_count: Option, protocol_version: ProtocolVersion, ) -> Result<(Vec<&'a ValueTransferOutput>, u64), anyhow::Error> { + validate_tally_transaction_for_containing_protocol( + ta_tx, + dr_pool, + consensus_constants, + active_wips, + data_requests_with_too_many_witnesses, + validator_count, + protocol_version, + // Preserve the legacy direct-call contract, where the single explicit protocol identifies + // both request economics and the containing block. Canonical block validation calls the + // two-protocol helper below so a V2.0 request crossing into V2.1 is classified correctly. + protocol_version, + get_protocol_version_activation_epoch(ProtocolVersion::V2_0), + ) +} + +#[allow(clippy::too_many_arguments)] +fn validate_tally_transaction_for_containing_protocol<'a>( + ta_tx: &'a TallyTransaction, + dr_pool: &mut DataRequestPool, + consensus_constants: &ConsensusConstants, + active_wips: &ActiveWips, + data_requests_with_too_many_witnesses: &HashSet, + validator_count: Option, + protocol_version: ProtocolVersion, + containing_protocol: ProtocolVersion, + wit2_activation_epoch: Epoch, +) -> Result<(Vec<&'a ValueTransferOutput>, u64), anyhow::Error> { + validate_request_protocol_for_block(containing_protocol, protocol_version)?; let validator_count = validator_count.unwrap_or(witnet_data_structures::DEFAULT_VALIDATOR_COUNT_FOR_TESTS); - let too_many_witnesses; - if let Some(dr_state) = dr_pool.data_request_state_mutable(&ta_tx.dr_pointer) { - too_many_witnesses = data_request_has_too_many_witnesses( - &dr_state.data_request, + let too_many_witnesses = if let Some(dr_state) = dr_pool.data_request_state(&ta_tx.dr_pointer) { + match classify_too_many_witnesses_tally_for_protocols( + dr_state, validator_count, - Some(dr_state.epoch), - ); - - if too_many_witnesses { - let wit2_activation_epoch = - get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - - // Check that for a TooManyWitnesses tally, the data request was also included in the block - if dr_state.epoch > wit2_activation_epoch - && !data_requests_with_too_many_witnesses.contains(&ta_tx.dr_pointer) - { + dr_pool.extra_rounds, + protocol_version, + containing_protocol, + wit2_activation_epoch, + data_requests_with_too_many_witnesses.contains(&ta_tx.dr_pointer), + )? { + TooManyWitnessesTallyMode::Ordinary => false, + TooManyWitnessesTallyMode::Sentinel => true, + TooManyWitnessesTallyMode::UnsupportedLegacyCrossing => { return Err(TransactionError::TooManyWitnessesDataRequestNotFound { hash: ta_tx.dr_pointer, } .into()); } + TooManyWitnessesTallyMode::InvalidEarlyTallyPopulation => { + return Err(TransactionError::InvalidTooManyWitnessesTallyState { + hash: ta_tx.dr_pointer, + } + .into()); + } } } else { return Err(TransactionError::DataRequestNotFound { hash: ta_tx.dr_pointer, } .into()); - } + }; let (expected_ta_tx, dr_state) = create_expected_tally_transaction( ta_tx, dr_pool, @@ -1086,6 +1929,18 @@ pub fn validate_tally_transaction<'a>( protocol_version, )?; + // `create_tally_checked` uses the supplied encoding-error bytes as an infallible fallback. + // The normal Radon error encoding is non-empty; an empty fallback means that even the + // deterministic sentinel could not be serialized. Reject it explicitly so a malformed or + // future encoder cannot make an empty miner tally compare equal to an empty expected tally. + if expected_ta_tx.tally.is_empty() { + return Err(TransactionError::MismatchedConsensus { + expected_tally: expected_ta_tx.tally, + miner_tally: ta_tx.tally.clone(), + } + .into()); + } + let sorted_out_of_consensus = ta_tx.out_of_consensus.iter().cloned().sorted().collect(); let sorted_expected_out_of_consensus = expected_ta_tx .out_of_consensus @@ -1145,18 +2000,41 @@ pub fn validate_tally_transaction<'a>( let commits_count = dr_state.info.commits.len(); let reveals_count = dr_state.info.reveals.len(); - let honests_count = commits_count - ta_tx.out_of_consensus.len(); + let strict_arithmetic = protocol_version >= ProtocolVersion::V2_1; + let honests_count = if strict_arithmetic { + commits_count + .checked_sub(ta_tx.out_of_consensus.len()) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + commits_count.wrapping_sub(ta_tx.out_of_consensus.len()) + }; let (liars_count, errors_count) = calculate_liars_and_errors_count_from_tally(ta_tx); - let expected_tally_change = calculate_tally_change( - commits_count, - reveals_count, - honests_count, - &dr_state.data_request, - ); + let expected_tally_change = if strict_arithmetic { + calculate_tally_change_checked( + commits_count, + reveals_count, + honests_count, + &dr_state.data_request, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + calculate_tally_change( + commits_count, + reveals_count, + honests_count, + &dr_state.data_request, + ) + }; - let collateral = if dr_state.data_request.collateral == 0 { + // Collateral-zero normalization is protocol-specific. Native Wit/2.1 uses the configured + // minimum, while Wit/2.0 deliberately preserves its serialized zero owner-only no-op (and + // pre-Wit/2.0 retains the historical minimum default). This value feeds both the expected + // tally outputs and reward arithmetic, so it must match consolidation exactly. + let collateral = if dr_state.data_request.collateral == 0 + && protocol_version != ProtocolVersion::V2_0 + { consensus_constants.collateral_minimum } else { dr_state.data_request.collateral @@ -1166,23 +2044,48 @@ pub fn validate_tally_transaction<'a>( let mut total_tally_value = 0; let is_after_second_hard_fork = active_wips.wips_0009_0011_0012(); let (reward, tally_extra_fee) = if is_after_second_hard_fork { - calculate_witness_reward( - commits_count, - liars_count, - errors_count, - dr_state.data_request.witness_reward, - collateral, - active_wips.wip0023(), - ) + if strict_arithmetic { + calculate_witness_reward_checked( + commits_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + active_wips.wip0023(), + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward( + commits_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + active_wips.wip0023(), + ) + } } else { - calculate_witness_reward_before_second_hard_fork( - commits_count, - reveals_count, - liars_count, - errors_count, - dr_state.data_request.witness_reward, - collateral, - ) + if strict_arithmetic { + calculate_witness_reward_before_second_hard_fork_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + calculate_witness_reward_before_second_hard_fork_legacy_checked( + commits_count, + reveals_count, + liars_count, + errors_count, + dr_state.data_request.witness_reward, + collateral, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } }; for (i, output) in ta_tx.outputs.iter().enumerate() { // Validation of tally change value @@ -1290,18 +2193,36 @@ pub fn validate_tally_transaction<'a>( } .into()); } - total_tally_value += output.value; + total_tally_value = add_value_for_protocol( + total_tally_value, + output.value, + protocol_version, + )?; } + let witnesses_count = u64::from(dr_state.data_request.witnesses); let expected_collateral_value = if commits_count > 0 { - if active_wips.wip0023() { - if honests_count > 0 { - collateral * (u64::from(dr_state.data_request.witnesses) - liars_count as u64) + let collateral_witnesses = if strict_arithmetic { + let liars_count_u64 = u64::try_from(liars_count) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?; + if active_wips.wip0023() && honests_count > 0 { + witnesses_count + .checked_sub(liars_count_u64) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? } else { - collateral * u64::from(dr_state.data_request.witnesses) + witnesses_count } + } else if active_wips.wip0023() && honests_count > 0 { + witnesses_count.wrapping_sub(liars_count as u64) + } else { + witnesses_count + }; + if strict_arithmetic { + collateral + .checked_mul(collateral_witnesses) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? } else { - collateral * u64::from(dr_state.data_request.witnesses) + collateral.wrapping_mul(collateral_witnesses) } } else { // In case of no commits, collateral does not affect @@ -1309,35 +2230,93 @@ pub fn validate_tally_transaction<'a>( }; if protocol_version < ProtocolVersion::V2_0 { let expected_dr_value = dr_state.data_request.checked_total_value()?; - let found_dr_value = dr_state.info.commits.len() as u64 - * dr_state.data_request.commit_and_reveal_fee - + dr_state.info.reveals.len() as u64 * dr_state.data_request.commit_and_reveal_fee - + tally_extra_fee - + total_tally_value; + let commits_fee = (dr_state.info.commits.len() as u64) + .wrapping_mul(dr_state.data_request.commit_and_reveal_fee); + let reveals_fee = (dr_state.info.reveals.len() as u64) + .wrapping_mul(dr_state.data_request.commit_and_reveal_fee); + let found_dr_value = commits_fee + .wrapping_add(reveals_fee) + .wrapping_add(tally_extra_fee) + .wrapping_add(total_tally_value); + let expected_value = expected_dr_value.wrapping_add(expected_collateral_value); // Validation of the total value of the data request - if found_dr_value != expected_dr_value + expected_collateral_value { + if found_dr_value != expected_value { return Err(TransactionError::InvalidTallyValue { value: found_dr_value, - expected_value: expected_dr_value + expected_collateral_value, + expected_value, } .into()); } } else { // Validate that the value returned to the data request launcher is correct - let expected_tally_value = if honests_count == 0 { + let expected_tally_value = if strict_arithmetic && honests_count == 0 { // If there are no honests because the data request fails, all rewards are returned // The returned commit-and-reveal fees depends on the amount of commits and reveals observed - u64::from(dr_state.data_request.witnesses) * dr_state.data_request.witness_reward - + (dr_state.data_request.witnesses as usize - commits_count) as u64 - * dr_state.data_request.commit_and_reveal_fee - + (dr_state.data_request.witnesses as usize - reveals_count) as u64 - * dr_state.data_request.commit_and_reveal_fee - } else { + let missing_commits = usize::from(dr_state.data_request.witnesses) + .checked_sub(commits_count) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let missing_reveals = usize::from(dr_state.data_request.witnesses) + .checked_sub(reveals_count) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let reward_value = witnesses_count + .checked_mul(dr_state.data_request.witness_reward) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let missing_commit_fees = u64::try_from(missing_commits) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)? + .checked_mul(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let missing_reveal_fees = u64::try_from(missing_reveals) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)? + .checked_mul(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + reward_value + .checked_add(missing_commit_fees) + .and_then(|value| value.checked_add(missing_reveal_fees)) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else if strict_arithmetic { // For non-revealers, the commit-and-reveal fee is refunded // For liars (which can be both revealers and non-revealers) and errors, the reward is refunded - (commits_count - reveals_count) as u64 * dr_state.data_request.commit_and_reveal_fee - + (errors_count + liars_count) as u64 * dr_state.data_request.witness_reward + let non_revealers = commits_count + .checked_sub(reveals_count) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let bad_committers = errors_count + .checked_add(liars_count) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let non_revealer_fees = u64::try_from(non_revealers) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)? + .checked_mul(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let bad_committer_rewards = u64::try_from(bad_committers) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)? + .checked_mul(dr_state.data_request.witness_reward) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + non_revealer_fees + .checked_add(bad_committer_rewards) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else if honests_count == 0 { + let missing_commits = usize::from(dr_state.data_request.witnesses) + .wrapping_sub(commits_count) as u64; + let missing_reveals = usize::from(dr_state.data_request.witnesses) + .wrapping_sub(reveals_count) as u64; + witnesses_count + .wrapping_mul(dr_state.data_request.witness_reward) + .wrapping_add( + missing_commits + .wrapping_mul(dr_state.data_request.commit_and_reveal_fee), + ) + .wrapping_add( + missing_reveals + .wrapping_mul(dr_state.data_request.commit_and_reveal_fee), + ) + } else { + let non_revealers = commits_count.wrapping_sub(reveals_count) as u64; + let bad_committers = errors_count.wrapping_add(liars_count) as u64; + non_revealers + .wrapping_mul(dr_state.data_request.commit_and_reveal_fee) + .wrapping_add( + bad_committers.wrapping_mul(dr_state.data_request.witness_reward), + ) }; if total_tally_value != expected_tally_value { @@ -1361,80 +2340,1900 @@ pub type ValidatedStakeTransaction<'a> = ( &'a Option, ); -/// Function to validate a stake transaction. +type ConsensusStakesError = StakesError; + +/// Add consensus amounts with strict Wit/2.1 arithmetic while preserving the release-wrapping +/// behavior of already-active protocol versions. +fn add_value_for_protocol( + left: u64, + right: u64, + protocol_version: ProtocolVersion, +) -> Result { + if protocol_version >= ProtocolVersion::V2_1 { + left.checked_add(right) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + } else { + Ok(left.wrapping_add(right)) + } +} + +/// Resolve the commission rate effective at one epoch from a validator policy. It applies pending commission activation rules used by delegated reward validation. +fn effective_commission( + policy: ValidatorPolicy, + epoch: Epoch, +) -> Result { + if policy.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: policy.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + if let Some(pending) = policy.pending_commission { + if pending.commission_percent > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission: pending.commission_percent, + maximum: MAX_COMMISSION_PERCENT, + }); + } + // A later authorization may replace an already-pending update, so the pending nonce can + // be greater than active by more than one; it must still be strictly newer than the last + // activated nonce. Keep this local reader fail-closed for direct projection callers that + // do not first traverse the full persisted-policy invariant. + if pending.nonce <= policy.commission_nonce { + return Err(StakesError::InvalidDelegationState { + reason: "pending commission nonce is not newer than the active nonce", + }); + } + } + let commission = policy + .pending_commission + .filter(|pending| pending.effective_epoch <= epoch) + .map_or(policy.commission_percent, |pending| { + pending.commission_percent + }); + if commission > MAX_COMMISSION_PERCENT { + return Err(StakesError::CommissionAboveMaximum { + commission, + maximum: MAX_COMMISSION_PERCENT, + }); + } + + Ok(commission) +} + +/// Enumerate authoritative outstanding Wit/2.0 commitments in deterministic key order. +/// +/// The active data-request pool, rather than the optional collateral sidecar cache, defines the +/// set. Values are the raw serialized Wit/2.0 collateral amounts, including zero. +pub fn outstanding_wit2_legacy_commitments( + dr_pool: &DataRequestPool, +) -> BTreeMap { + let protocol_info = get_protocol_info(); + outstanding_wit2_legacy_commitments_for_protocol(dr_pool, &protocol_info) +} + +/// Enumerate outstanding legacy commitments using a caller-owned protocol schedule snapshot. +/// +/// Activation and historical block replay must classify requests from the same immutable registry +/// generation as the surrounding transition; the compatibility wrapper above remains available +/// for live diagnostics. +pub fn outstanding_wit2_legacy_commitments_for_protocol( + dr_pool: &DataRequestPool, + protocol_info: &ProtocolInfo, +) -> BTreeMap { + dr_pool + .data_request_pool + .iter() + .filter(|(_, state)| { + protocol_info.all_versions.version_for_epoch(state.epoch) == ProtocolVersion::V2_0 + }) + .flat_map(|(data_request, state)| { + // Wit/2.0 consolidation debits the serialized collateral value exactly, including + // zero. The network minimum is only an eligibility capacity check for this protocol. + let collateral = Wit::from_nanowits(state.data_request.collateral); + state.info.commits.keys().map(move |validator| { + ( + CollateralReservationKey { + data_request: *data_request, + validator: *validator, + }, + collateral, + ) + }) + }) + .collect() +} + +/// Pool-derived owner locks for legacy witness rewards that have no collateral sidecar. +/// +/// There is deliberately no default/empty native constructor. Native Unstake callers must +/// obtain this evidence from the same authoritative request pool and schedule as their ledger. +/// Locks are conservative for one block: a request pending at block start stays locked even if +/// its tally settles in this block. The next block's pool releases it. Accepted cross-fork +/// commitments add locks before any full withdrawal can be projected. +#[derive(Clone, Debug)] +pub struct LegacyRewardLocks { + legacy_requests: BTreeSet, + owner_keys: BTreeSet>, + native_pool_evidence: bool, +} + +impl LegacyRewardLocks { + /// Capture native owner bindings for all outstanding Wit/2.0 commitments, including raw zero. + pub fn from_pool( + dr_pool: &DataRequestPool, + delegation_state: &DelegationState, + protocol_info: &ProtocolInfo, + ) -> DelegationResult { + let mut locks = Self { legacy_requests: BTreeSet::new(), owner_keys: BTreeSet::new(), native_pool_evidence: true }; + for (pointer, request) in &dr_pool.data_request_pool { + if protocol_info.all_versions.version_for_epoch(request.epoch) != ProtocolVersion::V2_0 { + continue; + } + locks.legacy_requests.insert(*pointer); + for validator in request.info.commits.keys() { + if let Some(obligation) = delegation_state + .legacy_activation_obligations + .get(&CollateralReservationKey { + data_request: *pointer, + validator: *validator, + }) + { + if let Some(owner) = obligation.canonical_live_owner { + locks.owner_keys.insert(StakeKey { + validator: *validator, + withdrawer: owner, + }); + } + } else { + locks.owner_keys.insert(delegation_state.owner_key(*validator)?); + } + } + } + Ok(locks) + } + + /// Historical Unstake has no native reward-lock rule. Explicitly reject a native fallback. + pub fn for_legacy_protocol(protocol: ProtocolVersion) -> DelegationResult { + if protocol >= ProtocolVersion::V2_1 { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake requires pool-derived legacy reward locks", + }); + } + Ok(Self { legacy_requests: BTreeSet::new(), owner_keys: BTreeSet::new(), native_pool_evidence: false }) + } + + /// Add only commitments accepted by the surrounding block/candidate projection. + pub fn include_accepted_commits( + &mut self, + commits: &[CommitTransaction], + delegation_state: &DelegationState, + ) -> DelegationResult<()> { + for commit in commits { + if self.legacy_requests.contains(&commit.body.dr_pointer) { + self.owner_keys.insert(delegation_state.owner_key(commit.body.proof.proof.pkh())?); + } + } + Ok(()) + } + + /// Sorted exact owner entries whose old request rewards have not safely passed the boundary. + pub fn owner_keys(&self) -> &BTreeSet> { + &self.owner_keys + } + + /// Protect an exact destination before removing its final balance. + pub fn ensure_entry_can_be_removed(&self, key: &StakeKey) -> DelegationResult<()> { + if !self.native_pool_evidence { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake requires pool-derived legacy reward locks", + }); + } + if self.owner_keys.contains(key) { + Err(ConsensusStakesError::StakeEntryLocked { key: key.clone() }) + } else { + Ok(()) + } + } +} + +/// Produce a deterministic, read-only Wit/2.1 activation report from authoritative active data +/// requests, the live stake ledger, and the optional pre-activation collateral sidecar cache. +pub fn delegated_staking_activation_preflight( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &DelegationState, + inspected_beacon: CheckpointBeacon, + minimum_stake: Wit, +) -> ActivationPreflightReport { + let protocol_info = get_protocol_info(); + delegated_staking_activation_preflight_for_protocol( + dr_pool, + stakes, + delegation_state, + inspected_beacon, + minimum_stake, + &protocol_info, + ) +} + +/// Produce the activation report from one caller-owned protocol schedule snapshot. +/// +/// The activation-preflight RPC runs inside the ChainManager actor, where the data-request pool, +/// stake ledger, and delegation state are observed together. Keeping request classification on the +/// same captured `ProtocolInfo` prevents a concurrent schedule publication from changing which +/// commitments are considered legacy halfway through the report. +pub fn delegated_staking_activation_preflight_for_protocol( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &DelegationState, + inspected_beacon: CheckpointBeacon, + minimum_stake: Wit, + protocol_info: &ProtocolInfo, +) -> ActivationPreflightReport { + let commitments = outstanding_wit2_legacy_commitments_for_protocol(dr_pool, protocol_info); + delegation_state.activation_preflight( + stakes, + &commitments, + inspected_beacon, + minimum_stake, + ) +} + +/// Reconstruct reservation metadata for pre-activation Wit/2.0 commitments. +/// +/// The old protocol already debited the sole legacy stake entry, but did not persist an exact +/// reservation. A V2.1 block-local view needs that metadata before projecting its tally and later +/// stake changes; otherwise validation can observe a different owner balance than consolidation. +pub fn track_pre_activation_collateral( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &mut DelegationState, + collateral_minimum: u64, + allow_missing_owners: bool, +) -> DelegationResult<()> { + let protocol_info = get_protocol_info(); + track_pre_activation_collateral_for_protocol( + dr_pool, + stakes, + delegation_state, + collateral_minimum, + allow_missing_owners, + &protocol_info, + ) +} + +/// Reconstruct pre-activation collateral using a caller-owned protocol schedule snapshot. +pub fn track_pre_activation_collateral_for_protocol( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &mut DelegationState, + collateral_minimum: u64, + allow_missing_owners: bool, + protocol_info: &ProtocolInfo, +) -> DelegationResult<()> { + track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool, + stakes, + delegation_state, + collateral_minimum, + allow_missing_owners, + false, + protocol_info, + ) +} + +/// Reconstruct legacy collateral and optionally reject cache entries without an authoritative +/// Wit/2.0 commitment during the one-time activation transition. +pub fn track_pre_activation_collateral_with_transition_check( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &mut DelegationState, + _collateral_minimum: u64, + allow_missing_owners: bool, + reject_orphaned_cached_reservations: bool, +) -> DelegationResult<()> { + let protocol_info = get_protocol_info(); + track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool, + stakes, + delegation_state, + _collateral_minimum, + allow_missing_owners, + reject_orphaned_cached_reservations, + &protocol_info, + ) +} + +/// Reconstruct legacy collateral with an explicit protocol schedule snapshot. #[allow(clippy::too_many_arguments)] -pub fn validate_stake_transaction<'a>( - st_tx: &'a StakeTransaction, - utxo_diff: &UtxoDiff<'_>, +pub fn track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &mut DelegationState, + _collateral_minimum: u64, + allow_missing_owners: bool, + reject_orphaned_cached_reservations: bool, + protocol_info: &ProtocolInfo, +) -> DelegationResult<()> { + // Reconstruct all commitments in a private copy. A later malformed commitment, stale cache, + // or orphaned reservation must not leave earlier entries materialized in the caller's state. + // Block validation already passes a block-local clone, but this helper is also exposed to + // standalone admission and activation callers where publishing a partial sidecar set would + // make a failed transition observable to subsequent checks. + let mut projected = delegation_state.clone(); + track_pre_activation_collateral_with_transition_check_in_place( + dr_pool, + stakes, + &mut projected, + _collateral_minimum, + allow_missing_owners, + reject_orphaned_cached_reservations, + protocol_info, + )?; + *delegation_state = projected; + Ok(()) +} + +/// Perform legacy-collateral reconstruction against a caller-provided private projection. +/// +/// The public wrapper stages this operation atomically; keeping the mutation logic separate also +/// makes it explicit that every caller observes either the complete reservation reconciliation or +/// the original state unchanged. +#[allow(clippy::too_many_arguments)] +fn track_pre_activation_collateral_with_transition_check_in_place( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &mut DelegationState, + _collateral_minimum: u64, + allow_missing_owners: bool, + reject_orphaned_cached_reservations: bool, + protocol_info: &ProtocolInfo, +) -> DelegationResult<()> { + let commitments = outstanding_wit2_legacy_commitments_for_protocol(dr_pool, protocol_info); + if reject_orphaned_cached_reservations { + // This is the one-time activation projection. Every preactivation reservation is an + // upgrade-history cache, so discard the complete map before rebuilding only the + // authoritative outstanding commitments below. This keeps the transition independent of + // the node's upgrade height and makes malformed/orphaned cache rows diagnostic-only. + delegation_state.collateral_reservations.clear(); + } + for (reservation_key, collateral) in &commitments { + let reservation_key = *reservation_key; + let collateral = *collateral; + let data_request = reservation_key.data_request; + let validator = reservation_key.validator; + let witness_reward = dr_pool + .data_request_pool + .get(&data_request) + .map(|request| Wit::from_nanowits(request.data_request.witness_reward)) + .ok_or(StakesError::InvalidDelegationState { + reason: "legacy activation commitment is missing its request terms", + })?; + if reject_orphaned_cached_reservations { + // The activation image cannot prove that a positive nominal Wit/2.0 collateral was + // actually debited by the old release-arithmetic wrapper. Persist the exact request + // terms as a non-spendable obligation instead of manufacturing a native escrow + // reservation or blocking the cross-fork tally forever. Raw-zero requests with one + // canonical live owner remain on the existing owner-only settlement path. + delegation_state.record_legacy_activation_obligation( + stakes, + reservation_key, + collateral, + witness_reward, + )?; + continue; + } + if delegation_state + .legacy_activation_obligations + .contains_key(&reservation_key) + { + // The activation-boundary record is authoritative for this pre-activation request; + // it is not a missing native reservation and must be settled through its recovery + // path when the tally arrives. + continue; + } + // Once the activation marker is already authoritative, every positive Wit/2.0 + // commitment included after the fork must have been debited by + // `reserve_legacy_collateral` in the same accepted block that created its sidecar. The + // first Wit/2.1 block is the only exception: it reconstructs pre-activation debits from + // the canonical live ledger, and is identified by `reject_orphaned_cached_reservations`. + // Re-materializing a missing positive sidecar on an ordinary post-activation replay would + // let a later legacy tally refund or reward stake that was never locked. + if delegation_state.activation_initialized + && !reject_orphaned_cached_reservations + && collateral.nanowits() != 0 + && !delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + return Err(StakesError::CollateralReservationNotFound { validator }); + } + // Wit/2.0 reservation sidecars are upgrade-history metadata, not consensus state. While + // the legacy protocol is active, leave an existing cache untouched; the transition path + // above clears it before rebuilding the authoritative projection. + if allow_missing_owners + && delegation_state + .collateral_reservations + .contains_key(&reservation_key) + { + continue; + } + let result = delegation_state.track_legacy_collateral( + stakes, + data_request, + validator, + collateral, + ); + match result { + Ok(()) => { + // The pre-activation cache is advisory and the tracker has just created the + // canonical owner-only record. The historical withdrawer is never checked as + // provenance; the one-time activation transition derives the owner from live + // stake. Post-marker replay still validates the newly tracked reservation shape. + if !allow_missing_owners && delegation_state.activation_initialized { + delegation_state.validate_active_legacy_collateral_owner(reservation_key)?; + } + } + Err(StakesError::ValidatorNotFound { .. }) if allow_missing_owners => { + // An upgraded binary can encounter a historical Wit/2.0 request after its sole + // stake entry has already exited. Do not alter pre-fork block validity; activation + // must remain strict and requires a live canonical owner for every outstanding + // commitment. Historical sidecar provenance cannot make this state activatable. + log::warn!( + "No live owner for outstanding data request {} and validator {}; Wit/2.1 activation is not safe until it settles or a live owner is restored", + data_request, + validator, + ); + } + Err(error) => return Err(error), + } + } + + if reject_orphaned_cached_reservations { + // The cache was cleared before the loop, so this is a defensive assertion that the + // canonical tracker did not publish an unexpected key while rebuilding commitments. + let orphaned_keys = delegation_state + .collateral_reservations + .keys() + .filter(|reservation_key| !commitments.contains_key(reservation_key)) + .copied() + .collect::>(); + for reservation_key in orphaned_keys { + delegation_state.discard_inert_legacy_sidecar(reservation_key); + } + } + + if reject_orphaned_cached_reservations + && delegation_state + .collateral_reservations + .keys() + .any(|reservation_key| !commitments.contains_key(reservation_key)) + { + // No optional cache row may survive the authoritative transition. + return Err(StakesError::InvalidDelegationState { + reason: "pre-activation collateral cache contains an orphaned reservation", + }); + } + + Ok(()) +} + +/// Build the canonical delegated-staking view used to validate standalone transactions in the +/// interval after Wit/2.1 becomes the current protocol but before its first block persists the +/// one-time activation marker. +/// +/// The live data-request pool, rather than upgrade-history sidecars, is authoritative for legacy +/// collateral in this interval. Returning an owned projection only when initialization is pending +/// lets ordinary post-activation admission continue borrowing the already-persisted state. +pub fn prepare_standalone_delegation_state( + dr_pool: &DataRequestPool, + stakes: &StakesTracker, + delegation_state: &DelegationState, epoch: Epoch, - epoch_constants: EpochConstants, - signatures_to_verify: &mut Vec, + collateral_minimum: u64, + minimum_stake: Wit, +) -> DelegationResult> { + let protocol_info = get_protocol_info(); + prepare_standalone_delegation_state_for_protocol( + dr_pool, + stakes, + delegation_state, + epoch, + collateral_minimum, + minimum_stake, + protocol_info.all_versions.version_for_epoch(epoch), + &protocol_info, + ) +} + +/// Build the standalone delegated-staking projection using a caller-selected protocol. +/// +/// Mempool admission can inspect a Wit/2.0 request while the current tip already reports +/// Wit/2.1. The referenced request protocol, not the live current-version convenience lookup, +/// determines whether activation-state reconstruction belongs in that validation. +pub fn prepare_standalone_delegation_state_for_protocol( + dr_pool: &DataRequestPool, stakes: &StakesTracker, - min_stake_nanowits: u64, - max_stake_nanowits: u64, -) -> Result, anyhow::Error> { - if get_protocol_version(Some(epoch)) == ProtocolVersion::V1_7 { - return Err(TransactionError::NoStakeTransactionsAllowed.into()); + delegation_state: &DelegationState, + epoch: Epoch, + collateral_minimum: u64, + minimum_stake: Wit, + protocol_version: ProtocolVersion, + protocol_info: &ProtocolInfo, +) -> DelegationResult> { + if protocol_version < ProtocolVersion::V2_1 { + return Ok(None); } - // Check that the amount of coins to stake is equal or greater than the minimum allowed - if st_tx.body.output.value < min_stake_nanowits { - Err(TransactionError::StakeBelowMinimum { - min_stake: min_stake_nanowits, - stake: st_tx.body.output.value, + // This helper is the activation projection used by standalone admission and policy/RPC + // inspection. Its compatibility minimum must agree with the native consensus authority even + // when the marker is already persisted (in which case no projection is needed): silently + // accepting a divergent argument would make a direct caller believe it had constructed a + // projection under a different threshold than the rest of the Wit/2.1 boundaries. + validate_v21_minimum_stake(minimum_stake.nanowits())?; + if delegation_state.activation_initialized { + return Ok(None); + } + + let mut projected = delegation_state.clone(); + projected.initialize_legacy_validators_for_activation(stakes, minimum_stake)?; + projected.activate_pending_commissions(epoch)?; + track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool, + stakes, + &mut projected, + collateral_minimum, + false, + true, + protocol_info, + )?; + projected.validate_stake_and_reservation_accounting(stakes, minimum_stake)?; + + Ok(Some(projected)) +} + +/// Project tally settlement into a block-local delegated-staking view. +/// +/// Consolidation refunds or consumes reservations before applying later stake and unstake +/// sections, so both block validation and block construction must use the same transition. +pub fn project_tally_transition( + transaction: &TallyTransaction, + dr_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch: Epoch, + collateral_minimum: u64, +) -> Result<(), anyhow::Error> { + project_tally_transition_for_protocol( + transaction, + dr_pool, + stakes, + delegation_state, + epoch, + collateral_minimum, + |request_epoch| get_protocol_version(Some(request_epoch)), + ) +} + +/// Project tally settlement using a request-protocol resolver pinned by the caller. +/// +/// Block validation and historical replay must not reinterpret a request's creation epoch after a +/// process-global protocol schedule update. The compatibility wrapper above retains the original +/// epoch-derived behavior for standalone callers. +#[allow(clippy::too_many_arguments)] +pub fn project_tally_transition_for_protocol( + transaction: &TallyTransaction, + dr_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch: Epoch, + collateral_minimum: u64, + request_protocol_for: F, +) -> Result<(), anyhow::Error> +where + F: Fn(Epoch) -> ProtocolVersion, +{ + let request_epoch = dr_pool + .data_request_state(&transaction.dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { + hash: transaction.dr_pointer, + })? + .epoch; + let protocol = request_protocol_for(request_epoch); + if protocol < ProtocolVersion::V2_0 { + return Ok(()); + } + if !delegation_state.activation_initialized && protocol < ProtocolVersion::V2_1 { + // Preserve the legacy transition, including its successful-prefix error behavior. + return project_tally_transition_inner( + transaction, dr_pool, stakes, delegation_state, epoch, collateral_minimum, + |_| protocol, + ); + } + if protocol >= ProtocolVersion::V2_1 && !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native tally transition requires the activation marker", + } + .into()); + } + + // Every post-activation tally, including a cross-fork Wit/2.0 zero-collateral tally, is a + // state-publication boundary. The zero-collateral branch has no reservation sidecar whose + // reward-key checks would otherwise authenticate the projection, so prove the complete + // policy/index image before the inner transition can credit the immutable owner. Keep the + // pending-commission timing guard here as well: full block projection materializes due changes + // first, while direct replay/consolidation callers must not publish a tally against stale + // policy metadata. + delegation_state.validate_authoritative_owner_policies(stakes)?; + delegation_state.validate_pending_commissions_at_epoch(epoch)?; + + // A tally may settle many validators, and even one honest validator requires a refund + // followed by a reward. Keep every reservation and balance unchanged if any later step + // fails; callers must never retry against a partially consumed tally. + stakes.validate_validator_index_checked()?; + let mut projected_stakes = stakes.try_clone_checked()?; + let mut projected_delegation_state = delegation_state.clone(); + project_tally_transition_inner( + transaction, + dr_pool, + &mut projected_stakes, + &mut projected_delegation_state, + epoch, + collateral_minimum, + |_| protocol, + )?; + *stakes = projected_stakes; + *delegation_state = projected_delegation_state; + Ok(()) +} + +fn project_tally_transition_inner( + transaction: &TallyTransaction, + dr_pool: &DataRequestPool, + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + epoch: Epoch, + collateral_minimum: u64, + request_protocol_for: F, +) -> Result<(), anyhow::Error> +where + F: Fn(Epoch) -> ProtocolVersion, +{ + let dr_state = dr_pool + .data_request_state(&transaction.dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { + hash: transaction.dr_pointer, })?; + let dr_protocol = request_protocol_for(dr_state.epoch); + // Pre-Wit/2 data requests never participated in stake collateral or reward accounting. + // Consolidation skips these legacy tallies for the same reason; keep validation and candidate + // projection aligned so an old request settling after the delegated-staking fork cannot + // mutate the projected owner/delegator ledger. + if dr_protocol < ProtocolVersion::V2_0 { + return Ok(()); + } + let required_collateral = if dr_protocol >= ProtocolVersion::V2_1 + && dr_state.data_request.collateral == 0 + { + collateral_minimum + } else { + dr_state.data_request.collateral + }; + let reward = Wit::from_nanowits(dr_state.data_request.witness_reward); + // Before the one-time activation marker, Wit/2.0 collateral sidecars are optional + // upgrade-history metadata. Once the marker is persisted, a positive Wit/2.0 collateral + // must have an exact reservation because the legacy debit has crossed into the canonical + // delegated ledger. Keep this state bit local so the compatibility projector can still be + // called directly for a pre-activation Wit/2.0 tally without making validity depend on a + // node's upgrade height. + let post_activation = delegation_state.activation_initialized; + let mut committers = dr_state.info.commits.keys().copied().collect::>(); + committers.sort_unstable(); + + // The authenticated Commit map is authoritative for reservation membership. Persisted + // ChainState validation already enforces this relationship, but this projector is also used + // by block-local validation, candidate construction, and consolidation after additional + // reservations may have been staged. Reject an orphan keyed to this request before settling + // any canonical committer so every consumer fails atomically on the same malformed state. + for reservation_key in delegation_state + .collateral_reservations + .keys() + .filter(|key| key.data_request == transaction.dr_pointer) + { + if post_activation && !dr_state.info.commits.contains_key(&reservation_key.validator) { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "collateral reservation has no canonical data-request committer", + } + .into()); + } } - // A stake transaction can only stake on an existing validator if the withdrawer address is the same - match stakes.check_validator_withdrawer( - st_tx.body.output.key.validator, - st_tx.body.output.key.withdrawer, - ) { - Ok(_) => (), - Err(_) => { + for validator in committers { + let reservation_key = CollateralReservationKey { + data_request: transaction.dr_pointer, + validator, + }; + let is_error = transaction.error_committers.contains(&validator); + let is_slashed = transaction.out_of_consensus.contains(&validator) && !is_error; + + if delegation_state + .legacy_activation_obligations + .contains_key(&reservation_key) + { + if dr_protocol != ProtocolVersion::V2_0 { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "activation obligation references a non-Wit/2.0 tally", + } + .into()); + } + delegation_state.settle_legacy_activation_obligation( + stakes, + reservation_key, + is_error, + is_slashed, + epoch, + )?; + continue; + } + + // Before activation, Wit/2.0 settlement is still authoritative in the legacy stake + // ledger. Any reservation sidecar was an optional upgrade-history cache and must not be + // validated or used to choose the debit/refund account. Remove the processed sidecar + // after the historical transition, matching legacy replay, so a resolved request cannot + // leave behind an activation orphan. The post-activation branch below handles the strict + // reservation/delegation projection once the marker is authoritative. + if !post_activation && dr_protocol < ProtocolVersion::V2_1 { + if is_slashed { + delegation_state + .collateral_reservations + .remove(&reservation_key); + continue; + } + let amount = if is_error { + Wit::from_nanowits(required_collateral) + } else { + Wit::from_nanowits( + required_collateral.wrapping_add(reward.nanowits()), + ) + }; + stakes.add_reward(validator, amount, epoch)?; + delegation_state + .collateral_reservations + .remove(&reservation_key); + continue; + } + + let has_reservation = delegation_state + .collateral_reservations + .contains_key(&reservation_key); + if has_reservation { + // A committed reward snapshot is immutable consensus data, but its stake entries are + // live ledger keys. Prove those exact identities before cloning or consuming the + // reservation so a zero-reward, error, or slash outcome cannot silently discard a + // missing weighted entry. + delegation_state.validate_committed_reward_keys(stakes, reservation_key)?; + if dr_protocol < ProtocolVersion::V2_1 { + // Activated Wit/2.0 sidecars remain owner-only compatibility metadata. Reject a + // native reward snapshot here rather than allowing legacy settlement to consume + // it and discard the committed delegated entitlements. + delegation_state.validate_active_legacy_collateral_owner(reservation_key)?; + } + // The request, not the mutable sidecar, is authoritative for the amount that was + // reserved. Recheck this immediately before either refund or slash so every tally + // consumer rejects a stale or malformed total consistently. Keep the historical debit + // vector intact: balances may have changed since Commit and must not be replanned. + delegation_state.validate_collateral_reservation_total( + reservation_key, + Wit::from_nanowits(required_collateral), + )?; + if dr_protocol >= ProtocolVersion::V2_1 + && delegation_state.collateral_reservations.get(&reservation_key) + .is_none_or(|reservation| reservation.reward_weights.is_empty()) + { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native tally reservation has no committed reward weights", + }.into()); + } + } + + if is_slashed { + if dr_protocol >= ProtocolVersion::V2_1 { + delegation_state.slash_reserved_collateral_checked(stakes, reservation_key)?; + } else if has_reservation { + delegation_state.slash_legacy_collateral(reservation_key)?; + } else if post_activation && dr_state.data_request.collateral != 0 { + // A positive Wit/2.0 collateral was already debited at commit time and must + // have been reconstructed at activation (or reserved by the post-activation + // commit projection). Accepting a missing sidecar after activation would hide + // malformed state instead of consuming the exact reservation that backs the + // slash; before activation, the historical path intentionally has no such + // sidecar requirement. + return Err(ConsensusStakesError::CollateralReservationNotFound { + validator, + } + .into()); + } + continue; + } + + if dr_protocol >= ProtocolVersion::V2_1 { + if !has_reservation { + return Err(ConsensusStakesError::CollateralReservationNotFound { + validator, + } + .into()); + } + let reservation = delegation_state.refund_collateral(stakes, reservation_key, epoch)?; + if reservation.reward_weights.is_empty() { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native tally reservation has no committed reward weights", + }.into()); + } + if reward == Wit::from_nanowits(0) || is_error { + continue; + } + delegation_state.distribute_committed_reward( + stakes, validator, reward, epoch, &reservation.reward_weights, + )?; + } else if has_reservation { + delegation_state.settle_legacy_collateral( + stakes, + reservation_key, + if is_error { + Wit::from_nanowits(0) + } else { + reward + }, + epoch, + )?; + } else { + // A serialized zero collateral is a valid Wit/2.0 no-op for stake reservation, so no + // sidecar exists to settle after activation. A positive collateral must have been + // reconstructed by the activation transition (or reserved by the post-activation + // commit path); accepting a missing sidecar here would credit collateral that was + // already debited at commit time and could mint stake on malformed state. + let collateral = Wit::from_nanowits(dr_state.data_request.collateral); + if post_activation && collateral.nanowits() != 0 { + return Err(ConsensusStakesError::CollateralReservationNotFound { + validator, + } + .into()); + } + delegation_state.settle_legacy_tally_without_reservation( + stakes, + validator, + Wit::from_nanowits(0), + if is_error { + Wit::from_nanowits(0) + } else { + reward + }, + epoch, + )?; + } + } + + Ok(()) +} + +/// Project Wit/2 commit and reveal inclusion rewards into a block-local delegation view. +/// +/// Inclusion rewards are settled before tally transitions. Requests created before Wit/2.0 +/// retain their pre-Wit/2 reward behavior and are deliberately excluded here. Cross-fork Wit/2.0 +/// requests remain owner-only, while native Wit/2.1 requests use delegated allocation. +pub fn project_wit2_inclusion_rewards( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + reveal_txns: &[RevealTransaction], +) -> Result<(), anyhow::Error> { + project_wit2_inclusion_rewards_for_protocol( + stakes, + delegation_state, + miner, + epoch, + dr_pool, + commit_txns, + reveal_txns, + |request_epoch| get_protocol_version(Some(request_epoch)), + ) +} + +/// Project Wit/2 inclusion rewards using a request-protocol resolver pinned by the caller. +#[allow(clippy::too_many_arguments)] +pub fn project_wit2_inclusion_rewards_for_protocol( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + reveal_txns: &[RevealTransaction], + request_protocol_for: F, +) -> Result<(), anyhow::Error> +where + F: Fn(Epoch) -> ProtocolVersion, +{ + if !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native inclusion rewards require the activation marker", + } + .into()); + } + // Commit and reveal credits are separate age-averaging operations. Preserve that ordering, + // but publish neither if resolving or crediting the other fails. Validate the source index + // before checked cloning can rebuild it and hide a malformed projection. + stakes.validate_validator_index_checked()?; + let mut projected_stakes = stakes.try_clone_checked()?; + let mut projected_delegation_state = delegation_state.clone(); + project_wit2_inclusion_rewards_inner( + &mut projected_stakes, + &mut projected_delegation_state, + miner, + epoch, + dr_pool, + commit_txns, + reveal_txns, + request_protocol_for, + )?; + *stakes = projected_stakes; + *delegation_state = projected_delegation_state; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn project_wit2_inclusion_rewards_inner( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + reveal_txns: &[RevealTransaction], + request_protocol_for: F, +) -> Result<(), anyhow::Error> +where + F: Fn(Epoch) -> ProtocolVersion, +{ + let mut native_commit_reward = 0u64; + let mut legacy_commit_reward = 0u64; + for transaction in commit_txns { + let dr_state = dr_pool + .data_request_state(&transaction.body.dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { + hash: transaction.body.dr_pointer, + })?; + let request_protocol = request_protocol_for(dr_state.epoch); + if request_protocol >= ProtocolVersion::V2_1 { + native_commit_reward = native_commit_reward + .checked_add(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + } else if request_protocol >= ProtocolVersion::V2_0 { + legacy_commit_reward = legacy_commit_reward + .checked_add(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + } + } + if native_commit_reward > 0 { + delegation_state.distribute_reward( + stakes, + miner, + Wit::from_nanowits(native_commit_reward), + epoch, + )?; + } + if legacy_commit_reward > 0 { + delegation_state.distribute_legacy_owner_reward( + stakes, + miner, + Wit::from_nanowits(legacy_commit_reward), + epoch, + )?; + } + + let mut native_reveal_reward = 0u64; + let mut legacy_reveal_reward = 0u64; + for transaction in reveal_txns { + let dr_state = dr_pool + .data_request_state(&transaction.body.dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { + hash: transaction.body.dr_pointer, + })?; + let request_protocol = request_protocol_for(dr_state.epoch); + if request_protocol >= ProtocolVersion::V2_1 { + native_reveal_reward = native_reveal_reward + .checked_add(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + } else if request_protocol >= ProtocolVersion::V2_0 { + legacy_reveal_reward = legacy_reveal_reward + .checked_add(dr_state.data_request.commit_and_reveal_fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + } + } + if native_reveal_reward > 0 { + delegation_state.distribute_reward( + stakes, + miner, + Wit::from_nanowits(native_reveal_reward), + epoch, + )?; + } + if legacy_reveal_reward > 0 { + delegation_state.distribute_legacy_owner_reward( + stakes, + miner, + Wit::from_nanowits(legacy_reveal_reward), + epoch, + )?; + } + + Ok(()) +} + +/// Project the Wit/2.1 miner share of transaction fees and block subsidy. +pub fn project_wit2_transaction_rewards( + stakes: &mut StakesTracker, + delegation_state: &mut DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + transaction_fees: u64, + block_reward: u64, +) -> DelegationResult<()> { + if !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native transaction rewards require the activation marker", + }); + } + let reward = transaction_fees + .checked_add(block_reward) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if reward > 0 { + delegation_state.distribute_reward(stakes, miner, Wit::from_nanowits(reward), epoch)?; + } + Ok(()) +} + +/// Apply the post-reward Wit/2 stake-age transitions in canonical consolidation order. +/// +/// These resets must be projected before stake transactions are applied: adding stake averages +/// the existing entry ages, so omitting a reset can produce a different final ledger (or a +/// different checked-arithmetic result) than consolidation. Wit/2.0 requests may legitimately +/// outlive the sole stake entry that backed their commitment; in that legacy case there is no live +/// witnessing age to reset. Native Wit/2.1 reservations keep their entries locked, so a missing +/// validator remains an error. +#[allow(clippy::too_many_arguments)] +pub fn apply_wit2_age_resets( + stakes: &mut StakesTracker, + delegation_state: &DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + minimum_stake: Wit, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], +) -> DelegationResult<()> { + let block_protocol = get_protocol_version(Some(epoch)); + apply_wit2_age_resets_inner( + stakes, + delegation_state, + miner, + epoch, + minimum_stake, + block_protocol, + commit_txns, + tally_txns, + |pointer| { + dr_pool + .data_request_state(&pointer) + .map(|state| get_protocol_version(Some(state.epoch))) + }, + None, + ) +} + +/// Apply the post-reward Wit/2 stake-age transitions using protocol versions pinned by the caller. +/// +/// Portable historical replay uses this entry point so neither the block protocol nor a request's +/// inclusion protocol is read from the mutable process-global schedule. Returning `None` from +/// `request_protocol_for` preserves the production missing-request behavior: the transaction is +/// treated as Wit/2 for age-reset purposes, while its preceding consensus transition is expected to +/// report the missing request state. +#[allow(clippy::too_many_arguments)] +pub fn apply_wit2_age_resets_for_protocol( + stakes: &mut StakesTracker, + delegation_state: &DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + minimum_stake: Wit, + block_protocol: ProtocolVersion, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], + request_protocol_for: F, +) -> DelegationResult<()> +where + F: Fn(Hash) -> Option, +{ + apply_wit2_age_resets_inner( + stakes, + delegation_state, + miner, + epoch, + minimum_stake, + block_protocol, + commit_txns, + tally_txns, + request_protocol_for, + None, + ) +} + +/// Apply post-reward Wit/2 age resets using an active-validator population proved before the +/// block's owner-membership-preserving commitment, tally, reward, and age-reset phases. +/// +/// This must not be called after any stake or unstake action. Standalone callers should use +/// [`apply_wit2_age_resets`], which derives and validates the active population itself. +#[allow(clippy::too_many_arguments)] +pub fn apply_wit2_age_resets_prevalidated( + stakes: &mut StakesTracker, + delegation_state: &DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + minimum_stake: Wit, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], + frozen_active_validators: &FrozenActiveValidatorSet, +) -> DelegationResult<()> { + let block_protocol = get_protocol_version(Some(epoch)); + apply_wit2_age_resets_prevalidated_for_protocol( + stakes, + delegation_state, + miner, + epoch, + minimum_stake, + block_protocol, + dr_pool, + commit_txns, + tally_txns, + |pointer| { + dr_pool + .data_request_state(&pointer) + .map(|state| get_protocol_version(Some(state.epoch))) + }, + frozen_active_validators, + ) +} + +/// Apply post-reward Wit/2 age resets with an explicit block protocol and prevalidated active set. +/// +/// Candidate and block replay use this variant to keep both block-level and request-level age +/// semantics on the same schedule snapshot used by the surrounding projection. The active set +/// must have been frozen before any commitment, tally, reward, stake, or unstake mutation. +#[allow(clippy::too_many_arguments)] +pub fn apply_wit2_age_resets_prevalidated_for_protocol( + stakes: &mut StakesTracker, + delegation_state: &DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + minimum_stake: Wit, + block_protocol: ProtocolVersion, + dr_pool: &DataRequestPool, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], + request_protocol_for: F, + frozen_active_validators: &FrozenActiveValidatorSet, +) -> DelegationResult<()> +where + F: Fn(Hash) -> Option, +{ + frozen_active_validators.validate_minimum_self_stake(minimum_stake)?; + apply_wit2_age_resets_inner( + stakes, + delegation_state, + miner, + epoch, + minimum_stake, + block_protocol, + commit_txns, + tally_txns, + request_protocol_for, + Some(frozen_active_validators), + ) +} + +#[allow(clippy::too_many_arguments)] +fn apply_wit2_age_resets_inner( + stakes: &mut StakesTracker, + delegation_state: &DelegationState, + miner: PublicKeyHash, + epoch: Epoch, + minimum_stake: Wit, + block_protocol: ProtocolVersion, + commit_txns: &[CommitTransaction], + tally_txns: &[TallyTransaction], + request_protocol_for: F, + frozen_active_validators: Option<&FrozenActiveValidatorSet>, +) -> DelegationResult<()> +where + F: Fn(Hash) -> Option, +{ + if block_protocol >= ProtocolVersion::V2_1 { + // The checked active-set derivation normalizes this compatibility argument internally, + // but the public non-prevalidated entry point must reject a divergent value rather than + // silently report success under a threshold different from its caller's expectation. + validate_v21_minimum_stake(minimum_stake.nanowits())?; + if !delegation_state.activation_initialized { + return Err(StakesError::InvalidDelegationState { + reason: "native age reset requires the activation marker", + }); + } + // The frozen set is not a state-identity proof. Revalidate the authoritative projection + // before using it for post-reward age resets, including the prevalidated entry point. + if frozen_active_validators.is_some() { + delegation_state.validate_authoritative_owner_policies(stakes)?; + } + } + + // Request/block chronology is part of the age-reset transition's input contract. Validate + // every supplied Commit and Tally before resetting mining age or any witnessing age; otherwise + // a direct caller that provides an impossible request-newer-than-block pair could observe a + // failed result with a partially mutated stake projection. The per-item checks below remain + // intentionally duplicated as a defensive assertion at the point where each request's + // protocol-specific reset branch is selected. + for transaction in commit_txns { + if let Some(request_protocol) = request_protocol_for(transaction.body.dr_pointer) { + validate_request_protocol_for_delegation(block_protocol, request_protocol)?; + } + } + for transaction in tally_txns { + if let Some(request_protocol) = request_protocol_for(transaction.dr_pointer) { + validate_request_protocol_for_delegation(block_protocol, request_protocol)?; + } + } + + if block_protocol >= ProtocolVersion::V2_1 { + let by_rank = if let Some(active_validators) = frozen_active_validators { + delegation_state.participating_stakes_checked(stakes)? + .by_validator_rank_checked_filtered(Capability::Mining, epoch, |validator| { + active_validators.contains(validator) + })?.collect::>() + } else { + delegation_state.by_active_validator_rank_checked( + stakes, Capability::Mining, epoch, minimum_stake, + )?.collect::>() + }; + // Native ranking excludes exact entries that have begun draining. Keep the mutation + // aligned with that same participation view: resetting every entry of an otherwise + // active validator would still change a draining withdrawer's later partial-withdrawal + // age arithmetic even though the entry no longer participates in mining. + let exclude_draining = delegation_state.activation_initialized; + stakes.reset_mining_age_ranked_filtered( + miner, + epoch, + &by_rank, + |key| !exclude_draining || !delegation_state.exit_intents.contains(key), + )?; + } else { + stakes.reset_mining_age(miner, epoch)?; + } + + for transaction in commit_txns { + let request_protocol = request_protocol_for(transaction.body.dr_pointer); + if let Some(request_protocol) = request_protocol { + validate_request_protocol_for_delegation(block_protocol, request_protocol)?; + } + if request_protocol.is_some_and(|protocol| protocol < ProtocolVersion::V2_0) { + continue; + } + + let validator = transaction.body.proof.proof.pkh(); + let reset_epoch = if block_protocol >= ProtocolVersion::V2_1 { + epoch + .checked_add(1) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + epoch.wrapping_add(1) + }; + if block_protocol >= ProtocolVersion::V2_1 + && request_protocol.is_some_and(|protocol| protocol < ProtocolVersion::V2_1) + { + match stakes.query_stakes_primary_checked(QueryStakesKey::Validator(validator)) { + Ok(_) => {} + Err(StakesError::ValidatorNotFound { .. }) => { + // Wit/2.0 allowed the sole stake entry to exit while a request was still + // outstanding. The request keeps its legacy collateral/reward semantics, but + // there is no live witnessing age to reset in this block. + continue; + } + Err(error) => return Err(error), + } + let owner = delegation_state.owner_withdrawer_or_legacy(stakes, validator)?; + match stakes.reset_age_for_key( + StakeKey { + validator, + withdrawer: owner, + }, + Capability::Witnessing, + reset_epoch, + 1, + ) { + Ok(()) => {} + Err(StakesError::EntryNotFound { .. }) => continue, + Err(error) => return Err(error), + } + } else if block_protocol >= ProtocolVersion::V2_1 { + let exclude_draining = delegation_state.activation_initialized; + stakes.reset_age_checked_filtered( + validator, + Capability::Witnessing, + reset_epoch, + 1, + |key| !exclude_draining || !delegation_state.exit_intents.contains(key), + )?; + } else { + stakes.reset_age( + validator, + Capability::Witnessing, + reset_epoch, + 1, + )?; + } + } + + for transaction in tally_txns { + let request_protocol = request_protocol_for(transaction.dr_pointer); + if let Some(request_protocol) = request_protocol { + validate_request_protocol_for_delegation(block_protocol, request_protocol)?; + } + if request_protocol.is_some_and(|protocol| protocol < ProtocolVersion::V2_0) { + continue; + } + + let mut liars = transaction + .out_of_consensus + .iter() + .filter(|validator| !transaction.error_committers.contains(validator)) + .copied() + .collect::>(); + liars.sort_unstable(); + for validator in liars { + let legacy_request_in_wit2_1_block = block_protocol >= ProtocolVersion::V2_1 + && request_protocol.is_some_and(|protocol| protocol < ProtocolVersion::V2_1); + let reset_epoch = if block_protocol >= ProtocolVersion::V2_1 { + epoch + .checked_add(10) + .ok_or(ConsensusStakesError::ArithmeticOverflow)? + } else { + epoch.wrapping_add(10) + }; + if legacy_request_in_wit2_1_block { + match stakes.query_stakes_primary_checked(QueryStakesKey::Validator(validator)) { + Ok(_) => {} + Err(StakesError::ValidatorNotFound { .. }) => { + // Wit/2.0 allowed the sole stake entry to exit while a request was still + // outstanding. The collateral outcome remains valid, but there is no live + // witnessing age to penalize. This must not depend on whether this node + // happened to cache a pre-activation reservation sidecar. + continue; + } + Err(error) => return Err(error), + } + + let owner = delegation_state.owner_withdrawer_or_legacy(stakes, validator)?; + match stakes.reset_age_for_key( + StakeKey { + validator, + withdrawer: owner, + }, + Capability::Witnessing, + reset_epoch, + 1, + ) { + Ok(()) => {} + Err(StakesError::EntryNotFound { .. }) => continue, + Err(error) => return Err(error), + } + } else if block_protocol >= ProtocolVersion::V2_1 { + let exclude_draining = delegation_state.activation_initialized; + stakes.reset_age_checked_filtered( + validator, + Capability::Witnessing, + reset_epoch, + 1, + |key| !exclude_draining || !delegation_state.exit_intents.contains(key), + )?; + } else { + stakes.reset_age( + validator, + Capability::Witnessing, + reset_epoch, + 1, + )?; + } + } + } + + Ok(()) +} + +/// Function to validate a stake transaction. +#[allow(clippy::too_many_arguments)] +pub fn validate_stake_transaction<'a>( + st_tx: &'a StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + signatures_to_verify: &mut Vec, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + min_stake_nanowits: u64, + max_stake_nanowits: u64, +) -> Result, anyhow::Error> { + validate_stake_transaction_for_protocol( + st_tx, + utxo_diff, + epoch, + epoch_constants, + signatures_to_verify, + stakes, + delegation_state, + network_magic, + min_stake_nanowits, + max_stake_nanowits, + get_protocol_version(Some(epoch)), + ) +} + +/// Validate a stake transaction with an explicitly selected containing protocol. +/// +/// Periodic mempool revalidation and other actor-local callers must use this entry point when +/// they already captured the schedule. The compatibility function above retains epoch-derived +/// behavior for callers without a pinned protocol context. +#[allow(clippy::too_many_arguments)] +pub fn validate_stake_transaction_for_protocol<'a>( + st_tx: &'a StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + signatures_to_verify: &mut Vec, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + min_stake_nanowits: u64, + max_stake_nanowits: u64, + protocol_version: ProtocolVersion, +) -> Result, anyhow::Error> { + validate_stake_transaction_inner( + st_tx, + utxo_diff, + epoch, + epoch_constants, + signatures_to_verify, + stakes, + delegation_state, + network_magic, + min_stake_nanowits, + max_stake_nanowits, + protocol_version, + None, + ) +} + +/// Validate a stake transaction after the caller has proved delegated-state invariants once for +/// the authoritative source snapshot. +/// +/// This is for sequential block/candidate projection only. The proof must still satisfy the +/// mutation contract on [`PrevalidatedDelegationInvariants`]. Standalone admission must call +/// [`validate_stake_transaction`] so malformed source state is checked on every independent call. +#[allow(clippy::too_many_arguments)] +pub fn validate_stake_transaction_prevalidated<'a>( + st_tx: &'a StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + signatures_to_verify: &mut Vec, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + min_stake_nanowits: u64, + max_stake_nanowits: u64, + prevalidated: &PrevalidatedDelegationInvariants, +) -> Result, anyhow::Error> { + validate_stake_transaction_prevalidated_for_protocol( + st_tx, + utxo_diff, + epoch, + epoch_constants, + signatures_to_verify, + stakes, + delegation_state, + network_magic, + min_stake_nanowits, + max_stake_nanowits, + get_protocol_version(Some(epoch)), + prevalidated, + ) +} + +/// Validate a stake transaction with a caller-selected protocol and a prevalidated source snapshot. +/// +/// Block and candidate replay must use this entry point so a mutable process-global schedule cannot +/// reinterpret the containing block between transaction validation and state projection. The +/// epoch-derived function above remains available for compatibility callers without a pinned +/// protocol context. +#[allow(clippy::too_many_arguments)] +pub fn validate_stake_transaction_prevalidated_for_protocol<'a>( + st_tx: &'a StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + signatures_to_verify: &mut Vec, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + min_stake_nanowits: u64, + max_stake_nanowits: u64, + protocol_version: ProtocolVersion, + prevalidated: &PrevalidatedDelegationInvariants, +) -> Result, anyhow::Error> { + validate_stake_transaction_inner( + st_tx, + utxo_diff, + epoch, + epoch_constants, + signatures_to_verify, + stakes, + delegation_state, + network_magic, + min_stake_nanowits, + max_stake_nanowits, + protocol_version, + Some(prevalidated), + ) +} + +#[allow(clippy::too_many_arguments)] +fn validate_stake_transaction_inner<'a>( + st_tx: &'a StakeTransaction, + utxo_diff: &UtxoDiff<'_>, + epoch: Epoch, + epoch_constants: EpochConstants, + signatures_to_verify: &mut Vec, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + min_stake_nanowits: u64, + max_stake_nanowits: u64, + protocol_version: ProtocolVersion, + _prevalidated: Option<&PrevalidatedDelegationInvariants>, +) -> Result, anyhow::Error> { + if protocol_version == ProtocolVersion::V1_7 { + return Err(TransactionError::NoStakeTransactionsAllowed.into()); + } + + if protocol_version >= ProtocolVersion::V2_1 { + validate_v21_stake_limits(min_stake_nanowits, max_stake_nanowits)?; + if !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native stake validation requires the activation marker", + } + .into()); + } + // After the one-time activation marker, every live or reserved validator must have an + // authoritative policy before any action is interpreted. Keep this guard at the + // transaction boundary as well as in block/candidate preparation so a direct standalone + // caller cannot treat a malformed reserved-only validator as a brand-new registration. + // + // A `PrevalidatedDelegationInvariants` value only documents the caller's frozen active-set + // contract; it is intentionally not a state-identity capability. Recheck the authoritative + // ledger/index projection here even for prevalidated callers so an unrelated or mixed state + // pair cannot bypass this safety boundary. + delegation_state.validate_authoritative_owner_policies(stakes)?; + // Block projection activates pending changes before entering this loop. Standalone + // validation has no mutation phase, so reject a persisted post-activation policy whose + // pending change should already have been materialized at this epoch. + delegation_state.validate_pending_commissions_at_epoch(epoch)?; + + let terms = st_tx + .body + .output + .authorization_terms + .as_ref() + .ok_or(TransactionError::MissingStakeAuthorizationTerms)?; + st_tx + .body + .authorization_is_valid_at(protocol_version, network_magic, epoch) + .map_err(|error| TransactionError::InvalidStakeAuthorization { + reason: error.to_string(), + })?; + + let validator = st_tx.body.output.key.validator; + let withdrawer = st_tx.body.output.key.withdrawer; + let validator_entries = match stakes + .query_stakes_primary_checked(QueryStakesKey::Validator(validator)) + { + Ok(entries) => entries, + Err(StakesError::ValidatorNotFound { .. }) => Vec::new(), + Err(error) => return Err(error.into()), + }; + + match terms.action { + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + let is_registration = terms.action == STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER; + if is_registration && (!validator_entries.is_empty() + || (delegation_state.activation_initialized + && delegation_state.validators.contains_key(&validator))) + { + return Err(ConsensusStakesError::ValidatorAlreadyRegistered { validator }.into()); + } + delegation_state.ensure_entry_accepts_deposit(&st_tx.body.output.key)?; + if st_tx.body.output.value < min_stake_nanowits { + return Err(TransactionError::StakeBelowMinimum { + min_stake: min_stake_nanowits, + stake: st_tx.body.output.value, + } + .into()); + } + + let current_total = validator_entries.iter().try_fold(0u64, |total, entry| { + total + .checked_add(entry.value.coins.nanowits()) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + })?; + // Like validator policies, pre-activation reservation sidecars are advisory and + // may differ with upgrade history. The builder and block validator reconstruct + // authoritative reservations from the live data-request pool before applying the + // cap; standalone admission must not make its answer depend on an inert cache. + let current_total = if delegation_state.activation_initialized { + delegation_state + .collateral_reservations + .iter() + .filter(|(key, _)| key.validator == validator) + .try_fold(current_total, |total, (_, reservation)| { + total + .checked_add(reservation.total.nanowits()) + .ok_or(ConsensusStakesError::ArithmeticOverflow) + })? + } else { + current_total + }; + let resulting_total = current_total + .checked_add(st_tx.body.output.value) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if resulting_total > max_stake_nanowits { + return Err(TransactionError::StakeAboveMaximum { + max_stake: max_stake_nanowits, + stake: resulting_total, + } + .into()); + } + + // Pre-activation policy sidecars are non-authoritative compatibility caches. The + // first Wit/2.1 block clears and rebuilds them from the live ledger, so standalone + // mempool validation must ignore them until that transition has set its marker. + let persisted_policy = delegation_state + .activation_initialized + .then(|| delegation_state.validators.get(&validator).copied()) + .flatten(); + let (owner_withdrawer, effective_commission) = + if let Some(policy) = persisted_policy { + (policy.owner_withdrawer, effective_commission(policy, epoch)?) + } else if validator_entries.is_empty() && is_registration { + // A brand-new validator establishes both its cold owner and + // its initial commission in this first authorization. + let commission = u8::try_from(terms.commission_percent) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?; + if commission > MAX_COMMISSION_PERCENT { + return Err(ConsensusStakesError::CommissionAboveMaximum { + commission, + maximum: MAX_COMMISSION_PERCENT, + } + .into()); + } + (withdrawer, commission) + } else if !validator_entries.is_empty() { + // A validator carried over from Wit/2.0 has a deterministic + // lazy owner and starts V2.1 at zero commission. + ( + delegation_state + .owner_withdrawer_or_legacy(stakes, validator)?, + 0, + ) + } else { + return Err(ConsensusStakesError::ValidatorRegistrationRequired { validator }.into()); + }; + + if terms.commission_percent != u32::from(effective_commission) { + return Err(TransactionError::DelegationCommissionMismatch { + validator, + authorized: terms.commission_percent, + effective: effective_commission, + } + .into()); + } + + // An empty live ledger represents either a brand-new validator or a retained + // policy whose owner has fully exited. In both cases there is no active owner + // yet; owner derivation must not turn first registration into a + // `ValidatorNotFound` validation failure. The withdrawer/owner comparison below + // still permits the registering owner and rejects third-party delegation until + // the owner has restored the minimum self-stake. + let owner_is_active = if validator_entries.is_empty() { + false + } else { + // The full policy/index proof was performed above for standalone admission or + // once at the block boundary for projected validation. Rechecking the exact + // owner key avoids a second complete ledger walk for this transaction. + (!delegation_state.activation_initialized + || !delegation_state.exit_intents.contains(&StakeKey { + validator, withdrawer: owner_withdrawer, + })) && stakes.validator_is_active_after_index_validation( + validator, + owner_withdrawer, + Wit::from_nanowits(min_stake_nanowits), + )? + }; + if withdrawer != owner_withdrawer && !owner_is_active { + return Err(TransactionError::InactiveValidatorOwner { + validator, + owner: owner_withdrawer, + } + .into()); + } + } + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + anyhow::ensure!(st_tx.body.output.value == 0, "exit request must not add stake"); + delegation_state.validate_exit_request(stakes, &st_tx.body.output.key, terms.nonce)?; + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => { + if st_tx.body.output.value != 0 { + return Err(TransactionError::CommissionUpdateWithStake { + validator, + value: st_tx.body.output.value, + } + .into()); + } + // Match the activation projection used by block building and validation: an inert + // Wit/2.0 sidecar cannot supply the owner or commission nonce before canonical + // activation initialization. + let policy = delegation_state + .activation_initialized + .then(|| delegation_state.validators.get(&validator).copied()) + .flatten(); + let owner_withdrawer = match policy { + Some(policy) => policy.owner_withdrawer, + None => delegation_state.owner_withdrawer_or_legacy(stakes, validator)?, + }; + if withdrawer != owner_withdrawer { + return Err(TransactionError::InvalidCommissionOwner { + validator, + used: withdrawer, + expected: owner_withdrawer, + } + .into()); + } + let last_nonce = policy.map_or(0, |policy| { + policy + .pending_commission + .map_or(policy.commission_nonce, |pending| pending.nonce) + }); + let expected_nonce = last_nonce + .checked_add(1) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if terms.nonce != expected_nonce { + return Err(ConsensusStakesError::InvalidCommissionNonce { + validator, + used: terms.nonce, + expected: expected_nonce, + } + .into()); + } + let commission = u8::try_from(terms.commission_percent) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?; + if commission > MAX_COMMISSION_PERCENT { + return Err(ConsensusStakesError::CommissionAboveMaximum { + commission, + maximum: MAX_COMMISSION_PERCENT, + } + .into()); + } + // Standalone admission must reject an update that the ordered block/candidate + // projection cannot schedule. Use the same caller-supplied clock and checked + // effective-epoch helper as consolidation; otherwise an overflow near the epoch + // limit or an unusable checkpoint period could leave an unmineable update in the + // mempool indefinitely. An uninitialized legacy owner starts at zero commission. + let current_commission = match policy { + Some(policy) => effective_commission(policy, epoch)?, + None => 0, + }; + let checkpoint_period = if commission > current_commission { + u64::from(epoch_constants.get_epoch_period(epoch) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?) + } else { + 0 + }; + commission_effective_epoch( + epoch, current_commission, commission, checkpoint_period, + ).ok_or(ConsensusStakesError::ArithmeticOverflow)?; + } + action => { + return Err(TransactionError::InvalidStakeAuthorization { + reason: format!("unsupported stake authorization action: {action}"), + } + .into()); + } + } + } else { + // Preserve the Wit/1.8 and Wit/2.0 one-validator/one-withdrawer rules. + if st_tx.body.output.value < min_stake_nanowits { + return Err(TransactionError::StakeBelowMinimum { + min_stake: min_stake_nanowits, + stake: st_tx.body.output.value, + } + .into()); + } + + if stakes + .check_validator_withdrawer( + st_tx.body.output.key.validator, + st_tx.body.output.key.withdrawer, + ) + .is_err() + { return Err(TransactionError::NoStakeFound { validator: st_tx.body.output.key.validator, withdrawer: st_tx.body.output.key.withdrawer, } .into()); } - } - // Check that the amount of coins to stake plus the alread staked amount is equal or smaller than the maximum allowed - let stakes_key = QueryStakesKey::Key(StakeKey { - validator: st_tx.body.output.key.validator, - withdrawer: st_tx.body.output.key.withdrawer, - }); - match stakes.query_stakes(stakes_key) { - Ok(stake_entry) => { - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let staked_amount: u64 = stake_entry - .first() - .map(|stake| stake.value.coins) - .unwrap() - .into(); - if staked_amount + st_tx.body.output.value > max_stake_nanowits { - Err(TransactionError::StakeAboveMaximum { - max_stake: max_stake_nanowits, - stake: staked_amount + st_tx.body.output.value, + let stakes_key = QueryStakesKey::Key(StakeKey { + validator: st_tx.body.output.key.validator, + withdrawer: st_tx.body.output.key.withdrawer, + }); + match stakes.query_stakes(stakes_key) { + Ok(stake_entry) => { + let stake = stake_entry.first().ok_or(TransactionError::NoStakeFound { + validator: st_tx.body.output.key.validator, + withdrawer: st_tx.body.output.key.withdrawer, })?; + let staked_amount: u64 = stake.value.coins.into(); + // Reject malformed oversized balances instead of allowing release-compatible + // wrapping to turn an over-cap legacy stake into a small resulting value. Valid + // Wit/1.8 and Wit/2.0 additions remain unchanged. + let resulting_stake = staked_amount + .checked_add(st_tx.body.output.value) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if resulting_stake > max_stake_nanowits { + return Err(TransactionError::StakeAboveMaximum { + max_stake: max_stake_nanowits, + stake: resulting_stake, + } + .into()); + } } - } - Err(_) => { - // Check that the amount of coins to stake is equal or smaller than the maximum allowed - if st_tx.body.output.value > max_stake_nanowits { - Err(TransactionError::StakeAboveMaximum { - max_stake: max_stake_nanowits, - stake: st_tx.body.output.value, - })?; + Err(StakesError::EntryNotFound { .. }) => { + if st_tx.body.output.value > max_stake_nanowits { + return Err(TransactionError::StakeAboveMaximum { + max_stake: max_stake_nanowits, + stake: st_tx.body.output.value, + } + .into()); + } } + Err(error) => return Err(error.into()), } - }; + } validate_transaction_signature( &st_tx.signatures, &st_tx.body.inputs, - st_tx.hash(), + st_tx.versioned_hash(protocol_version), utxo_diff, signatures_to_verify, )?; @@ -1442,17 +4241,45 @@ pub fn validate_stake_transaction<'a>( // A stake transaction must have at least one input if st_tx.body.inputs.is_empty() { Err(TransactionError::NoInputs { - tx_hash: st_tx.hash(), + tx_hash: st_tx.versioned_hash(protocol_version), })?; } - let fee = st_transaction_fee(st_tx, utxo_diff, epoch, epoch_constants)?; + let fee = st_transaction_fee_for_protocol( + st_tx, + utxo_diff, + epoch, + epoch_constants, + protocol_version, + )?; + + if protocol_version >= ProtocolVersion::V2_1 + && st_tx.body.output.authorization_terms.as_ref() + .is_some_and(|terms| terms.action == STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT) + { + anyhow::ensure!(fee > 0, "exit request must pay a positive UTXO fee"); + } + + if protocol_version >= ProtocolVersion::V2_1 + && st_tx + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| terms.action == STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE) + && fee == 0 + { + return Err(TransactionError::CommissionUpdateWithoutFee { + validator: st_tx.body.output.key.validator, + } + .into()); + } Ok(( st_tx.body.inputs.iter().collect(), &st_tx.body.output, fee, - st_tx.weight(), + st_tx.weight_for_protocol(protocol_version), &st_tx.body.change, )) } @@ -1461,33 +4288,190 @@ pub fn validate_stake_transaction<'a>( pub fn validate_unstake_transaction<'a>( ut_tx: &'a UnstakeTransaction, epoch: Epoch, + epoch_constants: EpochConstants, + stakes: &StakesTracker, + delegation_state: &DelegationState, + legacy_reward_locks: &LegacyRewardLocks, + min_stake_nanowits: u64, + unstake_delay: u64, +) -> Result<(u64, u32, Vec<&'a ValueTransferOutput>), anyhow::Error> { + validate_unstake_transaction_for_protocol( + ut_tx, + epoch, + epoch_constants, + stakes, + delegation_state, + legacy_reward_locks, + min_stake_nanowits, + unstake_delay, + get_protocol_version(Some(epoch)), + ) +} + +/// Validate an Unstake transaction with an explicitly selected containing protocol. +/// +/// Actor revalidation and historical replay must use this variant so timelock, minimum-stake, +/// and owner-exit semantics remain tied to the same schedule snapshot as the enclosing operation. +#[allow(clippy::too_many_arguments)] +pub fn validate_unstake_transaction_for_protocol<'a>( + ut_tx: &'a UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, + stakes: &StakesTracker, + delegation_state: &DelegationState, + legacy_reward_locks: &LegacyRewardLocks, + min_stake_nanowits: u64, + unstake_delay: u64, + protocol_version: ProtocolVersion, +) -> Result<(u64, u32, Vec<&'a ValueTransferOutput>), anyhow::Error> { + validate_unstake_transaction_inner( + ut_tx, + epoch, + epoch_constants, + stakes, + delegation_state, + legacy_reward_locks, + min_stake_nanowits, + unstake_delay, + protocol_version, + None, + ) +} + +/// Validate an unstake transaction after the caller has proved delegated-state invariants once for +/// the authoritative source snapshot. +/// +/// This is for sequential block/candidate projection only. The proof must still satisfy the +/// mutation contract on [`PrevalidatedDelegationInvariants`]. Standalone admission must call +/// [`validate_unstake_transaction`] so malformed source state is checked on every independent call. +pub fn validate_unstake_transaction_prevalidated<'a>( + ut_tx: &'a UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, + stakes: &StakesTracker, + delegation_state: &DelegationState, + legacy_reward_locks: &LegacyRewardLocks, + min_stake_nanowits: u64, + unstake_delay: u64, + prevalidated: &PrevalidatedDelegationInvariants, +) -> Result<(u64, u32, Vec<&'a ValueTransferOutput>), anyhow::Error> { + validate_unstake_transaction_prevalidated_for_protocol( + ut_tx, + epoch, + epoch_constants, + stakes, + delegation_state, + legacy_reward_locks, + min_stake_nanowits, + unstake_delay, + get_protocol_version(Some(epoch)), + prevalidated, + ) +} + +/// Validate an Unstake transaction with a caller-selected protocol and a prevalidated source +/// snapshot. +/// +/// Candidate/block replay uses this variant to keep Unstake arithmetic and timelock semantics on +/// the same containing protocol as the surrounding projection. The epoch-derived wrapper above is +/// retained for standalone and compatibility callers. +#[allow(clippy::too_many_arguments)] +pub fn validate_unstake_transaction_prevalidated_for_protocol<'a>( + ut_tx: &'a UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, stakes: &StakesTracker, + delegation_state: &DelegationState, + legacy_reward_locks: &LegacyRewardLocks, min_stake_nanowits: u64, unstake_delay: u64, + protocol_version: ProtocolVersion, + prevalidated: &PrevalidatedDelegationInvariants, ) -> Result<(u64, u32, Vec<&'a ValueTransferOutput>), anyhow::Error> { - let protocol_version = get_protocol_version(Some(epoch)); + validate_unstake_transaction_inner( + ut_tx, + epoch, + epoch_constants, + stakes, + delegation_state, + legacy_reward_locks, + min_stake_nanowits, + unstake_delay, + protocol_version, + Some(prevalidated), + ) +} +fn validate_unstake_transaction_inner<'a>( + ut_tx: &'a UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, + stakes: &StakesTracker, + delegation_state: &DelegationState, + legacy_reward_locks: &LegacyRewardLocks, + min_stake_nanowits: u64, + unstake_delay: u64, + protocol_version: ProtocolVersion, + _prevalidated: Option<&PrevalidatedDelegationInvariants>, +) -> Result<(u64, u32, Vec<&'a ValueTransferOutput>), anyhow::Error> { if protocol_version <= ProtocolVersion::V1_8 { return Err(TransactionError::NoUnstakeTransactionsAllowed.into()); } + let min_stake_nanowits = if protocol_version >= ProtocolVersion::V2_1 { + // A post-activation partial exit must use the same immutable minimum as deposits and + // collateral capacity. Do not let a mutable caller parameter manufacture a sub-minimum + // remainder that the persisted delegated ledger would reject. Validation alone is not + // enough here: normalize the value used by the remainder comparison as well. + validate_v21_minimum_stake(min_stake_nanowits)?; + DELEGATED_STAKING_MINIMUM_NANOWITS + } else { + min_stake_nanowits + }; + + if protocol_version >= ProtocolVersion::V2_1 { + if !delegation_state.activation_initialized { + return Err(ConsensusStakesError::InvalidDelegationState { + reason: "native unstake validation requires the activation marker", + } + .into()); + } + // Keep the unstake boundary fail-closed as well as block validation. A partial withdrawal + // does not otherwise consult the validator owner, so without this guard a live + // post-activation validator with missing policy metadata could slip through while a full + // withdrawal would fail later during owner/lock checks. As with Stake validation, the + // opaque prevalidated proof is only an active-set optimization contract and cannot replace + // this state/index check. + delegation_state.validate_authoritative_owner_policies(stakes)?; + } + if protocol_version >= ProtocolVersion::V2_1 { + delegation_state.validate_pending_commissions_at_epoch(epoch)?; + } + // Check if is unstaking more than the total stake - let mut amount_to_unstake = ut_tx.body.value() + ut_tx.body.fee; + let amount_to_unstake = + add_value_for_protocol(ut_tx.body.value(), ut_tx.body.fee, protocol_version)?; + if protocol_version >= ProtocolVersion::V2_1 && ut_tx.body.value() == 0 { + return Err(TransactionError::ZeroValueUnstakeOutput.into()); + } let validator = ut_tx.body.operator; let withdrawer = ut_tx.signature.public_key.pkh(); - let stakes_key = QueryStakesKey::Key(StakeKey { + let stake_key = StakeKey { validator, withdrawer, - }); - let staked_amount = match stakes.query_stakes(stakes_key) { + }; + let staked_amount = match (if protocol_version >= ProtocolVersion::V2_1 { + stakes.query_stakes_checked(QueryStakesKey::Key(stake_key.clone())) + } else { + stakes.query_stakes(QueryStakesKey::Key(stake_key.clone())) + }) { Ok(stake_entry) => { - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let staked_amount = stake_entry - .first() - .map(|stake| stake.value.coins) - .unwrap() - .into(); + let stake = stake_entry.first().ok_or(TransactionError::NoStakeFound { + validator, + withdrawer, + })?; + let staked_amount = stake.value.coins.into(); if amount_to_unstake > staked_amount { return Err(TransactionError::UnstakingMoreThanStaked { unstake: amount_to_unstake, @@ -1496,8 +4480,7 @@ pub fn validate_unstake_transaction<'a>( .into()); } - // TODO: modify this to enable delegated staking with multiple withdrawer addresses on a single validator - let nonce = stake_entry.first().map(|stake| stake.value.nonce).unwrap(); + let nonce = stake.value.nonce; if ut_tx.body.nonce != nonce { return Err(TransactionError::UnstakeInvalidNonce { used: ut_tx.body.nonce, @@ -1508,29 +4491,30 @@ pub fn validate_unstake_transaction<'a>( staked_amount } - Err(_) => { + Err(StakesError::EntryNotFound { .. }) => { return Err(TransactionError::NoStakeFound { validator, withdrawer, } .into()); } + Err(error) => return Err(error.into()), }; - // Introduced in V2_1: - // Apply a protocol-level "rule of convenience", where an amount to unstake of u64::MAX actually - // means "unstake all my stake". - // Please take note that this validation sctrictly needs to take place before checking whether - // the "remainder" amount meets the minimum stake. Otherwise, we would never reach this block. - if protocol_version >= ProtocolVersion::V2_1 && amount_to_unstake == 0 { - amount_to_unstake = staked_amount; - } - // Allowed unstake actions: // 1) Unstake the full balance (checked by the first condition) // 2) Unstake an amount such that the remainder (the stake amount that is left over) is greater // than the minimum allowed - let remainder = staked_amount - amount_to_unstake; + // The over-withdrawal check above proves this subtraction today. Keep the arithmetic + // explicit at the consensus boundary so a future change to lookup or fee ordering cannot + // turn malformed state into a release-mode wrapped remainder. + let remainder = staked_amount + .checked_sub(amount_to_unstake) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if protocol_version >= ProtocolVersion::V2_1 && remainder == 0 { + delegation_state.ensure_entry_can_be_removed(&stake_key)?; + legacy_reward_locks.ensure_entry_can_be_removed(&stake_key)?; + } if remainder > 0 && remainder < min_stake_nanowits { return Err(TransactionError::StakeBelowMinimum { min_stake: min_stake_nanowits, @@ -1539,13 +4523,39 @@ pub fn validate_unstake_transaction<'a>( .into()); } + if protocol_version >= ProtocolVersion::V2_1 && remainder == 0 { + let owner_withdrawer = + delegation_state.owner_withdrawer_or_legacy(stakes, validator)?; + let validator_entries = + stakes.query_stakes_primary_checked(QueryStakesKey::Validator(validator))?; + let has_third_party_stake = validator_entries.iter().any(|entry| { + entry.key.withdrawer != owner_withdrawer && entry.value.coins.nanowits() > 0 + }); + if withdrawer == owner_withdrawer + && (has_third_party_stake + || delegation_state.has_reservations_for_validator(validator)) + { + return Err(TransactionError::ValidatorOwnerStakeRequired { + validator, + owner: owner_withdrawer, + } + .into()); + } + } + // Validate unstake timestamp - validate_unstake_timelock(ut_tx, unstake_delay)?; + validate_unstake_timelock_for_protocol( + ut_tx, + epoch, + epoch_constants, + unstake_delay, + protocol_version, + )?; // validate unstake_signature - validate_unstake_signature(ut_tx, validator)?; + validate_unstake_signature_for_protocol(ut_tx, validator, protocol_version)?; - let fee = ut_transaction_fee(ut_tx, staked_amount)?; + let fee = ut_transaction_fee(ut_tx, staked_amount, protocol_version)?; let weight = ut_tx.weight(); Ok((fee, weight, vec![&ut_tx.body.withdrawal])) @@ -1554,14 +4564,76 @@ pub fn validate_unstake_transaction<'a>( /// Validate unstake timelock pub fn validate_unstake_timelock( ut_tx: &UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, + unstake_delay: u64, +) -> Result<(), anyhow::Error> { + validate_unstake_timelock_for_protocol( + ut_tx, + epoch, + epoch_constants, + unstake_delay, + get_protocol_version(Some(epoch)), + ) +} + +/// Validate a Wit/2.1 unstake timelock against a pinned protocol and epoch. It enforces the consensus delay without consulting mutable global protocol state. +pub(crate) fn validate_unstake_timelock_for_protocol( + ut_tx: &UnstakeTransaction, + epoch: Epoch, + epoch_constants: EpochConstants, unstake_delay: u64, + protocol_version: ProtocolVersion, ) -> Result<(), anyhow::Error> { - if ut_tx.body.withdrawal.time_lock < unstake_delay { - return Err(TransactionError::InvalidUnstakeTimelock { - time_lock: ut_tx.body.withdrawal.time_lock, - unstaking_delay_seconds: unstake_delay, + let time_lock = ut_tx.body.withdrawal.time_lock; + if protocol_version < ProtocolVersion::V2_1 { + if time_lock < unstake_delay { + return Err(TransactionError::InvalidUnstakeTimelock { + time_lock, + unstaking_delay_seconds: unstake_delay, + } + .into()); + } + return Ok(()); + } + + let checkpoint_zero_timestamp = u64::try_from(epoch_constants.checkpoint_zero_timestamp) + .map_err(|_| { + anyhow::anyhow!( + "checkpoint zero timestamp {} cannot be represented as u64", + epoch_constants.checkpoint_zero_timestamp + ) + })?; + if time_lock < checkpoint_zero_timestamp { + if time_lock < unstake_delay { + return Err(TransactionError::InvalidUnstakeTimelock { + time_lock, + unstaking_delay_seconds: unstake_delay, + } + .into()); + } + } else { + let epoch_timestamp = epoch_constants + .epoch_timestamp(epoch) + .map(|(timestamp, _)| timestamp) + .map_err(|error| { + anyhow::anyhow!("failed to get timestamp for epoch {epoch}: {error}") + })?; + let epoch_timestamp = u64::try_from(epoch_timestamp).map_err(|_| { + anyhow::anyhow!( + "epoch {epoch} timestamp {epoch_timestamp} cannot be represented as u64" + ) + })?; + let minimum_time_lock = epoch_timestamp + .checked_add(unstake_delay) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if time_lock < minimum_time_lock { + return Err(TransactionError::InvalidAbsoluteUnstakeTimelock { + time_lock, + minimum_time_lock, + } + .into()); } - .into()); } Ok(()) @@ -1571,6 +4643,24 @@ pub fn validate_unstake_timelock( pub fn validate_unstake_signature( ut_tx: &UnstakeTransaction, operator: PublicKeyHash, +) -> Result<(), anyhow::Error> { + validate_unstake_signature_for_protocol( + ut_tx, + operator, + get_protocol_version(None), + ) +} + +/// Validate an Unstake authorization using an explicitly selected protocol version. +/// +/// The Unstake wire image is currently unchanged across protocol versions, but its transaction +/// identity is still a consensus boundary. Keep signature verification tied to the caller's +/// pinned schedule so a future versioned field, a replay during a fork transition, or a snapshot +/// import cannot verify the body against the process-global protocol hash. +pub fn validate_unstake_signature_for_protocol( + ut_tx: &UnstakeTransaction, + operator: PublicKeyHash, + protocol_version: ProtocolVersion, ) -> Result<(), anyhow::Error> { let ut_tx_pkh = ut_tx.signature.public_key.pkh(); if ut_tx_pkh != ut_tx.body.withdrawal.pkh { @@ -1583,10 +4673,11 @@ pub fn validate_unstake_signature( } // Validate message body and signature - let Hash::SHA256(message) = ut_tx.hash(); + let transaction_hash = ut_tx.versioned_hash(protocol_version); + let Hash::SHA256(message) = transaction_hash; let fte = |e: anyhow::Error| TransactionError::VerifyTransactionSignatureFail { - hash: ut_tx.hash(), + hash: transaction_hash, msg: e.to_string(), }; @@ -1611,6 +4702,23 @@ pub fn validate_unstake_signature( pub fn validate_block_signature( block: &Block, signatures_to_verify: &mut Vec, +) -> Result<(), anyhow::Error> { + validate_block_signature_for_protocol( + block, + signatures_to_verify, + get_protocol_version(Some(block.block_header.beacon.checkpoint)), + ) +} + +/// Validate a block signature against an already selected protocol version. +/// +/// Historical replay and block validation must hash the header with the same pinned fork context +/// that selected transaction identities and VRF semantics. The compatibility wrapper above keeps +/// the original epoch-derived API for callers without that context. +pub fn validate_block_signature_for_protocol( + block: &Block, + signatures_to_verify: &mut Vec, + protocol_version: ProtocolVersion, ) -> Result<(), anyhow::Error> { let proof_pkh = block.block_header.proof.proof.pkh(); let signature_pkh = block.block_sig.public_key.pkh(); @@ -1627,9 +4735,7 @@ pub fn validate_block_signature( let signature = keyed_signature.signature.clone().try_into()?; let public_key = keyed_signature.public_key.clone().try_into()?; - let Hash::SHA256(message) = block.versioned_hash(get_protocol_version(Some( - block.block_header.beacon.checkpoint, - ))); + let Hash::SHA256(message) = block.versioned_hash(protocol_version); add_secp_block_signature_to_verify(signatures_to_verify, &public_key, &message, &signature); @@ -1642,7 +4748,7 @@ pub fn validate_pkh_signature( keyed_signature: &KeyedSignature, utxo_diff: &UtxoDiff<'_>, ) -> Result<(), anyhow::Error> { - let output = utxo_diff.get(input.output_pointer()); + let output = utxo_diff.try_get(input.output_pointer())?; if let Some(x) = output { let signature_pkh = PublicKeyHash::from_public_key(&keyed_signature.public_key); let expected_pkh = x.pkh; @@ -1685,7 +4791,11 @@ pub fn add_secp_block_signature_to_verify( }); } -/// Add vrf signatures to verification list +/// Add a data-request VRF proof while preserving the historical epoch-taking API. +/// +/// The protocol is resolved once at queue time. This compatibility wrapper must not defer that +/// lookup to [`verify_signatures`], because signature work may outlive the runtime schedule +/// generation under which transaction validation selected its V1/V2 message. pub fn add_dr_vrf_signature_to_verify( signatures_to_verify: &mut Vec, proof: &DataRequestEligibilityClaim, @@ -1694,6 +4804,30 @@ pub fn add_dr_vrf_signature_to_verify( target_hash: Hash, withdrawer: Option, inclusion_epoch: Option, +) { + add_dr_vrf_signature_to_verify_for_protocol( + signatures_to_verify, + proof, + vrf_input, + dr_hash, + target_hash, + withdrawer, + get_protocol_version(inclusion_epoch), + ); +} + +/// Add a data-request VRF proof with its already pinned request protocol. +/// +/// Consensus paths that already resolved the request's inclusion protocol should use this helper +/// so a later process-global schedule publication cannot reinterpret the queued proof. +pub fn add_dr_vrf_signature_to_verify_for_protocol( + signatures_to_verify: &mut Vec, + proof: &DataRequestEligibilityClaim, + vrf_input: CheckpointVRF, + dr_hash: Hash, + target_hash: Hash, + withdrawer: Option, + request_protocol_version: ProtocolVersion, ) { signatures_to_verify.push(SignaturesToVerify::VrfDr { proof: proof.clone(), @@ -1701,7 +4835,7 @@ pub fn add_dr_vrf_signature_to_verify( dr_hash, target_hash, withdrawer, - inclusion_epoch, + request_protocol_version, }) } @@ -1824,12 +4958,11 @@ fn increment_witnesses_counter( k: &Hash, rf: u32, ) { - hm.entry(*k) - .or_insert(WitnessesCount { - current: 0, - target: rf, - }) - .current += 1; + let count = hm.entry(*k).or_insert(WitnessesCount { + current: 0, + target: rf, + }); + count.current = count.current.saturating_add(1); } /// Update UTXO diff with the provided inputs and outputs. @@ -1837,7 +4970,11 @@ fn increment_witnesses_counter( /// If all the inputs have the same PKH, the outputs with that PKH will keep the maximum /// (most recent) block number of the inputs. Outputs with different PKH have the block number reset. /// And in case one of the inputs has a different PKH from another input, the block number of all the outputs is reset. -pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( +/// +/// The update is applied atomically: any storage, representation, or timestamp error leaves the +/// caller's diff unchanged. This is important at consensus boundaries, where malformed data must +/// be rejected rather than converted into a panic or a partially updated candidate view. +pub fn try_update_utxo_diff<'a, IterInputs, IterOutputs>( utxo_diff: &mut UtxoDiff<'_>, inputs: IterInputs, outputs: IterOutputs, @@ -1845,10 +4982,44 @@ pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( epoch: Epoch, epoch_constants: EpochConstants, checkpoint_zero_timestamp: i64, -) where +) -> Result<(), anyhow::Error> +where + IterInputs: IntoIterator, + IterOutputs: IntoIterator, +{ + try_update_utxo_diff_for_protocol( + utxo_diff, + inputs, + outputs, + tx_hash, + epoch, + epoch_constants, + checkpoint_zero_timestamp, + get_protocol_version(Some(epoch)), + ) +} + +/// Update a UTXO diff using an explicitly selected protocol version. +/// +/// Historical snapshot replay uses this entry point so relative-timelock normalization follows its +/// pinned protocol schedule instead of mutable process-global state. The update remains atomic: any +/// storage, representation, or timestamp error leaves the caller's diff unchanged. +#[allow(clippy::too_many_arguments)] +pub fn try_update_utxo_diff_for_protocol<'a, IterInputs, IterOutputs>( + utxo_diff: &mut UtxoDiff<'_>, + inputs: IterInputs, + outputs: IterOutputs, + tx_hash: Hash, + epoch: Epoch, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, + protocol_version: ProtocolVersion, +) -> Result<(), anyhow::Error> +where IterInputs: IntoIterator, IterOutputs: IntoIterator, { + let mut updated = utxo_diff.clone(); let mut input_pkh = None; let mut block_number_input = 0; let mut first = true; @@ -1859,7 +5030,13 @@ pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( if !first && input_pkh.is_none() { // No need to check PKH and block number, there is more than one distinct input PKH - } else if !first && input_pkh != utxo_diff.get(output_pointer).map(|vt| vt.pkh) { + } else if !first + && input_pkh + != updated + .try_get(output_pointer) + .map_err(|error| anyhow::anyhow!("failed to read input UTXO: {error}"))? + .map(|vt| vt.pkh) + { // PKH of this input is different from the previous input, no need to check block number input_pkh = None; } else { @@ -1867,23 +5044,28 @@ pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( if first { first = false; // Store the PKH of the first element - input_pkh = utxo_diff.get(output_pointer).map(|vt| vt.pkh); + input_pkh = updated + .try_get(output_pointer) + .map_err(|error| anyhow::anyhow!("failed to read input UTXO: {error}"))? + .map(|vt| vt.pkh); } // Update block number to max of inputs - let block_number = utxo_diff - .included_in_block_number(output_pointer) + let block_number = updated + .try_included_in_block_number(output_pointer) + .map_err(|error| anyhow::anyhow!("failed to read input UTXO age: {error}"))? .unwrap_or(0); block_number_input = std::cmp::max(block_number, block_number_input); } - utxo_diff.remove_utxo(*output_pointer); + updated.remove_utxo(*output_pointer); } for (index, output) in outputs.into_iter().enumerate() { // Add the new outputs to the utxo_diff let output_pointer = OutputPointer { transaction_id: tx_hash, - output_index: u32::try_from(index).unwrap(), + output_index: u32::try_from(index) + .map_err(|_| anyhow::anyhow!("UTXO output index {index} exceeds u32::MAX"))?, }; let block_number = if input_pkh == Some(output.pkh) { @@ -1892,17 +5074,32 @@ pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( None }; - #[allow(clippy::cast_sign_loss)] - let output_to_insert = if get_protocol_version(Some(epoch)) >= ProtocolVersion::V2_0 { - if output.time_lock < checkpoint_zero_timestamp.try_into().unwrap() { - let epoch_timestamp = match epoch_constants.epoch_timestamp(epoch) { - Ok((timestamp, _)) => timestamp, - Err(e) => panic!("Failed to get timestamp for epoch {epoch}: {e}"), - }; + let output_to_insert = if protocol_version >= ProtocolVersion::V2_0 { + let checkpoint_zero_timestamp = u64::try_from(checkpoint_zero_timestamp).map_err(|_| { + anyhow::anyhow!( + "checkpoint zero timestamp {checkpoint_zero_timestamp} cannot be represented as u64" + ) + })?; + if output.time_lock < checkpoint_zero_timestamp { + let epoch_timestamp = epoch_constants + .epoch_timestamp(epoch) + .map(|(timestamp, _)| timestamp) + .map_err(|error| { + anyhow::anyhow!("failed to get timestamp for epoch {epoch}: {error}") + })?; + let epoch_timestamp = u64::try_from(epoch_timestamp).map_err(|_| { + anyhow::anyhow!( + "epoch {epoch} timestamp {epoch_timestamp} cannot be represented as u64" + ) + })?; ValueTransferOutput { pkh: output.pkh, value: output.value, - time_lock: output.time_lock + epoch_timestamp as u64, + time_lock: output.time_lock.checked_add(epoch_timestamp).ok_or_else(|| { + anyhow::anyhow!( + "UTXO timelock overflows u64 for epoch {epoch} (output index {index})" + ) + })?, } } else { output.clone() @@ -1911,15 +5108,95 @@ pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( output.clone() }; - utxo_diff.insert_utxo(output_pointer, output_to_insert, block_number); + updated.insert_utxo(output_pointer, output_to_insert, block_number); } + + *utxo_diff = updated; + Ok(()) +} + +/// Backwards-compatible infallible wrapper for callers that already operate on trusted data. +/// Consensus and candidate-construction code must use [`try_update_utxo_diff`] instead. +pub fn update_utxo_diff<'a, IterInputs, IterOutputs>( + utxo_diff: &mut UtxoDiff<'_>, + inputs: IterInputs, + outputs: IterOutputs, + tx_hash: Hash, + epoch: Epoch, + epoch_constants: EpochConstants, + checkpoint_zero_timestamp: i64, +) where + IterInputs: IntoIterator, + IterOutputs: IntoIterator, +{ + try_update_utxo_diff( + utxo_diff, + inputs, + outputs, + tx_hash, + epoch, + epoch_constants, + checkpoint_zero_timestamp, + ) + .expect("failed to update UTXO diff") } -/// Function to validate transactions in a block and update a utxo_set and a `TransactionsPool` +/// Function to validate transactions in a block and update a utxo_set and a `TransactionsPool`. /// -/// This uses a `Visitor` that will visit each transaction as well as its fee and weight. +/// This uses a `Visitor` that will visit each transaction as well as its fee and weight. The +/// compatibility entry point snapshots the process protocol registry before delegating to the +/// explicit variant below. #[allow(clippy::too_many_arguments)] pub fn validate_block_transactions( + utxo_set: &UnspentOutputsPool, + dr_pool: &mut DataRequestPool, + block: &Block, + vrf_input: CheckpointVRF, + signatures_to_verify: &mut Vec, + rep_eng: &ReputationEngine, + epoch_constants: EpochConstants, + block_number: u32, + consensus_constants: &ConsensusConstants, + consensus_constants_wit2: &ConsensusConstantsWit2, + active_wips: &ActiveWips, + visitor: Option<&mut dyn Visitor>, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, +) -> Result { + let protocol_info = get_protocol_info(); + let protocol_version = protocol_info + .all_versions + .version_for_epoch(block.block_header.beacon.checkpoint); + validate_block_transactions_for_protocol( + utxo_set, + dr_pool, + block, + vrf_input, + signatures_to_verify, + rep_eng, + epoch_constants, + block_number, + consensus_constants, + consensus_constants_wit2, + active_wips, + visitor, + stakes, + delegation_state, + network_magic, + &protocol_info, + protocol_version, + ) +} + +/// Validate block transactions using an already snapshotted protocol schedule. +/// +/// Historical block replay must use the same containing-block protocol for transaction hashes, +/// UTXO timelocks, delegated-staking projections, and request-era lookups. The schedule snapshot +/// is supplied by the caller so an async signature-verification boundary cannot reinterpret a +/// block after the first validation phase has completed. +#[allow(clippy::too_many_arguments)] +pub fn validate_block_transactions_for_protocol( utxo_set: &UnspentOutputsPool, dr_pool: &mut DataRequestPool, block: &Block, @@ -1933,24 +5210,47 @@ pub fn validate_block_transactions( active_wips: &ActiveWips, mut visitor: Option<&mut dyn Visitor>, stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + protocol_info: &witnet_data_structures::proto::versioning::ProtocolInfo, + block_protocol_version: ProtocolVersion, ) -> Result { + // Immediate TooManyWitnesses tallies need their same-block request in the pool while the tally + // is checked. Keep that synthetic state local: later transaction, mint, Merkle, or deferred + // signature failure must not mutate the caller's live oracle pool. Consolidation materializes + // the request immediately before applying the accepted tally. + let mut validation_dr_pool = dr_pool.clone(); + let dr_pool = &mut validation_dr_pool; let epoch = block.block_header.beacon.checkpoint; - let is_genesis = block.is_genesis(&consensus_constants.genesis_hash); + let expected_protocol_version = protocol_info + .all_versions + .version_for_epoch(epoch); + anyhow::ensure!( + expected_protocol_version == block_protocol_version, + "protocol snapshot/version mismatch for block epoch {epoch}: snapshot selects {expected_protocol_version:?}, caller supplied {block_protocol_version:?}" + ); + let is_genesis = block.is_genesis_for_protocol( + &consensus_constants.genesis_hash, + block_protocol_version, + ); let mut utxo_diff = UtxoDiff::new(utxo_set, block_number); // Init total fee - let mut total_fee = 0; + let mut total_fee: u64 = 0; + // Wit/2.1 miner rewards exclude commit/reveal inclusion fees and tally witness rewards. + // Keep this accumulator separate from the legacy mint accounting below. + let mut wit2_transaction_fees: u64 = 0; // When validating genesis block, keep track of total value created // The value created in the genesis block cannot be greater than 2^64 - the total block reward, // So the total amount is always representable by a u64 + let total_block_reward = try_total_block_reward( + consensus_constants.initial_block_reward, + consensus_constants.halving_period, + )?; let max_total_value_genesis = u64::MAX - - total_block_reward( - consensus_constants.initial_block_reward, - consensus_constants.halving_period, - ); + .checked_sub(total_block_reward) + .ok_or_else(|| anyhow::anyhow!("total block reward exceeds the u64 supply"))?; let mut genesis_value_available = max_total_value_genesis; - let block_protocol_version = get_protocol_version(Some(epoch)); - // Check stake transactions are added in V1_8 at the earliest if block_protocol_version == ProtocolVersion::V1_7 && !block.txns.stake_txns.is_empty() { return Err(TransactionError::NoStakeTransactionsAllowed.into()); @@ -1984,12 +5284,14 @@ pub fn validate_block_transactions( epoch_constants, signatures_to_verify, consensus_constants.max_vt_weight, - None, + Some(block_protocol_version), )?; (inputs, outputs, fee, transaction.weight()) }; - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; + wit2_transaction_fees = + add_value_for_protocol(wit2_transaction_fees, fee, block_protocol_version)?; // Update vt weight let acc_weight = vt_weight.saturating_add(weight); @@ -2002,7 +5304,7 @@ pub fn validate_block_transactions( } vt_weight = acc_weight; - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, inputs, outputs, @@ -2010,7 +5312,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree let txn_hash = transaction.versioned_hash(block_protocol_version); @@ -2031,16 +5334,89 @@ pub fn validate_block_transactions( let block_beacon = block.block_header.beacon; let mut commit_hs = HashSet::with_capacity(block.txns.commit_txns.len()); let collateral_age = consensus_constants_wit2.get_collateral_age(active_wips); - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(epoch); + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(block_protocol_version); + // Validate the authoritative in-memory indexes before `try_clone_checked` rebuilds its + // secondary indexes from `by_key`. Validating only the clone would silently canonicalize an + // omitted or duplicated source row and lose the evidence this invariant is meant to reject. + let (delegation_prevalidation, source_active_validators) = if block_protocol_version + >= ProtocolVersion::V2_1 + { + let (prevalidation, active_validators) = prevalidate_delegation_snapshot( + stakes, + delegation_state, + Wit::from_nanowits(min_stake), + )?; + (Some(prevalidation), Some(active_validators)) + } else { + (None, None) + }; + // Commit validation is otherwise read-only. Keep a block-local accounting + // view so several valid-looking commitments cannot reserve the same stake. + // Block validation is a fallible consensus boundary in every protocol version. A poisoned + // entry lock is malformed local state, not a valid legacy result; use the checked clone before + // projecting either the historical or delegated path so it cannot unwind the validator. + let mut projected_stakes = stakes.try_clone_checked()?; + let mut projected_delegation_state = delegation_state.clone(); + if block_protocol_version >= ProtocolVersion::V2_1 { + let activation_transition = !projected_delegation_state.activation_initialized; + if activation_transition { + projected_delegation_state.initialize_legacy_validators_for_activation( + &projected_stakes, + Wit::from_nanowits(min_stake), + )?; + } else { + projected_delegation_state.initialize_legacy_validators(&projected_stakes)?; + } + // Consolidation activates due changes before rewarding or processing this block's + // transactions. Materialize the same state in the validation projection so a later + // same-block commission authorization observes the same active commission and nonce. + projected_delegation_state.activate_pending_commissions(epoch)?; + track_pre_activation_collateral_with_transition_check_for_protocol( + dr_pool, + &projected_stakes, + &mut projected_delegation_state, + consensus_constants.collateral_minimum, + false, + activation_transition, + protocol_info, + )?; + if activation_transition { + projected_delegation_state.validate_stake_and_reservation_accounting( + &projected_stakes, + Wit::from_nanowits(min_stake), + )?; + } + } + let mut legacy_reward_locks = if block_protocol_version >= ProtocolVersion::V2_1 { + LegacyRewardLocks::from_pool(dr_pool, &projected_delegation_state, protocol_info)? + } else { + LegacyRewardLocks::for_legacy_protocol(block_protocol_version)? + }; + // Install the source-frozen native request population in the canonical activation projection + // before commitments, inclusion rewards, tallies, or stake actions can change the projected + // ledger. Activation initialization preserves the canonical live owner and all pre-action stake + // balances, so this matches candidate construction without proving the clone that was just + // rebuilt from `by_key`. + let projected_active_validators = source_active_validators; + let projected_active_validator_count = projected_active_validators + .as_ref() + .map_or_else(|| stakes.validator_count(), |frozen| frozen.len()); + let maximum_commit_rounds = consensus_constants + .extra_rounds + .checked_add(1) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; for transaction in &block.txns.commit_txns { let data_request_protocol_version = if let Some(dr_state) = dr_pool.data_request_state(&transaction.body.dr_pointer) { - get_protocol_version(Some(dr_state.epoch)) + protocol_info + .all_versions + .version_for_epoch(dr_state.epoch) } else { ProtocolVersion::default() }; - let (dr_pointer, dr_witnesses, fee) = validate_commit_transaction( + let (dr_pointer, dr_witnesses, fee) = validate_commit_transaction_inner( transaction, dr_pool, vrf_input, @@ -2055,10 +5431,13 @@ pub fn validate_block_transactions( consensus_constants.minimum_difficulty, active_wips, consensus_constants.superblock_period, + block_protocol_version, data_request_protocol_version, - stakes, - consensus_constants.extra_rounds + 1, + &projected_stakes, + &projected_delegation_state, + maximum_commit_rounds, min_stake, + projected_active_validators.as_ref(), )?; // Validation for only one commit for pkh/data request in a block @@ -2067,15 +5446,56 @@ pub fn validate_block_transactions( return Err(TransactionError::DuplicatedCommit { pkh, dr_pointer }.into()); } - total_fee += fee; + if block_protocol_version >= ProtocolVersion::V2_1 + && data_request_protocol_version >= ProtocolVersion::V2_0 + { + let serialized_collateral = dr_pool + .data_request_state(&dr_pointer) + .ok_or(TransactionError::DataRequestNotFound { hash: dr_pointer })? + .data_request + .collateral; + let required_collateral = if data_request_protocol_version >= ProtocolVersion::V2_1 + && serialized_collateral == 0 + { + consensus_constants.collateral_minimum + } else { + serialized_collateral + }; + let reservation_key = CollateralReservationKey { + data_request: dr_pointer, + validator: pkh, + }; + let collateral = Wit::from_nanowits(required_collateral); + if data_request_protocol_version >= ProtocolVersion::V2_1 { + projected_delegation_state.reserve_collateral( + &mut projected_stakes, + reservation_key, + collateral, + epoch, + Wit::from_nanowits(min_stake), + )?; + } else { + // A post-activation block can still contain a Wit/2.0 request. Its + // owner-only collateral must be projected as a reservation too, or + // later same-block owner exits could bypass the legacy lock. + projected_delegation_state.reserve_legacy_collateral( + &mut projected_stakes, + reservation_key, + collateral, + epoch, + Wit::from_nanowits(min_stake), + )?; + } + } + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; increment_witnesses_counter(&mut commits_number, &dr_pointer, u32::from(dr_witnesses)); let (inputs, outputs) = ( transaction.body.collateral.iter(), transaction.body.outputs.iter(), ); - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, inputs, outputs, @@ -2083,7 +5503,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree let txn_hash = transaction.versioned_hash(block_protocol_version); @@ -2107,7 +5528,19 @@ pub fn validate_block_transactions( let mut re_mt = ProgressiveMerkleTree::sha256(); let mut reveal_hs = HashSet::with_capacity(block.txns.reveal_txns.len()); for transaction in &block.txns.reveal_txns { - let fee = validate_reveal_transaction(transaction, dr_pool, signatures_to_verify)?; + let data_request_protocol_version = dr_pool + .data_request_state(&transaction.body.dr_pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + .ok_or(TransactionError::DataRequestNotFound { + hash: transaction.body.dr_pointer, + })?; + let fee = validate_reveal_transaction_for_protocol( + transaction, + dr_pool, + signatures_to_verify, + data_request_protocol_version, + block_protocol_version, + )?; // Validation for only one reveal for pkh/data request in a block let pkh = transaction.body.pkh; @@ -2116,13 +5549,25 @@ pub fn validate_block_transactions( return Err(TransactionError::DuplicatedReveal { pkh, dr_pointer }.into()); } - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; // Add new hash to merkle tree let txn_hash = transaction.versioned_hash(block_protocol_version); let Hash::SHA256(sha) = txn_hash; re_mt.push(Sha256(sha)); } + if block_protocol_version >= ProtocolVersion::V2_1 { + project_wit2_inclusion_rewards_for_protocol( + &mut projected_stakes, + &mut projected_delegation_state, + block.block_header.proof.proof.pkh(), + epoch, + dr_pool, + &block.txns.commit_txns, + &block.txns.reveal_txns, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + )?; + } let re_hash_merkle_root = re_mt.root(); // Validate data request transactions in a block @@ -2144,8 +5589,21 @@ pub fn validate_block_transactions( .saturating_add(dro.weight()) .saturating_add(dro.extra_weight()); - if data_request_has_too_many_witnesses(&dro, stakes.validator_count(), Some(epoch)) - { + let dr_protocol_version = dr_pool + .data_request_state(&dr) + .map_or(block_protocol_version, |state| { + protocol_info.all_versions.version_for_epoch(state.epoch) + }); + let validator_count = if dr_protocol_version >= ProtocolVersion::V2_1 { + projected_active_validator_count + } else { + stakes.validator_count() + }; + if data_request_has_too_many_witnesses_for_protocol( + &dro, + validator_count, + dr_protocol_version, + ) { too_many_witnesses_drs.insert(dr); } } @@ -2168,9 +5626,11 @@ pub fn validate_block_transactions( active_wips, Some(block_protocol_version), )?; - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; + wit2_transaction_fees = + add_value_for_protocol(wit2_transaction_fees, fee, block_protocol_version)?; - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, inputs, outputs, @@ -2178,7 +5638,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree let txn_hash = transaction.versioned_hash(block_protocol_version); @@ -2211,28 +5672,32 @@ pub fn validate_block_transactions( // First loop over all data requests to make sure they are valid // If they are definitely valid, we can start processing the requests with too many witnesses - let validators_count = stakes.validator_count(); let mut data_requests_to_reset = HashSet::::new(); let mut data_requests_with_too_many_witnesses = HashSet::::new(); for transaction in &block.txns.data_request_txns { - if data_request_has_too_many_witnesses( + if data_request_has_too_many_witnesses_for_protocol( &transaction.body.dr_output, - validators_count, - Some(epoch), + if block_protocol_version >= ProtocolVersion::V2_1 { + projected_active_validator_count + } else { + stakes.validator_count() + }, + block_protocol_version, ) { log::debug!( "Temporarily adding data request {} to data request pool for validation purposes", transaction.versioned_hash(block_protocol_version) ); - dr_pool.process_data_request( + dr_pool.process_data_request_for_protocol( transaction, epoch, Some(block.versioned_hash(block_protocol_version)), + block_protocol_version, )?; let dr_tx_hash = transaction.versioned_hash(block_protocol_version); if let Some(dr_state) = dr_pool.data_request_state_mutable(&dr_tx_hash) { if dr_state.stage != DataRequestStage::TALLY { - dr_state.update_stage(0, true); + dr_state.try_update_stage(0, true)?; data_requests_to_reset .insert(transaction.versioned_hash(block_protocol_version)); } @@ -2248,22 +5713,34 @@ pub fn validate_block_transactions( let mut ta_mt = ProgressiveMerkleTree::sha256(); let mut tally_hs = HashSet::with_capacity(block.txns.tally_txns.len()); let mut expected_tally_ready_drs = dr_pool.get_tally_ready_drs(); + let wit2_activation_epoch = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0) + .unwrap_or(Epoch::MAX); for transaction in &block.txns.tally_txns { let data_request_protocol_version = if let Some(dr_state) = dr_pool.data_request_state(&transaction.dr_pointer) { - get_protocol_version(Some(dr_state.epoch)) + protocol_info + .all_versions + .version_for_epoch(dr_state.epoch) } else { ProtocolVersion::default() }; - let (outputs, fee) = match validate_tally_transaction( + let (outputs, fee) = match validate_tally_transaction_for_containing_protocol( transaction, dr_pool, consensus_constants, active_wips, &data_requests_with_too_many_witnesses, - Some(stakes.validator_count()), + Some(if data_request_protocol_version >= ProtocolVersion::V2_1 { + projected_active_validator_count + } else { + stakes.validator_count() + }), data_request_protocol_version, + block_protocol_version, + wit2_activation_epoch, ) { Ok((outputs, fee)) => { reset_data_request_stage(&data_requests_to_reset, transaction.dr_pointer, dr_pool); @@ -2296,9 +5773,9 @@ pub fn validate_block_transactions( return Err(TransactionError::DuplicatedTally { dr_pointer }.into()); } - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, vec![], outputs, @@ -2306,7 +5783,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree let txn_hash = transaction.versioned_hash(block_protocol_version); @@ -2325,47 +5803,213 @@ pub fn validate_block_transactions( .into()); } + if block_protocol_version >= ProtocolVersion::V2_1 { + for transaction in &block.txns.tally_txns { + project_tally_transition_for_protocol( + transaction, + dr_pool, + &mut projected_stakes, + &mut projected_delegation_state, + epoch, + consensus_constants.collateral_minimum, + |request_epoch| protocol_info.all_versions.version_for_epoch(request_epoch), + )?; + } + + // Stake and unstake transactions are validated after rewards are projected. Compute + // their fees on a cloned UTXO view first so the cap/self-stake checks see the same miner + // reward that consolidation applies, while preserving the transaction order below. + let mut reward_utxo_diff = utxo_diff.clone(); + for transaction in &block.txns.stake_txns { + let fee = st_transaction_fee_for_protocol( + transaction, + &reward_utxo_diff, + epoch, + epoch_constants, + block_protocol_version, + )?; + wit2_transaction_fees = + add_value_for_protocol(wit2_transaction_fees, fee, block_protocol_version)?; + try_update_utxo_diff_for_protocol( + &mut reward_utxo_diff, + transaction.body.inputs.iter(), + transaction.body.change.iter(), + transaction.versioned_hash(block_protocol_version), + epoch, + epoch_constants, + consensus_constants.checkpoint_zero_timestamp, + block_protocol_version, + )?; + } + for transaction in &block.txns.unstake_txns { + wit2_transaction_fees = add_value_for_protocol( + wit2_transaction_fees, + transaction.body.fee, + block_protocol_version, + )?; + // Unstakes are last in canonical fee order, so their outputs cannot affect a later + // transaction fee in this temporary projection. + } + project_wit2_transaction_rewards( + &mut projected_stakes, + &mut projected_delegation_state, + block.block_header.proof.proof.pkh(), + epoch, + wit2_transaction_fees, + consensus_constants_wit2 + .get_validator_block_reward_for_protocol(block_protocol_version), + )?; + let frozen_active_validators = projected_active_validators.as_ref().ok_or( + ConsensusStakesError::InvalidDelegationState { + reason: "block validation lost its prevalidated active-validator snapshot", + }, + )?; + apply_wit2_age_resets_prevalidated_for_protocol( + &mut projected_stakes, + &projected_delegation_state, + block.block_header.proof.proof.pkh(), + epoch, + Wit::from_nanowits(min_stake), + block_protocol_version, + dr_pool, + &block.txns.commit_txns, + &block.txns.tally_txns, + |pointer| { + dr_pool + .data_request_state(&pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + }, + frozen_active_validators, + )?; + } + let st_root = if block_protocol_version >= ProtocolVersion::V1_8 { // validate stake transactions in a block let mut st_mt = ProgressiveMerkleTree::sha256(); let mut st_weight: u32 = 0; - // Check if the block contains more than one stake tx from the same operator - let duplicate = block - .txns - .stake_txns - .iter() - .map(|stake_tx| &stake_tx.body.output.authorization.public_key) - .duplicates() - .next(); + // Wit/2.1 permits independent delegators to the same validator in one + // block. Deposit/registration actions are the entries that consume a + // `(validator, withdrawer)` identity slot; policy-only actions use their + // own nonce/exit sequencing below and must not collide merely because + // they carry the same output key (for example, an owner top-up and a + // commission update in one block). The legacy branch remains + // operator-scoped under its 1:1 invariant. + let duplicate_validator = if block_protocol_version >= ProtocolVersion::V2_1 { + block + .txns + .stake_txns + .iter() + .filter(|stake_tx| { + stake_tx + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| { + matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE + | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER + ) + }) + }) + .map(|stake_tx| stake_tx.body.output.key.clone()) + .duplicates() + .next() + .map(|key| key.validator) + } else { + block + .txns + .stake_txns + .iter() + .map(|stake_tx| stake_tx.body.output.authorization.public_key.pkh()) + .duplicates() + .next() + }; - if let Some(duplicate) = duplicate { + if let Some(validator) = duplicate_validator { return Err(BlockError::RepeatedStakeOperator { - pkh: duplicate.pkh(), + pkh: validator, } .into()); } - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(epoch); - let max_stake = consensus_constants_wit2.get_validator_max_stake_nanowits(epoch); + if block_protocol_version >= ProtocolVersion::V2_1 { + let delegated_deposit_entries = block + .txns + .stake_txns + .iter() + .filter(|transaction| { + transaction + .body + .output + .authorization_terms + .as_ref() + .is_some_and(|terms| { + matches!(terms.action, STAKE_AUTHORIZATION_ACTION_DELEGATE + | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER) + }) + }) + .map(|transaction| { + ( + transaction.body.output.key.validator, + transaction.body.output.key.withdrawer, + ) + }) + .collect::>(); + if let Some((validator, withdrawer)) = block + .txns + .unstake_txns + .iter() + .map(|transaction| { + ( + transaction.body.operator, + transaction.signature.public_key.pkh(), + ) + }) + .find(|entry| delegated_deposit_entries.contains(entry)) + { + return Err(BlockError::ConflictingStakeAndUnstakeEntry { + validator, + withdrawer, + } + .into()); + } + } + + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(block_protocol_version); + let max_stake = consensus_constants_wit2 + .get_validator_max_stake_nanowits_for_protocol(block_protocol_version); for transaction in &block.txns.stake_txns { - let (inputs, _output, fee, weight, change) = validate_stake_transaction( + let (validation_stakes, validation_delegation_state) = + if block_protocol_version >= ProtocolVersion::V2_1 { + (&projected_stakes, &projected_delegation_state) + } else { + (stakes, delegation_state) + }; + let (inputs, _output, fee, weight, change) = validate_stake_transaction_inner( transaction, &utxo_diff, epoch, epoch_constants, signatures_to_verify, - stakes, + validation_stakes, + validation_delegation_state, + network_magic, min_stake, max_stake, + block_protocol_version, + delegation_prevalidation.as_ref(), )?; - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; // Update st weight let acc_weight = st_weight.saturating_add(weight); - let max_stake_block_weight = - consensus_constants_wit2.get_maximum_stake_block_weight(epoch); + let max_stake_block_weight = consensus_constants_wit2 + .get_maximum_stake_block_weight_for_protocol(block_protocol_version); if acc_weight > max_stake_block_weight { return Err(BlockError::TotalStakeWeightLimitExceeded { weight: acc_weight, @@ -2375,8 +6019,101 @@ pub fn validate_block_transactions( } st_weight = acc_weight; + if block_protocol_version >= ProtocolVersion::V2_1 { + let terms = transaction + .body + .output + .authorization_terms + .as_ref() + .ok_or(TransactionError::MissingStakeAuthorizationTerms)?; + let validator = transaction.body.output.key.validator; + let withdrawer = transaction.body.output.key.withdrawer; + match terms.action { + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER => { + // The primary map is the source of validator identity. Rebuilding or + // reading a stale secondary index must not turn a delegation into a new + // validator registration with a different owner. + let validator_exists = match projected_stakes + .query_stakes_primary_checked(QueryStakesKey::Validator(validator)) + { + Ok(_) => true, + Err(StakesError::ValidatorNotFound { .. }) => false, + Err(error) => return Err(error.into()), + }; + if terms.action == STAKE_AUTHORIZATION_ACTION_DELEGATE { + projected_delegation_state + .ensure_validator_policy(&projected_stakes, validator)?; + } else { + anyhow::ensure!(!validator_exists + && !projected_delegation_state.validators.contains_key(&validator), + "owner registration cannot replace an existing validator"); + projected_delegation_state.register_validator_if_absent( + validator, + withdrawer, + u8::try_from(terms.commission_percent)?, + )?; + } + projected_stakes.add_stake( + transaction.body.output.key.clone(), + Wit::from_nanowits(transaction.body.output.value), + epoch, + true, + Wit::from_nanowits(min_stake), + )?; + } + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT => { + projected_delegation_state.request_exit( + &projected_stakes, transaction.body.output.key.clone(), terms.nonce, + )?; + } + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE => { + let policy = projected_delegation_state + .ensure_validator_policy(&projected_stakes, validator)?; + let current_commission = projected_delegation_state + .effective_commission_at(validator, epoch)?; + let requested_commission = u8::try_from(terms.commission_percent)?; + let checkpoint_period = if requested_commission > current_commission { + u64::from( + epoch_constants + .get_epoch_period(epoch) + .map_err(|_| ConsensusStakesError::ArithmeticOverflow)?, + ) + } else { + 0 + }; + let effective_epoch = commission_effective_epoch( + epoch, + current_commission, + requested_commission, + checkpoint_period, + ) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + if policy.owner_withdrawer != withdrawer { + return Err(TransactionError::InvalidCommissionOwner { + validator, + used: withdrawer, + expected: policy.owner_withdrawer, + } + .into()); + } + projected_delegation_state.schedule_commission( + validator, + requested_commission, + epoch, + effective_epoch, + terms.nonce, + )?; + } + action => { + return Err(anyhow::anyhow!( + "unsupported stake authorization action during validation: {action}" + )); + } + } + } + let outputs = change.iter().collect_vec(); - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, inputs, outputs, @@ -2384,7 +6121,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree st_mt.push(transaction.versioned_hash(block_protocol_version).into()); @@ -2407,31 +6145,71 @@ pub fn validate_block_transactions( let mut ut_mt = ProgressiveMerkleTree::sha256(); let mut ut_weight: u32 = 0; - // Check if the block contains more than one unstake tx from the same operator - let duplicate = block - .txns - .unstake_txns - .iter() - .map(|unstake_txn| unstake_txn.body.operator) - .duplicates() - .next(); + // Wit/2.1 permits independent withdrawers to unstake from the same validator in one + // block, so reject only a repeated `(validator, withdrawer)` entry there. Preserve the + // historical Wit/2.0 operator-scoped duplicate rule even if a malformed legacy ledger + // happens to contain more than one withdrawer for a validator. + let duplicate_validator = if block_protocol_version >= ProtocolVersion::V2_1 { + block + .txns + .unstake_txns + .iter() + .map(|unstake_txn| { + ( + unstake_txn.body.operator, + unstake_txn.signature.public_key.pkh(), + ) + }) + .duplicates() + .next() + .map(|(validator, _withdrawer)| validator) + } else { + block + .txns + .unstake_txns + .iter() + .map(|unstake_txn| unstake_txn.body.operator) + .duplicates() + .next() + }; - if let Some(duplicate) = duplicate { - return Err(BlockError::RepeatedUnstakeOperator { pkh: duplicate }.into()); + if let Some(validator) = duplicate_validator { + return Err(BlockError::RepeatedUnstakeOperator { pkh: validator }.into()); } + if block_protocol_version >= ProtocolVersion::V2_1 { + legacy_reward_locks.include_accepted_commits(&block.txns.commit_txns, &projected_delegation_state)?; + } for transaction in &block.txns.unstake_txns { - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(epoch); - let unstake_delay = consensus_constants_wit2.get_unstaking_delay_seconds(epoch); - let (fee, weight, outputs) = - validate_unstake_transaction(transaction, epoch, stakes, min_stake, unstake_delay)?; + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(block_protocol_version); + let unstake_delay = consensus_constants_wit2 + .get_unstaking_delay_seconds_for_protocol(block_protocol_version); + let (validation_stakes, validation_delegation_state) = + if block_protocol_version >= ProtocolVersion::V2_1 { + (&projected_stakes, &projected_delegation_state) + } else { + (stakes, delegation_state) + }; + let (fee, weight, outputs) = validate_unstake_transaction_inner( + transaction, + epoch, + epoch_constants, + validation_stakes, + validation_delegation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + block_protocol_version, + delegation_prevalidation.as_ref(), + )?; - total_fee += fee; + total_fee = add_value_for_protocol(total_fee, fee, block_protocol_version)?; // Update ut weight let acc_weight = ut_weight.saturating_add(weight); - let max_unstake_block_weight = - consensus_constants_wit2.get_maximum_unstake_block_weight(epoch); + let max_unstake_block_weight = consensus_constants_wit2 + .get_maximum_unstake_block_weight_for_protocol(block_protocol_version); if acc_weight > max_unstake_block_weight { return Err(BlockError::TotalUnstakeWeightLimitExceeded { weight: acc_weight, @@ -2441,7 +6219,28 @@ pub fn validate_block_transactions( } ut_weight = acc_weight; - update_utxo_diff( + if block_protocol_version >= ProtocolVersion::V2_1 { + let stake_key = StakeKey { + validator: transaction.body.operator, + withdrawer: transaction.signature.public_key.pkh(), + }; + let requested = transaction + .body + .value() + .checked_add(transaction.body.fee) + .ok_or(ConsensusStakesError::ArithmeticOverflow)?; + let amount = Wit::from_nanowits(requested); + projected_stakes.remove_stake( + stake_key.clone(), + amount, + epoch, + true, + Wit::from_nanowits(min_stake), + )?; + projected_delegation_state.complete_exit_if_removed(&projected_stakes, &stake_key)?; + } + + try_update_utxo_diff_for_protocol( &mut utxo_diff, vec![], outputs, @@ -2449,7 +6248,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; // Add new hash to merkle tree ut_mt.push(transaction.versioned_hash(block_protocol_version).into()); @@ -2463,16 +6263,17 @@ pub fn validate_block_transactions( if !is_genesis { // Validate mint - validate_mint_transaction( + validate_mint_transaction_for_protocol( &block.txns.mint, total_fee, block_beacon.checkpoint, consensus_constants.initial_block_reward, consensus_constants.halving_period, + block_protocol_version, )?; // Insert mint in utxo - update_utxo_diff( + try_update_utxo_diff_for_protocol( &mut utxo_diff, vec![], block.txns.mint.outputs.iter(), @@ -2480,7 +6281,8 @@ pub fn validate_block_transactions( epoch, epoch_constants, consensus_constants.checkpoint_zero_timestamp, - ); + block_protocol_version, + )?; } // Validate Merkle Root @@ -2519,6 +6321,8 @@ pub fn validate_block( consensus_constants: &ConsensusConstants, active_wips: &ActiveWips, stakes: &StakesTracker, + delegation_state: &DelegationState, + minimum_self_stake: u64, protocol_version: ProtocolVersion, replication_factor: u16, ) -> Result<(), anyhow::Error> { @@ -2546,16 +6350,46 @@ pub fn validate_block( } else if chain_beacon.hash_prev_block == consensus_constants.bootstrap_hash { // If the chain_beacon hash_prev_block is the bootstrap hash, only accept blocks // with the genesis_block_hash - validate_genesis_block(block, consensus_constants.genesis_hash).map_err(Into::into) + validate_genesis_block_for_protocol( + block, + consensus_constants.genesis_hash, + protocol_version, + ) + .map_err(Into::into) } else { - let target_hash = if protocol_version == ProtocolVersion::V2_0 { + // The first Wit/2.1 block validates its header against the pre-activation chain state, + // while the transaction pass publishes the one-time owner-policy projection afterward. + // Stage the same canonical policy image for this read-only eligibility check so the + // public native eligibility guard cannot reject the activation block itself. This local + // clone is never persisted here; activation and legacy collateral reconstruction remain + // owned by `validate_block_transactions_for_protocol`. + let projected_eligibility_delegation_state = if protocol_version >= ProtocolVersion::V2_1 + && !delegation_state.activation_initialized + { + let mut projected = delegation_state.clone(); + projected.initialize_legacy_validators_for_activation( + stakes, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + )?; + Some(projected) + } else { + None + }; + let eligibility_delegation_state = projected_eligibility_delegation_state + .as_ref() + .unwrap_or(delegation_state); + let target_hash = if protocol_version >= ProtocolVersion::V2_0 { let validator = block.block_sig.public_key.pkh(); - let eligibility = stakes.mining_eligibility(validator, block_epoch, replication_factor); - if eligibility == Ok(Eligible::No(InsufficientPower)) - || eligibility == Ok(Eligible::No(NotStaking)) - { - return Err(BlockError::ValidatorNotEligible { validator }.into()); - } + validate_wit2_block_mining_eligibility( + validator, + protocol_version, + block_epoch, + replication_factor, + stakes, + eligibility_delegation_state, + minimum_self_stake, + None, + )?; Hash::max() } else { @@ -2579,7 +6413,7 @@ pub fn validate_block( target_hash, ); - validate_block_signature(block, signatures_to_verify) + validate_block_signature_for_protocol(block, signatures_to_verify, protocol_version) } } @@ -2603,7 +6437,9 @@ pub fn validate_genesis_block( genesis_block: &Block, expected_genesis_hash: Hash, ) -> Result<(), BlockError> { - // Compare the hash first + // Preserve the historical API semantics for direct callers and fixtures. Consensus paths + // should use `validate_genesis_block_for_protocol` so the block identity is tied to the + // containing block's explicit fork. if genesis_block.hash() != expected_genesis_hash { return Err(BlockError::GenesisBlockHashMismatch { block_hash: genesis_block.hash(), @@ -2611,6 +6447,32 @@ pub fn validate_genesis_block( }); } + validate_genesis_block_shape(genesis_block) +} + +/// Validate a genesis block using an explicitly selected protocol version. +/// +/// The ordinary `Block::hash()` method follows the process-global guessed protocol. That is not +/// safe when a node replays epoch zero after a later fork has activated, because legacy block +/// header encoding may differ. This entry point keeps the identity check on the caller's pinned +/// protocol and shares the canonical unused-field validation with the compatibility wrapper. +pub fn validate_genesis_block_for_protocol( + genesis_block: &Block, + expected_genesis_hash: Hash, + protocol_version: ProtocolVersion, +) -> Result<(), BlockError> { + let block_hash = genesis_block.versioned_hash(protocol_version); + if block_hash != expected_genesis_hash { + return Err(BlockError::GenesisBlockHashMismatch { + block_hash, + expected_hash: expected_genesis_hash, + }); + } + + validate_genesis_block_shape(genesis_block) +} + +fn validate_genesis_block_shape(genesis_block: &Block) -> Result<(), BlockError> { // Create a new genesis block with the same fields, and compare that they are equal // This ensure that there is no extra information in the unused fields, for example // in the signature as it does not affect the block hash @@ -2653,14 +6515,121 @@ pub fn validate_new_transaction( active_wips: &ActiveWips, superblock_period: u16, stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, + protocol_version: ProtocolVersion, + max_rounds: u16, + consensus_constants_wit2: &ConsensusConstantsWit2, +) -> Result { + let protocol_info = get_protocol_info(); + validate_new_transaction_for_protocol( + transaction, + ( + reputation_engine, + unspent_outputs_pool, + data_request_pool, + ), + vrf_input, + current_epoch, + epoch_constants, + block_number, + signatures_to_verify, + collateral_minimum, + collateral_age, + max_vt_weight, + max_dr_weight, + minimum_reppoe_difficulty, + required_reward_collateral_ratio, + active_wips, + superblock_period, + stakes, + delegation_state, + network_magic, + protocol_version, + max_rounds, + consensus_constants_wit2, + &protocol_info, + ) +} + +/// Validate a standalone transaction using a caller-pinned protocol schedule. +/// +/// The compatibility wrapper above snapshots the process-wide registry at invocation. Actor +/// admission paths that already captured a schedule for their turn should call this variant so +/// request-era protocol lookup and containing-block policy bounds cannot observe a later registry +/// publication halfway through validation. +#[allow(clippy::too_many_arguments)] +pub fn validate_new_transaction_for_protocol( + transaction: &Transaction, + (reputation_engine, unspent_outputs_pool, data_request_pool): ( + &ReputationEngine, + &UnspentOutputsPool, + &DataRequestPool, + ), + vrf_input: CheckpointVRF, + current_epoch: Epoch, + epoch_constants: EpochConstants, + block_number: u32, + signatures_to_verify: &mut Vec, + collateral_minimum: u64, + collateral_age: u32, + max_vt_weight: u32, + max_dr_weight: u32, + minimum_reppoe_difficulty: u32, + required_reward_collateral_ratio: u64, + active_wips: &ActiveWips, + superblock_period: u16, + stakes: &StakesTracker, + delegation_state: &DelegationState, + network_magic: u16, protocol_version: ProtocolVersion, max_rounds: u16, consensus_constants_wit2: &ConsensusConstantsWit2, + protocol_info: &ProtocolInfo, ) -> Result { let utxo_diff = UtxoDiff::new(unspent_outputs_pool, block_number); - let min_stake = consensus_constants_wit2.get_validator_min_stake_nanowits(current_epoch); - let max_stake = consensus_constants_wit2.get_validator_max_stake_nanowits(current_epoch); - let unstake_delay = consensus_constants_wit2.get_unstaking_delay_seconds(current_epoch); + // Commit admission carries the data-request protocol separately from the protocol of the + // prospective block/current tip. Stake bounds and the activation projection belong to that + // containing-block protocol, while Stake/Unstake admission already receives it directly. + // Commit admission has two protocol contexts: the request's creation protocol is supplied by + // the node caller, while stake bounds, activation projection, and block-level collateral + // semantics follow the prospective containing block at the current tip. Snapshot that block + // protocol once; never re-read the live registry after choosing it. + let policy_protocol_version = if matches!(transaction, Transaction::Commit(_)) { + protocol_info.all_versions.version_for_epoch(current_epoch) + } else { + protocol_version + }; + let min_stake = consensus_constants_wit2 + .get_validator_min_stake_nanowits_for_protocol(policy_protocol_version); + let max_stake = consensus_constants_wit2 + .get_validator_max_stake_nanowits_for_protocol(policy_protocol_version); + let unstake_delay = consensus_constants_wit2 + .get_unstaking_delay_seconds_for_protocol(policy_protocol_version); + // Before the first Wit/2.1 block, the persisted delegation sidecars are deliberately inert. + // Standalone commit/stake/unstake admission nevertheless has the live oracle pool available, + // so validate against the same canonical activation projection as block construction instead + // of admitting an action that is already known to exceed capacity or violate a reservation. + let projected_delegation_state = if matches!( + transaction, + Transaction::Commit(_) | Transaction::Stake(_) | Transaction::Unstake(_) + ) { + prepare_standalone_delegation_state_for_protocol( + data_request_pool, + stakes, + delegation_state, + current_epoch, + collateral_minimum, + Wit::from_nanowits(min_stake), + policy_protocol_version, + &protocol_info, + )? + } else { + None + }; + let delegation_state = projected_delegation_state + .as_ref() + .unwrap_or(delegation_state); match transaction { Transaction::ValueTransfer(tx) => validate_vt_transaction( @@ -2684,47 +6653,95 @@ pub fn validate_new_transaction( max_dr_weight, required_reward_collateral_ratio, active_wips, - None, - ) - .map(|(_, _, fee)| fee), - Transaction::Commit(tx) => validate_commit_transaction( - tx, - data_request_pool, - vrf_input, - signatures_to_verify, - reputation_engine, - current_epoch, - epoch_constants, - &utxo_diff, - collateral_minimum, - collateral_age, - block_number, - minimum_reppoe_difficulty, - active_wips, - superblock_period, - protocol_version, - stakes, - max_rounds, - min_stake, + Some(protocol_version), ) .map(|(_, _, fee)| fee), + Transaction::Commit(tx) => { + // Commit validity follows the protocol that created the referenced data request, + // not merely the current block protocol. This matters when a Wit/2.0 request remains + // in the commit stage across Wit/2.1 activation: standalone admission must retain + // its legacy owner-only collateral and eligibility rules, matching block validation + // and consolidation. + let data_request_protocol = data_request_pool + .data_request_state(&tx.body.dr_pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + .ok_or(TransactionError::DataRequestNotFound { + hash: tx.body.dr_pointer, + })?; + validate_commit_transaction_inner( + tx, + data_request_pool, + vrf_input, + signatures_to_verify, + reputation_engine, + current_epoch, + epoch_constants, + &utxo_diff, + collateral_minimum, + collateral_age, + block_number, + minimum_reppoe_difficulty, + active_wips, + superblock_period, + policy_protocol_version, + data_request_protocol, + stakes, + delegation_state, + max_rounds, + min_stake, + None, + ) + .map(|(_, _, fee)| fee) + } Transaction::Reveal(tx) => { - validate_reveal_transaction(tx, data_request_pool, signatures_to_verify) + let data_request_protocol = data_request_pool + .data_request_state(&tx.body.dr_pointer) + .map(|state| protocol_info.all_versions.version_for_epoch(state.epoch)) + .ok_or(TransactionError::DataRequestNotFound { + hash: tx.body.dr_pointer, + })?; + validate_reveal_transaction_for_protocol( + tx, + data_request_pool, + signatures_to_verify, + data_request_protocol, + protocol_version, + ) } - Transaction::Stake(tx) => validate_stake_transaction( + Transaction::Stake(tx) => validate_stake_transaction_inner( tx, &utxo_diff, current_epoch, epoch_constants, signatures_to_verify, stakes, + delegation_state, + network_magic, min_stake, max_stake, + policy_protocol_version, + None, ) .map(|(_, _, fee, _, _)| fee), Transaction::Unstake(tx) => { - validate_unstake_transaction(tx, current_epoch, stakes, min_stake, unstake_delay) - .map(|(fee, _, _)| fee) + let legacy_reward_locks = if policy_protocol_version >= ProtocolVersion::V2_1 { + LegacyRewardLocks::from_pool(data_request_pool, delegation_state, protocol_info)? + } else { + LegacyRewardLocks::for_legacy_protocol(policy_protocol_version)? + }; + validate_unstake_transaction_inner( + tx, + current_epoch, + epoch_constants, + stakes, + delegation_state, + &legacy_reward_locks, + min_stake, + unstake_delay, + policy_protocol_version, + None, + ) + .map(|(fee, _, _)| fee) } _ => Err(TransactionError::NotValidTransaction.into()), } @@ -2759,17 +6776,28 @@ pub fn hash_merkle_tree_root(hashes: &[Hash]) -> Hash { /// Function to validate block's merkle tree pub fn validate_merkle_tree(block: &Block) -> bool { - // Compute `hash_merkle_root` and build block header - let merkle_roots = BlockMerkleRoots { - mint_hash: block.txns.mint.hash(), - vt_hash_merkle_root: merkle_tree_root(&block.txns.value_transfer_txns), - dr_hash_merkle_root: merkle_tree_root(&block.txns.data_request_txns), - commit_hash_merkle_root: merkle_tree_root(&block.txns.commit_txns), - reveal_hash_merkle_root: merkle_tree_root(&block.txns.reveal_txns), - tally_hash_merkle_root: merkle_tree_root(&block.txns.tally_txns), - stake_hash_merkle_root: merkle_tree_root(&block.txns.stake_txns), - unstake_hash_merkle_root: merkle_tree_root(&block.txns.unstake_txns), - }; + // Derive the protocol from the block being checked and use the same canonical root builder + // as mining and full block validation. In particular, Wit/2.1 stake authorization terms are + // included in transaction identities only after activation, while pre-activation stake roots + // remain the protocol-gated default value. + let protocol_version = get_protocol_version(Some(block.block_header.beacon.checkpoint)); + + validate_merkle_tree_for_protocol(block, protocol_version) +} + +/// Validate a block's transaction Merkle roots using an already selected protocol version. +pub fn validate_merkle_tree_for_protocol(block: &Block, protocol_version: ProtocolVersion) -> bool { + + // A default protocol-gated root does not commit to a forbidden transaction list. Mirror the + // full block validator's activation checks here so a decoded legacy block cannot hide Stake or + // Unstake bodies behind the default root and still pass an inventory/snapshot integrity check. + if (protocol_version < ProtocolVersion::V1_8 && !block.txns.stake_txns.is_empty()) + || (protocol_version < ProtocolVersion::V2_0 && !block.txns.unstake_txns.is_empty()) + { + return false; + } + + let merkle_roots = BlockMerkleRoots::from_transactions(&block.txns, protocol_version); merkle_roots == block.block_header.merkle_roots } @@ -2777,28 +6805,57 @@ pub fn validate_merkle_tree(block: &Block) -> bool { /// Calculate the block mining reward. /// Returns nanowits. pub fn block_reward(epoch: Epoch, initial_block_reward: u64, halving_period: u32) -> u64 { - let initial_reward: u64 = initial_block_reward; + try_block_reward(epoch, initial_block_reward, halving_period) + .expect("halving period must be nonzero") +} + +/// Calculate the block mining reward without panicking on malformed consensus constants. +/// Returns nanowits. +pub fn try_block_reward( + epoch: Epoch, + initial_block_reward: u64, + halving_period: u32, +) -> Result { + if halving_period == 0 { + return Err(anyhow::anyhow!("halving period must be nonzero")); + } + let halvings = epoch / halving_period; - if halvings < 64 { - initial_reward >> halvings + Ok(if halvings < 64 { + initial_block_reward >> halvings } else { 0 - } + }) } /// Calculate the total amount of wits that will be rewarded to miners. pub fn total_block_reward(initial_block_reward: u64, halving_period: u32) -> u64 { + try_total_block_reward(initial_block_reward, halving_period) + .expect("block reward schedule overflows u64") +} + +/// Calculate the total block reward without panicking on malformed consensus constants. +pub fn try_total_block_reward( + initial_block_reward: u64, + halving_period: u32, +) -> Result { + if halving_period == 0 { + return Err(anyhow::anyhow!("halving period must be nonzero")); + } + let mut total_reward = 0u64; let mut base_reward = initial_block_reward; while base_reward != 0 { let new_reward = base_reward .checked_mul(u64::from(halving_period)) - .expect("overflow"); - total_reward = total_reward.checked_add(new_reward).expect("overflow"); + .ok_or_else(|| anyhow::anyhow!("block reward schedule overflows u64"))?; + total_reward = total_reward + .checked_add(new_reward) + .ok_or_else(|| anyhow::anyhow!("block reward schedule overflows u64"))?; base_reward >>= 1; } - total_reward + Ok(total_reward) } /// Compare block candidates. @@ -2830,7 +6887,7 @@ pub fn compare_block_candidates( s: &VrfSlots, protocol_version: ProtocolVersion, ) -> Ordering { - if protocol_version == ProtocolVersion::V2_0 { + if protocol_version >= ProtocolVersion::V2_0 { match b1_power.cmp(&b2_power) { // Equal power, first compare VRF hash and finally the block hash Ordering::Equal => { @@ -2872,6 +6929,39 @@ pub fn compare_block_candidates( } } +/// Verify one data-request VRF proof against its protocol-selected message and exact target. +/// +/// Candidate construction uses this before admitting a Wit/2.1 Commit group, while ordinary +/// validation calls it from the batched signature verifier below. Passing the request protocol +/// explicitly keeps the V1/V2 message choice tied to the request inclusion epoch rather than the +/// containing block. +pub fn verify_dr_vrf_signature( + proof: &DataRequestEligibilityClaim, + vrf: &mut VrfCtx, + vrf_input: CheckpointVRF, + dr_hash: Hash, + target_hash: Hash, + withdrawer: Option, + request_protocol_version: ProtocolVersion, +) -> Result { + let vrf_hash = if request_protocol_version >= ProtocolVersion::V2_0 { + proof.verify_v2(vrf, vrf_input, dr_hash, withdrawer) + } else { + proof.verify_v1(vrf, vrf_input, dr_hash) + } + .map_err(|_| TransactionError::InvalidDataRequestPoe)?; + + if vrf_hash > target_hash { + return Err(TransactionError::DataRequestEligibilityDoesNotMeetTarget { + vrf_hash, + target_hash, + } + .into()); + } + + Ok(vrf_hash) +} + /// Blocking process to verify signatures pub fn verify_signatures( signatures_to_verify: Vec, @@ -2903,22 +6993,17 @@ pub fn verify_signatures( dr_hash, target_hash, withdrawer, - inclusion_epoch, + request_protocol_version, } => { - let vrf_hash = if get_protocol_version(inclusion_epoch) >= ProtocolVersion::V2_0 { - proof.verify_v2(vrf, vrf_input, dr_hash, withdrawer) - } else { - proof.verify_v1(vrf, vrf_input, dr_hash) - } - .map_err(|_| TransactionError::InvalidDataRequestPoe)?; - - if vrf_hash > target_hash { - return Err(TransactionError::DataRequestEligibilityDoesNotMeetTarget { - vrf_hash, - target_hash, - } - .into()); - } + verify_dr_vrf_signature( + &proof, + vrf, + vrf_input, + dr_hash, + target_hash, + withdrawer, + request_protocol_version, + )?; } SignaturesToVerify::SecpTx { public_key, @@ -2947,23 +7032,35 @@ pub fn verify_signatures( }, } })?, - SignaturesToVerify::SuperBlockVote { superblock_vote } => { - // Validates secp256k1 signature only, bn256 signature is not validated + SignaturesToVerify::SuperBlockVote { + superblock_vote, + bn256_public_key, + } => { + superblock_vote.verify_bn256_signature(&bn256_public_key)?; + let secp_message = superblock_vote.secp256k1_signature_message(); let secp_message_hash = calculate_sha256(&secp_message); - verify( - &superblock_vote - .secp256k1_signature - .public_key - .try_into() - .unwrap(), - &secp_message_hash.0, - &superblock_vote - .secp256k1_signature - .signature - .try_into() - .unwrap(), - )?; + let public_key = superblock_vote + .secp256k1_signature + .public_key + .clone() + .try_into() + .map_err(|_| BlockError::VerifySignatureFail { + hash: Hash::from(secp_message_hash), + })?; + let signature = superblock_vote + .secp256k1_signature + .signature + .clone() + .try_into() + .map_err(|_| BlockError::VerifySignatureFail { + hash: Hash::from(secp_message_hash), + })?; + verify(&public_key, &secp_message_hash.0, &signature).map_err(|_| { + BlockError::VerifySignatureFail { + hash: Hash::from(secp_message_hash), + } + })?; } } } @@ -2974,10 +7071,1332 @@ pub fn verify_signatures( #[cfg(test)] mod tests { use super::*; + use witnet_data_structures::{ + chain::SecretKey, + vrf::{VrfMessage, VrfProof}, + }; + use witnet_protected::Protected; const INITIAL_BLOCK_REWARD: u64 = 250 * 1_000_000_000; const HALVING_PERIOD: u32 = 3_500_000; + #[test] + fn native_commission_admission_requires_a_representable_effective_epoch() { + use witnet_crypto::{ + secp256k1::{PublicKey as SecpPublicKey, SecretKey as SecpSecretKey}, + signature::sign, + }; + use witnet_data_structures::{ + chain::{PublicKey as ChainPublicKey, Signature as ChainSignature, StakeAuthorization}, + transaction::StakeTransactionBody, + }; + + // Explicit containing protocol and a local clock only: no global schedule installation. + let secret_bytes = [1; 32]; + let public_key = ChainPublicKey::from(SecpPublicKey::from_secret_key_global( + &SecpSecretKey::from_slice(&secret_bytes).unwrap(), + )); + let validator = public_key.pkh(); + let owner = PublicKeyHash::from_bytes(&[62; 20]).unwrap(); + let input_pointer = OutputPointer::default(); + let mut utxos = UnspentOutputsPool::default(); + utxos.insert(input_pointer, ValueTransferOutput { + pkh: validator, value: 1, time_lock: 0, + }, 0); + let utxo_diff = UtxoDiff::new(&utxos, 0); + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let key = StakeKey { validator, withdrawer: owner }; + let mut stakes = StakesTracker::default(); + stakes.add_stake(key.clone(), minimum, 1, true, minimum).unwrap(); + let mut delegation = DelegationState { + activation_initialized: true, + ..Default::default() + }; + delegation.register_validator_if_absent(validator, owner, 0).unwrap(); + let clock = EpochConstants { + checkpoint_zero_timestamp: 0, + checkpoints_period: 45, + checkpoint_zero_timestamp_wit2: 0, + checkpoints_period_wit2: 45, + }; + let transaction = |commission| { + let terms = StakeAuthorization::commission_update( + 0, validator, owner, Epoch::MAX, commission, 1, + ); + let authorization = KeyedSignature { + signature: ChainSignature::from(sign( + SecpSecretKey::from_slice(&secret_bytes).unwrap(), + &terms.signing_digest().unwrap(), + ).unwrap()), + public_key: public_key.clone(), + }; + let body = StakeTransactionBody::new( + vec![Input::new(input_pointer)], + StakeOutput { + authorization, + authorization_terms: Some(terms), + key: key.clone(), + value: 0, + }, + None, + ); + let Hash::SHA256(digest) = body.versioned_hash(ProtocolVersion::V2_1); + let signature = KeyedSignature { + signature: ChainSignature::from(sign( + SecpSecretKey::from_slice(&secret_bytes).unwrap(), &digest, + ).unwrap()), + public_key: public_key.clone(), + }; + StakeTransaction::new(body, vec![signature]) + }; + let increase = transaction(1); + let mut signatures = Vec::new(); + let (_, _, fee, _, _) = validate_stake_transaction_for_protocol( + &increase, &utxo_diff, 20, clock, &mut signatures, &stakes, &delegation, + 0, DELEGATED_STAKING_MINIMUM_NANOWITS, DELEGATED_STAKING_MAXIMUM_NANOWITS, + ProtocolVersion::V2_1, + ).unwrap(); + assert_eq!(fee, 1); + verify_signatures(signatures, &mut VrfCtx::secp256k1().unwrap()).unwrap(); + + for (epoch, clock) in [ + (Epoch::MAX - 1, clock), + (20, EpochConstants { checkpoints_period_wit2: 0, ..clock }), + ] { + let error = validate_stake_transaction_for_protocol( + &increase, &utxo_diff, epoch, clock, &mut Vec::new(), &stakes, &delegation, + 0, DELEGATED_STAKING_MINIMUM_NANOWITS, DELEGATED_STAKING_MAXIMUM_NANOWITS, + ProtocolVersion::V2_1, + ).unwrap_err(); + assert!(matches!(error.downcast_ref::(), + Some(ConsensusStakesError::ArithmeticOverflow))); + } + // A no-op/decrease still needs only the next epoch, even at the last usable inclusion + // epoch; do not incorrectly apply the increase notice interval to this branch. + let no_op = transaction(0); + assert!(validate_stake_transaction_for_protocol( + &no_op, &utxo_diff, Epoch::MAX - 1, clock, &mut Vec::new(), &stakes, &delegation, + 0, DELEGATED_STAKING_MINIMUM_NANOWITS, DELEGATED_STAKING_MAXIMUM_NANOWITS, + ProtocolVersion::V2_1, + ).is_ok()); + assert!(delegation.validators[&validator].pending_commission.is_none()); + } + + #[test] + fn legacy_reward_locks_preserve_zero_collateral_owner_until_next_block() { + let validator = CommitTransaction::default().body.proof.proof.pkh(); + let owner = PublicKeyHash::from_bytes(&[61; 20]).unwrap(); + let owner_key = StakeKey { validator, withdrawer: owner }; + let mut delegation_state = DelegationState::default(); + delegation_state.activation_initialized = true; + delegation_state.register_validator_if_absent(validator, owner, 0).unwrap(); + let mut protocol_info = ProtocolInfo::default(); + protocol_info.register(0, ProtocolVersion::V2_0, 45); + protocol_info.register(10, ProtocolVersion::V2_1, 45); + let pointer = Hash::with_first_u32(961); + let mut commit = CommitTransaction::default(); + commit.body.dr_pointer = pointer; + let mut request = DataRequestState::default(); + request.epoch = 1; + request.data_request.collateral = 0; + request.info.commits.insert(validator, commit.clone()); + let mut pool = DataRequestPool::default(); + pool.data_request_pool.insert(pointer, request); + + let locks = LegacyRewardLocks::from_pool(&pool, &delegation_state, &protocol_info).unwrap(); + assert!(delegation_state.collateral_reservations.is_empty()); + assert_eq!(locks.owner_keys(), &BTreeSet::from([owner_key.clone()])); + assert_eq!(locks.ensure_entry_can_be_removed(&owner_key), + Err(ConsensusStakesError::StakeEntryLocked { key: owner_key.clone() })); + // A tally can remove the request during this block, but its captured owner lock does not + // change. Only the next block may derive a new proof from the settled authoritative pool. + let request = pool.data_request_pool.remove(&pointer).unwrap(); + assert!(locks.ensure_entry_can_be_removed(&owner_key).is_err()); + let next_block = LegacyRewardLocks::from_pool(&pool, &delegation_state, &protocol_info).unwrap(); + assert_eq!(next_block.ensure_entry_can_be_removed(&owner_key), Ok(())); + + let mut uncommitted_request = request; + uncommitted_request.info.commits.clear(); + pool.data_request_pool.insert(pointer, uncommitted_request); + let mut same_block = LegacyRewardLocks::from_pool(&pool, &delegation_state, &protocol_info).unwrap(); + assert!(same_block.owner_keys().is_empty()); + same_block.include_accepted_commits(&[commit], &delegation_state).unwrap(); + assert!(same_block.ensure_entry_can_be_removed(&owner_key).is_err()); + assert!(LegacyRewardLocks::for_legacy_protocol(ProtocolVersion::V2_1).is_err()); + // Constructing the compatibility proof with an old protocol cannot smuggle it into a + // later native full-withdrawal check. + assert!(LegacyRewardLocks::for_legacy_protocol(ProtocolVersion::V2_0).unwrap() + .ensure_entry_can_be_removed(&owner_key).is_err()); + } + + #[test] + fn native_full_unstake_requires_legacy_zero_reward_destination() { + use witnet_data_structures::transaction::UnstakeTransactionBody; + let validator = CommitTransaction::default().body.proof.proof.pkh(); + let owner = KeyedSignature::default().public_key.pkh(); + let key = StakeKey { validator, withdrawer: owner }; + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let mut stakes = StakesTracker::default(); + stakes.add_stake(key.clone(), minimum, 1, true, minimum).unwrap(); + let mut delegation_state = DelegationState::default(); + delegation_state.activation_initialized = true; + delegation_state.register_validator_if_absent(validator, owner, 0).unwrap(); + delegation_state.request_exit(&stakes, key.clone(), 1).unwrap(); + let mut request = DataRequestState::default(); + request.epoch = 1; + request.data_request.collateral = 0; + request.info.commits.insert(validator, CommitTransaction::default()); + let mut pool = DataRequestPool::default(); + pool.data_request_pool.insert(Hash::with_first_u32(962), request); + let mut protocol_info = ProtocolInfo::default(); + protocol_info.register(0, ProtocolVersion::V2_0, 45); + protocol_info.register(10, ProtocolVersion::V2_1, 45); + let locks = LegacyRewardLocks::from_pool(&pool, &delegation_state, &protocol_info).unwrap(); + let transaction = UnstakeTransaction::new(UnstakeTransactionBody::new( + validator, ValueTransferOutput { pkh: owner, value: minimum.nanowits(), time_lock: 0 }, 0, 1, + ), KeyedSignature::default()); + let error = validate_unstake_transaction_for_protocol( + &transaction, 11, EpochConstants::default(), &stakes, &delegation_state, &locks, + minimum.nanowits(), 0, ProtocolVersion::V2_1, + ).unwrap_err(); + assert!(matches!(error.downcast_ref::(), + Some(ConsensusStakesError::StakeEntryLocked { key: rejected }) if *rejected == key)); + assert!(delegation_state.collateral_reservations.is_empty()); + } + + #[test] + fn wit2_block_mining_eligibility_preserves_v2_0_entry_ranking() { + let pkh = |byte| PublicKeyHash::from_bytes(&[byte; 20]).unwrap(); + let wits = |value: u64| { + Wit::from_nanowits(value.checked_mul(NANOWITS_PER_WIT).unwrap()) + }; + let stronger_validator = pkh(31); + let weaker_validator = pkh(33); + let minimum = wits(10_000); + let mut stakes = StakesTracker::default(); + for (validator, withdrawer, amount) in [ + (stronger_validator, pkh(32), 100_000), + (weaker_validator, pkh(34), 50_000), + ] { + stakes + .add_stake( + StakeKey { + validator, + withdrawer, + }, + wits(amount), + 1, + true, + minimum, + ) + .unwrap(); + } + let delegation_state = DelegationState::default(); + + validate_wit2_block_mining_eligibility( + stronger_validator, + ProtocolVersion::V2_0, + 100, + 1, + &stakes, + &delegation_state, + minimum.nanowits(), + None, + ) + .unwrap(); + + let error = validate_wit2_block_mining_eligibility( + weaker_validator, + ProtocolVersion::V2_0, + 100, + 1, + &stakes, + &delegation_state, + minimum.nanowits(), + None, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(BlockError::ValidatorNotEligible { + validator: rejected, + }) if *rejected == weaker_validator + )); + } + + #[test] + fn wit2_block_mining_eligibility_aggregates_and_filters_v2_1_validators() { + let pkh = |byte| PublicKeyHash::from_bytes(&[byte; 20]).unwrap(); + let wits = |value: u64| { + Wit::from_nanowits(value.checked_mul(NANOWITS_PER_WIT).unwrap()) + }; + let aggregated_validator = pkh(41); + let competing_validator = pkh(42); + let boundary_validator = pkh(43); + let insertion_minimum = wits(10_000); + let mut stakes = StakesTracker::default(); + for (validator, withdrawer, amount) in [ + // Neither entry alone outranks the competitor, but the validator-wide sum does. + (aggregated_validator, pkh(1), 60_000), + (competing_validator, pkh(2), 100_000), + // The owner is exactly at the mandatory self-stake; delegated power does not replace + // that requirement but the validator remains active at the boundary. + (boundary_validator, pkh(3), 10_000), + ] { + stakes + .add_stake( + StakeKey { + validator, + withdrawer, + }, + wits(amount), + 1, + true, + insertion_minimum, + ) + .unwrap(); + } + let mut delegation_state = DelegationState::default(); + delegation_state + .initialize_legacy_validators_for_activation(&stakes, insertion_minimum) + .unwrap(); + for (validator, withdrawer, amount) in [ + (aggregated_validator, pkh(11), 60_000), + ] { + stakes + .add_stake( + StakeKey { validator, withdrawer }, + wits(amount), + 2, + true, + insertion_minimum, + ) + .unwrap(); + } + let (_, frozen) = prevalidate_delegation_snapshot( + &stakes, + &delegation_state, + insertion_minimum, + ) + .unwrap(); + // Native activity uses the immutable 10,000 WIT owner minimum. The validator with exactly + // that self-stake is therefore active even though its aggregate delegated power is high. + assert_eq!(frozen.len(), 3); + + // Deriving the active set inside the helper must aggregate both delegations. + validate_wit2_block_mining_eligibility( + aggregated_validator, + ProtocolVersion::V2_1, + 100, + 1, + &stakes, + &delegation_state, + insertion_minimum.nanowits(), + None, + ) + .unwrap(); + + for rejected_validator in [competing_validator, boundary_validator] { + let error = validate_wit2_block_mining_eligibility( + rejected_validator, + ProtocolVersion::V2_1, + 100, + 1, + &stakes, + &delegation_state, + insertion_minimum.nanowits(), + Some(&frozen), + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(BlockError::ValidatorNotEligible { + validator: rejected, + }) if *rejected == rejected_validator + )); + } + } + + #[test] + fn wit2_block_mining_eligibility_rejects_malformed_v2_1_policy() { + let pkh = |byte| PublicKeyHash::from_bytes(&[byte; 20]).unwrap(); + let minimum = Wit::from_nanowits(10_000_u64 * NANOWITS_PER_WIT); + let validator = pkh(51); + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + StakeKey { + validator, + withdrawer: pkh(52), + }, + minimum, + 1, + true, + minimum, + ) + .unwrap(); + let mut delegation_state = DelegationState::default(); + delegation_state + .initialize_legacy_validators_for_activation(&stakes, minimum) + .unwrap(); + delegation_state + .validators + .get_mut(&validator) + .unwrap() + .commission_percent = MAX_COMMISSION_PERCENT + 1; + + let error = validate_wit2_block_mining_eligibility( + validator, + ProtocolVersion::V2_1, + 100, + 1, + &stakes, + &delegation_state, + minimum.nanowits(), + None, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::>(), + Some(StakesError::CommissionAboveMaximum { + commission, + maximum: MAX_COMMISSION_PERCENT, + }) if *commission == MAX_COMMISSION_PERCENT + 1 + )); + } + + #[test] + fn frozen_active_validator_set_rejects_a_different_minimum() { + let frozen = FrozenActiveValidatorSet { + active_validators: BTreeSet::new(), + minimum_self_stake: Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + }; + + assert!(matches!( + frozen.validate_minimum_self_stake(Wit::from_nanowits( + DELEGATED_STAKING_MINIMUM_NANOWITS - 1, + )), + Err(StakesError::InvalidDelegationState { + reason: "frozen active-validator set does not use the fixed minimum self-stake", + }) + )); + } + + #[test] + fn prevalidate_delegation_snapshot_rejects_a_different_minimum() { + let stakes = StakesTracker::default(); + let delegation_state = DelegationState::default(); + + assert!(matches!( + prevalidate_delegation_snapshot( + &stakes, + &delegation_state, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS - 1), + ), + Err(StakesError::InvalidDelegationState { + reason: "Wit/2.1 stake minimum must be the fixed 10,000 WIT minimum", + }) + )); + } + + #[test] + fn standalone_activation_projection_rejects_a_different_minimum() { + let result = prepare_standalone_delegation_state_for_protocol( + &DataRequestPool::default(), + &StakesTracker::default(), + &DelegationState::default(), + 0, + 0, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS - 1), + ProtocolVersion::V2_1, + &ProtocolInfo::default(), + ); + + assert!(matches!( + result, + Err(StakesError::InvalidDelegationState { + reason: "Wit/2.1 stake minimum must be the fixed 10,000 WIT minimum", + }) + )); + } + + #[test] + fn age_reset_projection_rejects_a_different_minimum() { + let pkh = PublicKeyHash::from_bytes(&[1; 20]).unwrap(); + let mut stakes = StakesTracker::default(); + + let result = apply_wit2_age_resets_for_protocol( + &mut stakes, + &DelegationState::default(), + pkh, + 0, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS - 1), + ProtocolVersion::V2_1, + &[], + &[], + |_| None, + ); + + assert!(matches!( + result, + Err(StakesError::InvalidDelegationState { + reason: "Wit/2.1 stake minimum must be the fixed 10,000 WIT minimum", + }) + )); + } + + #[test] + fn native_projection_boundaries_require_the_activation_marker() { + let validator = PublicKeyHash::from_bytes(&[2; 20]).unwrap(); + let pointer = Hash::with_first_u32(2); + let mut dr_state = DataRequestState::default(); + dr_state.epoch = 0; + let mut dr_pool = DataRequestPool::default(); + dr_pool.data_request_pool.insert(pointer, dr_state); + let mut tally = TallyTransaction::default(); + tally.dr_pointer = pointer; + + let mut stakes = StakesTracker::default(); + let mut delegation_state = DelegationState::default(); + let error = project_tally_transition_for_protocol( + &tally, + &dr_pool, + &mut stakes, + &mut delegation_state, + 0, + 0, + |_| ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native tally transition requires the activation marker" + )); + + let error = project_wit2_inclusion_rewards_for_protocol( + &mut stakes, + &mut delegation_state, + validator, + 0, + &dr_pool, + &[], + &[], + |_| ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native inclusion rewards require the activation marker" + )); + + let error = project_wit2_transaction_rewards( + &mut stakes, + &mut delegation_state, + validator, + 0, + 1, + 0, + ) + .unwrap_err(); + assert!(matches!( + error, + ConsensusStakesError::InvalidDelegationState { reason } + if reason == "native transaction rewards require the activation marker" + )); + + let error = apply_wit2_age_resets_for_protocol( + &mut stakes, + &delegation_state, + validator, + 0, + Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS), + ProtocolVersion::V2_1, + &[], + &[], + |_| None, + ) + .unwrap_err(); + assert!(matches!( + error, + ConsensusStakesError::InvalidDelegationState { reason } + if reason == "native age reset requires the activation marker" + )); + + let utxos = UnspentOutputsPool::default(); + let utxo_diff = UtxoDiff::new(&utxos, 0); + let error = validate_stake_transaction_for_protocol( + &StakeTransaction::default(), + &utxo_diff, + 0, + EpochConstants::default(), + &mut Vec::new(), + &stakes, + &delegation_state, + 0, + DELEGATED_STAKING_MINIMUM_NANOWITS, + DELEGATED_STAKING_MAXIMUM_NANOWITS, + ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native stake validation requires the activation marker" + )); + + let legacy_reward_locks = LegacyRewardLocks::for_legacy_protocol(ProtocolVersion::V2_0).unwrap(); + let error = validate_unstake_transaction_for_protocol( + &UnstakeTransaction::default(), + 0, + EpochConstants::default(), + &stakes, + &delegation_state, + &legacy_reward_locks, + DELEGATED_STAKING_MINIMUM_NANOWITS, + 1, + ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native unstake validation requires the activation marker" + )); + + let error = validate_wit2_block_mining_eligibility( + validator, + ProtocolVersion::V2_1, + 0, + 1, + &stakes, + &delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + None, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native block eligibility requires the activation marker" + )); + + let error = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + 0, + 1, + 1, + 4, + &stakes, + &delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + None, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "native Commit eligibility requires the activation marker" + )); + assert!(!delegation_state.activation_initialized); + assert!(delegation_state.validators.is_empty()); + assert!(stakes + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap() + .is_empty()); + } + + #[test] + fn activation_obligation_cannot_settle_a_non_wit2_tally() { + let validator = PublicKeyHash::from_bytes(&[8; 20]).unwrap(); + let pointer = Hash::with_first_u32(8); + let mut request = DataRequestState::default(); + request.epoch = 0; + request.info.commits.insert(validator, CommitTransaction::default()); + let mut dr_pool = DataRequestPool::default(); + dr_pool.data_request_pool.insert(pointer, request); + + let reservation_key = CollateralReservationKey { + data_request: pointer, + validator, + }; + let mut delegation_state = DelegationState::default(); + delegation_state.activation_initialized = true; + delegation_state.legacy_activation_obligations.insert( + reservation_key, + witnet_data_structures::staking::delegation::LegacyActivationObligation { + raw_collateral: Wit::from_nanowits(1), + witness_reward: Wit::from_nanowits(2), + canonical_live_owner: None, + settlement: + witnet_data_structures::staking::delegation::LegacyActivationSettlement::Pending, + }, + ); + let mut stakes = StakesTracker::default(); + let mut tally = TallyTransaction::default(); + tally.dr_pointer = pointer; + + let error = project_tally_transition_for_protocol( + &tally, + &dr_pool, + &mut stakes, + &mut delegation_state, + 1, + 0, + |_| ProtocolVersion::V2_1, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ConsensusStakesError::InvalidDelegationState { reason }) + if *reason == "activation obligation references a non-Wit/2.0 tally" + )); + assert!(matches!( + delegation_state + .legacy_activation_obligations + .get(&reservation_key) + .unwrap() + .settlement, + witnet_data_structures::staking::delegation::LegacyActivationSettlement::Pending + )); + } + + #[test] + fn standalone_activation_projection_defers_a_missing_owner_obligation_without_mutating_stakes() { + let eligible_validator = PublicKeyHash::from_bytes(&[9; 20]).unwrap(); + let eligible_owner = PublicKeyHash::from_bytes(&[10; 20]).unwrap(); + let missing_validator = PublicKeyHash::from_bytes(&[11; 20]).unwrap(); + let pointer = Hash::with_first_u32(9); + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + StakeKey { + validator: eligible_validator, + withdrawer: eligible_owner, + }, + minimum, + 0, + true, + minimum, + ) + .unwrap(); + let stakes_before = stakes + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap(); + + let mut request = DataRequestState::default(); + request.epoch = 1; + request.data_request.collateral = 123; + request.data_request.witness_reward = 7; + request + .info + .commits + .insert(missing_validator, CommitTransaction::default()); + let mut dr_pool = DataRequestPool::default(); + dr_pool.data_request_pool.insert(pointer, request); + + let mut protocol_info = ProtocolInfo::default(); + protocol_info.register(0, ProtocolVersion::V2_0, 45); + protocol_info.register(10, ProtocolVersion::V2_1, 45); + + let original_state = DelegationState::default(); + let projected = prepare_standalone_delegation_state_for_protocol( + &dr_pool, + &stakes, + &original_state, + 10, + 0, + minimum, + ProtocolVersion::V2_1, + &protocol_info, + ) + .unwrap() + .expect("activation should produce a delegated-staking projection"); + + assert_eq!( + stakes + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap(), + stakes_before + ); + assert!(projected.activation_initialized); + assert!(projected.validators.contains_key(&eligible_validator)); + let reservation_key = CollateralReservationKey { + data_request: pointer, + validator: missing_validator, + }; + let obligation = projected + .legacy_activation_obligations + .get(&reservation_key) + .expect("missing owner must be retained as a recovery obligation"); + assert_eq!(obligation.raw_collateral, Wit::from_nanowits(123)); + assert_eq!(obligation.witness_reward, Wit::from_nanowits(7)); + assert_eq!(obligation.canonical_live_owner, None); + assert!(matches!( + obligation.settlement, + witnet_data_structures::staking::delegation::LegacyActivationSettlement::Pending + )); + assert!(!projected.collateral_reservations.contains_key(&reservation_key)); + + let mut stakes_after = stakes.clone(); + let mut projected_after_tally = projected.clone(); + let mut tally = TallyTransaction::default(); + tally.dr_pointer = pointer; + project_tally_transition_for_protocol( + &tally, + &dr_pool, + &mut stakes_after, + &mut projected_after_tally, + 11, + 0, + |_| ProtocolVersion::V2_0, + ) + .unwrap(); + assert_eq!( + stakes_after + .query_stakes_primary_checked(QueryStakesKey::All) + .unwrap(), + stakes_before + ); + assert!(matches!( + projected_after_tally + .legacy_activation_obligations + .get(&reservation_key) + .unwrap() + .settlement, + witnet_data_structures::staking::delegation::LegacyActivationSettlement::Deferred { + collateral, + witness_reward, + } if collateral == Wit::from_nanowits(123) + && witness_reward == Wit::from_nanowits(7) + )); + } + + #[test] + fn cross_fork_zero_collateral_tally_rejects_an_unrelated_malformed_policy() { + let validator = PublicKeyHash::from_bytes(&[3; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[4; 20]).unwrap(); + let unrelated_validator = PublicKeyHash::from_bytes(&[5; 20]).unwrap(); + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let owner_key = StakeKey { + validator, + withdrawer: owner, + }; + let mut stakes = StakesTracker::default(); + stakes + .add_stake(owner_key.clone(), minimum, 1, true, minimum) + .unwrap(); + + let mut delegation_state = DelegationState::default(); + delegation_state.activation_initialized = true; + delegation_state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + delegation_state + .register_validator_if_absent(unrelated_validator, owner, 0) + .unwrap(); + delegation_state + .validators + .get_mut(&unrelated_validator) + .unwrap() + .commission_percent = MAX_COMMISSION_PERCENT + 1; + + let pointer = Hash::with_first_u32(14); + let mut request = DataRequestState::default(); + request.epoch = 10; + request.data_request.collateral = 0; + request.data_request.witness_reward = 1; + request.info.commits.insert(validator, CommitTransaction::default()); + let mut dr_pool = DataRequestPool::default(); + dr_pool.data_request_pool.insert(pointer, request); + let mut tally = TallyTransaction::default(); + tally.dr_pointer = pointer; + + let before = stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key.clone())) + .unwrap()[0] + .value + .coins; + let error = project_tally_transition_for_protocol( + &tally, + &dr_pool, + &mut stakes, + &mut delegation_state, + 12, + 0, + |_| ProtocolVersion::V2_0, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::>(), + Some(StakesError::CommissionAboveMaximum { commission, maximum }) + if *commission == MAX_COMMISSION_PERCENT + 1 + && *maximum == MAX_COMMISSION_PERCENT + )); + assert_eq!( + stakes + .query_stakes_checked(QueryStakesKey::Key(owner_key)) + .unwrap()[0] + .value + .coins, + before + ); + } + + #[test] + fn commit_eligibility_rejects_a_request_newer_than_its_block_protocol() { + let validator = PublicKeyHash::from_bytes(&[6; 20]).unwrap(); + let stakes = StakesTracker::default(); + let delegation_state = DelegationState::default(); + let error = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_0, + ProtocolVersion::V2_1, + 0, + 1, + 0, + 4, + &stakes, + &delegation_state, + DELEGATED_STAKING_MINIMUM_NANOWITS, + None, + ) + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::>(), + Some(StakesError::InvalidDelegationState { reason }) + if *reason == "request protocol is newer than its containing block protocol" + )); + } + + #[test] + fn reveal_validation_rejects_a_request_newer_than_its_block_protocol() { + let error = validate_reveal_transaction_for_protocol( + &RevealTransaction::default(), + &DataRequestPool::default(), + &mut Vec::new(), + ProtocolVersion::V2_1, + ProtocolVersion::V2_0, + ) + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::>(), + Some(StakesError::InvalidDelegationState { reason }) + if *reason == "request protocol is newer than its containing block protocol" + )); + } + + #[test] + fn mixed_inclusion_rewards_preserve_cross_fork_owner_only_economics() { + let validator = PublicKeyHash::from_bytes(&[12; 20]).unwrap(); + let owner = PublicKeyHash::from_bytes(&[13; 20]).unwrap(); + let delegator = PublicKeyHash::from_bytes(&[14; 20]).unwrap(); + let minimum = Wit::from_nanowits(DELEGATED_STAKING_MINIMUM_NANOWITS); + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + StakeKey { + validator, + withdrawer: owner, + }, + Wit::from_nanowits(20_000 * NANOWITS_PER_WIT), + 1, + true, + minimum, + ) + .unwrap(); + stakes + .add_stake( + StakeKey { + validator, + withdrawer: delegator, + }, + Wit::from_nanowits(20_000 * NANOWITS_PER_WIT), + 1, + true, + minimum, + ) + .unwrap(); + let mut delegation_state = DelegationState::default(); + delegation_state + .register_validator_if_absent(validator, owner, 0) + .unwrap(); + delegation_state.activation_initialized = true; + + let legacy_pointer = Hash::with_first_u32(12); + let native_pointer = Hash::with_first_u32(13); + let mut legacy_request = DataRequestState::default(); + legacy_request.epoch = 10; + legacy_request.data_request.commit_and_reveal_fee = 100 * NANOWITS_PER_WIT; + let mut native_request = DataRequestState::default(); + native_request.epoch = 11; + native_request.data_request.commit_and_reveal_fee = 100 * NANOWITS_PER_WIT; + let mut dr_pool = DataRequestPool::default(); + dr_pool + .data_request_pool + .insert(legacy_pointer, legacy_request); + dr_pool + .data_request_pool + .insert(native_pointer, native_request); + + let mut legacy_commit = CommitTransaction::default(); + legacy_commit.body.dr_pointer = legacy_pointer; + let mut native_commit = CommitTransaction::default(); + native_commit.body.dr_pointer = native_pointer; + + project_wit2_inclusion_rewards_for_protocol( + &mut stakes, + &mut delegation_state, + validator, + 12, + &dr_pool, + &[legacy_commit, native_commit], + &[], + |request_epoch| { + if request_epoch == 10 { + ProtocolVersion::V2_0 + } else { + ProtocolVersion::V2_1 + } + }, + ) + .unwrap(); + + // The cross-fork fee adds all 100 WIT to the owner. The native fee is split equally by + // the zero-commission delegated allocator, so the delegator receives only 50 WIT. + assert_eq!( + stakes + .query_stakes_checked(QueryStakesKey::Key(StakeKey { validator, withdrawer: owner })) + .unwrap()[0] + .value + .coins + .nanowits(), + 20_150 * NANOWITS_PER_WIT + ); + assert_eq!( + stakes + .query_stakes_checked(QueryStakesKey::Key(StakeKey { validator, withdrawer: delegator })) + .unwrap()[0] + .value + .coins + .nanowits(), + 20_050 * NANOWITS_PER_WIT + ); + } + + #[test] + fn commit_eligibility_uses_the_progressively_debited_view_and_frozen_population() { + let pkh = |byte| PublicKeyHash::from_bytes(&[byte; 20]).unwrap(); + let wits = |value: u64| { + Wit::from_nanowits(value.checked_mul(NANOWITS_PER_WIT).unwrap()) + }; + let validator = pkh(1); + let owner = pkh(2); + let competing_validator = pkh(3); + let competing_owner = pkh(4); + let minimum = wits(10_000); + let epoch = 100; + let mut stakes = StakesTracker::default(); + stakes + .add_stake( + StakeKey { + validator, + withdrawer: owner, + }, + wits(100_000), + 1, + true, + minimum, + ) + .unwrap(); + stakes + .add_stake( + StakeKey { + validator: competing_validator, + withdrawer: competing_owner, + }, + wits(50_000), + 1, + true, + minimum, + ) + .unwrap(); + let mut delegation_state = DelegationState::default(); + delegation_state + .initialize_legacy_validators_for_activation(&stakes, minimum) + .unwrap(); + let (_, frozen) = + prevalidate_delegation_snapshot(&stakes, &delegation_state, minimum).unwrap(); + + let (target_before, owner_salt) = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + epoch, + 1, + 1, + 4, + &stakes, + &delegation_state, + minimum.nanowits(), + Some(&frozen), + ) + .unwrap(); + assert!(target_before > Hash::min()); + assert_eq!(owner_salt, Some(owner)); + + delegation_state + .reserve_collateral( + &mut stakes, + CollateralReservationKey { + data_request: Hash::with_first_u32(1), + validator, + }, + wits(60_000), + epoch, + minimum, + ) + .unwrap(); + let error = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + epoch, + 1, + 1, + 4, + &stakes, + &delegation_state, + minimum.nanowits(), + Some(&frozen), + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(TransactionError::ValidatorNotEligible { + validator: rejected, + }) if *rejected == validator + )); + } + + #[test] + fn commit_eligibility_selects_native_or_cross_fork_power_from_request_protocol() { + let pkh = |byte| PublicKeyHash::from_bytes(&[byte; 20]).unwrap(); + let wits = |value: u64| { + Wit::from_nanowits(value.checked_mul(NANOWITS_PER_WIT).unwrap()) + }; + let validator = pkh(21); + let owner = pkh(22); + let delegator = pkh(23); + let competing_validator = pkh(24); + let competing_owner = pkh(25); + let minimum = wits(10_000); + let mut stakes = StakesTracker::default(); + for (validator, withdrawer, amount) in [ + (validator, owner, 10_000), + (competing_validator, competing_owner, 50_000), + ] { + stakes + .add_stake( + StakeKey { + validator, + withdrawer, + }, + wits(amount), + 1, + true, + minimum, + ) + .unwrap(); + } + let mut delegation_state = DelegationState::default(); + delegation_state + .initialize_legacy_validators_for_activation(&stakes, minimum) + .unwrap(); + stakes + .add_stake( + StakeKey { + validator, + withdrawer: delegator, + }, + wits(100_000), + 2, + true, + minimum, + ) + .unwrap(); + let (_, frozen) = + prevalidate_delegation_snapshot(&stakes, &delegation_state, minimum).unwrap(); + + let (_, native_owner_salt) = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_1, + 100, + 1, + 0, + 4, + &stakes, + &delegation_state, + minimum.nanowits(), + Some(&frozen), + ) + .unwrap(); + assert_eq!(native_owner_salt, Some(owner)); + + let (_, legacy_owner_salt) = resolve_commit_stake_eligibility( + validator, + ProtocolVersion::V2_1, + ProtocolVersion::V2_0, + 100, + 1, + 0, + 4, + &stakes, + &delegation_state, + minimum.nanowits(), + Some(&frozen), + ) + .unwrap(); + assert_eq!(legacy_owner_salt, Some(owner)); + } + + #[test] + fn commit_vrf_verifier_binds_request_protocol_beacon_owner_and_target() { + let mut producer = VrfCtx::secp256k1().unwrap(); + let secret_key = SecretKey { + bytes: Protected::from([7; 32]), + }; + let vrf_input = CheckpointVRF { + checkpoint: 42, + hash_prev_vrf: Hash::with_first_u32(8), + }; + let dr_pointer = Hash::with_first_u32(9); + let owner = PublicKeyHash::from_bytes(&[10; 20]).unwrap(); + let message = VrfMessage::data_request_v2(vrf_input, dr_pointer, Some(owner)); + let proof = DataRequestEligibilityClaim { + proof: VrfProof::create(&mut producer, &secret_key, &message) + .unwrap() + .0, + }; + + let mut verifier = VrfCtx::secp256k1().unwrap(); + let proof_hash = verify_dr_vrf_signature( + &proof, + &mut verifier, + vrf_input, + dr_pointer, + Hash::max(), + Some(owner), + ProtocolVersion::V2_1, + ) + .unwrap(); + assert_ne!(proof_hash, Hash::min()); + + let wrong_input = CheckpointVRF { + checkpoint: vrf_input.checkpoint + 1, + ..vrf_input + }; + for (input, salt, protocol, target) in [ + ( + wrong_input, + Some(owner), + ProtocolVersion::V2_1, + Hash::max(), + ), + ( + vrf_input, + Some(PublicKeyHash::from_bytes(&[11; 20]).unwrap()), + ProtocolVersion::V2_1, + Hash::max(), + ), + ( + vrf_input, + Some(owner), + ProtocolVersion::V1_8, + Hash::max(), + ), + ] { + assert!(verify_dr_vrf_signature( + &proof, + &mut verifier, + input, + dr_pointer, + target, + salt, + protocol, + ) + .is_err()); + } + assert!(matches!( + verify_dr_vrf_signature( + &proof, + &mut verifier, + vrf_input, + dr_pointer, + Hash::min(), + Some(owner), + ProtocolVersion::V2_1, + ) + .unwrap_err() + .downcast_ref::(), + Some(TransactionError::DataRequestEligibilityDoesNotMeetTarget { .. }) + )); + } + + #[test] + fn queued_commit_vrf_pins_the_request_protocol_before_verification() { + let proof = DataRequestEligibilityClaim::default(); + let vrf_input = CheckpointVRF::default(); + let dr_pointer = Hash::with_first_u32(12); + let mut explicit = Vec::new(); + add_dr_vrf_signature_to_verify_for_protocol( + &mut explicit, + &proof, + vrf_input, + dr_pointer, + Hash::max(), + None, + ProtocolVersion::V2_1, + ); + assert!(matches!( + explicit.as_slice(), + [SignaturesToVerify::VrfDr { + request_protocol_version: ProtocolVersion::V2_1, + .. + }] + )); + + // The legacy helper remains source-compatible, but freezes the registry result now rather + // than asking the verifier to reinterpret the epoch after asynchronous work was queued. + let inclusion_epoch = Some(13); + let expected_protocol = get_protocol_version(inclusion_epoch); + let mut compatible = Vec::new(); + add_dr_vrf_signature_to_verify( + &mut compatible, + &proof, + vrf_input, + dr_pointer, + Hash::max(), + None, + inclusion_epoch, + ); + assert!(matches!( + compatible.as_slice(), + [SignaturesToVerify::VrfDr { + request_protocol_version, + .. + }] if *request_protocol_version == expected_protocol + )); + } + #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_sign_loss)] @@ -3001,4 +8420,10 @@ mod tests { assert_eq!(reward(3_500_000 * 65), 0); assert_eq!(reward(3_500_000 * 100), 0); } + + #[test] + fn checked_reward_helpers_reject_malformed_schedules() { + assert!(try_block_reward(0, INITIAL_BLOCK_REWARD, 0).is_err()); + assert!(try_total_block_reward(u64::MAX, u32::MAX).is_err()); + } } diff --git a/wallet/src/actors/worker/methods.rs b/wallet/src/actors/worker/methods.rs index e2a2187f5e..60a339ba79 100644 --- a/wallet/src/actors/worker/methods.rs +++ b/wallet/src/actors/worker/methods.rs @@ -1,4 +1,7 @@ -use std::convert::{TryFrom, TryInto}; +use std::{ + collections::{HashMap, HashSet}, + convert::{TryFrom, TryInto}, +}; use futures_util::compat::Compat01As03; use jsonrpc_core as rpc; @@ -7,11 +10,14 @@ use serde_json::{Value, json}; use witnet_crypto::{key::ExtendedSK, mnemonic}; use witnet_data_structures::{ chain::{ - Block, CheckpointBeacon, DataRequestInfo, OutputPointer, RADRequest, StateMachine, - ValueTransferOutput, + Block, CheckpointBeacon, DataRequestInfo, OutputPointer, PublicKeyHash, RADRequest, + StateMachine, ValueTransferOutput, }, fee::AbsoluteFee, - proto::versioning::VersionedHashable, + proto::versioning::{ProtocolVersion, VersionedHashable}, + staking::prelude::{ + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS, StakeKey, + }, transaction::Transaction, }; use witnet_futures_utils::TryFutureExt2; @@ -33,6 +39,62 @@ pub enum IndexTransactionQuery { DataRequestReport(String), } +/// Build a bounded, canonical withdrawer filter for the wallet's stake projection request. It is used by Worker::wallet_stakes_at_tip before calling the filtered or compatibility RPC methods. +fn stake_entries_at_tip_withdrawer_filter( + owned_withdrawers: &HashSet, +) -> Option> { + if owned_withdrawers.len() > MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS { + return None; + } + + let mut withdrawers: Vec<_> = owned_withdrawers.iter().copied().collect(); + withdrawers.sort_unstable(); + Some(withdrawers) +} + +/// Validate a tip-bound node projection and retain only entries controlled by this wallet. It accepts both filtered upgraded-node responses and complete legacy responses while enforcing one canonical tip and key order. +fn authoritative_wallet_stakes_from_snapshot( + snapshot: types::StakeEntriesAtTip, + expected_tip: CheckpointBeacon, + owned_withdrawers: &HashSet, +) -> Result, u64>> { + if snapshot.tip != expected_tip { + return Err(Error::Node(anyhow::anyhow!( + "stake snapshot tip {:?} changed while indexing expected tip {:?}", + snapshot.tip, + expected_tip + ))); + } + + let mut previous_key: Option> = None; + let mut authoritative = HashMap::new(); + for entry in snapshot.entries { + if previous_key + .as_ref() + .is_some_and(|previous| previous >= &entry.key) + { + return Err(Error::Node(anyhow::anyhow!( + "stakeEntriesAtTip returned duplicate or non-canonical key {:?}", + entry.key + ))); + } + previous_key = Some(entry.key.clone()); + + if owned_withdrawers.contains(&entry.key.withdrawer) + && authoritative + .insert(entry.key.clone(), entry.value.coins) + .is_some() + { + return Err(Error::Node(anyhow::anyhow!( + "stakeEntriesAtTip returned duplicate wallet key {:?}", + entry.key + ))); + } + } + + Ok(authoritative) +} + impl Worker { pub fn start( concurrency: usize, @@ -442,7 +504,29 @@ impl Worker { // Note: this code can be further refactored by only updating the transient addresses wallet._sync_address_generation(txns.clone())?; - let filtered_txns = wallet.filter_wallet_transactions(txns)?; + let block_tip = CheckpointBeacon { + checkpoint: block_info.epoch, + hash_prev_block: block_info.block_hash, + }; + // During catch-up the node's live stake ledger is ahead of intermediate historical + // blocks, so transaction indexing remains the only honest representation for those + // blocks. The final catch-up block and every live block are reconciled against one + // ChainManager-atomic snapshot bound to this exact hash. Keep the RPC out of pre-V2.1 + // processing so an upgraded wallet can still synchronize against an older node. + let protocol_version = self + .node + .protocol_info + .all_versions + .version_for_epoch(block_info.epoch); + let authoritative_stakes = if protocol_version >= ProtocolVersion::V2_1 + && self.node.get_last_beacon() == block_tip + { + Some(self.wallet_stakes_at_tip(wallet, block_tip)?) + } else { + None + }; + + let filtered_txns = wallet.filter_wallet_transactions_for_protocol(txns, protocol_version)?; log::debug!( "Indexing block #{} ({}) with {} transactions ({})", block_info.epoch, @@ -452,12 +536,74 @@ impl Worker { ); // Extending transactions with metadata queried from the node let extended_txns = self.extend_transactions_data(filtered_txns)?; - let balance_movements = - wallet.index_block_transactions(block_info, &extended_txns, confirmed)?; + let balance_movements = wallet + .index_block_transactions_with_stake_reconciliation_for_protocol( + block_info, + &extended_txns, + confirmed, + authoritative_stakes.as_ref(), + protocol_version, + )?; Ok(balance_movements) } + /// Read the authoritative stake projection for the wallet's current canonical tip. It combines owned_public_key_hashes, stakeEntriesAtTip queries, and authoritative_wallet_stakes_from_snapshot without applying a context-free minimum. + fn wallet_stakes_at_tip( + &self, + wallet: &types::Wallet, + expected_tip: CheckpointBeacon, + ) -> Result, u64>> { + let owned_withdrawers = wallet.owned_public_key_hashes()?; + let withdrawer_filter = stake_entries_at_tip_withdrawer_filter(&owned_withdrawers); + if withdrawer_filter.is_none() { + // Do not make a wallet permanently unsyncable solely because it has derived more + // addresses than one defensive RPC request permits. This uncommon case retains the + // original full-ledger behavior and the same exact-tip validation below. + log::warn!( + "wallet controls {} withdrawers, above the stakeEntriesAtTip filter limit {}; using the compatibility full-ledger query", + owned_withdrawers.len(), + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS + ); + } + let snapshot = match withdrawer_filter.as_deref() { + Some(withdrawers) => futures::executor::block_on( + self.query_stake_entries_at_tip_for_withdrawers(withdrawers), + ), + None => futures::executor::block_on(self.query_stake_entries_at_tip()), + }?; + + let authoritative = + authoritative_wallet_stakes_from_snapshot(snapshot, expected_tip, &owned_withdrawers)?; + + // The canonical tip projection also carries activation-grandfathered owner entries below + // the native minimum, including zero balances. The node endpoint is the authoritative + // post-block ledger, so do not apply a context-free native-floor check here: the wallet + // does not have the validator policy needed to distinguish those migration entries from + // native stake, and rejecting them makes the wallet unable to reconcile after activation. + Ok(authoritative) + } + + /// Reconcile the wallet's locally indexed stake balances with the authoritative node tip. It queries `stakeEntriesAtTip`, preserves activation-grandfathered owners, and updates wallet state through the existing repository reconciliation path. + fn reconcile_wallet_tip_stakes(&self, wallet: &types::Wallet) -> Result<()> { + let wallet_tip = wallet.public_data()?.last_sync; + if self + .node + .protocol_info + .all_versions + .version_for_epoch(wallet_tip.checkpoint) + < ProtocolVersion::V2_1 + { + return Ok(()); + } + if self.node.get_last_beacon() != wallet_tip { + return Ok(()); + } + + let authoritative = self.wallet_stakes_at_tip(wallet, wallet_tip)?; + Ok(wallet.reconcile_stakes_at_tip(wallet_tip, &authoritative)?) + } + pub fn notify_client( &self, wallet: &types::Wallet, @@ -467,7 +613,7 @@ impl Worker { // Make a grab of the sink, cloning immediately so as to drop the read lock ASAP. let sink = sink .read() - .expect("Read locks should only fail if poisoned") + .map_err(|_| Error::Repository(crate::repository::Error::MutexPoison))? .clone(); if let Some(sink) = sink.as_ref() { @@ -761,6 +907,58 @@ impl Worker { } } + /// Issue a complete or filtered stakeEntriesAtTip JSON-RPC request. It is shared by query_stake_entries_at_tip and query_stake_entries_at_tip_for_withdrawers. + async fn query_stake_entries_at_tip_request( + &self, + withdrawers: Option<&[PublicKeyHash]>, + ) -> Result { + let method = String::from("stakeEntriesAtTip"); + let req = match withdrawers { + None => jsonrpc::Request::method(method) + .timeout(self.node.requests_timeout) + .params(Vec::::new()), + Some(withdrawers) => jsonrpc::Request::method(method) + .timeout(self.node.requests_timeout) + .params(json!({ "withdrawers": withdrawers })), + } + .expect("params failed serialization"); + let res = self.node.get_client().actor.send(req).flatten_err().await; + + match res { + Ok(json) => serde_json::from_value::(json) + .map_err(node_error), + Err(err) => { + log::warn!("stakeEntriesAtTip request failed: {err}"); + Err(err) + } + } + } + + /// Ask a Witnet node for the complete live stake ledger and the exact canonical tip that owns + /// it. This preserves the original no-parameter wallet/node contract. + pub async fn query_stake_entries_at_tip(&self) -> Result { + self.query_stake_entries_at_tip_request(None).await + } + + /// Ask a Witnet node for only the listed withdrawer indexes and the exact canonical tip that owns them. It canonicalizes the bounded filter and preserves compatibility with nodes that return the complete projection. + pub async fn query_stake_entries_at_tip_for_withdrawers( + &self, + withdrawers: &[PublicKeyHash], + ) -> Result { + if withdrawers.len() > MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS { + return Err(Error::Node(anyhow::anyhow!( + "stakeEntriesAtTip accepts at most {} withdrawers, got {}", + MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS, + withdrawers.len() + ))); + } + let mut canonical = withdrawers.to_vec(); + canonical.sort_unstable(); + canonical.dedup(); + self.query_stake_entries_at_tip_request(Some(&canonical)) + .await + } + /// Sync wrapper in order to clear transient addresses in case of errors pub fn sync( &self, @@ -923,6 +1121,12 @@ impl Worker { } } + // A wallet that was already at the node tip may not have indexed any block in this sync + // run. Reconcile that exact tip explicitly so upgrading from the transaction-only mirror + // does not wait for a future block. The helper is idempotent for a tip already reconciled + // during `index_txns`. + self.reconcile_wallet_tip_stakes(wallet)?; + let events = Some(vec![types::Event::SyncFinish( first_beacon.checkpoint, latest_beacon.checkpoint, @@ -1313,3 +1517,103 @@ fn validate_birth_date( Ok(()) } } + +#[cfg(test)] +mod stake_entries_at_tip_tests { + use super::*; + use witnet_data_structures::chain::Hash; + + fn pkh(byte: u8) -> PublicKeyHash { + PublicKeyHash::from_bytes(&[byte; 20]).unwrap() + } + + fn snapshot_entry( + validator: PublicKeyHash, + withdrawer: PublicKeyHash, + coins: u64, + ) -> types::StakeEntryAtTip { + types::StakeEntryAtTip { + key: StakeKey { + validator, + withdrawer, + }, + value: types::StakeValueAtTip { coins }, + } + } + + #[test] + fn empty_filter_is_explicit_and_oversized_wallet_falls_back_to_full_query() { + let empty = HashSet::new(); + assert_eq!( + stake_entries_at_tip_withdrawer_filter(&empty), + Some(Vec::new()) + ); + + let first = pkh(1); + let second = pkh(2); + assert_eq!( + stake_entries_at_tip_withdrawer_filter(&HashSet::from([second, first])), + Some(vec![first, second]) + ); + + let mut oversized = HashSet::new(); + for index in 0..=MAX_STAKE_ENTRIES_AT_TIP_WITHDRAWERS { + let mut bytes = [0_u8; 20]; + bytes[..8].copy_from_slice(&(index as u64).to_le_bytes()); + oversized.insert(PublicKeyHash::from_bytes(&bytes).unwrap()); + } + assert_eq!(stake_entries_at_tip_withdrawer_filter(&oversized), None); + } + + #[test] + fn old_node_full_projection_is_safe_at_filtered_wallet_boundary() { + let expected_tip = CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::SHA256([7; 32]), + }; + let owned = pkh(3); + let foreign = pkh(4); + let owned_key = StakeKey { + validator: pkh(1), + withdrawer: owned, + }; + let snapshot = types::StakeEntriesAtTip { + tip: expected_tip, + // An older node ignores the additive filter and returns the canonical full ledger. + entries: vec![ + snapshot_entry(owned_key.validator, owned, 11), + snapshot_entry(pkh(2), foreign, 22), + ], + }; + + let authoritative = authoritative_wallet_stakes_from_snapshot( + snapshot, + expected_tip, + &HashSet::from([owned]), + ) + .unwrap(); + + assert_eq!(authoritative, HashMap::from([(owned_key, 11)])); + } + + #[test] + fn old_node_full_projection_cannot_populate_an_explicit_empty_wallet_filter() { + let expected_tip = CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::SHA256([8; 32]), + }; + let snapshot = types::StakeEntriesAtTip { + tip: expected_tip, + entries: vec![snapshot_entry(pkh(1), pkh(2), 11)], + }; + + let authoritative = authoritative_wallet_stakes_from_snapshot( + snapshot, + expected_tip, + &HashSet::new(), + ) + .unwrap(); + + assert!(authoritative.is_empty()); + } +} diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 48dca18489..d6086687eb 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -23,7 +23,6 @@ use anyhow::Error; use witnet_config::config::Config; use witnet_data_structures::{ chain::{CheckpointBeacon, EpochConstants}, - get_protocol_version_activation_epoch, get_protocol_version_period, proto::versioning::{ProtocolInfo, ProtocolVersion}, }; use witnet_net::client::tcp::JsonRpcClient; @@ -56,22 +55,6 @@ pub fn run(conf: Config) -> Result<(), Error> { let node_urls = conf.wallet.node_url; let rocksdb_opts = conf.rocksdb.to_rocksdb_options(); - let checkpoints_period = conf.consensus_constants.checkpoints_period; - let checkpoint_zero_timestamp = conf.consensus_constants.checkpoint_zero_timestamp; - let activation_epoch_wit2 = get_protocol_version_activation_epoch(ProtocolVersion::V2_0); - let checkpoint_zero_timestamp_wit2 = if activation_epoch_wit2 == u32::MAX { - i64::MAX - } else { - checkpoint_zero_timestamp + i64::from(activation_epoch_wit2) * i64::from(checkpoints_period) - }; - let checkpoints_period_wit2 = get_protocol_version_period(ProtocolVersion::V2_0); - let epoch_constants = EpochConstants { - checkpoint_zero_timestamp, - checkpoints_period, - checkpoint_zero_timestamp_wit2, - checkpoints_period_wit2, - }; - let genesis_hash = conf.consensus_constants.genesis_hash; let genesis_prev_hash = conf.consensus_constants.bootstrap_hash; @@ -135,6 +118,31 @@ pub fn run(conf: Config) -> Result<(), Error> { node_client.valid_connection().await; let protocol_info = get_protocol_info(node_client.clone()).await?; + protocol_info + .validate_timing_topology(&consensus_constants) + .map_err(|error| anyhow::format_err!( + "node protocol schedule is incompatible with wallet consensus constants: {error}" + ))?; + let activation_epoch_wit2 = protocol_info + .all_versions + .try_get_activation_epoch(ProtocolVersion::V2_0); + let checkpoints_period_wit2 = activation_epoch_wit2 + .and_then(|_| { + protocol_info + .all_checkpoints_periods + .get(&ProtocolVersion::V2_0) + .copied() + }) + .unwrap_or(u16::MAX); + let epoch_constants = EpochConstants::try_new( + consensus_constants.checkpoint_zero_timestamp, + consensus_constants.checkpoints_period, + activation_epoch_wit2, + checkpoints_period_wit2, + ) + .map_err(|error| anyhow::format_err!( + "node protocol schedule has an unrepresentable wallet epoch clock: {error}" + ))?; let params = params::Params { testnet, diff --git a/wallet/src/model.rs b/wallet/src/model.rs index b48e491bb0..664ad96344 100644 --- a/wallet/src/model.rs +++ b/wallet/src/model.rs @@ -10,8 +10,8 @@ use crate::{ }; use witnet_data_structures::{ chain::{ - DataRequestInfo, Hash, KeyedSignature, OutputPointer, PublicKeyHash, StakeOutput, - ValueTransferOutput, + DataRequestInfo, Hash, KeyedSignature, OutputPointer, PublicKeyHash, StakeAuthorization, + StakeOutput, ValueTransferOutput, }, serialization_helpers::number_from_string, staking::prelude::StakeKey, @@ -369,10 +369,7 @@ impl From for ValueTransferOutput { } } -/// FIXME authorization is not being used but we may need it in the future -/// when we support creating stake and unstake tx from the wallet and -/// we could need them in the storage to avoid syncing the whole wallet -/// Stake information including amount, key and authorization +/// Stake information including amount, key, authorization, and optional Wit/2.1 terms. #[derive(Clone, Debug, Eq, Deserialize, PartialEq, Serialize)] pub struct StakeOutputInfo { /// Staked amount @@ -385,6 +382,9 @@ pub struct StakeOutputInfo { pub key: StakeKey, /// Stake authorization pub authorization: KeyedSignature, + /// Wit/2.1 authorization terms, omitted for legacy stake outputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_terms: Option, } #[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)] @@ -408,19 +408,30 @@ pub type UtxoSet = HashMap; pub type StakeOutputSet = HashMap, StakeOutput>; +/// Exact live stake amounts owned by the wallet, independent from optional transaction +/// authorization provenance. +pub type StakeBalanceSet = HashMap, u64>; + /// Map of output pointer to timestamp. /// Used to mark outputs that have been recently used in a transaction. /// They will not be used again until this timestamp. +/// +/// This map is an availability policy only. Wallet insertion is synchronous under the wallet-state +/// write lock and no delayed callback compares or restores these timestamps, so opaque rollback +/// ownership is neither encoded in nor inferred from this persisted map. pub type UsedOutputs = HashMap; /// Function that returns a cleaned UsedOutputs, it means, without output pointer non existing /// in the UtxoSet and without output pointer with old timestamps -#[allow(clippy::cast_sign_loss)] pub fn clean_used_outputs(used_outputs: &UsedOutputs, utxo_set: &UtxoSet) -> UsedOutputs { let mut new_hm = UsedOutputs::default(); - let now = get_timestamp() as u64; + let now = i128::from(get_timestamp()); for (o, &ts) in used_outputs.iter() { - if utxo_set.contains_key(o) && ts < now { + // Selection excludes an output only while its usage timeout is strictly later than the + // current timestamp. Preserve exactly those live reservations across a pending-state + // reload; retaining expired entries and dropping future ones inverted that contract and + // could make a locally pending stake transaction's inputs immediately selectable again. + if utxo_set.contains_key(o) && i128::from(ts) > now { new_hm.insert(o.clone(), ts); } } diff --git a/wallet/src/params.rs b/wallet/src/params.rs index 7f9ccf87bb..01158baea7 100644 --- a/wallet/src/params.rs +++ b/wallet/src/params.rs @@ -67,11 +67,13 @@ impl NodeParams { /// Update the `last_beacon` field with the information of the latest block that the node has /// consolidated into its block chain. /// This is a best-effort method. It will silently do nothing if the write lock on `last_beacon` - /// cannot be acquired or if the new beacon looks older than the current one. + /// cannot be acquired or if the new beacon looks older than the current one. A same-height + /// replacement is retained because a canonical reorganization can change the tip hash without + /// advancing its checkpoint; tip-bound state queries must then target the replacement hash. pub fn update_last_beacon(&self, new_beacon: CheckpointBeacon) { let lock = (*self.last_beacon).write(); if let Ok(mut beacon) = lock - && new_beacon.checkpoint > beacon.checkpoint + && new_beacon.checkpoint >= beacon.checkpoint { *beacon = new_beacon } diff --git a/wallet/src/repository/error.rs b/wallet/src/repository/error.rs index bf1a52f157..3f760982e8 100644 --- a/wallet/src/repository/error.rs +++ b/wallet/src/repository/error.rs @@ -18,6 +18,8 @@ pub enum Error { TransactionBalanceOverflow, #[error("transaction balance underflowed")] TransactionBalanceUnderflow, + #[error("system timestamp {0} is before the Unix epoch")] + InvalidTimestamp(i64), #[error("Invalid PKH: {0}")] Pkh(PublicKeyHashParseError), #[error( @@ -50,6 +52,8 @@ pub enum Error { WrongMetadataType(String), #[error("block consolidation failed: {0}")] BlockConsolidation(String), + #[error("authoritative stake reconciliation failed: {0}")] + StakeReconciliation(String), #[error("hash parsing failed: {0}")] HashParse(HashParseError), #[error("failed creating a transaction: {0}")] diff --git a/wallet/src/repository/keys.rs b/wallet/src/repository/keys.rs index f08b9f72ed..b67357962c 100644 --- a/wallet/src/repository/keys.rs +++ b/wallet/src/repository/keys.rs @@ -156,6 +156,22 @@ pub fn account_stake_output_set(account_index: u32) -> Key Key { + Key::new(format!("account-{account_index}-stake-balance-set")) +} + +/// An account's retained authorization provenance for stake keys that may temporarily disappear +/// from the live consensus ledger while collateral is reserved. +#[inline] +pub fn account_stake_output_provenance(account_index: u32) -> Key { + Key::new(format!( + "account-{account_index}-stake-output-provenance" + )) +} + /// An account's next index to use for generating an address. #[inline] pub fn account_next_index(account_index: u32, keychain: u32) -> Key { diff --git a/wallet/src/repository/wallet/mod.rs b/wallet/src/repository/wallet/mod.rs index 8b9d9589cc..6e268f240d 100644 --- a/wallet/src/repository/wallet/mod.rs +++ b/wallet/src/repository/wallet/mod.rs @@ -9,6 +9,7 @@ use std::{ }; use bech32::ToBase32; +use serde::{Deserialize, Serialize}; use state::State; use witnet_crypto::{ hash::calculate_sha256, @@ -16,13 +17,17 @@ use witnet_crypto::{ secp256k1, signature, }; use witnet_data_structures::{ + bincode_compatibility::{self, LegacyStakeOutput}, chain::{ CheckpointBeacon, DataRequestOutput, Environment, Epoch, EpochConstants, Hash, Hashable, - Input, KeyedSignature, OutputPointer, PublicKeyHash, StakeOutput, ValueTransferOutput, + Input, KeyedSignature, OutputPointer, PublicKeyHash, + STAKE_AUTHORIZATION_ACTION_DELEGATE, STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + StakeOutput, ValueTransferOutput, }, + error::TransactionError, fee::{AbsoluteFee, Fee}, get_environment, - proto::versioning::{ProtocolInfo, VersionedHashable}, + proto::versioning::{ProtocolVersion, VersionedHashable}, radon_error::RadonError, staking::prelude::StakeKey, transaction::{ @@ -40,7 +45,7 @@ use witnet_util::timestamp::get_timestamp; use crate::{ constants, crypto, db::{Database, GetWith, WriteBatch as _}, - model::{self, StakeOutputInfo}, + model, params::Params, types, }; @@ -56,8 +61,207 @@ struct AccountMutation { balance_movement: model::BalanceMovement, utxo_inserts: Vec<(model::OutPtr, model::OutputInfo)>, utxo_removals: Vec, - stake_output_inserts: Vec, - stake_output_removals: Vec, + stake_output_insert: Option, + stake_balance_removal: Option<(StakeKey, u64)>, +} + +/// Return whether a stake transaction changes the wallet's live stake balance. +/// +/// Wit/2.1 commission updates and exit intents reuse the zero-valued stake envelope but must not +/// create a phantom live balance. Explicit owner registration does deposit a real balance. +fn stake_output_adds_balance( + stake_output: &StakeOutput, + protocol_version: witnet_data_structures::proto::versioning::ProtocolVersion, +) -> bool { + if stake_output.value == 0 { + return false; + } + + // Authorization terms are unknown to the legacy schema and are intentionally inert before + // Wit/2.1. A historical/pre-activation block may still arrive through an upgraded node with + // an appended field, so do not let a malformed or action-1 sidecar hide a real legacy stake + // output from the wallet's financial mirror. Once Wit/2.1 is active, actions 0 and 2 create + // stake balance; actions 1 and 3 carry only policy changes. + protocol_version < witnet_data_structures::proto::versioning::ProtocolVersion::V2_1 + || stake_output + .authorization_terms + .as_ref() + .is_some_and(|terms| matches!( + terms.action, + STAKE_AUTHORIZATION_ACTION_DELEGATE | STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + )) +} + +/// Derive the exact live-balance layout understood by the new wallet state from historical full +/// StakeOutput records. This is the only fallback used when the independently persisted balance +/// map is absent. +fn stake_balances_from_outputs( + outputs: &model::StakeOutputSet, +) -> model::StakeBalanceSet { + outputs + .iter() + .map(|(key, output)| (key.clone(), output.value)) + .collect() +} + +/// Sum the authoritative live stake map without relying on optional authorization records. +fn checked_staked_balance(balances: &model::StakeBalanceSet) -> Result { + balances + .values() + .try_fold(0_u64, |total, amount| total.checked_add(*amount)) + .ok_or(Error::TransactionBalanceOverflow) +} + +/// Recompute the spendable and time-locked balance without allowing a corrupt or adversarially +/// large persisted UTXO set to panic the wallet process. +fn checked_utxo_balance( + utxos: &model::UtxoSet, + timestamp: u64, + is_available: impl Fn(u64, u64) -> bool, +) -> Result { + utxos.values().try_fold( + model::BalanceInfo::default(), + |mut balance, output| { + let amount = if is_available(timestamp, output.time_lock) { + &mut balance.available + } else { + &mut balance.locked + }; + *amount = amount + .checked_add(output.amount) + .ok_or(Error::TransactionBalanceOverflow)?; + + Ok(balance) + }, + ) +} + +/// Project optional transaction authorization provenance onto one exact live consensus view. +/// +/// Restored/pre-birth entries legitimately have an authoritative amount without the Stake +/// transaction bytes that created them. Such entries are omitted from this compatibility view; +/// their financial value remains complete in `StakeBalanceSet`. Existing entries retain genuine +/// authorization metadata, local entries absent from the authoritative view are removed, and no +/// signature or authorization terms are ever synthesized. +fn reconcile_provenance_backed_stake_outputs( + current: &model::StakeOutputSet, + provenance: &model::StakeOutputSet, + authoritative: &model::StakeBalanceSet, +) -> Result { + authoritative + .iter() + .filter_map(|(key, amount)| { + let current_output = current.get(key).or_else(|| provenance.get(key))?; + if ¤t_output.key != key { + return Some(Err(Error::StakeReconciliation(format!( + "wallet stake map key {key:?} does not match its stored output key {:?}", + current_output.key + )))); + } + + let mut reconciled = current_output.clone(); + reconciled.value = *amount; + Some(Ok((key.clone(), reconciled))) + }) + .collect() +} + +/// Wallet transaction-history layout written before stake authorization terms were persisted. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyBalanceMovement { + #[serde(skip)] + db_key: u32, + kind: model::MovementType, + amount: u64, + transaction: LegacyWalletTransaction, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyWalletTransaction { + block: Option, + confirmed: bool, + data: LegacyTransactionData, + hash: String, + miner_fee: u64, + timestamp: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +enum LegacyTransactionData { + ValueTransfer(model::VtData), + DataRequest(model::DrData), + Tally(model::TallyData), + Mint(model::MintData), + Commit(model::VtData), + Stake(LegacyStakeData), + Unstake(model::UnstakeData), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct LegacyStakeData { + inputs: Vec, + output: LegacyStakeOutput, + change: Option, +} + +impl From for model::BalanceMovement { + fn from(movement: LegacyBalanceMovement) -> Self { + Self { + db_key: movement.db_key, + kind: movement.kind, + amount: movement.amount, + transaction: movement.transaction.into(), + } + } +} + +impl From for model::WalletTransaction { + fn from(transaction: LegacyWalletTransaction) -> Self { + Self { + block: transaction.block, + confirmed: transaction.confirmed, + data: transaction.data.into(), + hash: transaction.hash, + miner_fee: transaction.miner_fee, + timestamp: transaction.timestamp, + } + } +} + +impl From for model::TransactionData { + fn from(data: LegacyTransactionData) -> Self { + match data { + LegacyTransactionData::ValueTransfer(data) => Self::ValueTransfer(data), + LegacyTransactionData::DataRequest(data) => Self::DataRequest(data), + LegacyTransactionData::Tally(data) => Self::Tally(data), + LegacyTransactionData::Mint(data) => Self::Mint(data), + LegacyTransactionData::Commit(data) => Self::Commit(data), + LegacyTransactionData::Stake(data) => Self::Stake(model::StakeData { + inputs: data.inputs, + output: data.output.into(), + change: data.change, + }), + LegacyTransactionData::Unstake(data) => Self::Unstake(data), + } + } +} + +fn normalize_stake_output_set_bytes(bytes: &[u8]) -> Vec { + bincode_compatibility::normalize_stake_output_set(bytes).unwrap_or_default() +} + +fn normalize_transaction_bytes(bytes: &[u8]) -> Vec { + bincode_compatibility::normalize_transaction(bytes).unwrap_or_default() +} + +fn normalize_balance_movement_bytes(bytes: &[u8]) -> Vec { + if bincode_compatibility::deserialize_exact::(bytes).is_ok() { + return bytes.to_vec(); + } + + bincode_compatibility::deserialize_exact::(bytes) + .and_then(|movement| bincode::serialize(&model::BalanceMovement::from(movement))) + .unwrap_or_default() } /// Struct that keep the unspent outputs pool and the own unspent outputs pool @@ -268,7 +472,40 @@ where pub fn clear_pending_state(&self) -> Result<()> { let account = 0; + // Complete every fallible database read before taking the write lock or changing live + // state. A corrupt/missing value must leave the prior pending view intact. + let transaction_next_id = self + .db + .get_or_default(&keys::transaction_next_id(account))?; + let utxo_set = self.db.get_or_default(&keys::account_utxo_set(account))?; + let stake_output_set = self + .db + .get_opt_with( + &keys::account_stake_output_set(account), + normalize_stake_output_set_bytes, + )? + .unwrap_or_default(); + let stake_balance_set = self + .db + .get_opt(&keys::account_stake_balance_set(account))? + .unwrap_or_else(|| stake_balances_from_outputs(&stake_output_set)); + let stake_output_provenance = self + .db + .get_opt_with( + &keys::account_stake_output_provenance(account), + normalize_stake_output_set_bytes, + )? + .unwrap_or_else(|| stake_output_set.clone()); + let stake_output_set = reconcile_provenance_backed_stake_outputs( + &stake_output_set, + &stake_output_provenance, + &stake_balance_set, + )?; + let mut confirmed_balance = self.db.get_or_default(&keys::account_balance(account))?; + confirmed_balance.staked = checked_staked_balance(&stake_balance_set)?; + let mut state = self.state.write()?; + let used_outputs = model::clean_used_outputs(&state.used_outputs, &utxo_set); state.last_sync = state.last_confirmed; state.pending_blocks.clear(); @@ -277,16 +514,20 @@ where state.pending_addresses_by_path.clear(); state.pending_addresses_by_block.clear(); state.local_movements.clear(); + // `balance.local` is the aggregate of `local_movements`. Clearing the movement map + // without resetting this field leaves a stale spendable amount visible after pending + // state recovery (and can make a retried stake appear to retain funds that no longer have + // a local receipt). + state.balance.local = 0; state.db_movements_to_update.clear(); - - // Restore state from database - state.transaction_next_id = self - .db - .get_or_default(&keys::transaction_next_id(account))?; - state.utxo_set = self.db.get_or_default(&keys::account_utxo_set(account))?; - state.used_outputs = model::clean_used_outputs(&state.used_outputs, &state.utxo_set); - state.balance.confirmed = self.db.get_or_default(&keys::account_balance(account))?; - state.balance.unconfirmed = state.balance.confirmed; + state.transaction_next_id = transaction_next_id; + state.utxo_set = utxo_set; + state.stake_balance_set = stake_balance_set; + state.stake_output_set = stake_output_set; + state.stake_output_provenance = stake_output_provenance; + state.used_outputs = used_outputs; + state.balance.confirmed = confirmed_balance; + state.balance.unconfirmed = confirmed_balance; Ok(()) } @@ -307,10 +548,29 @@ where let transaction_next_id = db.get_or_default(&keys::transaction_next_id(account))?; let utxo_set: model::UtxoSet = db.get_or_default(&keys::account_utxo_set(account))?; - let stake_output_set: model::StakeOutputSet = - db.get_or_default(&keys::account_stake_output_set(account))?; - let timestamp = - u64::try_from(get_timestamp()).expect("Get timestamp should return a positive value"); + let stake_output_set: model::StakeOutputSet = db + .get_opt_with( + &keys::account_stake_output_set(account), + normalize_stake_output_set_bytes, + )? + .unwrap_or_default(); + let stake_balance_set: model::StakeBalanceSet = db + .get_opt(&keys::account_stake_balance_set(account))? + .unwrap_or_else(|| stake_balances_from_outputs(&stake_output_set)); + let stake_output_provenance: model::StakeOutputSet = db + .get_opt_with( + &keys::account_stake_output_provenance(account), + normalize_stake_output_set_bytes, + )? + .unwrap_or_else(|| stake_output_set.clone()); + let stake_output_set = reconcile_provenance_backed_stake_outputs( + &stake_output_set, + &stake_output_provenance, + &stake_balance_set, + )?; + let raw_timestamp = get_timestamp(); + let timestamp = u64::try_from(raw_timestamp) + .map_err(|_| Error::InvalidTimestamp(raw_timestamp))?; let v2_balance_fix = |bytes: &[u8]| { if bytes.len() == 16 { [bytes, &[0u8; 8]].concat() @@ -318,37 +578,20 @@ where Vec::from(bytes) } }; - let balance_info = db + let mut balance_info = match db .get_opt_with(&keys::account_balance(account), v2_balance_fix)? - .unwrap_or_else(|| { + { + Some(balance_info) => balance_info, + None => { // compute balance from utxo set if is not cached in the // database, this is mostly used for testing where overflow // checks are enabled - let mut balance_info = utxo_set - .values() - .map(|balance| (balance.amount, balance.time_lock)) - .fold( - model::BalanceInfo::default(), - |mut acc: model::BalanceInfo, (amount, time_lock)| { - if timestamp >= time_lock { - acc.available = - acc.available.checked_add(amount).expect("balance overflow"); - } else { - acc.locked = - acc.locked.checked_add(amount).expect("balance overflow"); - } - - acc - }, - ); - - balance_info.staked = stake_output_set - .values() - .map(|stake_output| stake_output.value) - .sum(); - - balance_info - }); + checked_utxo_balance(&utxo_set, timestamp, |now, time_lock| now >= time_lock)? + } + }; + // The independently persisted live map is the financial authority. For an older wallet + // database this map was derived from its complete StakeOutput records immediately above. + balance_info.staked = checked_staked_balance(&stake_balance_set)?; let balance = model::WalletBalance { local: 0, unconfirmed: balance_info, @@ -356,8 +599,13 @@ where }; log::debug!("Wallet {id} has balance: {balance:?}"); + // A missing synchronization marker is valid for an older wallet database and falls back + // to genesis. Decode and backend failures are not equivalent to absence: silently treating + // a corrupt marker as genesis can make the next sync replay history, duplicate movements, + // or discard the wallet's exact delegated-stake tip. Preserve the compatibility fallback + // while propagating an actual database error. let last_sync = db - .get(&keys::wallet_last_sync()) + .get_opt(&keys::wallet_last_sync())? .unwrap_or(CheckpointBeacon { checkpoint: 0, hash_prev_block: params.genesis_prev_hash, @@ -392,10 +640,14 @@ where ); let keychains = [external_key, internal_key]; let epoch_constants = params.epoch_constants; - let birth_date = db.get(&keys::birth_date()).unwrap_or(CheckpointBeacon { - checkpoint: 0, - hash_prev_block: params.genesis_prev_hash, - }); + // Birth date has the same legacy-absence semantics as the sync marker, but a malformed + // persisted value must not silently widen the wallet's historical scan back to genesis. + let birth_date = db + .get_opt(&keys::birth_date())? + .unwrap_or(CheckpointBeacon { + checkpoint: 0, + hash_prev_block: params.genesis_prev_hash, + }); let state = RwLock::new(State { name, @@ -408,7 +660,9 @@ where balance, transaction_next_id, utxo_set, + stake_balance_set, stake_output_set, + stake_output_provenance, used_outputs: Default::default(), epoch_constants, last_sync, @@ -559,6 +813,63 @@ where self.addresses(constants::INTERNAL_KEYCHAIN, offset, limit) } + /// Return every public-key hash currently controlled by this account. + /// + /// The set includes persisted external/internal derivations, synchronization lookahead + /// addresses, and withdrawers already represented in the local stake mirror. It is used to + /// filter the node's complete tip-bound stake ledger without trusting a transaction payer to + /// identify ownership. + pub fn owned_public_key_hashes(&self) -> Result> { + let state = self.state.read()?; + let mut owned = HashSet::new(); + + for index in 0..state.next_external_index { + owned.insert( + self._get_address( + &state, + state.account, + constants::EXTERNAL_KEYCHAIN, + index, + )? + .pkh, + ); + } + for index in 0..state.next_internal_index { + owned.insert( + self._get_address( + &state, + state.account, + constants::INTERNAL_KEYCHAIN, + index, + )? + .pkh, + ); + } + + owned.extend(state.transient_external_addresses.keys().copied()); + owned.extend(state.transient_internal_addresses.keys().copied()); + owned.extend( + state + .stake_balance_set + .keys() + .map(|key| key.withdrawer), + ); + owned.extend( + state + .stake_output_set + .keys() + .map(|key| key.withdrawer), + ); + owned.extend( + state + .stake_output_provenance + .keys() + .map(|key| key.withdrawer), + ); + + Ok(owned) + } + /// Return a list of internal or external addresses. fn addresses(&self, keychain: u32, offset: u32, limit: u32) -> Result { let state = self.state.read()?; @@ -590,8 +901,15 @@ where let state = self.state.read()?; let account = state.account; - // Total amount of state and db transactions - let total = state.transaction_next_id + u32::try_from(state.local_movements.len()).unwrap(); + // Total amount of state and db transactions. A corrupt or adversarial in-memory pending + // map must not wrap the public count or panic while converting its length to the wire + // `u32` transaction index domain. + let local_movement_count = u32::try_from(state.local_movements.len()) + .map_err(|_| Error::TransactionIdOverflow)?; + let total = state + .transaction_next_id + .checked_add(local_movement_count) + .ok_or(Error::TransactionIdOverflow)?; let mut transactions: Vec = Vec::new(); // Query database `transaction_next_id` to compute total amount of transactions @@ -645,7 +963,12 @@ where ); }); - transactions.extend_from_slice(&all_pending_movements[range_pending]); + let pending_slice = all_pending_movements.get(range_pending).ok_or_else(|| { + Error::Failure(anyhow::anyhow!( + "pending wallet movement ranges do not match their indexed block snapshots" + )) + })?; + transactions.extend_from_slice(pending_slice); } // Build a HashMap @@ -656,7 +979,7 @@ where if let Some(range_db) = range_db { for index in range_db.rev() { - let index = u32::try_from(index).unwrap(); + let index = u32::try_from(index).map_err(|_| Error::TransactionIdOverflow)?; // Check if there is a pending update for the queried balance movement, // otherwise query the database @@ -740,7 +1063,16 @@ where /// Get a transaction if exists. pub fn get_transaction(&self, account: u32, index: u32) -> Result { - Ok(self.db.get(&keys::transaction_movement(account, index))?) + self.db + .get_opt_with( + &keys::transaction_movement(account, index), + normalize_balance_movement_bytes, + )? + .ok_or_else(|| { + Error::from(crate::db::Error::DbKeyNotFound { + key: format!("{:?}", keys::transaction_movement(account, index)), + }) + }) } /// Get a previously put serialized value. @@ -785,8 +1117,47 @@ where pub fn filter_wallet_transactions( &self, txns: impl Iterator, + ) -> Result> { + // Preserve the historical source-compatible helper for callers that do not carry a block + // protocol. Such callers already receive canonical legacy/V2.1 transaction objects; the + // block-indexing path below uses the explicit protocol-aware variant so appended terms + // cannot change pre-Wit/2.1 wallet ownership semantics. + // Without block context, retain the historical terms-inert behavior. A legacy caller may + // still pass a terms-free Stake transaction; interpreting that compatibility API as native + // Wit/2.1 would incorrectly hide its positive stake output. The block-indexing path uses + // `filter_wallet_transactions_for_protocol` with the containing epoch's protocol. + self.filter_wallet_transactions_for_protocol(txns, ProtocolVersion::V2_0) + } + + /// Filter transactions using the protocol of the block that contains them. + /// + /// Authorization terms are unknown to pre-Wit/2.1 schemas and are inert there. An upgraded + /// node can still expose an appended field while a wallet catches up, so the filter must make + /// the same protocol choice as `_get_account_mutation` rather than interpreting a V2 action + /// from the transaction object in isolation. + pub fn filter_wallet_transactions_for_protocol( + &self, + txns: impl Iterator, + protocol_version: ProtocolVersion, ) -> Result> { let state = self.state.read()?; + let owns_pkh = |pkh: &PublicKeyHash| -> Result { + Ok(self.db.get_opt(&keys::pkh(pkh))?.is_some() + || state.transient_external_addresses.contains_key(pkh) + || state.transient_internal_addresses.contains_key(pkh) + || state + .stake_balance_set + .keys() + .any(|key| &key.withdrawer == pkh) + || state + .stake_output_set + .keys() + .any(|key| &key.withdrawer == pkh) + || state + .stake_output_provenance + .keys() + .any(|key| &key.withdrawer == pkh)) + }; let mut filtered_txns = vec![]; for txn in txns { @@ -811,30 +1182,52 @@ where if let Transaction::Tally(tally) = &txn { // There is a DR transaction persisted in database whose tally was found or // there is a DR transaction in pending state whose tally was found - if state + let has_pending_request = state .pending_dr_movements - .contains_key(&tally.dr_pointer.to_string()) - || self - .db - .get(&keys::transactions_index(tally.dr_pointer.as_ref())) - .is_ok() + .contains_key(&tally.dr_pointer.to_string()); + let has_persisted_request = if has_pending_request { + false + } else { + self.db + .get_opt(&keys::transactions_index(tally.dr_pointer.as_ref()))? + .is_some() + }; + if has_pending_request || has_persisted_request { + filtered_txns.push(txn.clone()); + continue; + } + } + + // A delegated stake can belong to this wallet through its withdrawer even when every + // funding input and the optional change belong to another party. Mirror the mutation + // boundary's ownership rule so such deposits are not discarded before indexing. + if let Transaction::Stake(stake) = &txn { + let output = &stake.body.output; + if stake_output_adds_balance(output, protocol_version) + && (state.stake_balance_set.contains_key(&output.key) + || state.stake_output_set.contains_key(&output.key) + || state.stake_output_provenance.contains_key(&output.key) + || owns_pkh(&output.key.withdrawer)?) { filtered_txns.push(txn.clone()); continue; } } - let check_db_and_transient = |output: &ValueTransferOutput| { - self.db.get(&keys::pkh(&output.pkh)).is_ok() - || state.transient_external_addresses.contains_key(&output.pkh) - || state.transient_internal_addresses.contains_key(&output.pkh) - }; - // Check if any input or output is from the wallet (input is an UTXO or output points to any wallet's pkh) - if inputs + let owns_input = inputs .iter() - .any(|input| state.utxo_set.contains_key(&input.output_pointer().into())) - || outputs.iter().any(check_db_and_transient) - { + .any(|input| state.utxo_set.contains_key(&input.output_pointer().into())); + let mut owns_output = false; + if !owns_input { + for output in outputs { + if owns_pkh(&output.pkh)? { + owns_output = true; + break; + } + } + } + // Check if any input or output is from the wallet (input is an UTXO or output points to any wallet's pkh) + if owns_input || owns_output { filtered_txns.push(txn.clone()); } } @@ -849,7 +1242,55 @@ where txns: &[model::ExtendedTransaction], confirmed: bool, ) -> Result> { - let mut state = self.state.write()?; + self.index_block_transactions_with_stake_reconciliation( + block_info, + txns, + confirmed, + None, + ) + } + + /// Index a block and optionally replace its transaction-derived stake mirror with the exact + /// consensus amounts bound to this same block tip. + /// + /// All in-memory mutations are applied to a cloned state. Confirmed database writes use one + /// batch, and the staged state is published only after that batch succeeds, so a transaction, + /// reconciliation, serialization, or storage error cannot leave a partially indexed block in + /// memory. + pub fn index_block_transactions_with_stake_reconciliation( + &self, + block_info: &model::Beacon, + txns: &[model::ExtendedTransaction], + confirmed: bool, + authoritative_stakes: Option<&HashMap, u64>>, + ) -> Result> { + let protocol_version = self + .params + .protocol_info + .all_versions + .version_for_epoch(block_info.epoch); + self.index_block_transactions_with_stake_reconciliation_for_protocol( + block_info, + txns, + confirmed, + authoritative_stakes, + protocol_version, + ) + } + + /// Index a block using one caller-captured protocol version for every transaction identity and + /// stake projection. The worker uses this when its node schedule is the operation authority; + /// the compatibility wrapper above derives the version from the wallet's configured schedule. + pub(crate) fn index_block_transactions_with_stake_reconciliation_for_protocol( + &self, + block_info: &model::Beacon, + txns: &[model::ExtendedTransaction], + confirmed: bool, + authoritative_stakes: Option<&HashMap, u64>>, + protocol_version: ProtocolVersion, + ) -> Result> { + let mut live_state = self.state.write()?; + let mut state = live_state.clone(); let mut addresses = HashMap::new(); let mut block_balance_movements = Vec::new(); let mut dr_balance_movements = HashMap::new(); @@ -858,15 +1299,16 @@ where // Index all transactions for txn in txns { // Transactions are only indexed if they do not exist in database, or if resynchronizing. - match self._index_transaction(&mut state, &mut addresses, txn, block_info, confirmed) { + match self._index_transaction( + &mut state, + &mut addresses, + txn, + block_info, + confirmed, + protocol_version, + ) { Ok(Some(balance_movement)) => { if let Transaction::DataRequest(dr_tx) = &txn.transaction { - let protocol_version = self - .params - .protocol_info - .all_versions - .version_for_epoch(block_info.epoch); - dr_balance_movements.insert( dr_tx.versioned_hash(protocol_version).to_string(), (block_info.block_hash, block_balance_movements.len()), @@ -891,21 +1333,38 @@ where "Found a tally for data request {:?} that was in pending state", tally.dr_pointer.to_string() ); + let pending_block_key = pending_block_hash.to_string(); let dr_movement = state .pending_movements - .get(&pending_block_hash.to_string()) - .unwrap()[index] - .clone(); + .get(&pending_block_key) + .and_then(|movements| movements.get(index)) + .cloned() + .ok_or_else(|| { + Error::BlockConsolidation(format!( + "pending data request movement {index} was not found for block {pending_block_hash}" + )) + })?; match &dr_movement.transaction.data.clone() { model::TransactionData::DataRequest(dr_data) => { let mut updated_dr_movement = dr_movement; updated_dr_movement.transaction.data = build_updated_dr_transaction_data(dr_data, tally, &txn.metadata)?; - state + let pending_movements = state .pending_movements - .get_mut(&pending_block_hash.to_string()) - .unwrap()[index] = updated_dr_movement; + .get_mut(&pending_block_key) + .ok_or_else(|| { + Error::BlockConsolidation(format!( + "pending data request movements were not found for block {pending_block_hash}" + )) + })?; + let pending_movement = + pending_movements.get_mut(index).ok_or_else(|| { + Error::BlockConsolidation(format!( + "pending data request movement {index} was not found for block {pending_block_hash}" + )) + })?; + *pending_movement = updated_dr_movement; state .pending_dr_movements .remove(&tally.dr_pointer.to_string()); @@ -949,29 +1408,20 @@ where } } - let timestamp = convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch); - state.balance.unconfirmed = state - .utxo_set - .values() - .map(|balance| (balance.amount, balance.time_lock)) - .fold( - model::BalanceInfo::default(), - |mut acc, (amount, time_lock)| { - if timestamp > time_lock { - acc.available = - acc.available.checked_add(amount).expect("balance overflow"); - } else { - acc.locked = acc.locked.checked_add(amount).expect("balance overflow"); - } + if let Some(authoritative_stakes) = authoritative_stakes { + state.stake_output_set = reconcile_provenance_backed_stake_outputs( + &state.stake_output_set, + &state.stake_output_provenance, + authoritative_stakes, + )?; + state.stake_balance_set = authoritative_stakes.clone(); + } - acc - }, - ); - state.balance.unconfirmed.staked = state - .stake_output_set - .values() - .map(|stake_output| stake_output.value) - .sum(); + let timestamp = + convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch)?; + state.balance.unconfirmed = + checked_utxo_balance(&state.utxo_set, timestamp, |now, time_lock| now > time_lock)?; + state.balance.unconfirmed.staked = checked_staked_balance(&state.stake_balance_set)?; let addresses = addresses.into_values().collect(); @@ -985,7 +1435,9 @@ where addresses, state.transaction_next_id, state.utxo_set.clone(), + state.stake_balance_set.clone(), state.stake_output_set.clone(), + state.stake_output_provenance.clone(), &state.balance.unconfirmed, block_info, )?; @@ -1019,7 +1471,9 @@ where beacon: block_info.clone(), transaction_next_id: state.transaction_next_id, utxo_set: state.utxo_set.clone(), + stake_balance_set: state.stake_balance_set.clone(), stake_output_set: state.stake_output_set.clone(), + stake_output_provenance: state.stake_output_provenance.clone(), }; state @@ -1041,13 +1495,94 @@ where for balance_movement in &block_balance_movements { state .pending_transactions - .insert(balance_movement.transaction.hash.parse().unwrap()); + .insert(balance_movement.transaction.hash.parse()?); } } + *live_state = state; + Ok(block_balance_movements) } + /// Reconcile an already-indexed wallet tip when synchronization has no new block to carry the + /// authoritative stake view (for example, immediately after upgrading an already-synced + /// wallet). + /// + /// A confirmed tip is updated in one exact-live/output-provenance/balance database batch before + /// memory is published. A pending tip updates its existing full block snapshot in memory so + /// later consolidation persists the correction together with that block's original movements + /// and UTXOs. + pub fn reconcile_stakes_at_tip( + &self, + tip: CheckpointBeacon, + authoritative_stakes: &HashMap, u64>, + ) -> Result<()> { + let mut live_state = self.state.write()?; + if live_state.last_sync != tip { + return Err(Error::StakeReconciliation(format!( + "wallet tip {:?} changed before stake snapshot {:?} could be applied", + live_state.last_sync, tip + ))); + } + + let mut state = live_state.clone(); + state.stake_output_set = reconcile_provenance_backed_stake_outputs( + &state.stake_output_set, + &state.stake_output_provenance, + authoritative_stakes, + )?; + state.stake_balance_set = authoritative_stakes.clone(); + let staked = checked_staked_balance(&state.stake_balance_set)?; + state.balance.unconfirmed.staked = staked; + + if state.last_confirmed == tip { + state.balance.confirmed.staked = staked; + let mut batch = self.db.batch(); + batch.put( + &keys::account_stake_balance_set(state.account), + state.stake_balance_set.clone(), + )?; + batch.put( + &keys::account_stake_output_set(state.account), + state.stake_output_set.clone(), + )?; + batch.put( + &keys::account_stake_output_provenance(state.account), + state.stake_output_provenance.clone(), + )?; + batch.put( + &keys::account_balance(state.account), + state.balance.confirmed, + )?; + self.db.write(batch)?; + } else { + let block_hash = tip.hash_prev_block.to_string(); + let stake_balance_set = state.stake_balance_set.clone(); + let stake_output_set = state.stake_output_set.clone(); + let stake_output_provenance = state.stake_output_provenance.clone(); + let block_state = state.pending_blocks.get_mut(&block_hash).ok_or_else(|| { + Error::StakeReconciliation(format!( + "pending wallet snapshot for exact tip {tip:?} was not found" + )) + })?; + if block_state.beacon.epoch != tip.checkpoint + || block_state.beacon.block_hash != tip.hash_prev_block + { + return Err(Error::StakeReconciliation(format!( + "pending wallet snapshot {:?} does not match exact tip {tip:?}", + block_state.beacon + ))); + } + block_state.stake_balance_set = stake_balance_set; + block_state.stake_output_set = stake_output_set; + block_state.stake_output_provenance = stake_output_provenance; + block_state.balance.staked = staked; + } + + *live_state = state; + Ok(()) + } + #[allow(clippy::too_many_arguments)] fn _persist_block_txns( &self, @@ -1055,7 +1590,9 @@ where addresses: Vec>, transaction_next_id: u32, utxo_set: model::UtxoSet, + stake_balance_set: model::StakeBalanceSet, stake_output_set: model::StakeOutputSet, + stake_output_provenance: model::StakeOutputSet, balance: &model::BalanceInfo, block_info: &model::Beacon, ) -> Result<()> { @@ -1090,7 +1627,15 @@ where // Write account state batch.put(&keys::transaction_next_id(account), transaction_next_id)?; batch.put(&keys::account_utxo_set(account), utxo_set)?; + batch.put( + &keys::account_stake_balance_set(account), + stake_balance_set, + )?; batch.put(&keys::account_stake_output_set(account), stake_output_set)?; + batch.put( + &keys::account_stake_output_provenance(account), + stake_output_provenance, + )?; batch.put(&keys::account_balance(account), balance)?; // Persist addresses @@ -1232,7 +1777,16 @@ where let mut keyed_signatures = vec![]; for input in inputs { - let key_balance = state.utxo_set.get(&input.output_pointer().into()).unwrap(); + let output_pointer = *input.output_pointer(); + let model_output_pointer: model::OutPtr = (&output_pointer).into(); + let key_balance = state + .utxo_set + .get(&model_output_pointer) + .ok_or_else(|| { + Error::Failure(anyhow::anyhow!( + "selected wallet input {output_pointer} disappeared before signing" + )) + })?; let model::Path { keychain, index, .. @@ -1240,7 +1794,11 @@ where let parent_key = state .keychains .get(keychain as usize) - .expect("could not get keychain"); + .ok_or_else(|| { + Error::Failure(anyhow::anyhow!( + "wallet input {output_pointer} references unknown keychain {keychain}" + )) + })?; let extended_sign_key = parent_key.derive(&KeyPath::default().index(index))?; @@ -1267,7 +1825,9 @@ where selected_utxos: HashSet, preview: bool, ) -> Result { - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let raw_timestamp = get_timestamp(); + let timestamp = u64::try_from(raw_timestamp) + .map_err(|_| Error::InvalidTimestamp(raw_timestamp))?; self.build_inputs_outputs_wallet( outputs, @@ -1291,7 +1851,9 @@ where preview: bool, ) -> Result { let utxo_strategy = UtxoSelectionStrategy::Random { from: None }; - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let raw_timestamp = get_timestamp(); + let timestamp = u64::try_from(raw_timestamp) + .map_err(|_| Error::InvalidTimestamp(raw_timestamp))?; self.build_inputs_outputs_wallet( vec![], @@ -1316,7 +1878,11 @@ where ) -> Result { let pkh = if let Some(input) = force_input { let forced = input.output_pointer(); - let key_balance = state.utxo_set.get(&forced.into()).unwrap(); + let key_balance = state.utxo_set.get(&forced.into()).ok_or_else(|| { + Error::Failure(anyhow::anyhow!( + "selected wallet change input {forced} disappeared before change-address calculation" + )) + })?; key_balance.pkh } else { @@ -1427,21 +1993,22 @@ where txn: &model::ExtendedTransaction, block_info: &model::Beacon, confirmed: bool, + protocol_version: ProtocolVersion, ) -> Result> { // Wallet's account mutation (utxo set changes + balance movement) let account_mutation = - match self._get_account_mutation(state, txn, block_info, confirmed)? { + match self._get_account_mutation( + state, + txn, + block_info, + confirmed, + Some(protocol_version), + )? { // If UTXO set has not changed, then there is no balance movement derived from the transaction being processed None => return Ok(None), Some(account_mutation) => account_mutation, }; - let protocol_version = self - .params - .protocol_info - .all_versions - .version_for_epoch(block_info.epoch); - // If exists, remove transaction from local pending movements let txn_hash = txn.transaction.versioned_hash(protocol_version); if let Some(local_movement) = state.local_movements.remove(&txn_hash) { @@ -1466,49 +2033,65 @@ where state.utxo_set.insert(pointer.clone(), key_balance.clone()); } - // FIXME: only one stake output per transaction is supported. It doesn't need to be a vec - // Update memory state: `stake_output_set` - for stake_output_remove in &account_mutation.stake_output_removals { - let output = state.stake_output_set.get(&stake_output_remove.key); - if let Some(output) = output { - if output.value == stake_output_remove.amount { - state.stake_output_set.remove(&output.key.clone()); - } else { - state.stake_output_set.insert( - output.key.clone(), - StakeOutput { - value: output - .value - .checked_sub(stake_output_remove.amount) - .ok_or(Error::TransactionValueOverflow)?, - authorization: output.authorization.clone(), - key: output.key.clone(), - }, + // Financial stake mutations apply to the exact amount map. Full StakeOutput records remain + // an optional projection for keys whose real transaction authorization is available. + if let Some((key, amount)) = &account_mutation.stake_balance_removal { + match state.stake_balance_set.get(key).copied() { + Some(current) if current == *amount => { + state.stake_balance_set.remove(key); + } + Some(current) => { + state.stake_balance_set.insert( + key.clone(), + current + .checked_sub(*amount) + .ok_or(Error::TransactionValueOverflow)?, + ); + } + None if state.stake_output_set.contains_key(key) => { + return Err(Error::StakeReconciliation(format!( + "provenance-backed live stake key {key:?} has no authoritative wallet balance" + ))); + } + None => { + // During historical catch-up, retained provenance can outlive a temporarily + // absent transaction-derived balance. Keep absence unchanged; the final exact- + // tip projection supplies the authoritative amount without inventing a delta. + log::debug!( + "Unstake for provenance-only key {key:?} has no local live balance to remove" ); } } } - for stake_output_insert in &account_mutation.stake_output_inserts { - let output = state.stake_output_set.get(&stake_output_insert.key); - - let amount = if let Some(output) = output { - output - .value - .checked_sub(stake_output_insert.amount) - .ok_or(Error::TransactionValueOverflow)? - } else { - stake_output_insert.amount - }; - state.stake_output_set.insert( + if let Some(stake_output_insert) = &account_mutation.stake_output_insert { + let amount = state + .stake_balance_set + .get(&stake_output_insert.key) + .copied() + .unwrap_or_default() + .checked_add(stake_output_insert.amount) + .ok_or(Error::TransactionValueOverflow)?; + state + .stake_balance_set + .insert(stake_output_insert.key.clone(), amount); + // Retain only real transaction bytes. The live amount is projected separately and may + // include prior amount-only balance discovered from the node tip. + state.stake_output_provenance.insert( stake_output_insert.key.clone(), StakeOutput { - value: amount, + value: stake_output_insert.amount, authorization: stake_output_insert.authorization.clone(), + authorization_terms: stake_output_insert.authorization_terms.clone(), key: stake_output_insert.key.clone(), }, ); } + state.stake_output_set = reconcile_provenance_backed_stake_outputs( + &state.stake_output_set, + &state.stake_output_provenance, + &state.stake_balance_set, + )?; // Update `transaction_next_id` state.transaction_next_id = state @@ -1543,14 +2126,17 @@ where let mut received_payments = info.received_payments.clone(); received_payments.push(output_pointer.to_string()); let current_timestamp = - convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch); + convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch)?; let first_payment_date = Some(info.first_payment_date.unwrap_or(current_timestamp)); let updated_address = model::Address { info: model::AddressInfo { label: info.label.clone(), received_payments, - received_amount: info.received_amount + key_balance.amount, + received_amount: info + .received_amount + .checked_add(key_balance.amount) + .ok_or(Error::TransactionBalanceOverflow)?, first_payment_date, last_payment_date: Some(current_timestamp), }, @@ -1596,7 +2182,11 @@ where // Mark UTXOs as used so we don't double spend // Save the timestamp to after which the UTXO can be spent again let tx_pending_timeout = self.params.pending_transactions_timeout_seconds; - let timestamp = u64::try_from(get_timestamp()).unwrap(); + let timestamp = u64::try_from(get_timestamp()) + .map_err(|_| TransactionError::PendingTimeoutOverflow)?; + let pending_until = timestamp + .checked_add(tx_pending_timeout) + .ok_or(TransactionError::PendingTimeoutOverflow)?; let inputs = match &txn.transaction { Transaction::ValueTransfer(tx) => Some(&tx.body.inputs), @@ -1623,16 +2213,25 @@ where }; if let Some(inputs) = inputs { wallet_utxos - .set_used_output_pointer(inputs.iter().cloned(), timestamp + tx_pending_timeout); + .set_used_output_pointer(inputs.iter().cloned(), pending_until); } + // A pending transaction has no containing beacon, so its admission/hash protocol must be + // captured once for the whole operation. Re-reading `current_version` after mutation + // construction could otherwise classify a Wit/2.1 action under one schedule entry and + // persist its movement under a different version if activation is published concurrently. + let protocol_version = self.params.protocol_info.current_version; if let Some(mut account_mutation) = - self._get_account_mutation(state, txn, &model::Beacon::default(), false)? + self._get_account_mutation( + state, + txn, + &model::Beacon::default(), + false, + Some(protocol_version), + )? { account_mutation.balance_movement.transaction.timestamp = - u64::try_from(get_timestamp()) - .expect("Get timestamp should return a positive value"); - let protocol_version = self.params.protocol_info.current_version; + timestamp; let txn_hash = txn.transaction.versioned_hash(protocol_version); state .local_movements @@ -1669,7 +2268,19 @@ where Transaction::Commit(commit) => commit.body.outputs, Transaction::Tally(tally) => tally.outputs, Transaction::Mint(mint) => mint.outputs, - Transaction::Stake(stake) => stake.body.change.into_iter().collect(), + Transaction::Stake(stake) => { + let mut outputs: Vec<_> = stake.body.change.into_iter().collect(); + // A delegation can be funded by an unrelated payer, so its withdrawer may be + // the only wallet-owned address present in the transaction. Treat that + // identity as an observed output for synchronization lookahead purposes; the + // zero timelock/value metadata is not indexed as a UTXO. + outputs.push(ValueTransferOutput { + pkh: stake.body.output.key.withdrawer, + value: 0, + time_lock: 0, + }); + outputs + } Transaction::Unstake(unstake) => Vec::from([unstake.body.withdrawal]), _ => vec![], }) @@ -1745,15 +2356,26 @@ where txn: &model::ExtendedTransaction, block_info: &model::Beacon, confirmed: bool, + protocol_override: Option, ) -> Result> { // Inputs and outputs from different transaction types let (inputs, outputs, stake_output) = extract_inputs_and_outputs(&txn.transaction)?; + // Confirmed history is bound to its containing block's epoch. Pending wallet movements + // have no block beacon, but their transaction hash and admission context already use the + // currently enforced protocol; do not reinterpret a Wit/2.1 action-1 envelope as a + // legacy stake merely because the compatibility placeholder beacon is epoch zero. + let protocol_version = protocol_override.unwrap_or_else(|| { + self.params + .protocol_info + .all_versions + .version_for_epoch(block_info.epoch) + }); let mut utxo_removals: Vec = vec![]; let mut utxo_inserts: Vec<(model::OutPtr, model::OutputInfo)> = vec![]; let mut resolved_inputs: Vec = vec![]; - let mut stake_output_inserts: Vec = vec![]; - let mut stake_output_removals: Vec = vec![]; + let mut stake_output_insert = None; + let mut stake_balance_removal = None; let mut input_amount: u64 = 0; for input in inputs.iter() { @@ -1797,19 +2419,18 @@ where } } } - if own_outputs.contains_key(&output.pkh) { - let protocol_version = self - .params - .protocol_info - .all_versions - .version_for_epoch(block_info.epoch); + if own_outputs.contains_key(&output.pkh) && output.value > 0 { let out_ptr = model::OutPtr { txn_hash: txn .transaction .versioned_hash(protocol_version) .as_ref() .to_vec(), - output_index: u32::try_from(index).unwrap(), + output_index: u32::try_from(index).map_err(|_| { + Error::Failure(anyhow::anyhow!( + "wallet transaction output index {index} does not fit in u32" + )) + })?, }; let output_info = model::OutputInfo { amount: output.value, @@ -1835,30 +2456,53 @@ where } if let Some(stake_output) = stake_output { - let stake_output_info = model::StakeOutputInfo { - authorization: stake_output.authorization, - key: stake_output.key, - amount: stake_output.value, - }; - stake_output_inserts.push(stake_output_info); + let key = &stake_output.key; + let owns_withdrawer = state.stake_balance_set.contains_key(key) + || state.stake_output_set.contains_key(key) + || state.stake_output_provenance.contains_key(key) + || self.db.get_opt(&keys::pkh(&key.withdrawer))?.is_some() + || state + .transient_external_addresses + .contains_key(&key.withdrawer) + || state + .transient_internal_addresses + .contains_key(&key.withdrawer); + if owns_withdrawer && stake_output_adds_balance(&stake_output, protocol_version) { + let stake_output_info = model::StakeOutputInfo { + authorization: stake_output.authorization, + authorization_terms: stake_output.authorization_terms, + key: stake_output.key, + amount: stake_output.value, + }; + stake_output_insert = Some(stake_output_info); + } } if let Transaction::Unstake(unstake) = &txn.transaction { - let stake_output_info = model::StakeOutputInfo { - // FIXME assess if we can simplify stakeOutputInfo removing the authorization - // field or we need to keep it for future use during staking implementation - authorization: KeyedSignature::default(), - key: StakeKey { - withdrawer: unstake.body.withdrawal.pkh, - validator: unstake.body.operator, - }, - amount: unstake.body.withdrawal.value, + let key = StakeKey { + withdrawer: unstake.body.withdrawal.pkh, + validator: unstake.body.operator, }; - stake_output_removals.push(stake_output_info); + if state.stake_balance_set.contains_key(&key) + || state.stake_output_set.contains_key(&key) + || state.stake_output_provenance.contains_key(&key) + { + let amount = unstake + .body + .withdrawal + .value + .checked_add(unstake.body.fee) + .ok_or(Error::TransactionValueOverflow)?; + stake_balance_removal = Some((key, amount)); + } } // If UTXO set has not changed, then there is no balance movement derived from the transaction being processed - if utxo_inserts.is_empty() && utxo_removals.is_empty() { + if utxo_inserts.is_empty() + && utxo_removals.is_empty() + && stake_output_insert.is_none() + && stake_balance_removal.is_none() + { return Ok(None); } @@ -1868,16 +2512,14 @@ where (input_amount - output_amount, model::MovementType::Negative) }; - let protocol_version = self - .params - .protocol_info - .all_versions - .version_for_epoch(block_info.epoch); - - // Build the balance movement, first computing the miner fee + // Build the balance movement, first computing the miner fee. Metadata is node-supplied, + // so aggregate it with checked arithmetic before it can influence wallet state. let miner_fee: u64 = match &txn.metadata { Some(model::TransactionMetadata::InputValues(input_values)) => { - let total_input_amount = input_values.iter().fold(0, |acc, x| acc + x.value); + let total_input_amount = input_values + .iter() + .try_fold(0_u64, |acc, x| acc.checked_add(x.value)) + .ok_or(Error::TransactionBalanceOverflow)?; match txn.transaction.clone() { Transaction::Stake(st) => total_input_amount @@ -1886,7 +2528,10 @@ where // Genesis block (no inputs) or empty block with only `MintTransaction` _ if total_input_amount == 0 => 0, _ => { - let total_output_amount = outputs.iter().fold(0, |acc, x| acc + x.value); + let total_output_amount = outputs + .iter() + .try_fold(0_u64, |acc, x| acc.checked_add(x.value)) + .ok_or(Error::TransactionBalanceOverflow)?; total_input_amount .checked_sub(total_output_amount) @@ -1923,18 +2568,18 @@ where kind, amount, block_info, - convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch), + convert_block_epoch_to_timestamp(state.epoch_constants, block_info.epoch)?, confirmed, own_outputs, - &self.params.protocol_info, + protocol_version, )?; Ok(Some(AccountMutation { balance_movement, utxo_inserts, utxo_removals, - stake_output_inserts, - stake_output_removals, + stake_output_insert, + stake_balance_removal, })) } @@ -1957,7 +2602,9 @@ where /// Get previously created Transaction by its hash. pub fn get_db_transaction(&self, hex_hash: &str) -> Result> { - let txn = self.db.get_opt(&keys::transaction(hex_hash))?; + let txn = self + .db + .get_opt_with(&keys::transaction(hex_hash), normalize_transaction_bytes)?; Ok(txn) } @@ -2037,11 +2684,12 @@ where pub fn try_consolidate_block(&self, block_hash: &str) -> Result<()> { let mut state = self.state.write()?; - // Retrieve and remove pending changes of the block - let block_state = state.pending_blocks.remove(block_hash).ok_or_else(|| { + // Borrow/clone pending changes while composing the database transaction. They remain + // available for a retry if serialization or the atomic storage write fails. + let block_state = state.pending_blocks.get(block_hash).cloned().ok_or_else(|| { Error::BlockConsolidation(format!("beacon not found for pending block {block_hash}")) })?; - let mut movements = state.pending_movements.remove(block_hash).ok_or_else(|| { + let mut movements = state.pending_movements.get(block_hash).cloned().ok_or_else(|| { Error::BlockConsolidation(format!( "balance movements not found for pending block {block_hash}" )) @@ -2049,7 +2697,8 @@ where movements.extend( state .db_movements_to_update - .remove(block_hash) + .get(block_hash) + .cloned() .ok_or_else(|| { Error::BlockConsolidation(format!( "balance movements not found for pending block {block_hash}" @@ -2057,9 +2706,18 @@ where })?, ); + // Parse every transaction identifier before the durable write. A malformed pending + // movement must not make the database commit succeed and then panic while memory still + // advertises the block as retryable. + let pending_transaction_hashes: Vec = movements + .iter() + .map(|movement| movement.transaction.hash.parse()) + .collect::>()?; + let addresses = state .pending_addresses_by_block - .remove(block_hash) + .get(block_hash) + .cloned() .ok_or_else(|| { Error::BlockConsolidation(format!( "address infos not found for pending block {block_hash}" @@ -2072,19 +2730,30 @@ where addresses, block_state.transaction_next_id, block_state.utxo_set.clone(), + block_state.stake_balance_set.clone(), block_state.stake_output_set.clone(), + block_state.stake_output_provenance.clone(), &block_state.balance, &block_state.beacon, )?; + // The batch is durable, so the retry metadata can now be removed together. + state.pending_blocks.remove(block_hash); + state.pending_movements.remove(block_hash); + state.db_movements_to_update.remove(block_hash); + state.pending_addresses_by_block.remove(block_hash); + // Update pending DR movements if they were persisted // balance_movements_to_persist. - movements.iter().for_each(|x| { - state.pending_dr_movements.remove(&x.transaction.hash); - state - .pending_transactions - .remove(&x.transaction.hash.parse().unwrap()); - }); + movements + .iter() + .zip(pending_transaction_hashes) + .for_each(|(movement, transaction_hash)| { + state + .pending_dr_movements + .remove(&movement.transaction.hash); + state.pending_transactions.remove(&transaction_hash); + }); // If everything was OK, update `last_confirmed` beacon state.last_confirmed = CheckpointBeacon { @@ -2102,35 +2771,57 @@ where Ok(()) } - /// Clear all chain data for a wallet in memory and resets synchronization status in database. + /// Clear all chain data for a wallet in memory and in the persistent account snapshot. /// - /// Proceed with caution, as this wipes the following data entirely on memory: + /// Proceed with caution, as this wipes the following data entirely in memory: /// - Synchronization status /// - Balances /// - Movements /// - Addresses and their metadata /// - /// Resets synchronization status in database: + /// Resets the persistent account snapshot atomically: /// - Last synchronization status set to the genesis block /// - Transaction index set to zero /// - External and internal address indices set to zero + /// - Balance, UTXO, live-stake, and stake-output provenance sets emptied pub fn clear_chain_data(&self) -> Result<()> { let mut state = self.state.write()?; - state.clear_chain_data(); - + let account = state.account; + let birth_date = state.birth_date; + let empty_balance = model::BalanceInfo::default(); + let empty_utxo_set = model::UtxoSet::default(); + let empty_stake_balance_set = model::StakeBalanceSet::default(); + let empty_stake_output_set = model::StakeOutputSet::default(); let mut batch = self.db.batch(); - batch.put(&keys::wallet_last_sync(), state.birth_date)?; - batch.put(&keys::transaction_next_id(0), 0)?; + batch.put(&keys::wallet_last_sync(), birth_date)?; + batch.put(&keys::transaction_next_id(account), 0)?; batch.put( - &keys::account_next_index(0, constants::EXTERNAL_KEYCHAIN), + &keys::account_next_index(account, constants::EXTERNAL_KEYCHAIN), 0, )?; batch.put( - &keys::account_next_index(0, constants::INTERNAL_KEYCHAIN), + &keys::account_next_index(account, constants::INTERNAL_KEYCHAIN), 0, )?; + batch.put(&keys::account_balance(account), empty_balance)?; + batch.put(&keys::account_utxo_set(account), &empty_utxo_set)?; + batch.put( + &keys::account_stake_balance_set(account), + &empty_stake_balance_set, + )?; + batch.put( + &keys::account_stake_output_set(account), + &empty_stake_output_set, + )?; + batch.put( + &keys::account_stake_output_provenance(account), + &empty_stake_output_set, + )?; self.db.write(batch)?; + // Publish the matching in-memory reset only after the atomic database write succeeds. + state.clear_chain_data(); + Ok(()) } @@ -2182,14 +2873,16 @@ where } } -fn convert_block_epoch_to_timestamp(epoch_constants: EpochConstants, epoch: Epoch) -> u64 { - // In case of error, return timestamp 0 - match epoch_constants.epoch_timestamp(epoch) { - Ok((timestamp, _)) => { - u64::try_from(timestamp).expect("Epoch timestamp should return a positive value") - } - Err(_) => 0, - } +fn convert_block_epoch_to_timestamp( + epoch_constants: EpochConstants, + epoch: Epoch, +) -> Result { + let (timestamp, _) = epoch_constants.epoch_timestamp(epoch).map_err(|error| { + Error::BlockConsolidation(format!( + "cannot derive the timestamp for wallet block epoch {epoch}: {error}" + )) + })?; + u64::try_from(timestamp).map_err(|_| Error::InvalidTimestamp(timestamp)) } // Extract inputs and output from a transaction @@ -2235,7 +2928,7 @@ fn build_balance_movement( timestamp: u64, confirmed: bool, own_outputs: HashMap, - protocol_info: &ProtocolInfo, + protocol_version: ProtocolVersion, ) -> Result { // Input values with their ValueTransferOutput data let transaction_inputs = match &txn.metadata { @@ -2291,10 +2984,6 @@ fn build_balance_movement( ))); } }; - let protocol_version = protocol_info - .all_versions - .version_for_epoch(block_info.epoch); - Ok(model::BalanceMovement { db_key: identifier, kind, diff --git a/wallet/src/repository/wallet/state.rs b/wallet/src/repository/wallet/state.rs index 732b936642..92d343566a 100644 --- a/wallet/src/repository/wallet/state.rs +++ b/wallet/src/repository/wallet/state.rs @@ -14,8 +14,13 @@ pub struct StateSnapshot { pub transaction_next_id: u32, /// Current UTXO set (including pending movements) pub utxo_set: model::UtxoSet, - /// Current stake output set (including pending movements) + /// Provenance-backed live stake-output compatibility view (including pending movements) pub stake_output_set: model::StakeOutputSet, + /// Exact live stake amounts (including pending movements). This remains complete even when a + /// restored wallet has no original Stake transaction authorization for an owned entry. + pub stake_balance_set: model::StakeBalanceSet, + /// Authorization provenance retained for live and temporarily absent stake keys. + pub stake_output_provenance: model::StakeOutputSet, } /// A single wallet state. It includes: @@ -25,7 +30,7 @@ pub struct StateSnapshot { /// TODO: refactor all synchronization-related fields (e.g. transient addresses) from `State` into /// `SynchronizationState` structure so that the number of fields in `State` does not keep /// growing. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct State { /// Current account index pub account: u32, @@ -75,8 +80,16 @@ pub struct State { pub transaction_next_id: u32, /// Current UTXO set (including pending movements) pub utxo_set: model::UtxoSet, - /// Current StakeOutput set (including pending movements). Used to track the staking balances. + /// Live StakeOutput records for entries whose original authorization provenance is available. + /// This is a compatibility/inspection projection; `stake_balance_set` owns financial state. pub stake_output_set: model::StakeOutputSet, + /// Exact current stake amount for every owned live entry, including entries discovered from an + /// authoritative tip without their historical Stake transaction bytes. + pub stake_balance_set: model::StakeBalanceSet, + /// Original stake transaction metadata by key, retained independently from live balances. + /// Consensus collateral reservations can temporarily remove a zeroed entry and recreate it on + /// refund without another stake transaction carrying these bytes. + pub stake_output_provenance: model::StakeOutputSet, /// Outputs that have been locally tagged as used. This prevents double spending /// an output while a first spending transaction is pending. pub used_outputs: model::UsedOutputs, @@ -113,6 +126,9 @@ impl State { self.pending_movements.clear(); self.pending_transactions.clear(); self.transaction_next_id = Default::default(); + self.stake_balance_set.clear(); + self.stake_output_set.clear(); + self.stake_output_provenance.clear(); self.utxo_set.clear(); self.used_outputs.clear(); self.transient_internal_addresses.clear(); diff --git a/wallet/src/repository/wallet/tests/mod.rs b/wallet/src/repository/wallet/tests/mod.rs index b859fce953..0a66d084d9 100644 --- a/wallet/src/repository/wallet/tests/mod.rs +++ b/wallet/src/repository/wallet/tests/mod.rs @@ -1,15 +1,873 @@ -use std::{collections::HashMap, iter::FromIterator as _, mem}; +use std::{collections::HashMap, iter::FromIterator as _, mem, sync::Arc}; use witnet_data_structures::{ - chain::Hashable, transaction::VTTransaction, transaction_factory::calculate_weight, + chain::{ + Hashable, STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE, + STAKE_AUTHORIZATION_ACTION_DELEGATE, STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER, + STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT, StakeAuthorization, StakeOutput, + }, + proto::versioning::ProtocolVersion, + transaction::{ + StakeTransaction, StakeTransactionBody, UnstakeTransaction, UnstakeTransactionBody, + VTTransaction, + }, + transaction_factory::calculate_weight, }; -use crate::{db::HashMapDb, repository::wallet::tests::factories::vtt_from_body, *}; +use crate::{ + db::{HashMapDb, PlainDb}, + repository::wallet::tests::factories::vtt_from_body, + *, +}; use super::*; mod factories; +#[test] +fn authoritative_stakes_replace_values_and_remove_absent_entries() { + let retained_key = StakeKey::from((factories::pkh(), factories::pkh())); + let removed_key = StakeKey::from((factories::pkh(), factories::pkh())); + let retained = StakeOutput { + key: retained_key.clone(), + value: 10, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + retained_key.validator, + retained_key.withdrawer, + Epoch::MAX, + 0, + )), + ..Default::default() + }; + let retained_authorization = retained.authorization.clone(); + let mut current = model::StakeOutputSet::from([ + (retained_key.clone(), retained), + ( + removed_key.clone(), + StakeOutput { + key: removed_key, + value: 20, + ..Default::default() + }, + ), + ]); + let authoritative = HashMap::from([(retained_key.clone(), 77)]); + + current = reconcile_provenance_backed_stake_outputs(¤t, ¤t, &authoritative) + .unwrap(); + + assert_eq!(current.len(), 1); + assert_eq!(current[&retained_key].value, 77); + assert_eq!(current[&retained_key].authorization, retained_authorization); + assert!(current[&retained_key].authorization_terms.is_some()); +} + +#[test] +fn authoritative_unknown_stake_key_is_amount_only_without_fabricated_provenance() { + let unknown_key = StakeKey::from((factories::pkh(), factories::pkh())); + let authoritative = model::StakeBalanceSet::from([(unknown_key.clone(), 1)]); + let outputs = reconcile_provenance_backed_stake_outputs( + &model::StakeOutputSet::default(), + &model::StakeOutputSet::default(), + &authoritative, + ) + .unwrap(); + + assert!(outputs.is_empty()); + assert_eq!(authoritative[&unknown_key], 1); + assert_eq!(checked_staked_balance(&authoritative).unwrap(), 1); +} + +#[test] +fn utxo_balance_overflow_is_a_structured_error() { + let first = model::OutPtr { + txn_hash: vec![1; 32], + output_index: 0, + }; + let second = model::OutPtr { + txn_hash: vec![2; 32], + output_index: 0, + }; + let utxos = model::UtxoSet::from([ + ( + first, + model::OutputInfo { + amount: u64::MAX, + pkh: factories::pkh(), + time_lock: 0, + }, + ), + ( + second, + model::OutputInfo { + amount: 1, + pkh: factories::pkh(), + time_lock: 0, + }, + ), + ]); + + let error = checked_utxo_balance(&utxos, 1, |now, time_lock| now >= time_lock) + .unwrap_err(); + + assert!(matches!(error, repository::Error::TransactionBalanceOverflow)); +} + +#[test] +fn extended_transaction_metadata_sum_overflow_is_a_structured_error() { + let (wallet, _) = factories::wallet(None); + let address = wallet.gen_external_address(None).unwrap(); + let block = factories::BlockInfo::default().create(); + let transaction = factories::vtt_from_body(VTTransactionBody::new( + vec![], + vec![ValueTransferOutput { + pkh: address.pkh, + value: 1, + time_lock: 0, + }], + )); + let metadata = model::TransactionMetadata::InputValues(vec![ + ValueTransferOutput { + pkh: address.pkh, + value: u64::MAX, + time_lock: 0, + }, + ValueTransferOutput { + pkh: address.pkh, + value: 1, + time_lock: 0, + }, + ]); + let transaction = model::ExtendedTransaction { + transaction: transaction.transaction, + metadata: Some(metadata), + }; + + let error = wallet + .index_block_transactions(&block, &[transaction], true) + .unwrap_err(); + + assert!(matches!(error, repository::Error::TransactionBalanceOverflow)); +} + +#[test] +fn wallet_block_timestamp_failures_are_structured_errors() { + let before_epoch = EpochConstants { + checkpoint_zero_timestamp: -1, + ..EpochConstants::default() + }; + assert!(matches!( + convert_block_epoch_to_timestamp(before_epoch, 0), + Err(repository::Error::InvalidTimestamp(-1)) + )); + + let overflowing = EpochConstants { + checkpoint_zero_timestamp: i64::MAX, + ..EpochConstants::default() + }; + assert!(matches!( + convert_block_epoch_to_timestamp(overflowing, 1), + Err(repository::Error::BlockConsolidation(_)) + )); +} + +#[test] +fn prebirth_live_stake_persists_exact_balance_without_fabricating_history() { + let (wallet, db) = factories::wallet(None); + let withdrawer = wallet.gen_external_address(None).unwrap().pkh; + let key = StakeKey::from((factories::pkh(), withdrawer)); + let authoritative = model::StakeBalanceSet::from([(key.clone(), 42)]); + + let movements = wallet + .index_block_transactions_with_stake_reconciliation( + &factories::BlockInfo::default().create(), + &[], + true, + Some(&authoritative), + ) + .unwrap(); + + assert!(movements.is_empty()); + let state = wallet.state.read().unwrap(); + assert_eq!(state.stake_balance_set[&key], 42); + assert!(!state.stake_output_set.contains_key(&key)); + assert!(!state.stake_output_provenance.contains_key(&key)); + assert_eq!(state.balance.confirmed.staked, 42); + drop(state); + + let persisted: model::StakeBalanceSet = db + .get(&keys::account_stake_balance_set(0)) + .unwrap(); + let persisted_outputs: model::StakeOutputSet = db + .get(&keys::account_stake_output_set(0)) + .unwrap(); + let persisted_provenance: model::StakeOutputSet = db + .get(&keys::account_stake_output_provenance(0)) + .unwrap(); + assert_eq!(persisted[&key], 42); + assert!(persisted_outputs.is_empty()); + assert!(persisted_provenance.is_empty()); +} + +#[test] +fn pending_prebirth_balance_is_carried_into_atomic_consolidation() { + let (wallet, db) = factories::wallet(None); + let withdrawer = wallet.gen_external_address(None).unwrap().pkh; + let key = StakeKey::from((factories::pkh(), withdrawer)); + let block = factories::BlockInfo::default().create(); + + wallet + .index_block_transactions_with_stake_reconciliation( + &block, + &[], + false, + Some(&model::StakeBalanceSet::from([(key.clone(), 19)])), + ) + .unwrap(); + wallet + .try_consolidate_block(&block.block_hash.to_string()) + .unwrap(); + + let persisted: model::StakeBalanceSet = db + .get(&keys::account_stake_balance_set(0)) + .unwrap(); + let persisted_balance: model::BalanceInfo = db.get(&keys::account_balance(0)).unwrap(); + assert_eq!(persisted[&key], 19); + assert_eq!(persisted_balance.staked, 19); + assert!(!wallet.state.read().unwrap().pending_blocks.contains_key( + &block.block_hash.to_string() + )); +} + +#[test] +fn legacy_full_stake_outputs_seed_the_new_balance_map_on_unlock() { + let (_wallet, db) = factories::wallet(None); + let key = StakeKey::from((factories::pkh(), factories::pkh())); + let output = StakeOutput { + key: key.clone(), + value: 33, + ..Default::default() + }; + db.put( + &keys::account_stake_output_set(0), + model::StakeOutputSet::from([(key.clone(), output.clone())]), + ) + .unwrap(); + db.put( + &keys::account_balance(0), + model::BalanceInfo { + staked: 33, + ..Default::default() + }, + ) + .unwrap(); + + let (restored, _) = factories::wallet(Some(db)); + let state = restored.state.read().unwrap(); + assert_eq!(state.stake_balance_set[&key], 33); + assert_eq!(state.stake_output_set[&key], output); + assert_eq!(state.stake_output_provenance[&key], output); + assert_eq!(state.balance.confirmed.staked, 33); +} + +#[test] +fn archived_release_stake_output_bytes_load_through_current_wallet() { + let validator = witnet_data_structures::chain::PublicKeyHash::default(); + let withdrawer = witnet_data_structures::chain::PublicKeyHash::from_bytes(&[7; 20]).unwrap(); + let key = StakeKey::from((validator, withdrawer)); + let historical_bytes = hex::decode( + include_str!("fixtures/historical_stake_output_set_from_release_33c42f0eae4438005b7ff22ceb993d19878ede15.hex") + .trim(), + ) + .unwrap(); + let db = HashMapDb::from_iter([( + keys::account_stake_output_set(0).as_ref().to_vec(), + historical_bytes, + )]); + + let (wallet, _) = factories::wallet(Some(db)); + let state = wallet.state.read().unwrap(); + assert_eq!(state.stake_balance_set[&key], 33); + assert_eq!(state.stake_output_set[&key].value, 33); + assert_eq!(state.stake_output_set[&key].authorization_terms, None); + assert_eq!(state.stake_output_provenance[&key].value, 33); +} + +#[test] +fn archived_release_stake_output_bytes_load_through_production_rocksdb_reader() { + let historical_bytes = hex::decode( + include_str!("fixtures/historical_stake_output_set_from_release_33c42f0eae4438005b7ff22ceb993d19878ede15.hex") + .trim(), + ) + .unwrap(); + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "witnet-historical-wallet-rocksdb-{}-{suffix}", + std::process::id() + )); + let mut options = rocksdb::Options::default(); + options.create_if_missing(true); + let raw_db = Arc::new(rocksdb::DB::open(&options, &path).unwrap()); + raw_db + .put( + keys::account_stake_output_set(0).as_ref(), + historical_bytes, + ) + .unwrap(); + raw_db.flush().unwrap(); + + let db = PlainDb::new(raw_db.clone()); + let outputs: model::StakeOutputSet = db + .get_opt_with( + &keys::account_stake_output_set(0), + normalize_stake_output_set_bytes, + ) + .unwrap() + .expect("archived wallet value must be present in RocksDB"); + let key = outputs.keys().next().unwrap(); + assert_eq!(outputs[key].value, 33); + assert_eq!(outputs[key].authorization_terms, None); + + drop(db); + drop(raw_db); + let _ = std::fs::remove_dir_all(path); +} + +#[test] +fn authoritative_refund_can_restore_a_temporarily_absent_key_from_provenance() { + let key = StakeKey::from((factories::pkh(), factories::pkh())); + let provenance = model::StakeOutputSet::from([( + key.clone(), + StakeOutput { + key: key.clone(), + value: 10, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + key.validator, + key.withdrawer, + Epoch::MAX, + 0, + )), + ..Default::default() + }, + )]); + + let restored = reconcile_provenance_backed_stake_outputs( + &model::StakeOutputSet::default(), + &provenance, + &HashMap::from([(key.clone(), 7)]), + ) + .unwrap(); + + assert_eq!(restored[&key].value, 7); + assert!(restored[&key].authorization_terms.is_some()); +} + +#[test] +fn confirmed_reconciliation_persists_live_amount_and_provenance_atomically() { + let (wallet, db) = factories::wallet(None); + let key = StakeKey::from((factories::pkh(), factories::pkh())); + let output = StakeOutput { + key: key.clone(), + value: 10, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + key.validator, + key.withdrawer, + Epoch::MAX, + 0, + )), + ..Default::default() + }; + { + let mut state = wallet.state.write().unwrap(); + state.stake_balance_set.insert(key.clone(), output.value); + state.stake_output_set.insert(key.clone(), output.clone()); + state + .stake_output_provenance + .insert(key.clone(), output.clone()); + } + + wallet + .index_block_transactions_with_stake_reconciliation( + &factories::BlockInfo::default().create(), + &[], + true, + Some(&HashMap::from([(key.clone(), 25)])), + ) + .unwrap(); + + let live: model::StakeOutputSet = db + .get(&keys::account_stake_output_set(0)) + .unwrap(); + let live_balances: model::StakeBalanceSet = db + .get(&keys::account_stake_balance_set(0)) + .unwrap(); + let provenance: model::StakeOutputSet = db + .get(&keys::account_stake_output_provenance(0)) + .unwrap(); + let balance: model::BalanceInfo = db.get(&keys::account_balance(0)).unwrap(); + assert_eq!(live[&key].value, 25); + assert_eq!(live_balances[&key], 25); + assert_eq!(provenance[&key], output); + assert_eq!(balance.staked, 25); +} + +#[test] +fn authoritative_absence_removes_live_balance_but_retains_real_provenance() { + let (wallet, db) = factories::wallet(None); + let key = StakeKey::from((factories::pkh(), factories::pkh())); + let output = StakeOutput { + key: key.clone(), + value: 10, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + key.validator, + key.withdrawer, + Epoch::MAX, + 0, + )), + ..Default::default() + }; + { + let mut state = wallet.state.write().unwrap(); + state.stake_balance_set.insert(key.clone(), output.value); + state.stake_output_set.insert(key.clone(), output.clone()); + state + .stake_output_provenance + .insert(key.clone(), output.clone()); + } + + wallet + .index_block_transactions_with_stake_reconciliation( + &factories::BlockInfo::default().create(), + &[], + true, + Some(&model::StakeBalanceSet::default()), + ) + .unwrap(); + + let state = wallet.state.read().unwrap(); + assert!(!state.stake_balance_set.contains_key(&key)); + assert!(!state.stake_output_set.contains_key(&key)); + assert_eq!(state.stake_output_provenance[&key], output); + assert_eq!(state.balance.confirmed.staked, 0); + drop(state); + + let persisted_balances: model::StakeBalanceSet = db + .get(&keys::account_stake_balance_set(0)) + .unwrap(); + let persisted_provenance: model::StakeOutputSet = db + .get(&keys::account_stake_output_provenance(0)) + .unwrap(); + assert!(persisted_balances.is_empty()); + assert_eq!(persisted_provenance[&key], output); +} + +#[test] +fn overflowing_authoritative_reconciliation_rolls_back_the_whole_block() { + let (wallet, db) = factories::wallet(None); + let address = wallet.gen_external_address(None).unwrap(); + let transaction = vtt_from_body(VTTransactionBody::new( + vec![Input::default()], + vec![ValueTransferOutput { + pkh: address.pkh, + value: 9, + time_lock: 0, + }], + )); + let first_key = StakeKey::from((factories::pkh(), address.pkh)); + let mut second_key = StakeKey::from((factories::pkh(), address.pkh)); + while second_key == first_key { + second_key = StakeKey::from((factories::pkh(), address.pkh)); + } + let authoritative = HashMap::from([(first_key, u64::MAX), (second_key, 1)]); + let block = factories::BlockInfo::default().create(); + + let error = wallet + .index_block_transactions_with_stake_reconciliation( + &block, + &[transaction], + true, + Some(&authoritative), + ) + .unwrap_err(); + + assert!(matches!(error, repository::Error::TransactionBalanceOverflow)); + assert_eq!(wallet.state.read().unwrap().transaction_next_id, 0); + assert!(wallet.state.read().unwrap().utxo_set.is_empty()); + assert_eq!(wallet.balance().unwrap(), model::WalletBalance::default()); + assert_eq!( + db.get_or_default(&keys::transaction_next_id(0)).unwrap(), + 0 + ); + let persisted_utxos: model::UtxoSet = + db.get_or_default(&keys::account_utxo_set(0)).unwrap(); + assert!(persisted_utxos.is_empty()); + let persisted_stakes: model::StakeBalanceSet = db + .get_or_default(&keys::account_stake_balance_set(0)) + .unwrap(); + assert!(persisted_stakes.is_empty()); +} + +#[test] +fn owned_public_key_hashes_cover_generated_and_known_stake_withdrawers() { + let (wallet, _) = factories::wallet(None); + let generated = wallet.gen_external_address(None).unwrap().pkh; + let stake_withdrawer = factories::pkh(); + let stake_key = StakeKey::from((factories::pkh(), stake_withdrawer)); + let amount_only_withdrawer = factories::pkh(); + let amount_only_key = StakeKey::from((factories::pkh(), amount_only_withdrawer)); + wallet + .state + .write() + .unwrap() + .stake_balance_set + .insert(amount_only_key, 1); + wallet + .state + .write() + .unwrap() + .stake_output_provenance + .insert( + stake_key.clone(), + StakeOutput { + key: stake_key, + value: 1, + ..Default::default() + }, + ); + + let owned = wallet.owned_public_key_hashes().unwrap(); + + assert!(owned.contains(&generated)); + assert!(owned.contains(&stake_withdrawer)); + assert!(owned.contains(&amount_only_withdrawer)); +} + +#[test] +fn provenance_only_withdrawer_is_reused_by_stake_filter_and_mutation() { + let (wallet, _) = factories::wallet(None); + let key = StakeKey::from((factories::pkh(), factories::pkh())); + wallet + .state + .write() + .unwrap() + .stake_output_provenance + .insert( + key.clone(), + StakeOutput { + key: key.clone(), + value: 10, + ..Default::default() + }, + ); + let transaction = Transaction::Stake(StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: factories::transaction_id(), + output_index: 0, + })], + StakeOutput { + key: key.clone(), + value: 5, + authorization_terms: Some(StakeAuthorization::delegation( + 0, + key.validator, + key.withdrawer, + Epoch::MAX, + 0, + )), + ..Default::default() + }, + None, + ), + vec![], + )); + + assert_eq!( + wallet + .filter_wallet_transactions(std::iter::once(transaction.clone())) + .unwrap(), + vec![transaction.clone()] + ); + wallet + .index_block_transactions( + &factories::BlockInfo::default().create(), + &[model::ExtendedTransaction { + transaction, + metadata: None, + }], + false, + ) + .unwrap(); + + let state = wallet.state.read().unwrap(); + assert_eq!(state.stake_balance_set[&key], 5); + assert_eq!(state.stake_output_set[&key].value, 5); +} + +#[test] +fn provenance_only_withdrawer_keeps_same_block_refund_unstake_visible() { + let (wallet, _) = factories::wallet(None); + let withdrawer = wallet.gen_external_address(None).unwrap().pkh; + let key = StakeKey::from((factories::pkh(), withdrawer)); + wallet + .state + .write() + .unwrap() + .stake_output_provenance + .insert( + key.clone(), + StakeOutput { + key: key.clone(), + value: 10, + ..Default::default() + }, + ); + let transaction = Transaction::Unstake(UnstakeTransaction::new( + UnstakeTransactionBody::new( + key.validator, + ValueTransferOutput { + pkh: key.withdrawer, + value: 4, + time_lock: 0, + }, + 1, + 0, + ), + KeyedSignature::default(), + )); + + assert_eq!( + wallet + .filter_wallet_transactions(std::iter::once(transaction.clone())) + .unwrap(), + vec![transaction.clone()] + ); + let movements = wallet + .index_block_transactions( + &factories::BlockInfo::default().create(), + &[model::ExtendedTransaction { + transaction, + metadata: None, + }], + false, + ) + .unwrap(); + + assert_eq!(movements.len(), 1); + assert_eq!(wallet.state.read().unwrap().utxo_set.len(), 1); + assert!(matches!( + &movements[0].transaction.data, + model::TransactionData::Unstake(_) + )); +} + +#[test] +fn amount_only_prebirth_entry_tracks_unstake_without_creating_provenance() { + let (wallet, _) = factories::wallet(None); + let withdrawer = wallet.gen_external_address(None).unwrap().pkh; + let key = StakeKey::from((factories::pkh(), withdrawer)); + wallet + .state + .write() + .unwrap() + .stake_balance_set + .insert(key.clone(), 10); + let transaction = Transaction::Unstake(UnstakeTransaction::new( + UnstakeTransactionBody::new( + key.validator, + ValueTransferOutput { + pkh: key.withdrawer, + value: 4, + time_lock: 0, + }, + 1, + 0, + ), + KeyedSignature::default(), + )); + + wallet + .index_block_transactions( + &factories::BlockInfo::default().create(), + &[model::ExtendedTransaction { + transaction, + metadata: None, + }], + false, + ) + .unwrap(); + + let state = wallet.state.read().unwrap(); + assert_eq!(state.stake_balance_set[&key], 5); + assert!(!state.stake_output_set.contains_key(&key)); + assert!(!state.stake_output_provenance.contains_key(&key)); + assert_eq!(state.balance.unconfirmed.staked, 5); +} + +#[test] +fn commission_update_does_not_create_wallet_stake_entry() { + let mut output = StakeOutput { + value: 1, + ..Default::default() + }; + assert!(stake_output_adds_balance(&output, ProtocolVersion::V2_0)); + + output.authorization_terms = Some(StakeAuthorization { + action: STAKE_AUTHORIZATION_ACTION_DELEGATE, + ..Default::default() + }); + assert!(stake_output_adds_balance(&output, ProtocolVersion::V2_1)); + + output.value = 0; + output.authorization_terms.as_mut().unwrap().action = + STAKE_AUTHORIZATION_ACTION_COMMISSION_UPDATE; + assert!(!stake_output_adds_balance(&output, ProtocolVersion::V2_1)); + + // A malformed positive policy action must not be reflected as stake either. Consensus should + // reject it, but wallet indexing remains defensive for unconfirmed/imported transactions. + output.value = 1; + assert!(!stake_output_adds_balance(&output, ProtocolVersion::V2_1)); + + output.authorization_terms.as_mut().unwrap().action = STAKE_AUTHORIZATION_ACTION_REGISTER_OWNER; + assert!(stake_output_adds_balance(&output, ProtocolVersion::V2_1)); + output.authorization_terms.as_mut().unwrap().action = STAKE_AUTHORIZATION_ACTION_REQUEST_EXIT; + assert!(!stake_output_adds_balance(&output, ProtocolVersion::V2_1)); + assert!(stake_output_adds_balance(&output, ProtocolVersion::V2_0)); + output.value = 0; + assert!(!stake_output_adds_balance(&output, ProtocolVersion::V2_1)); +} + +#[test] +fn foreign_funded_delegation_to_wallet_withdrawer_is_filtered_in() { + let (wallet, _) = factories::wallet(None); + let withdrawer = wallet.gen_external_address(None).unwrap().pkh; + let validator = factories::pkh(); + let transaction = Transaction::Stake(StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: factories::transaction_id(), + output_index: 0, + })], + StakeOutput { + authorization_terms: Some(StakeAuthorization::delegation( + 0, + validator, + withdrawer, + Epoch::MAX, + 0, + )), + key: StakeKey::from((validator, withdrawer)), + value: 1, + ..Default::default() + }, + None, + ), + vec![], + )); + + assert_eq!( + wallet + .filter_wallet_transactions(std::iter::once(transaction.clone())) + .unwrap(), + vec![transaction] + ); +} + +#[test] +fn foreign_funded_delegation_persists_a_transient_withdrawer_path() { + let (wallet, db) = factories::wallet(None); + wallet.initialize_transient_addresses(1, 0).unwrap(); + let withdrawer = *wallet + .state + .read() + .unwrap() + .transient_external_addresses + .keys() + .next() + .unwrap(); + let validator = factories::pkh(); + let transaction = Transaction::Stake(StakeTransaction::new( + StakeTransactionBody::new( + vec![Input::new(OutputPointer { + transaction_id: factories::transaction_id(), + output_index: 0, + })], + StakeOutput { + authorization_terms: Some(StakeAuthorization::delegation( + 0, + validator, + withdrawer, + Epoch::MAX, + 0, + )), + key: StakeKey::from((validator, withdrawer)), + value: 1, + ..Default::default() + }, + None, + ), + vec![], + )); + + wallet + ._sync_address_generation(std::iter::once(transaction)) + .unwrap(); + + assert_eq!(wallet.state.read().unwrap().next_external_index, 1); + let path: model::Path = db.get(&keys::pkh(&withdrawer)).unwrap(); + assert_eq!(path.keychain, constants::EXTERNAL_KEYCHAIN); + assert_eq!(path.index, 0); +} + +#[test] +fn legacy_stake_movement_decodes_without_authorization_terms() { + let legacy = LegacyBalanceMovement { + db_key: 0, + kind: model::MovementType::Positive, + amount: 10_000, + transaction: LegacyWalletTransaction { + block: None, + confirmed: true, + data: LegacyTransactionData::Stake(LegacyStakeData { + inputs: vec![], + output: LegacyStakeOutput { + authorization: Default::default(), + key: Default::default(), + value: 10_000, + }, + change: None, + }), + hash: String::new(), + miner_fee: 0, + timestamp: 0, + }, + }; + let legacy_bytes = bincode::serialize(&legacy).unwrap(); + let current_bytes = normalize_balance_movement_bytes(&legacy_bytes); + let current: model::BalanceMovement = + bincode_compatibility::deserialize_exact(¤t_bytes).unwrap(); + let model::TransactionData::Stake(stake) = current.transaction.data else { + panic!("expected stake movement"); + }; + + assert_eq!(stake.output.authorization_terms, None); + + let mut current = model::BalanceMovement::from(legacy); + let model::TransactionData::Stake(stake) = &mut current.transaction.data else { + panic!("expected stake movement"); + }; + stake.output.authorization_terms = Some(StakeAuthorization::default()); + let current_bytes = bincode::serialize(¤t).unwrap(); + assert_eq!(normalize_balance_movement_bytes(¤t_bytes), current_bytes); +} + #[test] fn test_wallet_public_data() { let (wallet, _db) = factories::wallet(None); diff --git a/wallet/src/types.rs b/wallet/src/types.rs index 7a1d565a47..bda1ba901a 100644 --- a/wallet/src/types.rs +++ b/wallet/src/types.rs @@ -16,6 +16,7 @@ use witnet_data_structures::{ }, fee::Fee, serialization_helpers::number_from_string, + staking::prelude::StakeKey, transaction::{ CommitTransaction, DRTransaction, DRTransactionBody, MintTransaction, RevealTransaction, StakeTransaction, TallyTransaction, Transaction, UnstakeTransaction, VTTransaction, @@ -171,6 +172,36 @@ pub struct GetBlockChainParams { #[derive(Clone, Debug, Deserialize)] pub struct ChainEntry(pub u32, pub String); +/// Wallet-facing subset of the node's tip-bound live stake projection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct StakeEntriesAtTip { + /// Canonical node tip whose post-block state is represented by `entries`. + pub tip: CheckpointBeacon, + /// Requested deterministic projection. Older nodes may return the complete ledger, so the + /// wallet always applies its owned-withdrawer filter at the response boundary. + pub entries: Vec, +} + +/// Identity and exact amount of one live consensus stake entry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct StakeEntryAtTip { + /// Validator/withdrawer identity of the entry. + pub key: StakeKey, + /// Exact live consensus amount. + pub value: StakeValueAtTip, +} + +/// Amount-only subset of the node's JSON-safe stake value. +/// +/// Epoch and nonce fields in the node response are deliberately ignored: wallet balance +/// reconciliation needs the consensus amount but must not duplicate staking policy state. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +pub struct StakeValueAtTip { + /// Live stake in nanowits, encoded as a decimal string by the node JSON API. + #[serde(deserialize_with = "number_from_string")] + pub coins: u64, +} + #[derive(Debug, Serialize, Deserialize)] pub enum BirthDate { #[serde(rename = "current")] @@ -424,8 +455,9 @@ pub struct DataRequestOutputHelper { deserialize_with = "number_from_string" )] pub min_consensus_percentage: u32, - // This field must be >= collateral_minimum, or zero - // If zero, it will be treated as collateral_minimum + // This field must be >= collateral_minimum, or zero. Pre-Wit/2.0 and native Wit/2.1 requests + // interpret zero as collateral_minimum; a cross-fork Wit/2.0 request preserves serialized + // zero owner-only compatibility semantics. #[serde( serialize_with = "u64_to_string", deserialize_with = "number_from_string" @@ -664,6 +696,32 @@ mod tests { SendTransactionRequest, }; + #[test] + fn deserialize_tip_bound_stake_projection_ignores_policy_metadata() { + let tip = CheckpointBeacon { + checkpoint: 42, + hash_prev_block: Hash::SHA256([7; 32]), + }; + let key = StakeKey::::default(); + let json = serde_json::json!({ + "tip": tip, + "entries": [{ + "key": key.clone(), + "value": { + "coins": "12345", + "epochs": { "mining": 1, "witnessing": 2 }, + "nonce": "3" + } + }] + }); + + let snapshot: StakeEntriesAtTip = serde_json::from_value(json).unwrap(); + + assert_eq!(snapshot.tip, tip); + assert_eq!(snapshot.entries[0].key, key); + assert_eq!(snapshot.entries[0].value.coins, 12_345); + } + #[test] fn test_deserialize_create_vtt() { let _e1: CreateVttRequest = serde_json::from_str(r#"{"session_id":"f29f2c88b8239fef509ccaaa504f943c3badcaff71d78d908d34cd461ea09f15","wallet_id":"87575c9031c01cf84dffc33fe2d28474d620dacd673f06990dc0318079ddfde7","outputs":[{"address":"wit1hrgcchsxezpf4cqy73djdelp03c9duwd4gx6yx","amount":"1"}],"fee":"1","label":""}"#).unwrap(); diff --git a/witnet.toml b/witnet.toml index ac8699daba..aae8efe404 100644 --- a/witnet.toml +++ b/witnet.toml @@ -62,6 +62,9 @@ reject_sybil_inbounds = true db_path = ".witnet/storage" # Enable in-memory index of data requests grouped by RAD hash rad_hashes_index = false +# Compressed secp256k1 public-key hex values trusted to sign non-genesis delegated-staking +# snapshot `.anchor` sidecars. Leave unset/empty unless the operator has approved the key set. +# snapshot_trust_public_keys = ["02..."] [jsonrpc] # Enables or disables the JSON-RPC server altogether. This is needed for using the CLI methods of the node. @@ -110,6 +113,9 @@ genesis_path = ".witnet/config/genesis_block.json" #mint_external_percentage = 50 # Set a minimum fee you require before your node includes a value transfer transaction into a block minimum_vtt_fee_nanowits = 1 +# Local delegated-staking mempool fee floors. Zero retains every consensus-valid fee. +minimum_st_fee_nanowits = 0 +minimum_ut_fee_nanowits = 0 [witnessing] # Tells how strict or lenient to be with inconsistent data sources. Paranoid level is defined as percentage of