From 42dedca8e8e9655461b60dd5f8ed1b5d588351b7 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 10:57:23 +0400 Subject: [PATCH 01/16] Add a seal type for PoS consensus data A block's seal is the (stake pool, VRF output) pair that identifies the block-producing opportunity claimed by a pool for a given slot. The VRF output is used for the seal identity instead of the VRF proof, because unlike the output, the proof bytes are not deterministic across signings of the same transcript. --- chainstate/types/src/lib.rs | 2 + chainstate/types/src/seal.rs | 156 +++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 chainstate/types/src/seal.rs diff --git a/chainstate/types/src/lib.rs b/chainstate/types/src/lib.rs index 71bb575614..09d5ef651f 100644 --- a/chainstate/types/src/lib.rs +++ b/chainstate/types/src/lib.rs @@ -14,6 +14,7 @@ // limitations under the License. pub mod pos_randomness; +pub mod seal; pub mod storage_result; pub mod vrf_tools; @@ -33,6 +34,7 @@ pub use crate::{ gen_block_index::{GenBlockIndex, GenBlockIndexRef}, height_skip::get_skip_height, locator::Locator, + seal::BlockSeal, }; mod ancestor; diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs new file mode 100644 index 0000000000..58590ca801 --- /dev/null +++ b/chainstate/types/src/seal.rs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The seal of a proof-of-stake block. + +use common::{ + chain::{PoolId, block::ConsensusData}, + primitives::H256, +}; +use crypto::vrf::VRFReturn; +use serialization::{Decode, Encode}; + +/// The seal of a proof-of-stake block: the stake pool that produced the block and +/// the VRF output that authorized the block production for the given slot. +/// +/// The VRF output is uniquely determined by the transcript it was produced over +/// (epoch index, randomness seed, block timestamp), so all valid blocks that share +/// the same seal were authorized by the same pool for the same slot. +/// +/// Note that the VRF proof is not a part of the seal: unlike the VRF output, the +/// proof bytes may differ between two signings of the same transcript, so the +/// proof cannot be used to identify a slot draw. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct BlockSeal { + /// Id of the stake pool that produced the block. + pool_id: PoolId, + /// The 32-byte VRF output from the block's consensus data. + vrf_output: H256, +} + +impl BlockSeal { + pub fn new(pool_id: PoolId, vrf_output: H256) -> Self { + Self { + pool_id, + vrf_output, + } + } + + /// Extract the seal from the block's consensus data. + /// + /// Returns `None` if the consensus data does not carry a proof-of-stake seal, + /// i.e. for `ConsensusData::None` and `ConsensusData::PoW`. + pub fn from_consensus_data(consensus_data: &ConsensusData) -> Option { + match consensus_data { + ConsensusData::PoS(pos_data) => { + let vrf_output = match pos_data.vrf_data() { + VRFReturn::Schnorrkel(vrf_data) => vrf_data.vrf_preout().into(), + }; + Some(Self { + pool_id: *pos_data.stake_pool_id(), + vrf_output, + }) + } + ConsensusData::None | ConsensusData::PoW(_) => None, + } + } + + pub fn pool_id(&self) -> &PoolId { + &self.pool_id + } + + pub fn vrf_output(&self) -> &H256 { + &self.vrf_output + } +} + +#[cfg(test)] +mod tests { + use super::*; + use common::chain::{ + block::{consensus_data::PoSData, consensus_data::PoWData, timestamp::BlockTimestamp}, + config::EpochIndex, + }; + use common::primitives::Compact; + use crypto::vrf::{VRFKeyKind, VRFPrivateKey}; + + fn make_pos_consensus_data( + vrf_sk: &VRFPrivateKey, + pool_id: PoolId, + epoch_index: EpochIndex, + seed: H256, + ) -> ConsensusData { + let timestamp = BlockTimestamp::from_int_seconds(1); + let transcript = crate::vrf_tools::construct_transcript(epoch_index, &seed, timestamp); + let vrf_data = vrf_sk.produce_vrf_data(transcript); + ConsensusData::PoS(PoSData::new(vec![], vec![], pool_id, vrf_data, Compact(1)).into()) + } + + fn make_seal(epoch_index: EpochIndex, seed: H256) -> (VRFPrivateKey, BlockSeal) { + let vrf_sk = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel).0; + let consensus_data = + make_pos_consensus_data(&vrf_sk, PoolId::new(H256::zero()), epoch_index, seed); + let seal = BlockSeal::from_consensus_data(&consensus_data).unwrap(); + (vrf_sk, seal) + } + + #[test] + fn seal_extraction_from_pos_consensus_data() { + let (vrf_sk, seal) = make_seal(0, H256::zero()); + let consensus_data = make_pos_consensus_data(&vrf_sk, *seal.pool_id(), 0, H256::zero()); + + assert_eq!(BlockSeal::from_consensus_data(&consensus_data), Some(seal)); + } + + #[test] + fn seal_extraction_from_non_pos_consensus_data() { + assert_eq!(BlockSeal::from_consensus_data(&ConsensusData::None), None); + + let pow_data = PoWData::new(Compact(1), 0); + assert_eq!( + BlockSeal::from_consensus_data(&ConsensusData::PoW(pow_data.into())), + None + ); + } + + #[test] + fn seal_codec_roundtrip() { + let (_, seal) = make_seal(0, H256::zero()); + + let encoded = seal.encode(); + let decoded = BlockSeal::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, seal); + + // The seal is used as a database key, so its encoding must be deterministic. + assert_eq!(seal.encode(), encoded); + } + + #[test] + fn same_slot_draw_produces_same_seal() { + let pool_id = PoolId::new(H256::zero()); + let (vrf_sk, seal_1) = make_seal(0, H256::zero()); + + // The VRF output is deterministic over the transcript even though the proof + // bytes are not, so two signings of the same transcript yield the same seal. + let consensus_data = make_pos_consensus_data(&vrf_sk, pool_id, 0, H256::zero()); + let seal_2 = BlockSeal::from_consensus_data(&consensus_data).unwrap(); + assert_eq!(seal_1, seal_2); + + // A different epoch means a different transcript and thus a different seal. + let consensus_data_other = make_pos_consensus_data(&vrf_sk, pool_id, 1, H256::zero()); + let seal_other = BlockSeal::from_consensus_data(&consensus_data_other).unwrap(); + assert_ne!(seal_1, seal_other); + } +} From 18afc044d2cb77fbfda71e41b8a57ecd6a86eaed Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 11:56:39 +0400 Subject: [PATCH 02/16] Add storage for PoS seal indexing and duplicate seal evidence Two new schema maps: the seal index (seal to the blocks known to carry it, bounded per seal by the indexing logic) and the append-only evidence records for seals seen on more than one block. The evidence records retain the signed headers, so they stay verifiable even if the blocks are later removed from storage. --- .../src/internal/store_tx/read_impls.rs | 31 ++++++++- .../src/internal/store_tx/write_impls.rs | 28 ++++++++- chainstate/storage/src/lib.rs | 23 +++++++ chainstate/storage/src/mock/mock_impl.rs | 39 +++++++++++- chainstate/storage/src/schema.rs | 12 +++- chainstate/types/src/lib.rs | 2 +- chainstate/types/src/seal.rs | 63 ++++++++++++++++++- 7 files changed, 191 insertions(+), 7 deletions(-) diff --git a/chainstate/storage/src/internal/store_tx/read_impls.rs b/chainstate/storage/src/internal/store_tx/read_impls.rs index dd9cf5dd6c..0a95be874a 100644 --- a/chainstate/storage/src/internal/store_tx/read_impls.rs +++ b/chainstate/storage/src/internal/store_tx/read_impls.rs @@ -16,7 +16,10 @@ use std::collections::{BTreeMap, BTreeSet}; use super::db; -use chainstate_types::{BlockIndex, EpochData, EpochStorageRead, SealedStorageTag, TipStorageTag}; +use chainstate_types::{ + BlockIndex, EpochData, EpochStorageRead, SealedStorageTag, TipStorageTag, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, +}; use common::{ chain::{ AccountNonce, AccountType, Block, DelegationId, GenBlock, OrderId, PoolId, Transaction, @@ -252,6 +255,19 @@ impl BlockchainStorageRead for super::StoreTxRo<'_, B let items = map.prefix_iter_decoded(&())?; Ok(items.collect::>()) } + + #[log_error] + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result> { + self.read::(seal) + } + + #[log_error] + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result> { + self.read::(block_id) + } } impl EpochStorageRead for super::StoreTxRo<'_, B> { @@ -584,6 +600,19 @@ impl BlockchainStorageRead for super::StoreTxRw<'_, B let items = map.prefix_iter_decoded(&())?; Ok(items.collect::>()) } + + #[log_error] + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result> { + self.read::(seal) + } + + #[log_error] + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result> { + self.read::(block_id) + } } impl EpochStorageRead for super::StoreTxRw<'_, B> { diff --git a/chainstate/storage/src/internal/store_tx/write_impls.rs b/chainstate/storage/src/internal/store_tx/write_impls.rs index b5c734e59b..21e7322dea 100644 --- a/chainstate/storage/src/internal/store_tx/write_impls.rs +++ b/chainstate/storage/src/internal/store_tx/write_impls.rs @@ -15,7 +15,10 @@ use super::{StoreTxRw, well_known}; use crate::{BlockchainStorageWrite, ChainstateStorageVersion, SealedStorageTag, TipStorageTag}; -use chainstate_types::{BlockIndex, EpochData, EpochStorageWrite}; +use chainstate_types::{ + BlockIndex, EpochData, EpochStorageWrite, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, +}; use common::{ chain::{ AccountNonce, AccountType, Block, DelegationId, GenBlock, OrderId, PoolId, Transaction, @@ -217,6 +220,29 @@ impl BlockchainStorageWrite for StoreTxRw<'_, B> { fn del_account_nonce_count(&mut self, account: &AccountType) -> crate::Result<()> { self.del::(account) } + + #[log_error] + fn set_seal_index_entry( + &mut self, + seal: &BlockSeal, + entry: &SealIndexEntry, + ) -> crate::Result<()> { + self.write::(seal, entry) + } + + #[log_error] + fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> crate::Result<()> { + self.del::(seal) + } + + #[log_error] + fn set_duplicate_seal_evidence( + &mut self, + block_id: &Id, + evidence: &DuplicateSealEvidence, + ) -> crate::Result<()> { + self.write::(block_id, evidence) + } } impl EpochStorageWrite for StoreTxRw<'_, B> { diff --git a/chainstate/storage/src/lib.rs b/chainstate/storage/src/lib.rs index e9f2e9906b..3fc4a23e9c 100644 --- a/chainstate/storage/src/lib.rs +++ b/chainstate/storage/src/lib.rs @@ -25,6 +25,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chainstate_types::{ BlockIndex, EpochStorageRead, EpochStorageWrite, SealedStorageTag, TipStorageTag, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, }; use common::{ chain::{ @@ -161,6 +162,15 @@ pub trait BlockchainStorageRead: /// Get the entire mainchain-block-by-height map as BTreeMap. This is used in the chainstate's /// "heavy" consistency checks. fn get_block_by_height_map(&self) -> crate::Result>>; + + /// Get the seal index entry of the given seal, if the seal has been seen on any block + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; + + /// Get the duplicate seal evidence that was recorded for the given block, if any + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result>; } /// Modifying operations on persistent blockchain data @@ -278,6 +288,19 @@ pub trait BlockchainStorageWrite: fn set_account_nonce_count(&mut self, account: &AccountType, nonce: AccountNonce) -> Result<()>; fn del_account_nonce_count(&mut self, account: &AccountType) -> Result<()>; + + /// Set the seal index entry of the given seal + fn set_seal_index_entry(&mut self, seal: &BlockSeal, entry: &SealIndexEntry) -> Result<()>; + + /// Remove the seal index entry of the given seal + fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> Result<()>; + + /// Record the duplicate seal evidence for the given block + fn set_duplicate_seal_evidence( + &mut self, + block_id: &Id, + evidence: &DuplicateSealEvidence, + ) -> Result<()>; } /// Operations on read-only transactions diff --git a/chainstate/storage/src/mock/mock_impl.rs b/chainstate/storage/src/mock/mock_impl.rs index 40426af7ff..e1b81b79be 100644 --- a/chainstate/storage/src/mock/mock_impl.rs +++ b/chainstate/storage/src/mock/mock_impl.rs @@ -17,7 +17,10 @@ use std::collections::{BTreeMap, BTreeSet}; -use chainstate_types::{BlockIndex, EpochData, EpochStorageRead, EpochStorageWrite}; +use chainstate_types::{ + BlockIndex, EpochData, EpochStorageRead, EpochStorageWrite, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, +}; use common::{ chain::{ AccountNonce, AccountType, Block, DelegationId, GenBlock, OrderId, PoolId, UtxoOutPoint, @@ -107,6 +110,12 @@ mockall::mock! { fn get_block_map_keys(&self) -> crate::Result>>; fn get_block_index_map(&self) -> crate::Result, BlockIndex>>; fn get_block_by_height_map(&self) -> crate::Result>>; + + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result>; } impl EpochStorageRead for Store { @@ -238,6 +247,14 @@ mockall::mock! { fn set_account_nonce_count(&mut self, account: &AccountType, nonce: AccountNonce) -> crate::Result<()>; fn del_account_nonce_count(&mut self, account: &AccountType) -> crate::Result<()>; + + fn set_seal_index_entry(&mut self, seal: &BlockSeal, entry: &SealIndexEntry) -> crate::Result<()>; + fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> crate::Result<()>; + fn set_duplicate_seal_evidence( + &mut self, + block_id: &Id, + evidence: &DuplicateSealEvidence, + ) -> crate::Result<()>; } impl EpochStorageWrite for Store { @@ -409,6 +426,12 @@ mockall::mock! { fn get_block_map_keys(&self) -> crate::Result>>; fn get_block_index_map(&self) -> crate::Result, BlockIndex>>; fn get_block_by_height_map(&self) -> crate::Result>>; + + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result>; } impl EpochStorageRead for StoreTxRo { @@ -536,6 +559,12 @@ mockall::mock! { fn get_block_map_keys(&self) -> crate::Result>>; fn get_block_index_map(&self) -> crate::Result, BlockIndex>>; fn get_block_by_height_map(&self) -> crate::Result>>; + + fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; + fn get_duplicate_seal_evidence( + &self, + block_id: &Id, + ) -> crate::Result>; } impl EpochStorageRead for StoreTxRw { @@ -667,6 +696,14 @@ mockall::mock! { fn set_account_nonce_count(&mut self, account: &AccountType, nonce: AccountNonce) -> crate::Result<()>; fn del_account_nonce_count(&mut self, account: &AccountType) -> crate::Result<()>; + + fn set_seal_index_entry(&mut self, seal: &BlockSeal, entry: &SealIndexEntry) -> crate::Result<()>; + fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> crate::Result<()>; + fn set_duplicate_seal_evidence( + &mut self, + block_id: &Id, + evidence: &DuplicateSealEvidence, + ) -> crate::Result<()>; } impl EpochStorageWrite for StoreTxRw { diff --git a/chainstate/storage/src/schema.rs b/chainstate/storage/src/schema.rs index c8d6275ed7..43af24ea7c 100644 --- a/chainstate/storage/src/schema.rs +++ b/chainstate/storage/src/schema.rs @@ -15,7 +15,10 @@ //! Chainstate database schema -use chainstate_types::{BlockIndex, EpochData}; +use chainstate_types::{ + BlockIndex, EpochData, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, +}; use common::{ chain::{ AccountNonce, AccountType, Block, DelegationId, GenBlock, OrderId, PoolId, Transaction, @@ -97,5 +100,12 @@ storage::decl_schema! { pub DBAccountingDelegationBalancesSealed: Map, /// Store for sealed accounting pool delegations balances pub DBAccountingPoolDelegationSharesSealed: Map<(PoolId, DelegationId), Amount>, + + /// Store for PoS seal index entries, i.e. the blocks known to carry each seal. + /// The number of blocks per entry is bounded by the seal indexing logic. + pub DBSealIndex: Map, + /// Store for duplicate PoS seal evidence records, keyed by the id of the block + /// whose processing discovered the duplication. + pub DBDuplicateSealEvidence: Map, DuplicateSealEvidence>, } } diff --git a/chainstate/types/src/lib.rs b/chainstate/types/src/lib.rs index 09d5ef651f..a92631c4cf 100644 --- a/chainstate/types/src/lib.rs +++ b/chainstate/types/src/lib.rs @@ -34,7 +34,7 @@ pub use crate::{ gen_block_index::{GenBlockIndex, GenBlockIndexRef}, height_skip::get_skip_height, locator::Locator, - seal::BlockSeal, + seal::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}, }; mod ancestor; diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 58590ca801..2b7ca1609f 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -16,8 +16,8 @@ //! The seal of a proof-of-stake block. use common::{ - chain::{PoolId, block::ConsensusData}, - primitives::H256, + chain::{Block, PoolId, block::ConsensusData, block::signed_block_header::SignedBlockHeader}, + primitives::{BlockHeight, H256, Id}, }; use crypto::vrf::VRFReturn; use serialization::{Decode, Encode}; @@ -76,6 +76,65 @@ impl BlockSeal { } } +/// An index entry of a seal: the blocks known to carry it. +/// +/// The number of blocks per entry is bounded by the storage layer, so the index +/// stays bounded even if a seal is deliberately reused on many blocks. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SealIndexEntry { + seal: BlockSeal, + blocks: Vec<(Id, BlockHeight)>, +} + +impl SealIndexEntry { + pub fn new(seal: BlockSeal, blocks: Vec<(Id, BlockHeight)>) -> Self { + Self { seal, blocks } + } + + pub fn seal(&self) -> &BlockSeal { + &self.seal + } + + pub fn blocks(&self) -> &[(Id, BlockHeight)] { + &self.blocks + } + + pub fn push_block(&mut self, block_id: Id, block_height: BlockHeight) { + self.blocks.push((block_id, block_height)); + } +} + +/// Evidence that a single seal was seen on more than one block. +/// +/// The record is self-certifying: each header carries the block signature of the +/// pool and its own VRF data, so a third party can verify that the same pool +/// produced all the listed blocks for the same slot. The headers are retained in +/// the record itself, so the evidence stays verifiable even if the blocks are +/// later removed from storage. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct DuplicateSealEvidence { + seal: BlockSeal, + headers: Vec, +} + +impl DuplicateSealEvidence { + pub fn new(seal: BlockSeal, headers: Vec) -> Self { + Self { seal, headers } + } + + pub fn seal(&self) -> &BlockSeal { + &self.seal + } + + pub fn headers(&self) -> &[SignedBlockHeader] { + &self.headers + } + + pub fn push_header(&mut self, header: SignedBlockHeader) { + self.headers.push(header); + } +} + #[cfg(test)] mod tests { use super::*; From 65cea4d03b3d3a7cd562f6cac1661610ac89713c Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 12:14:57 +0400 Subject: [PATCH 03/16] Index PoS block seals during block integration Every checked PoS block gets its seal indexed, so the index covers all branches, not only the best chain. When a seal is seen on more than one block, the signed headers of the known blocks are retained as a self-certifying evidence record, and the index entry of the seal is extended up to a conservative cap. Enabled by default and configurable via the chainstate config; no block validation behavior is affected. --- blockprod/src/tests/helpers.rs | 1 + chainstate/src/config.rs | 14 + chainstate/src/detail/chainstateref/mod.rs | 12 + .../src/detail/chainstateref/seal_index.rs | 297 ++++++++++++++++++ chainstate/src/detail/mod.rs | 18 +- .../chainstate_interface_impl_delegation.rs | 1 + node-lib/src/config_files/chainstate/mod.rs | 6 + node-lib/src/config_files/mod.rs | 2 + 8 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 chainstate/src/detail/chainstateref/seal_index.rs diff --git a/blockprod/src/tests/helpers.rs b/blockprod/src/tests/helpers.rs index b44164b5d8..7dc1a492ac 100644 --- a/blockprod/src/tests/helpers.rs +++ b/blockprod/src/tests/helpers.rs @@ -135,6 +135,7 @@ impl BlockprodTestSetupBuilder { enable_db_reckless_mode_in_ibd: Default::default(), max_orphan_blocks: Default::default(), allow_checkpoints_mismatch: Default::default(), + pos_seal_duplication_tracking: Default::default(), }; let mempool_config = self.mempool_config.unwrap_or_default(); diff --git a/chainstate/src/config.rs b/chainstate/src/config.rs index e3ef29c0b7..758d6aafce 100644 --- a/chainstate/src/config.rs +++ b/chainstate/src/config.rs @@ -21,6 +21,7 @@ use utils::make_config_setting; make_config_setting!(MaxDbCommitAttempts, usize, 10); make_config_setting!(MaxOrphanBlocks, usize, 512); make_config_setting!(MaxTipAge, Duration, Duration::from_secs(60 * 60 * 24)); +make_config_setting!(PosSealDuplicationTracking, bool, true); /// The chainstate subsystem configuration. #[derive(Debug, Clone, Default)] @@ -47,6 +48,10 @@ pub struct ChainstateConfig { /// If true, blocks and block headers will not be rejected if checkpoints mismatch is detected. pub allow_checkpoints_mismatch: Option, + + /// If true, the seals (stake pool id + VRF output) of the processed PoS blocks will be + /// indexed and the evidence of a seal seen on more than one block will be recorded. + pub pos_seal_duplication_tracking: PosSealDuplicationTracking, } impl ChainstateConfig { @@ -70,6 +75,15 @@ impl ChainstateConfig { self } + pub fn with_pos_seal_duplication_tracking(mut self, enable: bool) -> Self { + self.pos_seal_duplication_tracking = enable.into(); + self + } + + pub fn pos_seal_duplication_tracking_enabled(&self) -> bool { + *self.pos_seal_duplication_tracking + } + pub fn db_reckless_mode_in_ibd_enabled(&self) -> bool { self.enable_db_reckless_mode_in_ibd.unwrap_or(false) } diff --git a/chainstate/src/detail/chainstateref/mod.rs b/chainstate/src/detail/chainstateref/mod.rs index 2efd036bca..6529440b65 100644 --- a/chainstate/src/detail/chainstateref/mod.rs +++ b/chainstate/src/detail/chainstateref/mod.rs @@ -17,6 +17,7 @@ mod block_info; mod consistency_checker; mod epoch_seal; mod in_memory_reorg; +mod seal_index; mod tx_verifier_storage; use itertools::Itertools; @@ -1427,6 +1428,17 @@ impl ChainstateRe self.db_tx.add_block(block).map_err(BlockError::from) } + /// Index the seal of the given block, recording evidence if the seal was already + /// seen on another block. + #[log_error] + pub fn index_block_seal( + &mut self, + block: &WithId, + block_height: BlockHeight, + ) -> Result<(), BlockError> { + seal_index::index_block_seal(&mut self.db_tx, block, block_height) + } + #[log_error] pub fn set_block_index(&mut self, block_index: &BlockIndex) -> Result<(), BlockError> { self.db_tx.set_block_index(block_index).map_err(BlockError::from) diff --git a/chainstate/src/detail/chainstateref/seal_index.rs b/chainstate/src/detail/chainstateref/seal_index.rs new file mode 100644 index 0000000000..3e344b2c1e --- /dev/null +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -0,0 +1,297 @@ +// Copyright (c) 2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Indexing of PoS block seals and recording of duplicate seal evidence. +//! +//! Every block that carries a PoS seal gets its seal indexed. If a seal is seen +//! on more than one block, the signed headers of all the known blocks that carry +//! the seal are retained as a self-certifying evidence record. +//! +//! The index entry of a single seal is capped at [`MAX_BLOCKS_PER_SEAL`] blocks. +//! Once the cap is reached, the evidence records are still written for any new +//! blocks that carry the seal, but the index entry is no longer extended. + +use std::num::NonZeroUsize; + +use chainstate_storage::BlockchainStorageWrite; +use chainstate_types::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}; +use common::{ + chain::Block, + primitives::{BlockHeight, Id, Idable, id::WithId}, +}; +use logging::log; +use utils::log_error; + +use crate::BlockError; + +/// The maximum number of blocks a single seal is indexed for. +// The cap exists to bound the storage and processing costs of seal reuse. It is +// deliberately conservative: honest blocks never share a seal, so the cap only +// matters for deliberately reused seals. +pub const MAX_BLOCKS_PER_SEAL: NonZeroUsize = NonZeroUsize::new(8).unwrap(); + +/// Index the seal of the given block, recording evidence if the seal was already +/// seen on another block. +/// +/// This must be called for every block that has passed all checks, along with its +/// integration into the block tree, so that the seal index covers the blocks of +/// all branches, not only those of the best chain. +#[log_error] +pub fn index_block_seal( + db_tx: &mut S, + block: &WithId, + block_height: BlockHeight, +) -> Result<(), BlockError> { + let Some(seal) = BlockSeal::from_consensus_data(block.header().consensus_data()) else { + return Ok(()); + }; + + let block_id: Id = block.get_id(); + + let Some(mut entry) = db_tx.get_seal_index_entry(&seal)? else { + let entry = SealIndexEntry::new(seal, vec![(block_id, block_height)]); + db_tx.set_seal_index_entry(entry.seal(), &entry)?; + return Ok(()); + }; + + if entry.blocks().iter().any(|(existing_id, _)| existing_id == &block_id) { + // The block is already indexed, e.g. because of a retried transaction. + return Ok(()); + } + + record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; + + if entry.blocks().len() < MAX_BLOCKS_PER_SEAL.get() { + entry.push_block(block_id, block_height); + db_tx.set_seal_index_entry(entry.seal(), &entry)?; + } + + Ok(()) +} + +/// Retain the headers of the known blocks that carry the given seal, plus the +/// header of the newly seen block, as a duplicate seal evidence record. +fn record_duplicate_seal_evidence( + db_tx: &mut S, + seal: &BlockSeal, + known_blocks: &[(Id, BlockHeight)], + block: &WithId, +) -> Result<(), BlockError> { + let mut evidence = DuplicateSealEvidence::new(seal.clone(), Vec::new()); + for (existing_id, _) in known_blocks { + if let Some(header) = db_tx.get_block_header(existing_id)? { + evidence.push_header(header); + } + } + evidence.push_header(block.header().clone()); + + log::info!( + "A PoS seal of pool {} was seen on more than one block; recorded evidence for block {}", + seal.pool_id(), + block.get_id(), + ); + + db_tx.set_duplicate_seal_evidence(&block.get_id(), &evidence)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use super::*; + use chainstate_storage::mock::MockStoreTxRw; + use chainstate_types::vrf_tools::construct_transcript; + use common::{ + chain::{ + Destination, PoolId, TxOutput, + block::{ + BlockReward, ConsensusData, consensus_data::PoSData, timestamp::BlockTimestamp, + }, + config::Builder as ConfigBuilder, + stakelock::StakePoolData, + }, + primitives::{Amount, Compact, H256, per_thousand::PerThousand}, + }; + use crypto::vrf::{VRFKeyKind, VRFPrivateKey, VRFPublicKey}; + use mockall::predicate::eq; + + const TEST_HEIGHT: BlockHeight = BlockHeight::new(1); + + fn make_pos_block( + prev_block_id: H256, + vrf_sk: &VRFPrivateKey, + vrf_pk: &VRFPublicKey, + seed: H256, + ) -> WithId { + let chain_config = + ConfigBuilder::test_chain().epoch_length(NonZeroU64::new(3).unwrap()).build(); + + let timestamp = BlockTimestamp::from_int_seconds(1); + let epoch_index = chain_config.epoch_index_from_height(&TEST_HEIGHT.next_height()); + let transcript = construct_transcript(epoch_index, &seed, timestamp); + let vrf_data = vrf_sk.produce_vrf_data(transcript); + let pool_id = PoolId::new(H256::zero()); + + let stake_pool_data = StakePoolData::new( + Amount::from_atoms(1), + Destination::AnyoneCanSpend, + vrf_pk.clone(), + Destination::AnyoneCanSpend, + PerThousand::new(0).unwrap(), + Amount::ZERO, + ); + let reward_output = TxOutput::CreateStakePool(pool_id, Box::new(stake_pool_data)); + let pos_data = PoSData::new(vec![], vec![], pool_id, vrf_data, Compact(1)); + let block = common::chain::Block::new( + vec![], + prev_block_id.into(), + timestamp, + ConsensusData::PoS(pos_data.into()), + BlockReward::new(vec![reward_output]), + ) + .unwrap(); + WithId::new(block) + } + + #[test] + fn seal_of_a_pos_block_is_indexed() { + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + let block_id = block.get_id(); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(None)); + db.expect_set_seal_index_entry() + .times(1) + .withf(move |seal, entry| { + entry.seal() == seal && entry.blocks() == [(block_id, TEST_HEIGHT)] + }) + .return_const(Ok(())); + db.expect_set_duplicate_seal_evidence().times(0); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + + #[test] + fn seal_of_a_non_pos_block_is_not_indexed() { + let block = common::chain::Block::new( + vec![], + H256::zero().into(), + BlockTimestamp::from_int_seconds(1), + ConsensusData::None, + BlockReward::new(vec![]), + ) + .unwrap(); + let block = WithId::new(block); + + // No expectations: any storage access fails the test. + let mut db = MockStoreTxRw::new(); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + + #[test] + fn duplicate_seal_records_evidence() { + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let seed = H256::zero(); + let block_1 = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, seed); + let block_2 = make_pos_block(H256::from([1u8; 32]), &vrf_sk, &vrf_pk, seed); + + assert_ne!(block_1.get_id(), block_2.get_id()); + assert_eq!( + BlockSeal::from_consensus_data(block_1.header().consensus_data()), + BlockSeal::from_consensus_data(block_2.header().consensus_data()), + ); + + let id_1 = block_1.get_id(); + let id_2 = block_2.get_id(); + let header_1 = block_1.header().clone(); + let header_2 = block_2.header().clone(); + let entry = SealIndexEntry::new( + BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(), + vec![(id_1, TEST_HEIGHT)], + ); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_block_header() + .times(1) + .with(eq(id_1)) + .return_const(Ok(Some(header_1.clone()))); + db.expect_set_duplicate_seal_evidence() + .times(1) + .withf(move |block_id, evidence| { + block_id == &id_2 + && evidence.headers().len() == 2 + && evidence.headers()[0] == header_1 + && evidence.headers()[1] == header_2 + }) + .return_const(Ok(())); + db.expect_set_seal_index_entry() + .times(1) + .withf(|_, entry| entry.blocks().len() == 2) + .return_const(Ok(())); + + index_block_seal(&mut db, &block_2, TEST_HEIGHT).unwrap(); + } + + #[test] + fn already_indexed_block_is_skipped() { + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + let entry = SealIndexEntry::new( + BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(), + vec![(block.get_id(), TEST_HEIGHT)], + ); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + + // No write expectations: any write fails the test. + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + + #[test] + fn index_entry_is_capped_and_evidence_is_kept() { + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + let block_id = block.get_id(); + let header = block.header().clone(); + + let seal = BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(); + let known_blocks = (0..MAX_BLOCKS_PER_SEAL.get()) + .map(|i| (Id::new(H256::from([(i + 1) as u8; 32])), TEST_HEIGHT)) + .collect::>(); + let entry = SealIndexEntry::new(seal, known_blocks); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + // The headers of the known blocks may be unavailable, in which case the + // evidence record still retains the header of the new block. + db.expect_get_block_header().times(..).return_const(Ok(None)); + db.expect_set_duplicate_seal_evidence() + .times(1) + .withf(move |recorded_id, evidence| { + recorded_id == &block_id + && evidence.headers().len() == 1 + && evidence.headers()[0] == header + }) + .return_const(Ok(())); + db.expect_set_seal_index_entry().times(0); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } +} diff --git a/chainstate/src/detail/mod.rs b/chainstate/src/detail/mod.rs index 7ee1be69fc..ee5ecaa584 100644 --- a/chainstate/src/detail/mod.rs +++ b/chainstate/src/detail/mod.rs @@ -361,6 +361,7 @@ impl Chainstate chainstate_ref: &mut ChainstateRef, V>, block: &WithId, block_index: BlockIndex, + seal_duplication_tracking_enabled: bool, ) -> Result { let mut block_status = BlockStatus::new(); @@ -381,6 +382,12 @@ impl Chainstate .and_then(|_| chainstate_ref.persist_block(block)) .map_err(|err| BlockIntegrationError::BlockCheckError(err, block_status))?; + if seal_duplication_tracking_enabled { + chainstate_ref + .index_block_seal(block, block_index.block_height()) + .map_err(BlockIntegrationError::OtherNonValidationError)?; + } + // Note: we don't advance the stage to FullyChecked if activate_best_chain succeeds even // if we know that a reorg has occurred, because during a reorg multiple blocks get // checked. It's activate_best_chain's responsibility to update their statuses. @@ -436,8 +443,17 @@ impl Chainstate // Perform block checks; `integrate_block_result` is `Result`, where the bool // indicates whether a reorg has occurred. + let seal_duplication_tracking_enabled = + self.chainstate_config.pos_seal_duplication_tracking_enabled(); let integrate_block_result = self.with_rw_tx( - |chainstate_ref| Self::integrate_block(chainstate_ref, &block, block_index.clone()), + |chainstate_ref| { + Self::integrate_block( + chainstate_ref, + &block, + block_index.clone(), + seal_duplication_tracking_enabled, + ) + }, |attempt_number| { log::info!("Processing block {block_id}, attempt #{attempt_number}"); }, diff --git a/chainstate/src/interface/chainstate_interface_impl_delegation.rs b/chainstate/src/interface/chainstate_interface_impl_delegation.rs index d855c48503..3fbdac903c 100644 --- a/chainstate/src/interface/chainstate_interface_impl_delegation.rs +++ b/chainstate/src/interface/chainstate_interface_impl_delegation.rs @@ -503,6 +503,7 @@ mod tests { max_tip_age: Default::default(), enable_heavy_checks: Some(true), allow_checkpoints_mismatch: Default::default(), + pos_seal_duplication_tracking: Default::default(), }; let chainstate_storage = Store::new_empty().unwrap(); diff --git a/node-lib/src/config_files/chainstate/mod.rs b/node-lib/src/config_files/chainstate/mod.rs index 4c6336329a..3ac059deca 100644 --- a/node-lib/src/config_files/chainstate/mod.rs +++ b/node-lib/src/config_files/chainstate/mod.rs @@ -45,6 +45,10 @@ pub struct ChainstateConfigFile { /// If true, blocks and block headers will not be rejected if checkpoints mismatch is detected. pub allow_checkpoints_mismatch: Option, + + /// If true, the seals (stake pool id + VRF output) of the processed PoS blocks will be + /// indexed and the evidence of a seal seen on more than one block will be recorded. + pub pos_seal_duplication_tracking: Option, } impl From for ChainstateConfig { @@ -56,6 +60,7 @@ impl From for ChainstateConfig { max_tip_age, enable_heavy_checks, allow_checkpoints_mismatch, + pos_seal_duplication_tracking, } = config_file; ChainstateConfig { @@ -65,6 +70,7 @@ impl From for ChainstateConfig { max_tip_age: max_tip_age.map(Duration::from_secs).into(), enable_heavy_checks, allow_checkpoints_mismatch, + pos_seal_duplication_tracking: pos_seal_duplication_tracking.into(), } } } diff --git a/node-lib/src/config_files/mod.rs b/node-lib/src/config_files/mod.rs index 150c0668e6..18c0e4c7a8 100644 --- a/node-lib/src/config_files/mod.rs +++ b/node-lib/src/config_files/mod.rs @@ -153,6 +153,7 @@ fn chainstate_config( max_tip_age, enable_heavy_checks, allow_checkpoints_mismatch, + pos_seal_duplication_tracking, } = chainstate_config; let storage_backend = options.storage_backend.clone().unwrap_or(storage_backend); @@ -172,6 +173,7 @@ fn chainstate_config( max_tip_age, enable_heavy_checks, allow_checkpoints_mismatch, + pos_seal_duplication_tracking, }; ChainstateLauncherConfigFile { storage_backend, From 9e6bc75e7d1f8c801e239e8e376ccf9bb6758983 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 13:16:10 +0400 Subject: [PATCH 04/16] Add seal duplication tests and export the seal indexing helper Two integration tests: a seal seen on two valid sibling blocks records evidence while both blocks stay fully valid, and disabling the tracking records nothing. The shared stake pool test setup is factored into the test helpers, and the storage simulation test now replays the seal indexing for the manually assembled reference storage, matching the storage footprint of a processed block. --- chainstate/src/detail/chainstateref/mod.rs | 2 +- chainstate/src/detail/mod.rs | 2 + chainstate/src/lib.rs | 1 + chainstate/test-suite/src/tests/bootstrap.rs | 1 + .../test-suite/src/tests/helpers/pos.rs | 154 ++++++++++- chainstate/test-suite/src/tests/mod.rs | 1 + .../src/tests/pos_processing_tests.rs | 136 +--------- .../src/tests/seal_duplication_tests.rs | 243 ++++++++++++++++++ .../test-suite/src/tests/syncing_tests.rs | 1 + .../src/tests/tx_verification_simulation.rs | 8 + 10 files changed, 421 insertions(+), 128 deletions(-) create mode 100644 chainstate/test-suite/src/tests/seal_duplication_tests.rs diff --git a/chainstate/src/detail/chainstateref/mod.rs b/chainstate/src/detail/chainstateref/mod.rs index 6529440b65..cc00637196 100644 --- a/chainstate/src/detail/chainstateref/mod.rs +++ b/chainstate/src/detail/chainstateref/mod.rs @@ -17,7 +17,7 @@ mod block_info; mod consistency_checker; mod epoch_seal; mod in_memory_reorg; -mod seal_index; +pub(crate) mod seal_index; mod tx_verifier_storage; use itertools::Itertools; diff --git a/chainstate/src/detail/mod.rs b/chainstate/src/detail/mod.rs index ee5ecaa584..7d0752bb53 100644 --- a/chainstate/src/detail/mod.rs +++ b/chainstate/src/detail/mod.rs @@ -14,6 +14,8 @@ // limitations under the License. mod chainstateref; + +pub use chainstateref::seal_index::index_block_seal; mod error; mod error_classification; mod info; diff --git a/chainstate/src/lib.rs b/chainstate/src/lib.rs index 2ca6b9b613..c864acaefd 100644 --- a/chainstate/src/lib.rs +++ b/chainstate/src/lib.rs @@ -48,6 +48,7 @@ pub use crate::{ }; pub use chainstate_types::{BlockIndex, GenBlockIndex, GenBlockIndexRef, PropertyQueryError}; pub use constraints_value_accumulator; +pub use detail::index_block_seal; pub use detail::tx_verification_strategy::*; pub use interface::{chainstate_interface, chainstate_interface_impl_delegation}; pub use tx_verifier; diff --git a/chainstate/test-suite/src/tests/bootstrap.rs b/chainstate/test-suite/src/tests/bootstrap.rs index 5c1a55817f..21548adc62 100644 --- a/chainstate/test-suite/src/tests/bootstrap.rs +++ b/chainstate/test-suite/src/tests/bootstrap.rs @@ -746,6 +746,7 @@ fn check_reckless_mode( max_tip_age: Default::default(), enable_heavy_checks: Default::default(), allow_checkpoints_mismatch: Default::default(), + pos_seal_duplication_tracking: Default::default(), }) .build(); let use_reckless_mode = enable_db_reckless_mode_in_ibd.unwrap_or(false); diff --git a/chainstate/test-suite/src/tests/helpers/pos.rs b/chainstate/test-suite/src/tests/helpers/pos.rs index ff2d33128d..584f5b5423 100644 --- a/chainstate/test-suite/src/tests/helpers/pos.rs +++ b/chainstate/test-suite/src/tests/helpers/pos.rs @@ -13,13 +13,43 @@ // See the License for the specific language governing permissions and // limitations under the License. -use chainstate_test_framework::{TestFramework, calculate_new_pos_compact_target}; +use std::{borrow::Cow, num::NonZeroU64}; + +use chainstate::ChainstateConfig; +use chainstate_test_framework::{ + TestFramework, TransactionBuilder, calculate_new_pos_compact_target, + create_stake_pool_data_with_all_reward_to_staker, empty_witness, +}; use common::{ - chain::{CoinUnit, Genesis}, - primitives::{BlockHeight, Compact}, + Uint256, + chain::{ + ChainConfig, CoinUnit, ConsensusUpgrade, Destination, Genesis, NetUpgrades, + OutPointSourceId, PoSChainConfig, PoSChainConfigBuilder, PoolId, RequiredConsensus, + TxOutput, UtxoOutPoint, + block::BlockRewardTransactable, + config::Builder as ConfigBuilder, + signature::{ + inputsig::standard_signature::StandardInputSignature, + sighash::{input_commitments::SighashInputCommitment, sighashtype::SigHashType}, + }, + stakelock::StakePoolData, + }, + primitives::{BlockHeight, Compact, Idable as _}, }; use consensus::ConsensusPoSError; -use crypto::{key::PublicKey, vrf::VRFPublicKey}; +use crypto::{ + key::{PrivateKey, PublicKey}, + vrf::VRFPublicKey, +}; +use randomness::CryptoRng; +use utils::const_nz_u64; + +// It's important to have short epoch length, so that genesis and the first block can seal +// an epoch with pool, which is required for PoS validation to work. +pub const TEST_EPOCH_LENGTH: NonZeroU64 = const_nz_u64!(2); +pub const TEST_SEALED_EPOCH_DISTANCE: usize = 0; + +pub const MIN_DIFFICULTY: Uint256 = Uint256::MAX; pub fn calculate_new_target( tf: &TestFramework, @@ -43,3 +73,119 @@ pub fn create_custom_genesis_with_stake_pool( initial_pool_amount, ) } + +pub fn consensus_upgrades_with_pos_at_height(height: BlockHeight) -> NetUpgrades { + NetUpgrades::initialize(vec![ + (BlockHeight::new(0), ConsensusUpgrade::IgnoreConsensus), + ( + height, + ConsensusUpgrade::PoS { + initial_difficulty: Some(MIN_DIFFICULTY.into()), + config: PoSChainConfigBuilder::new_for_unit_test().build(), + }, + ), + ]) + .unwrap() +} + +pub fn add_block_with_stake_pool( + rng: &mut impl CryptoRng, + tf: &mut TestFramework, + stake_pool_data: StakePoolData, +) -> (UtxoOutPoint, PoolId) { + let genesis_outpoint = UtxoOutPoint::new( + OutPointSourceId::BlockReward(tf.genesis().get_id().into()), + 0, + ); + let pool_id = PoolId::from_utxo(&genesis_outpoint); + let tx = TransactionBuilder::new() + .add_input(genesis_outpoint.into(), empty_witness(rng)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .build(); + let tx_id = tx.transaction().get_id(); + + tf.make_block_builder().add_transaction(tx).build_and_process(rng).unwrap(); + + tf.progress_time_seconds_since_epoch(1); + + ( + UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), 0), + pool_id, + ) +} + +/// Create a chain genesis <- block_1, where block_1 has a tx with a StakePool output. +pub fn setup_chain_with_stake_pool( + rng: &mut impl CryptoRng, + vrf_pk: VRFPublicKey, +) -> (TestFramework, UtxoOutPoint, PoolId, PrivateKey) { + setup_chain_with_stake_pool_with_chainstate_config(rng, vrf_pk, ChainstateConfig::default()) +} + +/// Same as `setup_chain_with_stake_pool`, but with a custom chainstate configuration. +pub fn setup_chain_with_stake_pool_with_chainstate_config( + rng: &mut impl CryptoRng, + vrf_pk: VRFPublicKey, + chainstate_config: ChainstateConfig, +) -> (TestFramework, UtxoOutPoint, PoolId, PrivateKey) { + let net_upgrades = consensus_upgrades_with_pos_at_height(BlockHeight::new(2)); + let chain_config = ConfigBuilder::test_chain() + .consensus_upgrades(net_upgrades) + .epoch_length(TEST_EPOCH_LENGTH) + .sealed_epoch_distance_from_tip(TEST_SEALED_EPOCH_DISTANCE) + .build(); + + let mut tf = TestFramework::builder(rng) + .with_chain_config(chain_config) + .with_chainstate_config(chainstate_config) + .build(); + + let (stake_pool_data, staking_sk) = create_stake_pool_data_with_all_reward_to_staker( + rng, + tf.chainstate.get_chain_config().min_stake_pool_pledge(), + vrf_pk, + ); + let (stake_pool_outpoint, pool_id) = add_block_with_stake_pool(rng, &mut tf, stake_pool_data); + + (tf, stake_pool_outpoint, pool_id, staking_sk) +} + +pub fn produce_kernel_signature( + rng: &mut impl CryptoRng, + tf: &TestFramework, + staking_sk: &PrivateKey, + reward_outputs: &[TxOutput], + staking_destination: Destination, + kernel_outpoint: UtxoOutPoint, +) -> StandardInputSignature { + let kernel_input_utxo = tf.utxo(&kernel_outpoint).take_output(); + let kernel_inputs = vec![kernel_outpoint.into()]; + + let block_reward_tx = + BlockRewardTransactable::new(Some(kernel_inputs.as_slice()), Some(reward_outputs), None); + StandardInputSignature::produce_uniparty_signature_for_input( + staking_sk, + SigHashType::default(), + staking_destination, + &block_reward_tx, + &[SighashInputCommitment::Utxo(Cow::Borrowed(&kernel_input_utxo))], + 0, + rng, + ) + .unwrap() +} + +pub fn get_pos_chain_config( + chain_config: &ChainConfig, + block_height: BlockHeight, +) -> PoSChainConfig { + match chain_config.consensus_upgrades().consensus_status(block_height) { + RequiredConsensus::PoS(status) => status.get_chain_config().clone(), + RequiredConsensus::PoW(_) | RequiredConsensus::IgnoreConsensus => { + panic!("Invalid consensus") + } + } +} diff --git a/chainstate/test-suite/src/tests/mod.rs b/chainstate/test-suite/src/tests/mod.rs index 970e3e0a46..39d3ad38bc 100644 --- a/chainstate/test-suite/src/tests/mod.rs +++ b/chainstate/test-suite/src/tests/mod.rs @@ -55,6 +55,7 @@ mod pos_processing_tests; mod pos_retargeting_tests; mod processing_tests; mod reorgs_tests; +mod seal_duplication_tests; mod signature_tests; mod stake_pool_tests; mod syncing_tests; diff --git a/chainstate/test-suite/src/tests/pos_processing_tests.rs b/chainstate/test-suite/src/tests/pos_processing_tests.rs index b92b3e7ec3..ac939e6bed 100644 --- a/chainstate/test-suite/src/tests/pos_processing_tests.rs +++ b/chainstate/test-suite/src/tests/pos_processing_tests.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{borrow::Cow, num::NonZeroU64, time::Duration}; +use std::{num::NonZeroU64, time::Duration}; use rstest::rstest; @@ -32,23 +32,18 @@ use chainstate_types::{ vrf_tools::{ProofOfStakeVRFError, construct_transcript}, }; use common::{ - Uint256, chain::{ - AccountNonce, AccountOutPoint, AccountSpending, ChainConfig, ChainstateUpgradeBuilder, - ConsensusUpgrade, Destination, GenBlock, NetUpgrades, OutPointSourceId, PoSChainConfig, - PoSChainConfigBuilder, PoolId, PoolIdMismatchInKernelUtxoAndPoSDataForbidden, - RequiredConsensus, SignedTransaction, StakerDestinationUpdateForbidden, TxInput, TxOutput, - UtxoOutPoint, - block::{ - BlockRewardTransactable, ConsensusData, consensus_data::PoSData, - timestamp::BlockTimestamp, - }, + AccountNonce, AccountOutPoint, AccountSpending, ChainstateUpgradeBuilder, ConsensusUpgrade, + Destination, GenBlock, NetUpgrades, OutPointSourceId, PoSChainConfigBuilder, PoolId, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, SignedTransaction, + StakerDestinationUpdateForbidden, TxInput, TxOutput, UtxoOutPoint, + block::{ConsensusData, consensus_data::PoSData, timestamp::BlockTimestamp}, config::{Builder as ConfigBuilder, ChainType, EpochIndex, create_unit_test_config}, make_delegation_id, output_value::OutputValue, signature::{ inputsig::{InputWitness, standard_signature::StandardInputSignature}, - sighash::{input_commitments::SighashInputCommitment, sighashtype::SigHashType}, + sighash::sighashtype::SigHashType, }, stakelock::StakePoolData, timelock::OutputTimeLock, @@ -66,45 +61,13 @@ use test_utils::{ assert_matches, random::{Seed, make_seedable_rng}, }; -use utils::const_nz_u64; - -use super::helpers::pos::{calculate_new_target, create_custom_genesis_with_stake_pool}; - -// It's important to have short epoch length, so that genesis and the first block can seal -// an epoch with pool, which is required for PoS validation to work. -const TEST_EPOCH_LENGTH: NonZeroU64 = const_nz_u64!(2); -const TEST_SEALED_EPOCH_DISTANCE: usize = 0; - -const MIN_DIFFICULTY: Uint256 = Uint256::MAX; - -fn add_block_with_stake_pool( - rng: &mut impl CryptoRng, - tf: &mut TestFramework, - stake_pool_data: StakePoolData, -) -> (UtxoOutPoint, PoolId) { - let genesis_outpoint = UtxoOutPoint::new( - OutPointSourceId::BlockReward(tf.genesis().get_id().into()), - 0, - ); - let pool_id = PoolId::from_utxo(&genesis_outpoint); - let tx = TransactionBuilder::new() - .add_input(genesis_outpoint.into(), empty_witness(rng)) - .add_output(TxOutput::CreateStakePool( - pool_id, - Box::new(stake_pool_data), - )) - .build(); - let tx_id = tx.transaction().get_id(); - - tf.make_block_builder().add_transaction(tx).build_and_process(rng).unwrap(); - - tf.progress_time_seconds_since_epoch(1); - ( - UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), 0), - pool_id, - ) -} +use super::helpers::pos::{ + MIN_DIFFICULTY, TEST_EPOCH_LENGTH, TEST_SEALED_EPOCH_DISTANCE, add_block_with_stake_pool, + calculate_new_target, consensus_upgrades_with_pos_at_height, + create_custom_genesis_with_stake_pool, get_pos_chain_config, produce_kernel_signature, + setup_chain_with_stake_pool as setup_test_chain_with_stake_pool, +}; fn add_block_with_2_stake_pools( rng: &mut impl CryptoRng, @@ -153,45 +116,6 @@ fn add_block_with_2_stake_pools( (stake_outpoint1, pool_id1, outpoint2, pool_id2) } -fn consensus_upgrades_with_pos_at_height(height: BlockHeight) -> NetUpgrades { - NetUpgrades::initialize(vec![ - (BlockHeight::new(0), ConsensusUpgrade::IgnoreConsensus), - ( - height, - ConsensusUpgrade::PoS { - initial_difficulty: Some(MIN_DIFFICULTY.into()), - config: PoSChainConfigBuilder::new_for_unit_test().build(), - }, - ), - ]) - .unwrap() -} - -// Create a chain genesis <- block_1 -// block_1 has tx with StakePool output -fn setup_test_chain_with_stake_pool( - rng: &mut impl CryptoRng, - vrf_pk: VRFPublicKey, -) -> (TestFramework, UtxoOutPoint, PoolId, PrivateKey) { - let net_upgrades = consensus_upgrades_with_pos_at_height(BlockHeight::new(2)); - let chain_config = ConfigBuilder::test_chain() - .consensus_upgrades(net_upgrades) - .epoch_length(TEST_EPOCH_LENGTH) - .sealed_epoch_distance_from_tip(TEST_SEALED_EPOCH_DISTANCE) - .build(); - - let mut tf = TestFramework::builder(rng).with_chain_config(chain_config).build(); - - let (stake_pool_data, staking_sk) = create_stake_pool_data_with_all_reward_to_staker( - rng, - tf.chainstate.get_chain_config().min_stake_pool_pledge(), - vrf_pk, - ); - let (stake_pool_outpoint, pool_id) = add_block_with_stake_pool(rng, &mut tf, stake_pool_data); - - (tf, stake_pool_outpoint, pool_id, staking_sk) -} - // Create a chain genesis <- block_1 // block_1 has txs with 2 StakePool output fn setup_test_chain_with_2_stake_pools( @@ -268,40 +192,6 @@ fn setup_test_chain_with_2_stake_pools_with_net_upgrades( ) } -fn produce_kernel_signature( - rng: &mut impl CryptoRng, - tf: &TestFramework, - staking_sk: &PrivateKey, - reward_outputs: &[TxOutput], - staking_destination: Destination, - kernel_outpoint: UtxoOutPoint, -) -> StandardInputSignature { - let kernel_input_utxo = tf.utxo(&kernel_outpoint).take_output(); - let kernel_inputs = vec![kernel_outpoint.into()]; - - let block_reward_tx = - BlockRewardTransactable::new(Some(kernel_inputs.as_slice()), Some(reward_outputs), None); - StandardInputSignature::produce_uniparty_signature_for_input( - staking_sk, - SigHashType::default(), - staking_destination, - &block_reward_tx, - &[SighashInputCommitment::Utxo(Cow::Borrowed(&kernel_input_utxo))], - 0, - rng, - ) - .unwrap() -} - -fn get_pos_chain_config(chain_config: &ChainConfig, block_height: BlockHeight) -> PoSChainConfig { - match chain_config.consensus_upgrades().consensus_status(block_height) { - RequiredConsensus::PoS(status) => status.get_chain_config().clone(), - RequiredConsensus::PoW(_) | RequiredConsensus::IgnoreConsensus => { - panic!("Invalid consensus") - } - } -} - #[rstest] #[trace] #[case(Seed::from_entropy())] diff --git a/chainstate/test-suite/src/tests/seal_duplication_tests.rs b/chainstate/test-suite/src/tests/seal_duplication_tests.rs new file mode 100644 index 0000000000..7e0835d5f9 --- /dev/null +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -0,0 +1,243 @@ +// Copyright (c) 2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for the indexing of PoS block seals and the recording of duplicate seal evidence. + +use rstest::rstest; + +use chainstate::{BlockSource, ChainstateConfig}; +use chainstate_storage::{BlockchainStorageRead, Transactional as _}; +use chainstate_test_framework::{TestFramework, pos_mine}; +use chainstate_types::{BlockSeal, pos_randomness::PoSRandomness}; +use common::{ + chain::{ + Destination, PoolId, TxOutput, UtxoOutPoint, + block::{ConsensusData, timestamp::BlockTimestamp}, + signature::inputsig::InputWitness, + }, + primitives::{BlockHeight, Id, Idable}, +}; +use crypto::{ + key::{PrivateKey, PublicKey}, + vrf::{VRFKeyKind, VRFPrivateKey}, +}; +use randomness::CryptoRng; +use test_utils::random::{Seed, make_seedable_rng}; + +use super::helpers::pos::{ + calculate_new_target, get_pos_chain_config, produce_kernel_signature, + setup_chain_with_stake_pool, setup_chain_with_stake_pool_with_chainstate_config, +}; + +/// Mine two PoS consensus datas for the same slot by calling `pos_mine` twice with +/// byte-identical arguments. The same arguments make the mining find the same timestamp, +/// and thus produce the same VRF output (which identifies the seal), while the VRF proof +/// bytes differ between the two signings. +/// +/// Both blocks are built on top of the same parent and processed as fully valid blocks; +/// the second one becomes a side block of the first one. +/// +/// Returns the common seal and the ids of the two processed blocks. +fn process_two_blocks_with_same_seal( + rng: &mut impl CryptoRng, + tf: &mut TestFramework, + vrf_sk: &VRFPrivateKey, + stake_pool_outpoint: &UtxoOutPoint, + pool_id: PoolId, + staking_sk: &PrivateKey, +) -> ( + BlockSeal, + Id, + Id, +) { + let staking_destination = Destination::PublicKey(PublicKey::from_private_key(staking_sk)); + let reward_outputs = + vec![TxOutput::ProduceBlockFromStake(staking_destination.clone(), pool_id)]; + + let kernel_sig = produce_kernel_signature( + rng, + tf, + staking_sk, + reward_outputs.as_slice(), + staking_destination, + stake_pool_outpoint.clone(), + ); + + let chain_config = tf.chainstate.get_chain_config(); + let initial_randomness = chain_config.initial_randomness(); + let new_block_height = tf.best_block_index().block_height().next_height(); + let current_difficulty = calculate_new_target(tf, new_block_height).unwrap(); + let final_supply = chain_config.final_supply().unwrap(); + let pos_config = get_pos_chain_config(chain_config, new_block_height); + let initial_timestamp = BlockTimestamp::from_time(tf.current_time()); + let parent_id = tf.best_block_id(); + + let (pos_data_1, block_timestamp_1) = pos_mine( + rng, + &tf.storage.transaction_ro().unwrap(), + &pos_config, + initial_timestamp, + stake_pool_outpoint.clone(), + InputWitness::Standard(kernel_sig.clone()), + vrf_sk, + PoSRandomness::new(initial_randomness), + pool_id, + final_supply, + 1, + current_difficulty, + ) + .expect("should be able to mine"); + + let (pos_data_2, block_timestamp_2) = pos_mine( + rng, + &tf.storage.transaction_ro().unwrap(), + &pos_config, + initial_timestamp, + stake_pool_outpoint.clone(), + InputWitness::Standard(kernel_sig), + vrf_sk, + PoSRandomness::new(initial_randomness), + pool_id, + final_supply, + 1, + current_difficulty, + ) + .expect("should be able to mine"); + + assert_eq!(block_timestamp_1, block_timestamp_2); + // The VRF proofs differ, because they are randomized, but the VRF outputs, and thus + // the seals, must be identical. + assert_ne!(pos_data_1.vrf_data(), pos_data_2.vrf_data()); + + let consensus_data_1 = ConsensusData::PoS(pos_data_1.into()); + let consensus_data_2 = ConsensusData::PoS(pos_data_2.into()); + + let seal = BlockSeal::from_consensus_data(&consensus_data_1).unwrap(); + assert_eq!( + BlockSeal::from_consensus_data(&consensus_data_2), + Some(seal.clone()) + ); + + let block_1 = tf + .make_block_builder() + .with_parent(parent_id) + .with_consensus_data(consensus_data_1) + .with_block_signing_key(staking_sk.clone()) + .with_timestamp(block_timestamp_1) + .with_reward(reward_outputs.clone()) + .build(rng); + let block_id_1 = block_1.get_id(); + tf.process_block(block_1, BlockSource::Local).unwrap(); + + let block_2 = tf + .make_block_builder() + .with_parent(parent_id) + .with_consensus_data(consensus_data_2) + .with_block_signing_key(staking_sk.clone()) + .with_timestamp(block_timestamp_2) + .with_reward(reward_outputs) + .build(rng); + let block_id_2 = block_2.get_id(); + assert_ne!(block_id_1, block_id_2); + tf.process_block(block_2, BlockSource::Local).unwrap(); + + (seal, block_id_1, block_id_2) +} + +// Create a chain genesis <- block_1(StakePool), then process two blocks (block_2a and +// block_2b) that carry the same PoS seal (same pool and same VRF output) on top of +// block_1. Both blocks are fully valid; block_2b remains a side block. +// Check that the seal is indexed for both blocks and that the evidence recorded for the +// block on which the seal was seen for the second time retains both signed headers. +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn duplicate_pos_seal_records_evidence(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); + let (mut tf, stake_pool_outpoint, pool_id, staking_sk) = + setup_chain_with_stake_pool(&mut rng, vrf_pk); + + let (seal, block_id_a, block_id_b) = process_two_blocks_with_same_seal( + &mut rng, + &mut tf, + &vrf_sk, + &stake_pool_outpoint, + pool_id, + &staking_sk, + ); + + // The second block must not have replaced the first one as the best block. + assert_eq!( + tf.best_block_id(), + Id::::from(block_id_a) + ); + + let db_tx = tf.storage.transaction_ro().unwrap(); + + // The seal index contains both blocks at the same height. + let index_entry = db_tx.get_seal_index_entry(&seal).unwrap().unwrap(); + assert_eq!(index_entry.seal(), &seal); + let expected_block_height = BlockHeight::new(2); + assert_eq!( + index_entry.blocks(), + &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] + ); + + // The evidence is recorded for the block on which the seal was seen for the second + // time and it retains the signed headers of both blocks. + assert!(db_tx.get_duplicate_seal_evidence(&block_id_a).unwrap().is_none()); + let evidence = db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().unwrap(); + assert_eq!(evidence.seal(), &seal); + assert_eq!( + evidence.headers().iter().map(|header| header.get_id()).collect::>(), + vec![block_id_a, block_id_b] + ); +} + +// Same as `duplicate_pos_seal_records_evidence`, but with the seal tracking disabled in +// the chainstate config. Check that both blocks are still processed as fully valid, but +// neither the seal index nor the evidence is recorded. +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn seal_tracking_disabled_records_nothing(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); + let chainstate_config = ChainstateConfig { + pos_seal_duplication_tracking: false.into(), + ..Default::default() + }; + let (mut tf, stake_pool_outpoint, pool_id, staking_sk) = + setup_chain_with_stake_pool_with_chainstate_config(&mut rng, vrf_pk, chainstate_config); + + let (seal, block_id_a, block_id_b) = process_two_blocks_with_same_seal( + &mut rng, + &mut tf, + &vrf_sk, + &stake_pool_outpoint, + pool_id, + &staking_sk, + ); + + assert_eq!( + tf.best_block_id(), + Id::::from(block_id_a) + ); + + let db_tx = tf.storage.transaction_ro().unwrap(); + assert!(db_tx.get_seal_index_entry(&seal).unwrap().is_none()); + assert!(db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().is_none()); +} diff --git a/chainstate/test-suite/src/tests/syncing_tests.rs b/chainstate/test-suite/src/tests/syncing_tests.rs index 0901ba90fe..d3a01f73f7 100644 --- a/chainstate/test-suite/src/tests/syncing_tests.rs +++ b/chainstate/test-suite/src/tests/syncing_tests.rs @@ -745,6 +745,7 @@ fn initial_block_download( max_tip_age: Duration::from_secs(1).into(), enable_heavy_checks: Some(true), allow_checkpoints_mismatch: Default::default(), + pos_seal_duplication_tracking: Default::default(), }) .with_initial_time_since_genesis(2) .build(); diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index db1c22ce96..e61b37e9e1 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -164,6 +164,14 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ for (block, block_index) in all_blocks { db_tx.set_block_index(&block_index).unwrap(); db_tx.add_block(&block).unwrap(); + // A processed block also gets its seal indexed (see the seal indexing in the + // chainstate block integration). + chainstate::index_block_seal( + &mut db_tx, + &block.clone().into(), + block_index.block_height(), + ) + .unwrap(); } db_tx.commit().unwrap(); } From 9a27ce1eb9f3f0ccbf2a9c74a726bf52b5f0b046 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 13:27:11 +0400 Subject: [PATCH 05/16] Drop the unused seal tracking builder and document the index deletion --- chainstate/src/config.rs | 5 ----- chainstate/storage/src/lib.rs | 3 ++- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/chainstate/src/config.rs b/chainstate/src/config.rs index 758d6aafce..6ad8b6d6c2 100644 --- a/chainstate/src/config.rs +++ b/chainstate/src/config.rs @@ -75,11 +75,6 @@ impl ChainstateConfig { self } - pub fn with_pos_seal_duplication_tracking(mut self, enable: bool) -> Self { - self.pos_seal_duplication_tracking = enable.into(); - self - } - pub fn pos_seal_duplication_tracking_enabled(&self) -> bool { *self.pos_seal_duplication_tracking } diff --git a/chainstate/storage/src/lib.rs b/chainstate/storage/src/lib.rs index 3fc4a23e9c..251eeecb69 100644 --- a/chainstate/storage/src/lib.rs +++ b/chainstate/storage/src/lib.rs @@ -292,7 +292,8 @@ pub trait BlockchainStorageWrite: /// Set the seal index entry of the given seal fn set_seal_index_entry(&mut self, seal: &BlockSeal, entry: &SealIndexEntry) -> Result<()>; - /// Remove the seal index entry of the given seal + /// Remove the seal index entry of the given seal. Used by the seal index pruning + /// of the entries whose blocks fell out of the reorg range. fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> Result<()>; /// Record the duplicate seal evidence for the given block From 727f569460fc1e160046214877ff04719da95a36 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 14:27:20 +0400 Subject: [PATCH 06/16] Bound the evidence records per seal and address review findings Once a seal's index entry reaches its cap, new sightings are only logged: neither the index entry nor the evidence records are extended anymore, so the storage footprint of a single reused seal stays bounded. Already recorded evidence is never removed. A missing header of an indexed block is now logged as a warning instead of silently writing an incomplete evidence record, and the duplicate seal evidence map gets its deletion counterpart for future tooling. --- .../src/detail/chainstateref/seal_index.rs | 86 +++++++++++++++---- .../src/internal/store_tx/write_impls.rs | 5 ++ chainstate/storage/src/lib.rs | 12 ++- chainstate/storage/src/mock/mock_impl.rs | 2 + .../src/tests/seal_duplication_tests.rs | 72 ++++++++++++++++ .../src/tests/tx_verification_simulation.rs | 13 ++- chainstate/types/src/seal.rs | 4 +- node-lib/src/config_files/mod.rs | 2 + 8 files changed, 168 insertions(+), 28 deletions(-) diff --git a/chainstate/src/detail/chainstateref/seal_index.rs b/chainstate/src/detail/chainstateref/seal_index.rs index 3e344b2c1e..d6737fd8d6 100644 --- a/chainstate/src/detail/chainstateref/seal_index.rs +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -20,8 +20,10 @@ //! the seal are retained as a self-certifying evidence record. //! //! The index entry of a single seal is capped at [`MAX_BLOCKS_PER_SEAL`] blocks. -//! Once the cap is reached, the evidence records are still written for any new -//! blocks that carry the seal, but the index entry is no longer extended. +//! Once the cap is reached, neither the index entry nor the evidence records of +//! the seal are extended anymore, so the storage footprint of a single reused +//! seal stays bounded. The evidence records that were already recorded are never +//! removed by this module. use std::num::NonZeroUsize; @@ -71,11 +73,20 @@ pub fn index_block_seal( return Ok(()); } - record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; - if entry.blocks().len() < MAX_BLOCKS_PER_SEAL.get() { + record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; entry.push_block(block_id, block_height); db_tx.set_seal_index_entry(entry.seal(), &entry)?; + } else { + // The index entry of the seal is at its cap, so the seal reuse is already + // covered by the recorded evidence. Log the sighting, but do not extend the + // index and do not record redundant evidence, keeping the storage footprint + // of a single reused seal bounded. + log::info!( + "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached (block {})", + seal.pool_id(), + block.get_id(), + ); } Ok(()) @@ -91,8 +102,17 @@ fn record_duplicate_seal_evidence( ) -> Result<(), BlockError> { let mut evidence = DuplicateSealEvidence::new(seal.clone(), Vec::new()); for (existing_id, _) in known_blocks { - if let Some(header) = db_tx.get_block_header(existing_id)? { - evidence.push_header(header); + match db_tx.get_block_header(existing_id)? { + Some(header) => evidence.push_header(header), + None => { + // Unreachable in practice: indexed blocks are persisted together with + // their headers. If it ever happens, retain the rest of the evidence, + // but make the gap visible instead of silently writing a weak record. + log::warn!( + "The header of the indexed block {} is missing while recording duplicate seal evidence", + existing_id + ); + } } } evidence.push_header(block.header().clone()); @@ -265,11 +285,9 @@ mod tests { } #[test] - fn index_entry_is_capped_and_evidence_is_kept() { + fn index_entry_is_capped() { let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); - let block_id = block.get_id(); - let header = block.header().clone(); let seal = BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(); let known_blocks = (0..MAX_BLOCKS_PER_SEAL.get()) @@ -279,19 +297,53 @@ mod tests { let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); - // The headers of the known blocks may be unavailable, in which case the - // evidence record still retains the header of the new block. - db.expect_get_block_header().times(..).return_const(Ok(None)); + // The index entry of the seal is at its cap, so the seal reuse is already + // covered by the previously recorded evidence: no header is looked up, no + // new evidence is written and the index entry is not extended, keeping the + // storage footprint of a single reused seal bounded. + db.expect_get_block_header().times(0); + db.expect_set_duplicate_seal_evidence().times(0); + db.expect_set_seal_index_entry().times(0); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + + #[test] + fn duplicate_seal_records_evidence_with_missing_known_block_header() { + // Same as `duplicate_seal_records_evidence`, but the header of the known + // block is unavailable. The evidence record still gets written, retaining + // the header of the new block. + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let seed = H256::zero(); + let block_1 = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, seed); + let block_2 = make_pos_block(H256::from([1u8; 32]), &vrf_sk, &vrf_pk, seed); + + assert_ne!(block_1.get_id(), block_2.get_id()); + + let id_1 = block_1.get_id(); + let id_2 = block_2.get_id(); + let header_2 = block_2.header().clone(); + let entry = SealIndexEntry::new( + BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(), + vec![(id_1, TEST_HEIGHT)], + ); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_block_header().times(1).with(eq(id_1)).return_const(Ok(None)); db.expect_set_duplicate_seal_evidence() .times(1) - .withf(move |recorded_id, evidence| { - recorded_id == &block_id + .withf(move |block_id, evidence| { + block_id == &id_2 && evidence.headers().len() == 1 - && evidence.headers()[0] == header + && evidence.headers()[0] == header_2 }) .return_const(Ok(())); - db.expect_set_seal_index_entry().times(0); + db.expect_set_seal_index_entry() + .times(1) + .withf(|_, entry| entry.blocks().len() == 2) + .return_const(Ok(())); - index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + index_block_seal(&mut db, &block_2, TEST_HEIGHT).unwrap(); } } diff --git a/chainstate/storage/src/internal/store_tx/write_impls.rs b/chainstate/storage/src/internal/store_tx/write_impls.rs index 21e7322dea..ac32dcbcd9 100644 --- a/chainstate/storage/src/internal/store_tx/write_impls.rs +++ b/chainstate/storage/src/internal/store_tx/write_impls.rs @@ -243,6 +243,11 @@ impl BlockchainStorageWrite for StoreTxRw<'_, B> { ) -> crate::Result<()> { self.write::(block_id, evidence) } + + #[log_error] + fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()> { + self.del::(block_id) + } } impl EpochStorageWrite for StoreTxRw<'_, B> { diff --git a/chainstate/storage/src/lib.rs b/chainstate/storage/src/lib.rs index 251eeecb69..d0a6b39042 100644 --- a/chainstate/storage/src/lib.rs +++ b/chainstate/storage/src/lib.rs @@ -292,8 +292,10 @@ pub trait BlockchainStorageWrite: /// Set the seal index entry of the given seal fn set_seal_index_entry(&mut self, seal: &BlockSeal, entry: &SealIndexEntry) -> Result<()>; - /// Remove the seal index entry of the given seal. Used by the seal index pruning - /// of the entries whose blocks fell out of the reorg range. + /// Remove the seal index entry of the given seal. + /// + /// Currently unused: it is the deletion counterpart of the map, to be used by + /// the pruning of the entries whose blocks fell out of the reorg range. fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> Result<()>; /// Record the duplicate seal evidence for the given block @@ -302,6 +304,12 @@ pub trait BlockchainStorageWrite: block_id: &Id, evidence: &DuplicateSealEvidence, ) -> Result<()>; + + /// Remove the duplicate seal evidence recorded for the given block. + /// + /// Currently unused: it is the deletion counterpart of the map, for future + /// tooling (the indexing logic never removes already recorded evidence). + fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> Result<()>; } /// Operations on read-only transactions diff --git a/chainstate/storage/src/mock/mock_impl.rs b/chainstate/storage/src/mock/mock_impl.rs index e1b81b79be..fa845ed54e 100644 --- a/chainstate/storage/src/mock/mock_impl.rs +++ b/chainstate/storage/src/mock/mock_impl.rs @@ -255,6 +255,7 @@ mockall::mock! { block_id: &Id, evidence: &DuplicateSealEvidence, ) -> crate::Result<()>; + fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()>; } impl EpochStorageWrite for Store { @@ -704,6 +705,7 @@ mockall::mock! { block_id: &Id, evidence: &DuplicateSealEvidence, ) -> crate::Result<()>; + fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()>; } impl EpochStorageWrite for StoreTxRw { diff --git a/chainstate/test-suite/src/tests/seal_duplication_tests.rs b/chainstate/test-suite/src/tests/seal_duplication_tests.rs index 7e0835d5f9..35b548921b 100644 --- a/chainstate/test-suite/src/tests/seal_duplication_tests.rs +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -241,3 +241,75 @@ fn seal_tracking_disabled_records_nothing(#[case] seed: Seed) { assert!(db_tx.get_seal_index_entry(&seal).unwrap().is_none()); assert!(db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().is_none()); } + +// Create a chain genesis <- block_1(StakePool), then process two blocks (block_2a and +// block_2b) that carry the same PoS seal, with block_2b remaining a side block (see +// `process_two_blocks_with_same_seal`). Then extend the branch of block_2b with a child +// block, which makes it the best chain and triggers a reorg that disconnects block_2a. +// Check that the seal records survive the reorg: they are not rolled back on disconnect, +// so the seal is still indexed for both blocks and the evidence recorded for block_2b +// still retains both signed headers. +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn duplicate_seal_records_survive_reorg(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); + let (mut tf, stake_pool_outpoint, pool_id, staking_sk) = + setup_chain_with_stake_pool(&mut rng, vrf_pk); + + let (seal, block_id_a, block_id_b) = process_two_blocks_with_same_seal( + &mut rng, + &mut tf, + &vrf_sk, + &stake_pool_outpoint, + pool_id, + &staking_sk, + ); + + // block_2a is the tip and block_2b is its side sibling. + assert_eq!( + tf.best_block_id(), + Id::::from(block_id_a) + ); + + // Advance the time, so that the child block below is staked in a different slot + // and thus carries a different seal than the one under test. + tf.progress_time_seconds_since_epoch(30); + + // Extend the branch of block_2b with a child block, which makes it the best chain + // and triggers a reorg that disconnects block_2a. + let child_index = tf + .make_pos_block_builder() + .with_parent(block_id_b.into()) + .with_stake_pool_id(pool_id) + .with_stake_spending_key(staking_sk) + .with_vrf_key(vrf_sk) + .build_and_process(&mut rng) + .unwrap() + .unwrap(); + assert_eq!( + tf.best_block_id(), + Id::::from(*child_index.block_id()) + ); + + let db_tx = tf.storage.transaction_ro().unwrap(); + + // The seal index still contains both blocks at the same height; in particular, + // the record of the disconnected block_2a was not rolled back. + let index_entry = db_tx.get_seal_index_entry(&seal).unwrap().unwrap(); + let expected_block_height = BlockHeight::new(2); + assert_eq!( + index_entry.blocks(), + &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] + ); + + // The evidence recorded for block_2b survived the reorg as well. + let evidence = db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().unwrap(); + assert_eq!(evidence.seal(), &seal); + assert_eq!( + evidence.headers().iter().map(|header| header.get_id()).collect::>(), + vec![block_id_a, block_id_b] + ); + assert!(db_tx.get_duplicate_seal_evidence(&block_id_a).unwrap().is_none()); +} diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index e61b37e9e1..0a3cd4677f 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -22,7 +22,7 @@ use common::{ ChainstateUpgradeBuilder, ConsensusUpgrade, NetUpgrades, PoSChainConfigBuilder, TokenIdGenerationVersion, UtxoOutPoint, }, - primitives::BlockCount, + primitives::{BlockCount, id::WithId}, }; use crypto::{ key::{KeyKind, PrivateKey}, @@ -163,15 +163,14 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ let mut db_tx = reference_tf.storage.transaction_rw(None).unwrap(); for (block, block_index) in all_blocks { db_tx.set_block_index(&block_index).unwrap(); + // Wrap the block without the deep copy that `.into()` from a + // reference would need; `WithId` derefs to `Block`. + let block = WithId::new(block); db_tx.add_block(&block).unwrap(); // A processed block also gets its seal indexed (see the seal indexing in the // chainstate block integration). - chainstate::index_block_seal( - &mut db_tx, - &block.clone().into(), - block_index.block_height(), - ) - .unwrap(); + chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) + .unwrap(); } db_tx.commit().unwrap(); } diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 2b7ca1609f..ca5ae731ea 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -78,8 +78,8 @@ impl BlockSeal { /// An index entry of a seal: the blocks known to carry it. /// -/// The number of blocks per entry is bounded by the storage layer, so the index -/// stays bounded even if a seal is deliberately reused on many blocks. +/// The number of blocks per entry is bounded by the seal indexing logic, so the +/// index stays bounded even if a seal is deliberately reused on many blocks. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SealIndexEntry { seal: BlockSeal, diff --git a/node-lib/src/config_files/mod.rs b/node-lib/src/config_files/mod.rs index 18c0e4c7a8..aa0e75f51f 100644 --- a/node-lib/src/config_files/mod.rs +++ b/node-lib/src/config_files/mod.rs @@ -173,6 +173,8 @@ fn chainstate_config( max_tip_age, enable_heavy_checks, allow_checkpoints_mismatch, + // Intentionally not overridden by a CLI option: the config file is the only + // control surface for this setting. pos_seal_duplication_tracking, }; ChainstateLauncherConfigFile { From 096933ee25b7a31a9ed57adb905feafc020a2316 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 14:45:59 +0400 Subject: [PATCH 07/16] Remove the unused seal constructor and document the upgrade limitation --- chainstate/storage/src/schema.rs | 3 +++ chainstate/test-suite/src/tests/helpers/pos.rs | 4 ++-- .../test-suite/src/tests/tx_verification_simulation.rs | 5 +++-- chainstate/types/src/seal.rs | 7 ------- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/chainstate/storage/src/schema.rs b/chainstate/storage/src/schema.rs index 43af24ea7c..cfff98e1b5 100644 --- a/chainstate/storage/src/schema.rs +++ b/chainstate/storage/src/schema.rs @@ -103,9 +103,12 @@ storage::decl_schema! { /// Store for PoS seal index entries, i.e. the blocks known to carry each seal. /// The number of blocks per entry is bounded by the seal indexing logic. + /// Note: nodes that upgrade from a storage version without this map start with + /// an empty index; the index only covers the blocks processed after the upgrade. pub DBSealIndex: Map, /// Store for duplicate PoS seal evidence records, keyed by the id of the block /// whose processing discovered the duplication. + /// Note: like the seal index, this map starts empty on upgraded nodes. pub DBDuplicateSealEvidence: Map, DuplicateSealEvidence>, } } diff --git a/chainstate/test-suite/src/tests/helpers/pos.rs b/chainstate/test-suite/src/tests/helpers/pos.rs index 584f5b5423..be191f6461 100644 --- a/chainstate/test-suite/src/tests/helpers/pos.rs +++ b/chainstate/test-suite/src/tests/helpers/pos.rs @@ -184,8 +184,8 @@ pub fn get_pos_chain_config( ) -> PoSChainConfig { match chain_config.consensus_upgrades().consensus_status(block_height) { RequiredConsensus::PoS(status) => status.get_chain_config().clone(), - RequiredConsensus::PoW(_) | RequiredConsensus::IgnoreConsensus => { - panic!("Invalid consensus") + status @ (RequiredConsensus::PoW(_) | RequiredConsensus::IgnoreConsensus) => { + panic!("Invalid consensus at height {block_height}: {status:?}") } } } diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index 0a3cd4677f..ddb07ea139 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -167,8 +167,9 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ // reference would need; `WithId` derefs to `Block`. let block = WithId::new(block); db_tx.add_block(&block).unwrap(); - // A processed block also gets its seal indexed (see the seal indexing in the - // chainstate block integration). + // This replicates the integration-path seal indexing, which is gated by + // `pos_seal_duplication_tracking`; parity with `tf.storage` holds because all + // frameworks in this test use the default config (tracking enabled). chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) .unwrap(); } diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index ca5ae731ea..5168a279c6 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -41,13 +41,6 @@ pub struct BlockSeal { } impl BlockSeal { - pub fn new(pool_id: PoolId, vrf_output: H256) -> Self { - Self { - pool_id, - vrf_output, - } - } - /// Extract the seal from the block's consensus data. /// /// Returns `None` if the consensus data does not carry a proof-of-stake seal, From f43124a77de8be24059f4848cdf992d2a01df385 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 15:02:27 +0400 Subject: [PATCH 08/16] Pin the seal encoding with golden bytes and derive the test PoS height --- chainstate/test-suite/src/tests/helpers/pos.rs | 6 +++++- .../src/tests/seal_duplication_tests.rs | 8 ++++---- .../src/tests/tx_verification_simulation.rs | 7 ++++++- chainstate/types/src/seal.rs | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/chainstate/test-suite/src/tests/helpers/pos.rs b/chainstate/test-suite/src/tests/helpers/pos.rs index be191f6461..82c3b2aac6 100644 --- a/chainstate/test-suite/src/tests/helpers/pos.rs +++ b/chainstate/test-suite/src/tests/helpers/pos.rs @@ -74,6 +74,10 @@ pub fn create_custom_genesis_with_stake_pool( ) } +/// The height of the first PoS block in chains built by these helpers: the +/// stake-pool block sits at height 1, so the PoS (seal) blocks live at height 2. +pub const FIRST_POS_BLOCK_HEIGHT: BlockHeight = BlockHeight::new(2); + pub fn consensus_upgrades_with_pos_at_height(height: BlockHeight) -> NetUpgrades { NetUpgrades::initialize(vec![ (BlockHeight::new(0), ConsensusUpgrade::IgnoreConsensus), @@ -131,7 +135,7 @@ pub fn setup_chain_with_stake_pool_with_chainstate_config( vrf_pk: VRFPublicKey, chainstate_config: ChainstateConfig, ) -> (TestFramework, UtxoOutPoint, PoolId, PrivateKey) { - let net_upgrades = consensus_upgrades_with_pos_at_height(BlockHeight::new(2)); + let net_upgrades = consensus_upgrades_with_pos_at_height(FIRST_POS_BLOCK_HEIGHT); let chain_config = ConfigBuilder::test_chain() .consensus_upgrades(net_upgrades) .epoch_length(TEST_EPOCH_LENGTH) diff --git a/chainstate/test-suite/src/tests/seal_duplication_tests.rs b/chainstate/test-suite/src/tests/seal_duplication_tests.rs index 35b548921b..d3845ef70d 100644 --- a/chainstate/test-suite/src/tests/seal_duplication_tests.rs +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -27,7 +27,7 @@ use common::{ block::{ConsensusData, timestamp::BlockTimestamp}, signature::inputsig::InputWitness, }, - primitives::{BlockHeight, Id, Idable}, + primitives::{Id, Idable}, }; use crypto::{ key::{PrivateKey, PublicKey}, @@ -37,7 +37,7 @@ use randomness::CryptoRng; use test_utils::random::{Seed, make_seedable_rng}; use super::helpers::pos::{ - calculate_new_target, get_pos_chain_config, produce_kernel_signature, + FIRST_POS_BLOCK_HEIGHT, calculate_new_target, get_pos_chain_config, produce_kernel_signature, setup_chain_with_stake_pool, setup_chain_with_stake_pool_with_chainstate_config, }; @@ -190,7 +190,7 @@ fn duplicate_pos_seal_records_evidence(#[case] seed: Seed) { // The seal index contains both blocks at the same height. let index_entry = db_tx.get_seal_index_entry(&seal).unwrap().unwrap(); assert_eq!(index_entry.seal(), &seal); - let expected_block_height = BlockHeight::new(2); + let expected_block_height = FIRST_POS_BLOCK_HEIGHT; assert_eq!( index_entry.blocks(), &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] @@ -298,7 +298,7 @@ fn duplicate_seal_records_survive_reorg(#[case] seed: Seed) { // The seal index still contains both blocks at the same height; in particular, // the record of the disconnected block_2a was not rolled back. let index_entry = db_tx.get_seal_index_entry(&seal).unwrap().unwrap(); - let expected_block_height = BlockHeight::new(2); + let expected_block_height = FIRST_POS_BLOCK_HEIGHT; assert_eq!( index_entry.blocks(), &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index ddb07ea139..5fed35ab1d 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -169,7 +169,12 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ db_tx.add_block(&block).unwrap(); // This replicates the integration-path seal indexing, which is gated by // `pos_seal_duplication_tracking`; parity with `tf.storage` holds because all - // frameworks in this test use the default config (tracking enabled). + // frameworks in this test use the default config (tracking enabled), which + // implies the corollary: if any framework in this test ever switches to + // `pos_seal_duplication_tracking: false`, or the setting default changes, + // this call must be gated the same way as the integration path + // (`chainstate/src/detail/mod.rs`) or the storage dump comparison will + // diverge on the seal tables. chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) .unwrap(); } diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 5168a279c6..7dae52a977 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -189,6 +189,24 @@ mod tests { assert_eq!(seal.encode(), encoded); } + #[test] + fn seal_encoding_is_stable() { + // The seal is used as a database key: any change to the encoding would make + // previously written entries unreadable, so pin the exact encoding here. + let seal = BlockSeal { + pool_id: PoolId::new(H256::from([1u8; 32])), + vrf_output: H256::from([2u8; 32]), + }; + + let expected_encoded = { + let mut encoded = Vec::new(); + encoded.extend_from_slice(H256::from([1u8; 32]).as_bytes()); + encoded.extend_from_slice(H256::from([2u8; 32]).as_bytes()); + encoded + }; + assert_eq!(seal.encode(), expected_encoded); + } + #[test] fn same_slot_draw_produces_same_seal() { let pool_id = PoolId::new(H256::zero()); From 2f04c7a50cb088c9c363e7415020ee3949a1c799 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 15:23:26 +0400 Subject: [PATCH 09/16] Derive hashing and ordering for BlockSeal and gate the simulation indexing on the config --- .../src/tests/tx_verification_simulation.rs | 22 +++++++++++-------- chainstate/types/src/seal.rs | 10 ++++----- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index 5fed35ab1d..0278a83bac 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -16,6 +16,7 @@ use std::{collections::BTreeMap, num::NonZeroU64}; use super::*; +use chainstate::chainstate_interface::ChainstateInterface; use chainstate_storage::{BlockchainStorageWrite, TransactionRw, Transactional}; use common::{ chain::{ @@ -167,16 +168,19 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ // reference would need; `WithId` derefs to `Block`. let block = WithId::new(block); db_tx.add_block(&block).unwrap(); - // This replicates the integration-path seal indexing, which is gated by - // `pos_seal_duplication_tracking`; parity with `tf.storage` holds because all - // frameworks in this test use the default config (tracking enabled), which - // implies the corollary: if any framework in this test ever switches to - // `pos_seal_duplication_tracking: false`, or the setting default changes, - // this call must be gated the same way as the integration path - // (`chainstate/src/detail/mod.rs`) or the storage dump comparison will + // This replicates the integration-path seal indexing and is gated the same + // way, on the effective config of the reference framework + // (`pos_seal_duplication_tracking`), mirroring the integration path + // (`chainstate/src/detail/mod.rs`) so the storage dump comparison cannot // diverge on the seal tables. - chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) - .unwrap(); + if reference_tf + .chainstate + .get_chainstate_config() + .pos_seal_duplication_tracking_enabled() + { + chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) + .unwrap(); + } } db_tx.commit().unwrap(); } diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 7dae52a977..066c1b75af 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -32,7 +32,7 @@ use serialization::{Decode, Encode}; /// Note that the VRF proof is not a part of the seal: unlike the VRF output, the /// proof bytes may differ between two signings of the same transcript, so the /// proof cannot be used to identify a slot draw. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] pub struct BlockSeal { /// Id of the stake pool that produced the block. pool_id: PoolId, @@ -181,12 +181,10 @@ mod tests { fn seal_codec_roundtrip() { let (_, seal) = make_seal(0, H256::zero()); - let encoded = seal.encode(); - let decoded = BlockSeal::decode(&mut &encoded[..]).unwrap(); + // The exact encoding is pinned by `seal_encoding_is_stable`; here we only + // verify that decoding recovers the encoded seal. + let decoded = BlockSeal::decode(&mut &seal.encode()[..]).unwrap(); assert_eq!(decoded, seal); - - // The seal is used as a database key, so its encoding must be deterministic. - assert_eq!(seal.encode(), encoded); } #[test] From 847d91e494f702fd5aa0c287c6c067d92416f441 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 20 Sep 2026 18:42:17 +0400 Subject: [PATCH 10/16] Document the seal detection scope and drop the redundant error logging --- chainstate/src/detail/chainstateref/mod.rs | 1 - chainstate/types/src/seal.rs | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/chainstate/src/detail/chainstateref/mod.rs b/chainstate/src/detail/chainstateref/mod.rs index cc00637196..f49e5965cc 100644 --- a/chainstate/src/detail/chainstateref/mod.rs +++ b/chainstate/src/detail/chainstateref/mod.rs @@ -1430,7 +1430,6 @@ impl ChainstateRe /// Index the seal of the given block, recording evidence if the seal was already /// seen on another block. - #[log_error] pub fn index_block_seal( &mut self, block: &WithId, diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 066c1b75af..46e61c1cb6 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -25,13 +25,18 @@ use serialization::{Decode, Encode}; /// The seal of a proof-of-stake block: the stake pool that produced the block and /// the VRF output that authorized the block production for the given slot. /// -/// The VRF output is uniquely determined by the transcript it was produced over +/// A VRF proof is uniquely determined by the transcript it was produced over /// (epoch index, randomness seed, block timestamp), so all valid blocks that share /// the same seal were authorized by the same pool for the same slot. /// /// Note that the VRF proof is not a part of the seal: unlike the VRF output, the /// proof bytes may differ between two signings of the same transcript, so the /// proof cannot be used to identify a slot draw. +/// +/// Note also that the seal identity is bound to the timestamp of the slot: two +/// blocks that a pool produced for different (valid) timestamps have different +/// seals. The seal index detects the reuse of a single slot draw, not every form +/// of double block production. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] pub struct BlockSeal { /// Id of the stake pool that produced the block. From 506455a73ca99a1be02fbfc1b1cb3ab6f85c5142 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 08:43:55 +0400 Subject: [PATCH 11/16] Key the seal evidence by seal and share the indexing gate Re-key the duplicate seal evidence map from the block id to the seal, so a reused seal is covered by a single extendable record instead of a fresh copy of the known headers per sighting. Only retain headers whose consensus data carries the seal under test, and skip writing an evidence record without at least one corroborating header, so the store only holds meaningful records. Gate the seal indexing in a single helper (index_block_seal_if_enabled) shared by the integration path and the test-suite storage replication, and drop the internal indexing function from the crate root export. --- chainstate/src/detail/chainstateref/mod.rs | 11 +- .../src/detail/chainstateref/seal_index.rs | 279 +++++++++++++++--- chainstate/src/detail/mod.rs | 22 +- chainstate/src/lib.rs | 2 +- .../src/internal/store_tx/read_impls.rs | 8 +- .../src/internal/store_tx/write_impls.rs | 8 +- chainstate/storage/src/lib.rs | 13 +- chainstate/storage/src/mock/mock_impl.rs | 14 +- chainstate/storage/src/schema.rs | 6 +- .../src/tests/seal_duplication_tests.rs | 20 +- .../src/tests/tx_verification_simulation.rs | 23 +- chainstate/types/src/seal.rs | 5 +- 12 files changed, 306 insertions(+), 105 deletions(-) diff --git a/chainstate/src/detail/chainstateref/mod.rs b/chainstate/src/detail/chainstateref/mod.rs index f49e5965cc..ee0083e5b7 100644 --- a/chainstate/src/detail/chainstateref/mod.rs +++ b/chainstate/src/detail/chainstateref/mod.rs @@ -1429,13 +1429,18 @@ impl ChainstateRe } /// Index the seal of the given block, recording evidence if the seal was already - /// seen on another block. - pub fn index_block_seal( + /// seen on another block. A no-op if seal duplication tracking is disabled. + pub fn index_block_seal_if_enabled( &mut self, block: &WithId, block_height: BlockHeight, ) -> Result<(), BlockError> { - seal_index::index_block_seal(&mut self.db_tx, block, block_height) + seal_index::index_block_seal_if_enabled( + self.chainstate_config, + &mut self.db_tx, + block, + block_height, + ) } #[log_error] diff --git a/chainstate/src/detail/chainstateref/seal_index.rs b/chainstate/src/detail/chainstateref/seal_index.rs index d6737fd8d6..0db0704e09 100644 --- a/chainstate/src/detail/chainstateref/seal_index.rs +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -20,10 +20,11 @@ //! the seal are retained as a self-certifying evidence record. //! //! The index entry of a single seal is capped at [`MAX_BLOCKS_PER_SEAL`] blocks. -//! Once the cap is reached, neither the index entry nor the evidence records of +//! Once the cap is reached, neither the index entry nor the evidence record of //! the seal are extended anymore, so the storage footprint of a single reused -//! seal stays bounded. The evidence records that were already recorded are never -//! removed by this module. +//! seal stays bounded. A seal is covered by at most one evidence record, which +//! is rewritten in full on every new sighting below the cap. The evidence +//! records that were already recorded are never removed by this module. use std::num::NonZeroUsize; @@ -36,7 +37,7 @@ use common::{ use logging::log; use utils::log_error; -use crate::BlockError; +use crate::{BlockError, config::ChainstateConfig}; /// The maximum number of blocks a single seal is indexed for. // The cap exists to bound the storage and processing costs of seal reuse. It is @@ -44,6 +45,25 @@ use crate::BlockError; // matters for deliberately reused seals. pub const MAX_BLOCKS_PER_SEAL: NonZeroUsize = NonZeroUsize::new(8).unwrap(); +/// Index the seal of the given block if seal duplication tracking is enabled in +/// the given config. +/// +/// This is the single place where the indexing is gated on the config, so that +/// all the callers (the block integration path and the storage replication in +/// the test suite) cannot diverge on the seal tables. +pub fn index_block_seal_if_enabled( + chainstate_config: &ChainstateConfig, + db_tx: &mut S, + block: &WithId, + block_height: BlockHeight, +) -> Result<(), BlockError> { + if chainstate_config.pos_seal_duplication_tracking_enabled() { + index_block_seal(db_tx, block, block_height) + } else { + Ok(()) + } +} + /// Index the seal of the given block, recording evidence if the seal was already /// seen on another block. /// @@ -51,7 +71,7 @@ pub const MAX_BLOCKS_PER_SEAL: NonZeroUsize = NonZeroUsize::new(8).unwrap(); /// integration into the block tree, so that the seal index covers the blocks of /// all branches, not only those of the best chain. #[log_error] -pub fn index_block_seal( +fn index_block_seal( db_tx: &mut S, block: &WithId, block_height: BlockHeight, @@ -92,18 +112,47 @@ pub fn index_block_seal( Ok(()) } -/// Retain the headers of the known blocks that carry the given seal, plus the -/// header of the newly seen block, as a duplicate seal evidence record. +/// Extend the evidence record of the given seal with the header of the newly +/// seen block and the headers of the known blocks that carry the seal. +/// +/// The seal is covered by a single evidence record, which is extended with the +/// missing headers on every sighting below the cap and written back, so a +/// reused seal never stores more than one copy of each header. A header is only +/// retained if its consensus data carries the seal under test, so the record +/// stays self-certifying on its own. fn record_duplicate_seal_evidence( db_tx: &mut S, seal: &BlockSeal, known_blocks: &[(Id, BlockHeight)], block: &WithId, ) -> Result<(), BlockError> { - let mut evidence = DuplicateSealEvidence::new(seal.clone(), Vec::new()); + let mut evidence = db_tx + .get_duplicate_seal_evidence(seal)? + .unwrap_or_else(|| DuplicateSealEvidence::new(seal.clone(), Vec::new())); + for (existing_id, _) in known_blocks { + // The headers of the previously seen blocks are already retained in the + // record, so only the ones that are missing have to be collected. + if evidence.headers().iter().any(|header| header.get_id() == *existing_id) { + continue; + } match db_tx.get_block_header(existing_id)? { - Some(header) => evidence.push_header(header), + Some(header) + if BlockSeal::from_consensus_data(header.consensus_data()).as_ref() + == Some(seal) => + { + evidence.push_header(header) + } + Some(_) => { + // Unreachable in practice: the index entry only lists the blocks + // that were seen carrying this seal. Skip such a header instead of + // weakening the record with one that does not corroborate it. + log::warn!( + "The header of the indexed block {} does not carry the expected seal of pool {} while recording duplicate seal evidence", + existing_id, + seal.pool_id(), + ); + } None => { // Unreachable in practice: indexed blocks are persisted together with // their headers. If it ever happens, retain the rest of the evidence, @@ -117,13 +166,25 @@ fn record_duplicate_seal_evidence( } evidence.push_header(block.header().clone()); + // A single signed header proves nothing about duplication: if no headers of + // the other blocks that carry the seal could be collected, there is no + // evidence to record. The seal is still indexed by the caller, so the + // evidence can be filled in by a later sighting. + if evidence.headers().len() < 2 { + log::warn!( + "No corroborating headers available for the duplicate seal of pool {}; no evidence recorded", + seal.pool_id(), + ); + return Ok(()); + } + log::info!( "A PoS seal of pool {} was seen on more than one block; recorded evidence for block {}", seal.pool_id(), block.get_id(), ); - db_tx.set_duplicate_seal_evidence(&block.get_id(), &evidence)?; + db_tx.set_duplicate_seal_evidence(seal, &evidence)?; Ok(()) } @@ -155,12 +216,27 @@ mod tests { vrf_sk: &VRFPrivateKey, vrf_pk: &VRFPublicKey, seed: H256, + ) -> WithId { + make_pos_block_with_epoch(prev_block_id, vrf_sk, vrf_pk, seed, 0) + } + + /// Make a PoS block whose seal is derived from the given seed and the epoch + /// index offset from the epoch of the test height, so blocks made with the + /// same arguments share the seal, while a different epoch offset yields a + /// different seal. + fn make_pos_block_with_epoch( + prev_block_id: H256, + vrf_sk: &VRFPrivateKey, + vrf_pk: &VRFPublicKey, + seed: H256, + epoch_offset: u64, ) -> WithId { let chain_config = ConfigBuilder::test_chain().epoch_length(NonZeroU64::new(3).unwrap()).build(); let timestamp = BlockTimestamp::from_int_seconds(1); - let epoch_index = chain_config.epoch_index_from_height(&TEST_HEIGHT.next_height()); + let epoch_index = + chain_config.epoch_index_from_height(&TEST_HEIGHT.next_height()) + epoch_offset; let transcript = construct_transcript(epoch_index, &seed, timestamp); let vrf_data = vrf_sk.produce_vrf_data(transcript); let pool_id = PoolId::new(H256::zero()); @@ -237,24 +313,25 @@ mod tests { ); let id_1 = block_1.get_id(); - let id_2 = block_2.get_id(); let header_1 = block_1.header().clone(); let header_2 = block_2.header().clone(); - let entry = SealIndexEntry::new( - BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(), - vec![(id_1, TEST_HEIGHT)], - ); + let seal = BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(); + let entry = SealIndexEntry::new(seal.clone(), vec![(id_1, TEST_HEIGHT)]); let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_duplicate_seal_evidence() + .times(1) + .with(eq(seal.clone())) + .return_const(Ok(None)); db.expect_get_block_header() .times(1) .with(eq(id_1)) .return_const(Ok(Some(header_1.clone()))); db.expect_set_duplicate_seal_evidence() .times(1) - .withf(move |block_id, evidence| { - block_id == &id_2 + .withf(move |seal, evidence| { + evidence.seal() == seal && evidence.headers().len() == 2 && evidence.headers()[0] == header_1 && evidence.headers()[1] == header_2 @@ -268,6 +345,116 @@ mod tests { index_block_seal(&mut db, &block_2, TEST_HEIGHT).unwrap(); } + #[test] + fn duplicate_seal_extends_the_existing_evidence_record() { + // Same as `duplicate_seal_records_evidence`, but the seal already has an + // evidence record from a previous sighting. The record must be extended + // with the header of the newly seen block only, without duplicating the + // headers it already retains and without writing a second record. + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let seed = H256::zero(); + let block_1 = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, seed); + let block_2 = make_pos_block(H256::from([1u8; 32]), &vrf_sk, &vrf_pk, seed); + let block_3 = make_pos_block(H256::from([2u8; 32]), &vrf_sk, &vrf_pk, seed); + + let id_1 = block_1.get_id(); + let id_2 = block_2.get_id(); + let header_1 = block_1.header().clone(); + let header_2 = block_2.header().clone(); + let header_3 = block_3.header().clone(); + let seal = BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(); + + let entry = + SealIndexEntry::new(seal.clone(), vec![(id_1, TEST_HEIGHT), (id_2, TEST_HEIGHT)]); + let existing_evidence = + DuplicateSealEvidence::new(seal.clone(), vec![header_1.clone(), header_2.clone()]); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_duplicate_seal_evidence() + .times(1) + .with(eq(seal.clone())) + .return_const(Ok(Some(existing_evidence))); + // The headers of both known blocks are already retained in the record, + // so no header is looked up. + db.expect_get_block_header().times(0); + db.expect_set_duplicate_seal_evidence() + .times(1) + .withf(move |seal, evidence| { + evidence.seal() == seal + && evidence.headers().len() == 3 + && evidence.headers()[0] == header_1 + && evidence.headers()[1] == header_2 + && evidence.headers()[2] == header_3 + }) + .return_const(Ok(())); + db.expect_set_seal_index_entry() + .times(1) + .withf(|_, entry| entry.blocks().len() == 3) + .return_const(Ok(())); + + index_block_seal(&mut db, &block_3, TEST_HEIGHT).unwrap(); + } + + #[test] + fn evidence_skips_headers_that_do_not_carry_the_seal() { + // Same as `duplicate_seal_records_evidence`, but the header stored for one + // of the known blocks, while available, does not carry the seal under test + // (e.g. because of a storage inconsistency). Such a header must not weaken + // the evidence record. + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let seed = H256::zero(); + let block_1 = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, seed); + let block_2 = make_pos_block(H256::from([1u8; 32]), &vrf_sk, &vrf_pk, seed); + let block_3 = make_pos_block(H256::from([2u8; 32]), &vrf_sk, &vrf_pk, seed); + // A block that carries a different seal: the same seed signed for a + // different epoch produces a different VRF output. + let block_other_seal = + make_pos_block_with_epoch(H256::from([3u8; 32]), &vrf_sk, &vrf_pk, seed, 1); + assert_ne!( + BlockSeal::from_consensus_data(block_1.header().consensus_data()), + BlockSeal::from_consensus_data(block_other_seal.header().consensus_data()), + ); + + let id_1 = block_1.get_id(); + let id_2 = block_2.get_id(); + let header_1 = block_1.header().clone(); + let header_3 = block_3.header().clone(); + let seal = BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(); + let entry = + SealIndexEntry::new(seal.clone(), vec![(id_1, TEST_HEIGHT), (id_2, TEST_HEIGHT)]); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_duplicate_seal_evidence() + .times(1) + .with(eq(seal.clone())) + .return_const(Ok(None)); + db.expect_get_block_header() + .times(1) + .with(eq(id_1)) + .return_const(Ok(Some(header_1.clone()))); + db.expect_get_block_header() + .times(1) + .with(eq(id_2)) + .return_const(Ok(Some(block_other_seal.header().clone()))); + db.expect_set_duplicate_seal_evidence() + .times(1) + .withf(move |seal, evidence| { + evidence.seal() == seal + && evidence.headers().len() == 2 + && evidence.headers()[0] == header_1 + && evidence.headers()[1] == header_3 + }) + .return_const(Ok(())); + db.expect_set_seal_index_entry() + .times(1) + .withf(|_, entry| entry.blocks().len() == 3) + .return_const(Ok(())); + + index_block_seal(&mut db, &block_3, TEST_HEIGHT).unwrap(); + } + #[test] fn already_indexed_block_is_skipped() { let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); @@ -309,10 +496,11 @@ mod tests { } #[test] - fn duplicate_seal_records_evidence_with_missing_known_block_header() { + fn evidence_is_not_recorded_without_corroborating_headers() { // Same as `duplicate_seal_records_evidence`, but the header of the known - // block is unavailable. The evidence record still gets written, retaining - // the header of the new block. + // block is unavailable. A record of the new block's header alone proves + // nothing about the duplication, so no evidence is recorded; the block is + // still indexed, so a later sighting can fill in the evidence. let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); let seed = H256::zero(); let block_1 = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, seed); @@ -321,24 +509,17 @@ mod tests { assert_ne!(block_1.get_id(), block_2.get_id()); let id_1 = block_1.get_id(); - let id_2 = block_2.get_id(); - let header_2 = block_2.header().clone(); - let entry = SealIndexEntry::new( - BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(), - vec![(id_1, TEST_HEIGHT)], - ); + let seal = BlockSeal::from_consensus_data(block_1.header().consensus_data()).unwrap(); + let entry = SealIndexEntry::new(seal.clone(), vec![(id_1, TEST_HEIGHT)]); let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); - db.expect_get_block_header().times(1).with(eq(id_1)).return_const(Ok(None)); - db.expect_set_duplicate_seal_evidence() + db.expect_get_duplicate_seal_evidence() .times(1) - .withf(move |block_id, evidence| { - block_id == &id_2 - && evidence.headers().len() == 1 - && evidence.headers()[0] == header_2 - }) - .return_const(Ok(())); + .with(eq(seal.clone())) + .return_const(Ok(None)); + db.expect_get_block_header().times(1).with(eq(id_1)).return_const(Ok(None)); + db.expect_set_duplicate_seal_evidence().times(0); db.expect_set_seal_index_entry() .times(1) .withf(|_, entry| entry.blocks().len() == 2) @@ -346,4 +527,32 @@ mod tests { index_block_seal(&mut db, &block_2, TEST_HEIGHT).unwrap(); } + + #[test] + fn seal_indexing_is_gated_on_the_config() { + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + + // Disabled: the seal is not indexed, so no storage access happens at all. + let chainstate_config = ChainstateConfig { + pos_seal_duplication_tracking: false.into(), + ..Default::default() + }; + let mut db = MockStoreTxRw::new(); + index_block_seal_if_enabled(&chainstate_config, &mut db, &block, TEST_HEIGHT).unwrap(); + + // Enabled: the seal gets indexed. + let chainstate_config = ChainstateConfig::default(); + let block_id = block.get_id(); + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(None)); + db.expect_set_seal_index_entry() + .times(1) + .withf(move |seal, entry| { + entry.seal() == seal && entry.blocks() == [(block_id, TEST_HEIGHT)] + }) + .return_const(Ok(())); + db.expect_set_duplicate_seal_evidence().times(0); + index_block_seal_if_enabled(&chainstate_config, &mut db, &block, TEST_HEIGHT).unwrap(); + } } diff --git a/chainstate/src/detail/mod.rs b/chainstate/src/detail/mod.rs index 7d0752bb53..1ac8a6ba63 100644 --- a/chainstate/src/detail/mod.rs +++ b/chainstate/src/detail/mod.rs @@ -15,7 +15,7 @@ mod chainstateref; -pub use chainstateref::seal_index::index_block_seal; +pub use chainstateref::seal_index::index_block_seal_if_enabled; mod error; mod error_classification; mod info; @@ -363,7 +363,6 @@ impl Chainstate chainstate_ref: &mut ChainstateRef, V>, block: &WithId, block_index: BlockIndex, - seal_duplication_tracking_enabled: bool, ) -> Result { let mut block_status = BlockStatus::new(); @@ -384,11 +383,9 @@ impl Chainstate .and_then(|_| chainstate_ref.persist_block(block)) .map_err(|err| BlockIntegrationError::BlockCheckError(err, block_status))?; - if seal_duplication_tracking_enabled { - chainstate_ref - .index_block_seal(block, block_index.block_height()) - .map_err(BlockIntegrationError::OtherNonValidationError)?; - } + chainstate_ref + .index_block_seal_if_enabled(block, block_index.block_height()) + .map_err(BlockIntegrationError::OtherNonValidationError)?; // Note: we don't advance the stage to FullyChecked if activate_best_chain succeeds even // if we know that a reorg has occurred, because during a reorg multiple blocks get @@ -445,17 +442,8 @@ impl Chainstate // Perform block checks; `integrate_block_result` is `Result`, where the bool // indicates whether a reorg has occurred. - let seal_duplication_tracking_enabled = - self.chainstate_config.pos_seal_duplication_tracking_enabled(); let integrate_block_result = self.with_rw_tx( - |chainstate_ref| { - Self::integrate_block( - chainstate_ref, - &block, - block_index.clone(), - seal_duplication_tracking_enabled, - ) - }, + |chainstate_ref| Self::integrate_block(chainstate_ref, &block, block_index.clone()), |attempt_number| { log::info!("Processing block {block_id}, attempt #{attempt_number}"); }, diff --git a/chainstate/src/lib.rs b/chainstate/src/lib.rs index c864acaefd..d1c7849bc0 100644 --- a/chainstate/src/lib.rs +++ b/chainstate/src/lib.rs @@ -48,7 +48,7 @@ pub use crate::{ }; pub use chainstate_types::{BlockIndex, GenBlockIndex, GenBlockIndexRef, PropertyQueryError}; pub use constraints_value_accumulator; -pub use detail::index_block_seal; +pub use detail::index_block_seal_if_enabled; pub use detail::tx_verification_strategy::*; pub use interface::{chainstate_interface, chainstate_interface_impl_delegation}; pub use tx_verifier; diff --git a/chainstate/storage/src/internal/store_tx/read_impls.rs b/chainstate/storage/src/internal/store_tx/read_impls.rs index 0a95be874a..7d686a6c5b 100644 --- a/chainstate/storage/src/internal/store_tx/read_impls.rs +++ b/chainstate/storage/src/internal/store_tx/read_impls.rs @@ -264,9 +264,9 @@ impl BlockchainStorageRead for super::StoreTxRo<'_, B #[log_error] fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result> { - self.read::(block_id) + self.read::(seal) } } @@ -609,9 +609,9 @@ impl BlockchainStorageRead for super::StoreTxRw<'_, B #[log_error] fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result> { - self.read::(block_id) + self.read::(seal) } } diff --git a/chainstate/storage/src/internal/store_tx/write_impls.rs b/chainstate/storage/src/internal/store_tx/write_impls.rs index ac32dcbcd9..c77f486db8 100644 --- a/chainstate/storage/src/internal/store_tx/write_impls.rs +++ b/chainstate/storage/src/internal/store_tx/write_impls.rs @@ -238,15 +238,15 @@ impl BlockchainStorageWrite for StoreTxRw<'_, B> { #[log_error] fn set_duplicate_seal_evidence( &mut self, - block_id: &Id, + seal: &BlockSeal, evidence: &DuplicateSealEvidence, ) -> crate::Result<()> { - self.write::(block_id, evidence) + self.write::(seal, evidence) } #[log_error] - fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()> { - self.del::(block_id) + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> crate::Result<()> { + self.del::(seal) } } diff --git a/chainstate/storage/src/lib.rs b/chainstate/storage/src/lib.rs index d0a6b39042..323128bd01 100644 --- a/chainstate/storage/src/lib.rs +++ b/chainstate/storage/src/lib.rs @@ -166,10 +166,10 @@ pub trait BlockchainStorageRead: /// Get the seal index entry of the given seal, if the seal has been seen on any block fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; - /// Get the duplicate seal evidence that was recorded for the given block, if any + /// Get the duplicate seal evidence that was recorded for the given seal, if any fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result>; } @@ -298,18 +298,19 @@ pub trait BlockchainStorageWrite: /// the pruning of the entries whose blocks fell out of the reorg range. fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> Result<()>; - /// Record the duplicate seal evidence for the given block + /// Record the duplicate seal evidence of the given seal, replacing any + /// previously recorded evidence of the seal fn set_duplicate_seal_evidence( &mut self, - block_id: &Id, + seal: &BlockSeal, evidence: &DuplicateSealEvidence, ) -> Result<()>; - /// Remove the duplicate seal evidence recorded for the given block. + /// Remove the duplicate seal evidence recorded for the given seal. /// /// Currently unused: it is the deletion counterpart of the map, for future /// tooling (the indexing logic never removes already recorded evidence). - fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> Result<()>; + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> Result<()>; } /// Operations on read-only transactions diff --git a/chainstate/storage/src/mock/mock_impl.rs b/chainstate/storage/src/mock/mock_impl.rs index fa845ed54e..8618d8f948 100644 --- a/chainstate/storage/src/mock/mock_impl.rs +++ b/chainstate/storage/src/mock/mock_impl.rs @@ -114,7 +114,7 @@ mockall::mock! { fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result>; } @@ -252,10 +252,10 @@ mockall::mock! { fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> crate::Result<()>; fn set_duplicate_seal_evidence( &mut self, - block_id: &Id, + seal: &BlockSeal, evidence: &DuplicateSealEvidence, ) -> crate::Result<()>; - fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()>; + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> crate::Result<()>; } impl EpochStorageWrite for Store { @@ -431,7 +431,7 @@ mockall::mock! { fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result>; } @@ -564,7 +564,7 @@ mockall::mock! { fn get_seal_index_entry(&self, seal: &BlockSeal) -> crate::Result>; fn get_duplicate_seal_evidence( &self, - block_id: &Id, + seal: &BlockSeal, ) -> crate::Result>; } @@ -702,10 +702,10 @@ mockall::mock! { fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> crate::Result<()>; fn set_duplicate_seal_evidence( &mut self, - block_id: &Id, + seal: &BlockSeal, evidence: &DuplicateSealEvidence, ) -> crate::Result<()>; - fn del_duplicate_seal_evidence(&mut self, block_id: &Id) -> crate::Result<()>; + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> crate::Result<()>; } impl EpochStorageWrite for StoreTxRw { diff --git a/chainstate/storage/src/schema.rs b/chainstate/storage/src/schema.rs index cfff98e1b5..e9e5665bf8 100644 --- a/chainstate/storage/src/schema.rs +++ b/chainstate/storage/src/schema.rs @@ -106,9 +106,9 @@ storage::decl_schema! { /// Note: nodes that upgrade from a storage version without this map start with /// an empty index; the index only covers the blocks processed after the upgrade. pub DBSealIndex: Map, - /// Store for duplicate PoS seal evidence records, keyed by the id of the block - /// whose processing discovered the duplication. + /// Store for duplicate PoS seal evidence records, keyed by the seal itself, + /// so a reused seal is covered by a single, extendable evidence record. /// Note: like the seal index, this map starts empty on upgraded nodes. - pub DBDuplicateSealEvidence: Map, DuplicateSealEvidence>, + pub DBDuplicateSealEvidence: Map, } } diff --git a/chainstate/test-suite/src/tests/seal_duplication_tests.rs b/chainstate/test-suite/src/tests/seal_duplication_tests.rs index d3845ef70d..ab32dc5fed 100644 --- a/chainstate/test-suite/src/tests/seal_duplication_tests.rs +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -159,8 +159,8 @@ fn process_two_blocks_with_same_seal( // Create a chain genesis <- block_1(StakePool), then process two blocks (block_2a and // block_2b) that carry the same PoS seal (same pool and same VRF output) on top of // block_1. Both blocks are fully valid; block_2b remains a side block. -// Check that the seal is indexed for both blocks and that the evidence recorded for the -// block on which the seal was seen for the second time retains both signed headers. +// Check that the seal is indexed for both blocks and that the single evidence +// record of the seal retains both signed headers. #[rstest] #[trace] #[case(Seed::from_entropy())] @@ -196,10 +196,9 @@ fn duplicate_pos_seal_records_evidence(#[case] seed: Seed) { &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] ); - // The evidence is recorded for the block on which the seal was seen for the second - // time and it retains the signed headers of both blocks. - assert!(db_tx.get_duplicate_seal_evidence(&block_id_a).unwrap().is_none()); - let evidence = db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().unwrap(); + // The evidence is recorded per seal: a single record keyed by the seal + // itself retains the signed headers of both blocks. + let evidence = db_tx.get_duplicate_seal_evidence(&seal).unwrap().unwrap(); assert_eq!(evidence.seal(), &seal); assert_eq!( evidence.headers().iter().map(|header| header.get_id()).collect::>(), @@ -239,7 +238,7 @@ fn seal_tracking_disabled_records_nothing(#[case] seed: Seed) { let db_tx = tf.storage.transaction_ro().unwrap(); assert!(db_tx.get_seal_index_entry(&seal).unwrap().is_none()); - assert!(db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().is_none()); + assert!(db_tx.get_duplicate_seal_evidence(&seal).unwrap().is_none()); } // Create a chain genesis <- block_1(StakePool), then process two blocks (block_2a and @@ -247,7 +246,7 @@ fn seal_tracking_disabled_records_nothing(#[case] seed: Seed) { // `process_two_blocks_with_same_seal`). Then extend the branch of block_2b with a child // block, which makes it the best chain and triggers a reorg that disconnects block_2a. // Check that the seal records survive the reorg: they are not rolled back on disconnect, -// so the seal is still indexed for both blocks and the evidence recorded for block_2b +// so the seal is still indexed for both blocks and the evidence recorded for the seal // still retains both signed headers. #[rstest] #[trace] @@ -304,12 +303,11 @@ fn duplicate_seal_records_survive_reorg(#[case] seed: Seed) { &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] ); - // The evidence recorded for block_2b survived the reorg as well. - let evidence = db_tx.get_duplicate_seal_evidence(&block_id_b).unwrap().unwrap(); + // The evidence recorded for the seal survived the reorg as well. + let evidence = db_tx.get_duplicate_seal_evidence(&seal).unwrap().unwrap(); assert_eq!(evidence.seal(), &seal); assert_eq!( evidence.headers().iter().map(|header| header.get_id()).collect::>(), vec![block_id_a, block_id_b] ); - assert!(db_tx.get_duplicate_seal_evidence(&block_id_a).unwrap().is_none()); } diff --git a/chainstate/test-suite/src/tests/tx_verification_simulation.rs b/chainstate/test-suite/src/tests/tx_verification_simulation.rs index 0278a83bac..8bd264bfae 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -168,19 +168,16 @@ fn simulation(#[case] seed: Seed, #[case] max_blocks: usize, #[case] max_tx_per_ // reference would need; `WithId` derefs to `Block`. let block = WithId::new(block); db_tx.add_block(&block).unwrap(); - // This replicates the integration-path seal indexing and is gated the same - // way, on the effective config of the reference framework - // (`pos_seal_duplication_tracking`), mirroring the integration path - // (`chainstate/src/detail/mod.rs`) so the storage dump comparison cannot - // diverge on the seal tables. - if reference_tf - .chainstate - .get_chainstate_config() - .pos_seal_duplication_tracking_enabled() - { - chainstate::index_block_seal(&mut db_tx, &block, block_index.block_height()) - .unwrap(); - } + // The single shared helper keeps the seal-index gating identical to + // the integration path (`chainstate/src/detail/mod.rs`), so the + // storage dump comparison cannot diverge on the seal tables. + chainstate::index_block_seal_if_enabled( + &reference_tf.chainstate.get_chainstate_config(), + &mut db_tx, + &block, + block_index.block_height(), + ) + .unwrap(); } db_tx.commit().unwrap(); } diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 46e61c1cb6..245c9c0385 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -36,7 +36,10 @@ use serialization::{Decode, Encode}; /// Note also that the seal identity is bound to the timestamp of the slot: two /// blocks that a pool produced for different (valid) timestamps have different /// seals. The seal index detects the reuse of a single slot draw, not every form -/// of double block production. +/// of double block production: closing that gap, e.g. by coarsening the slot +/// identity below the one-second granularity of the VRF transcript, would have +/// to be anchored in the consensus rules themselves and is out of scope for the +/// index, which only retains what the consensus rules already authorize. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] pub struct BlockSeal { /// Id of the stake pool that produced the block. From fd9f1c44b055715202562d11ea31124125a6137b Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 09:44:33 +0400 Subject: [PATCH 12/16] Prefix the unused destructured block id in the disabled-tracking test --- chainstate/test-suite/src/tests/seal_duplication_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainstate/test-suite/src/tests/seal_duplication_tests.rs b/chainstate/test-suite/src/tests/seal_duplication_tests.rs index ab32dc5fed..223fe48941 100644 --- a/chainstate/test-suite/src/tests/seal_duplication_tests.rs +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -222,7 +222,7 @@ fn seal_tracking_disabled_records_nothing(#[case] seed: Seed) { let (mut tf, stake_pool_outpoint, pool_id, staking_sk) = setup_chain_with_stake_pool_with_chainstate_config(&mut rng, vrf_pk, chainstate_config); - let (seal, block_id_a, block_id_b) = process_two_blocks_with_same_seal( + let (seal, block_id_a, _block_id_b) = process_two_blocks_with_same_seal( &mut rng, &mut tf, &vrf_sk, From 10b4ac1595b852e44de3d2e0487ef65c40de0675 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 15:07:30 +0400 Subject: [PATCH 13/16] Backfill the missing seal evidence and keep indexing non-blocking A sighting of a seal whose index entry is at the cap now backfills the evidence record if none exists, since the earlier sightings below the cap may have failed to collect corroborating headers and no later sighting would fill it in; the record stays bounded by the index entry. A failure of the observational seal indexing no longer rejects an otherwise valid block: it is logged and the block integration continues, so the seal tracking cannot harm the liveness of the node. The monotonic growth of the seal tables is tracked by #2123. --- .../src/detail/chainstateref/seal_index.rs | 129 +++++++++++++++--- chainstate/src/detail/mod.rs | 16 ++- chainstate/storage/src/lib.rs | 6 +- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/chainstate/src/detail/chainstateref/seal_index.rs b/chainstate/src/detail/chainstateref/seal_index.rs index 0db0704e09..b9401cf632 100644 --- a/chainstate/src/detail/chainstateref/seal_index.rs +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -20,11 +20,14 @@ //! the seal are retained as a self-certifying evidence record. //! //! The index entry of a single seal is capped at [`MAX_BLOCKS_PER_SEAL`] blocks. -//! Once the cap is reached, neither the index entry nor the evidence record of -//! the seal are extended anymore, so the storage footprint of a single reused -//! seal stays bounded. A seal is covered by at most one evidence record, which -//! is rewritten in full on every new sighting below the cap. The evidence -//! records that were already recorded are never removed by this module. +//! Once the cap is reached, the index entry is not extended anymore, so the +//! storage footprint of a single reused seal stays bounded. The seal is covered +//! by at most one evidence record, which is extended with the missing headers +//! on every new sighting below the cap; past the cap, the sightings are only +//! logged, except that a seal without any evidence gets its record backfilled, +//! since earlier sightings may have failed to collect corroborating headers. +//! The evidence records that were already recorded are never removed by this +//! module. use std::num::NonZeroUsize; @@ -98,15 +101,30 @@ fn index_block_seal( entry.push_block(block_id, block_height); db_tx.set_seal_index_entry(entry.seal(), &entry)?; } else { - // The index entry of the seal is at its cap, so the seal reuse is already - // covered by the recorded evidence. Log the sighting, but do not extend the - // index and do not record redundant evidence, keeping the storage footprint - // of a single reused seal bounded. - log::info!( - "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached (block {})", - seal.pool_id(), - block.get_id(), - ); + // The index entry of the seal is at its cap, so the seal reuse is + // already covered by the recorded evidence. Log the sighting, but do + // not extend the index and do not extend the evidence, keeping the + // storage footprint of a single reused seal bounded. + // + // The evidence is backfilled if it does not exist at all: earlier + // sightings below the cap may have failed to collect corroborating + // headers, and past the cap no sighting would ever fill it in. The + // record written here is bounded by the index entry anyway, and once + // it exists, the sightings are only logged again. + if db_tx.get_duplicate_seal_evidence(&seal)?.is_none() { + log::info!( + "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached without any evidence (block {})", + seal.pool_id(), + block.get_id(), + ); + record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; + } else { + log::info!( + "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached (block {})", + seal.pool_id(), + block.get_id(), + ); + } } Ok(()) @@ -484,10 +502,16 @@ mod tests { let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); - // The index entry of the seal is at its cap, so the seal reuse is already - // covered by the previously recorded evidence: no header is looked up, no - // new evidence is written and the index entry is not extended, keeping the + // The index entry of the seal is at its cap and its evidence exists, so + // the seal reuse is already covered: no header is looked up, no new + // evidence is written and the index entry is not extended, keeping the // storage footprint of a single reused seal bounded. + db.expect_get_duplicate_seal_evidence().times(1).return_const(Ok(Some( + DuplicateSealEvidence::new( + BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(), + vec![block.header().clone(), block.header().clone()], + ), + ))); db.expect_get_block_header().times(0); db.expect_set_duplicate_seal_evidence().times(0); db.expect_set_seal_index_entry().times(0); @@ -495,6 +519,77 @@ mod tests { index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); } + #[test] + fn evidence_is_backfilled_when_the_cap_is_reached_without_evidence() { + // Same as `index_entry_is_capped`, but the seal has no evidence record: + // earlier sightings may have failed to collect corroborating headers, + // and past the cap no sighting would ever fill it in, so the sighting + // at the cap backfills the evidence instead of only being logged. The + // index entry is still not extended. + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + + let seal = BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(); + let known_blocks = (0..MAX_BLOCKS_PER_SEAL.get()) + .map(|i| (Id::new(H256::from([(i + 1) as u8; 32])), TEST_HEIGHT)) + .collect::>(); + let entry = SealIndexEntry::new(seal.clone(), known_blocks); + + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + // The record is looked up once for the backfill check and once while + // recording. + db.expect_get_duplicate_seal_evidence() + .times(2) + .with(eq(seal.clone())) + .return_const(Ok(None)); + // The headers of the known blocks are unavailable, so the backfilled + // record would carry the new block's header alone and is not written. + db.expect_get_block_header() + .times(MAX_BLOCKS_PER_SEAL.get()) + .return_const(Ok(None)); + db.expect_set_duplicate_seal_evidence().times(0); + db.expect_set_seal_index_entry().times(0); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + + #[test] + fn evidence_is_backfilled_with_the_known_headers_at_the_cap() { + // Same as above, but the headers of the known blocks are available: the + // backfilled evidence retains them along with the new block's header. + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_entropy(VRFKeyKind::Schnorrkel); + let block = make_pos_block(H256::zero(), &vrf_sk, &vrf_pk, H256::zero()); + + let seal = BlockSeal::from_consensus_data(block.header().consensus_data()).unwrap(); + let known_blocks = (0..MAX_BLOCKS_PER_SEAL.get()) + .map(|i| (Id::new(H256::from([(i + 1) as u8; 32])), TEST_HEIGHT)) + .collect::>(); + let entry = SealIndexEntry::new(seal.clone(), known_blocks); + + let header = block.header().clone(); + let mut db = MockStoreTxRw::new(); + db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); + db.expect_get_duplicate_seal_evidence() + .times(2) + .with(eq(seal.clone())) + .return_const(Ok(None)); + db.expect_get_block_header() + .times(MAX_BLOCKS_PER_SEAL.get()) + .return_const(Ok(Some(header.clone()))); + db.expect_set_duplicate_seal_evidence() + .times(1) + .withf(move |seal, evidence| { + evidence.seal() == seal + && evidence.headers().len() == MAX_BLOCKS_PER_SEAL.get() + 1 + && evidence.headers().iter().all(|h| *h == header) + }) + .return_const(Ok(())); + db.expect_set_seal_index_entry().times(0); + + index_block_seal(&mut db, &block, TEST_HEIGHT).unwrap(); + } + #[test] fn evidence_is_not_recorded_without_corroborating_headers() { // Same as `duplicate_seal_records_evidence`, but the header of the known diff --git a/chainstate/src/detail/mod.rs b/chainstate/src/detail/mod.rs index 1ac8a6ba63..e39d623f58 100644 --- a/chainstate/src/detail/mod.rs +++ b/chainstate/src/detail/mod.rs @@ -383,9 +383,19 @@ impl Chainstate .and_then(|_| chainstate_ref.persist_block(block)) .map_err(|err| BlockIntegrationError::BlockCheckError(err, block_status))?; - chainstate_ref - .index_block_seal_if_enabled(block, block_index.block_height()) - .map_err(BlockIntegrationError::OtherNonValidationError)?; + // Seal duplication tracking is observational: it feeds no validation + // rule, so its failure (e.g. a storage issue in the seal tables) must + // not reject an otherwise valid block and harm the liveness of the + // node. The indexing of the affected block is lost, which the logging + // makes visible. + if let Err(err) = + chainstate_ref.index_block_seal_if_enabled(block, block_index.block_height()) + { + log::error!( + "Failed to index the seal of the block {}: {err}", + block.get_id() + ); + } // Note: we don't advance the stage to FullyChecked if activate_best_chain succeeds even // if we know that a reorg has occurred, because during a reorg multiple blocks get diff --git a/chainstate/storage/src/lib.rs b/chainstate/storage/src/lib.rs index 323128bd01..73aae28bf4 100644 --- a/chainstate/storage/src/lib.rs +++ b/chainstate/storage/src/lib.rs @@ -294,8 +294,9 @@ pub trait BlockchainStorageWrite: /// Remove the seal index entry of the given seal. /// - /// Currently unused: it is the deletion counterpart of the map, to be used by - /// the pruning of the entries whose blocks fell out of the reorg range. + /// Currently unused: it is the deletion counterpart of the map, to be used + /// by the pruning of the entries whose blocks fell out of the reorg range. + /// The pruning itself is tracked by mintlayer/mintlayer-core#2123. fn del_seal_index_entry(&mut self, seal: &BlockSeal) -> Result<()>; /// Record the duplicate seal evidence of the given seal, replacing any @@ -310,6 +311,7 @@ pub trait BlockchainStorageWrite: /// /// Currently unused: it is the deletion counterpart of the map, for future /// tooling (the indexing logic never removes already recorded evidence). + /// The growth of the seal tables is tracked by mintlayer/mintlayer-core#2123. fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> Result<()>; } From 22e47f6aa46ee3f4021a35e73f805c8d4428d11d Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 15:38:41 +0400 Subject: [PATCH 14/16] Note the seal defaults and drop the duplicate logging and fetch Document the effective default of the seal tracking config option and the storage coupling of the seal value encodings, pass the already loaded evidence record to the recording helper instead of fetching it twice, and drop the log_error attribute that logged the same indexing failure twice (the caller logs it with the block id). --- .../src/detail/chainstateref/seal_index.rs | 37 +++++++++++++------ chainstate/types/src/seal.rs | 8 ++++ node-lib/src/config_files/chainstate/mod.rs | 1 + 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/chainstate/src/detail/chainstateref/seal_index.rs b/chainstate/src/detail/chainstateref/seal_index.rs index b9401cf632..81c589604b 100644 --- a/chainstate/src/detail/chainstateref/seal_index.rs +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -38,7 +38,6 @@ use common::{ primitives::{BlockHeight, Id, Idable, id::WithId}, }; use logging::log; -use utils::log_error; use crate::{BlockError, config::ChainstateConfig}; @@ -73,7 +72,6 @@ pub fn index_block_seal_if_enabled( /// This must be called for every block that has passed all checks, along with its /// integration into the block tree, so that the seal index covers the blocks of /// all branches, not only those of the best chain. -#[log_error] fn index_block_seal( db_tx: &mut S, block: &WithId, @@ -97,7 +95,7 @@ fn index_block_seal( } if entry.blocks().len() < MAX_BLOCKS_PER_SEAL.get() { - record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; + record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block, None)?; entry.push_block(block_id, block_height); db_tx.set_seal_index_entry(entry.seal(), &entry)?; } else { @@ -111,13 +109,22 @@ fn index_block_seal( // headers, and past the cap no sighting would ever fill it in. The // record written here is bounded by the index entry anyway, and once // it exists, the sightings are only logged again. - if db_tx.get_duplicate_seal_evidence(&seal)?.is_none() { + let existing_evidence = db_tx.get_duplicate_seal_evidence(&seal)?; + if existing_evidence.is_none() { log::info!( "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached without any evidence (block {})", seal.pool_id(), block.get_id(), ); - record_duplicate_seal_evidence(db_tx, &seal, entry.blocks(), block)?; + // The record is known to be absent, so it is started from empty + // instead of being loaded again. + record_duplicate_seal_evidence( + db_tx, + &seal, + entry.blocks(), + block, + Some(DuplicateSealEvidence::new(seal.clone(), Vec::new())), + )?; } else { log::info!( "A PoS seal of pool {} was seen on more than one block; the evidence cap is reached (block {})", @@ -138,15 +145,23 @@ fn index_block_seal( /// reused seal never stores more than one copy of each header. A header is only /// retained if its consensus data carries the seal under test, so the record /// stays self-certifying on its own. +/// +/// The already loaded record of the seal can be passed as `existing_evidence` +/// (an empty record if the caller knows that no evidence exists yet); +/// otherwise it is loaded here. fn record_duplicate_seal_evidence( db_tx: &mut S, seal: &BlockSeal, known_blocks: &[(Id, BlockHeight)], block: &WithId, + existing_evidence: Option, ) -> Result<(), BlockError> { - let mut evidence = db_tx - .get_duplicate_seal_evidence(seal)? - .unwrap_or_else(|| DuplicateSealEvidence::new(seal.clone(), Vec::new())); + let mut evidence = match existing_evidence { + Some(evidence) => evidence, + None => db_tx + .get_duplicate_seal_evidence(seal)? + .unwrap_or_else(|| DuplicateSealEvidence::new(seal.clone(), Vec::new())), + }; for (existing_id, _) in known_blocks { // The headers of the previously seen blocks are already retained in the @@ -537,10 +552,8 @@ mod tests { let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); - // The record is looked up once for the backfill check and once while - // recording. db.expect_get_duplicate_seal_evidence() - .times(2) + .times(1) .with(eq(seal.clone())) .return_const(Ok(None)); // The headers of the known blocks are unavailable, so the backfilled @@ -571,7 +584,7 @@ mod tests { let mut db = MockStoreTxRw::new(); db.expect_get_seal_index_entry().times(1).return_const(Ok(Some(entry))); db.expect_get_duplicate_seal_evidence() - .times(2) + .times(1) .with(eq(seal.clone())) .return_const(Ok(None)); db.expect_get_block_header() diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 245c9c0385..6bde963980 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -81,6 +81,10 @@ impl BlockSeal { /// /// The number of blocks per entry is bounded by the seal indexing logic, so the /// index stays bounded even if a seal is deliberately reused on many blocks. +/// +/// Note: the entry is stored as the value of the seal index, so, like the seal +/// key itself, any change to its encoding would make the previously written +/// entries unreadable. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SealIndexEntry { seal: BlockSeal, @@ -112,6 +116,10 @@ impl SealIndexEntry { /// produced all the listed blocks for the same slot. The headers are retained in /// the record itself, so the evidence stays verifiable even if the blocks are /// later removed from storage. +/// +/// Note: the record is stored as the value of the duplicate seal evidence map, +/// so, like the seal key itself, any change to its encoding would make the +/// previously written records unreadable. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct DuplicateSealEvidence { seal: BlockSeal, diff --git a/node-lib/src/config_files/chainstate/mod.rs b/node-lib/src/config_files/chainstate/mod.rs index 3ac059deca..637cc8ddf2 100644 --- a/node-lib/src/config_files/chainstate/mod.rs +++ b/node-lib/src/config_files/chainstate/mod.rs @@ -48,6 +48,7 @@ pub struct ChainstateConfigFile { /// If true, the seals (stake pool id + VRF output) of the processed PoS blocks will be /// indexed and the evidence of a seal seen on more than one block will be recorded. + /// Defaults to true. pub pos_seal_duplication_tracking: Option, } From f2f155f664ed5f02ee8cb2d3e0b77beb5adbcce7 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 16:15:23 +0400 Subject: [PATCH 15/16] Note the empty seal maps on upgrade and pin the value codecs Document at the storage version that the seal maps start empty on the upgraded nodes and only cover the blocks processed since, and pin the encodings of the seal index entry and the duplicate seal evidence with roundtrip tests, like the encoding of the seal key. --- chainstate/storage/src/internal/version.rs | 4 ++ chainstate/types/src/seal.rs | 57 +++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/chainstate/storage/src/internal/version.rs b/chainstate/storage/src/internal/version.rs index 9eb6cb1e57..8692949371 100644 --- a/chainstate/storage/src/internal/version.rs +++ b/chainstate/storage/src/internal/version.rs @@ -19,6 +19,10 @@ use serialization::{Decode, Encode}; pub struct ChainstateStorageVersion(u32); impl ChainstateStorageVersion { + /// Note: the PoS seal maps introduced alongside this version start empty on + /// the nodes that upgrade, so the seal duplication tracking only covers the + /// blocks processed after the upgrade and does not backfill the history + /// (see the schema docs). pub const CURRENT: Self = Self(11); pub fn new(value: u32) -> Self { diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs index 6bde963980..26b8557b32 100644 --- a/chainstate/types/src/seal.rs +++ b/chainstate/types/src/seal.rs @@ -148,7 +148,11 @@ impl DuplicateSealEvidence { mod tests { use super::*; use common::chain::{ - block::{consensus_data::PoSData, consensus_data::PoWData, timestamp::BlockTimestamp}, + GenBlock, + block::{ + BlockHeader, consensus_data::PoSData, consensus_data::PoWData, + signed_block_header::BlockHeaderSignature, timestamp::BlockTimestamp, + }, config::EpochIndex, }; use common::primitives::Compact; @@ -237,4 +241,55 @@ mod tests { let seal_other = BlockSeal::from_consensus_data(&consensus_data_other).unwrap(); assert_ne!(seal_1, seal_other); } + + #[test] + fn seal_index_entry_codec_roundtrip() { + let (_, seal) = make_seal(0, H256::zero()); + let entry = SealIndexEntry::new( + seal, + vec![ + (Id::new(H256::from([3u8; 32])), BlockHeight::new(7)), + (Id::new(H256::from([4u8; 32])), BlockHeight::new(8)), + ], + ); + + // The entry is stored as the value of the seal index: like the seal key, + // its encoding must stay readable (see `seal_encoding_is_stable`). + let decoded = SealIndexEntry::decode(&mut &entry.encode()[..]).unwrap(); + assert_eq!(decoded, entry); + } + + #[test] + fn duplicate_seal_evidence_codec_roundtrip() { + let (vrf_sk, seal) = make_seal(0, H256::zero()); + let block_header = BlockHeader::new( + Id::::new(H256::from([1u8; 32])), + H256::from([2u8; 32]), + H256::from([3u8; 32]), + BlockTimestamp::from_int_seconds(1), + make_pos_consensus_data(&vrf_sk, *seal.pool_id(), 0, H256::zero()), + ); + let evidence = DuplicateSealEvidence::new( + seal, + vec![ + SignedBlockHeader::new(BlockHeaderSignature::None, block_header), + SignedBlockHeader::new( + BlockHeaderSignature::None, + BlockHeader::new( + Id::::new(H256::from([5u8; 32])), + H256::from([6u8; 32]), + H256::from([7u8; 32]), + BlockTimestamp::from_int_seconds(2), + ConsensusData::None, + ), + ), + ], + ); + + // The record is stored as the value of the duplicate seal evidence map: + // like the seal key, its encoding must stay readable (see + // `seal_encoding_is_stable`). + let decoded = DuplicateSealEvidence::decode(&mut &evidence.encode()[..]).unwrap(); + assert_eq!(decoded, evidence); + } } From 6f48cfca8a07879a4b32d9a1c76413a487a4a8ad Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 21 Sep 2026 17:03:02 +0400 Subject: [PATCH 16/16] Note the advisory constraint and the growth bounds of the seal maps The storage version notes that the seal maps must remain advisory, with a version bump and a backfill required before they could gate block acceptance, and the schema docs spell out the concrete growth: the index grows proportionally to the chain history like the other per-block tables, while the evidence records exist only for the actually reused seals and are kept permanently. --- chainstate/storage/src/internal/version.rs | 4 +++- chainstate/storage/src/schema.rs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/chainstate/storage/src/internal/version.rs b/chainstate/storage/src/internal/version.rs index 8692949371..c723a9f2b4 100644 --- a/chainstate/storage/src/internal/version.rs +++ b/chainstate/storage/src/internal/version.rs @@ -22,7 +22,9 @@ impl ChainstateStorageVersion { /// Note: the PoS seal maps introduced alongside this version start empty on /// the nodes that upgrade, so the seal duplication tracking only covers the /// blocks processed after the upgrade and does not backfill the history - /// (see the schema docs). + /// (see the schema docs). The maps must remain advisory: if seal duplication + /// ever gates block acceptance, a storage version bump with a backfill (or a + /// consensus-rule anchoring of the seals) will be required. pub const CURRENT: Self = Self(11); pub fn new(value: u32) -> Self { diff --git a/chainstate/storage/src/schema.rs b/chainstate/storage/src/schema.rs index e9e5665bf8..dac78b7690 100644 --- a/chainstate/storage/src/schema.rs +++ b/chainstate/storage/src/schema.rs @@ -105,10 +105,17 @@ storage::decl_schema! { /// The number of blocks per entry is bounded by the seal indexing logic. /// Note: nodes that upgrade from a storage version without this map start with /// an empty index; the index only covers the blocks processed after the upgrade. + /// The map grows by roughly one entry per processed PoS block, i.e. proportionally + /// to the chain history like the other per-block tables; its pruning is tracked + /// by mintlayer/mintlayer-core#2123. pub DBSealIndex: Map, /// Store for duplicate PoS seal evidence records, keyed by the seal itself, /// so a reused seal is covered by a single, extendable evidence record. /// Note: like the seal index, this map starts empty on upgraded nodes. + /// The records exist only for the seals that were actually reused (none on + /// an honest network, at most one bounded record per reused seal here), + /// and they are kept permanently as the self-certifying duplication + /// evidence. pub DBDuplicateSealEvidence: Map, } }