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..6ad8b6d6c2 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,10 @@ impl ChainstateConfig { 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..ee0083e5b7 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; +pub(crate) mod seal_index; mod tx_verifier_storage; use itertools::Itertools; @@ -1427,6 +1428,21 @@ 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. 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_if_enabled( + self.chainstate_config, + &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..81c589604b --- /dev/null +++ b/chainstate/src/detail/chainstateref/seal_index.rs @@ -0,0 +1,666 @@ +// 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 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; + +use chainstate_storage::BlockchainStorageWrite; +use chainstate_types::{BlockSeal, DuplicateSealEvidence, SealIndexEntry}; +use common::{ + chain::Block, + primitives::{BlockHeight, Id, Idable, id::WithId}, +}; +use logging::log; + +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 +// 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 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. +/// +/// 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. +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(()); + } + + if entry.blocks().len() < MAX_BLOCKS_PER_SEAL.get() { + 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 { + // 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. + 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(), + ); + // 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 {})", + seal.pool_id(), + block.get_id(), + ); + } + } + + Ok(()) +} + +/// 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. +/// +/// 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 = 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 + // 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) + 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, + // 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()); + + // 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(seal, &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 { + 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()) + 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()); + + 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 header_1 = block_1.header().clone(); + let header_2 = block_2.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)]); + + 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 |seal, evidence| { + evidence.seal() == seal + && 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 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); + 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() { + 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, known_blocks); + + 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 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); + + 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))); + db.expect_get_duplicate_seal_evidence() + .times(1) + .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(1) + .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 + // 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); + 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 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(None)); + db.expect_set_duplicate_seal_evidence().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_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 7ee1be69fc..e39d623f58 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_if_enabled; mod error; mod error_classification; mod info; @@ -381,6 +383,20 @@ impl Chainstate .and_then(|_| chainstate_ref.persist_block(block)) .map_err(|err| BlockIntegrationError::BlockCheckError(err, block_status))?; + // 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 // checked. It's activate_best_chain's responsibility to update their statuses. 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/chainstate/src/lib.rs b/chainstate/src/lib.rs index 2ca6b9b613..d1c7849bc0 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_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 dd9cf5dd6c..7d686a6c5b 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, + seal: &BlockSeal, + ) -> crate::Result> { + self.read::(seal) + } } 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, + seal: &BlockSeal, + ) -> crate::Result> { + self.read::(seal) + } } 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..c77f486db8 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,34 @@ 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, + seal: &BlockSeal, + evidence: &DuplicateSealEvidence, + ) -> crate::Result<()> { + self.write::(seal, evidence) + } + + #[log_error] + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> crate::Result<()> { + self.del::(seal) + } } impl EpochStorageWrite for StoreTxRw<'_, B> { diff --git a/chainstate/storage/src/internal/version.rs b/chainstate/storage/src/internal/version.rs index 9eb6cb1e57..c723a9f2b4 100644 --- a/chainstate/storage/src/internal/version.rs +++ b/chainstate/storage/src/internal/version.rs @@ -19,6 +19,12 @@ 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). 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/lib.rs b/chainstate/storage/src/lib.rs index e9f2e9906b..73aae28bf4 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 seal, if any + fn get_duplicate_seal_evidence( + &self, + seal: &BlockSeal, + ) -> crate::Result>; } /// Modifying operations on persistent blockchain data @@ -278,6 +288,31 @@ 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. + /// + /// 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 + /// previously recorded evidence of the seal + fn set_duplicate_seal_evidence( + &mut self, + seal: &BlockSeal, + evidence: &DuplicateSealEvidence, + ) -> Result<()>; + + /// 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). + /// The growth of the seal tables is tracked by mintlayer/mintlayer-core#2123. + 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 40426af7ff..8618d8f948 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, + seal: &BlockSeal, + ) -> crate::Result>; } impl EpochStorageRead for Store { @@ -238,6 +247,15 @@ 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, + seal: &BlockSeal, + evidence: &DuplicateSealEvidence, + ) -> crate::Result<()>; + fn del_duplicate_seal_evidence(&mut self, seal: &BlockSeal) -> crate::Result<()>; } impl EpochStorageWrite for Store { @@ -409,6 +427,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, + seal: &BlockSeal, + ) -> crate::Result>; } impl EpochStorageRead for StoreTxRo { @@ -536,6 +560,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, + seal: &BlockSeal, + ) -> crate::Result>; } impl EpochStorageRead for StoreTxRw { @@ -667,6 +697,15 @@ 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, + seal: &BlockSeal, + evidence: &DuplicateSealEvidence, + ) -> 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 c8d6275ed7..dac78b7690 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,22 @@ 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. + /// 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, } } 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..82c3b2aac6 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,123 @@ pub fn create_custom_genesis_with_stake_pool( initial_pool_amount, ) } + +/// 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), + ( + 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(FIRST_POS_BLOCK_HEIGHT); + 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(), + status @ (RequiredConsensus::PoW(_) | RequiredConsensus::IgnoreConsensus) => { + panic!("Invalid consensus at height {block_height}: {status:?}") + } + } +} 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..223fe48941 --- /dev/null +++ b/chainstate/test-suite/src/tests/seal_duplication_tests.rs @@ -0,0 +1,313 @@ +// 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::{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::{ + 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, +}; + +/// 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 single evidence +// record of the seal 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 = FIRST_POS_BLOCK_HEIGHT; + assert_eq!( + index_entry.blocks(), + &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] + ); + + // 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::>(), + 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(&seal).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 the seal +// 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 = FIRST_POS_BLOCK_HEIGHT; + assert_eq!( + index_entry.blocks(), + &[(block_id_a, expected_block_height), (block_id_b, expected_block_height),] + ); + + // 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] + ); +} 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..8bd264bfae 100644 --- a/chainstate/test-suite/src/tests/tx_verification_simulation.rs +++ b/chainstate/test-suite/src/tests/tx_verification_simulation.rs @@ -16,13 +16,14 @@ use std::{collections::BTreeMap, num::NonZeroU64}; use super::*; +use chainstate::chainstate_interface::ChainstateInterface; use chainstate_storage::{BlockchainStorageWrite, TransactionRw, Transactional}; use common::{ chain::{ ChainstateUpgradeBuilder, ConsensusUpgrade, NetUpgrades, PoSChainConfigBuilder, TokenIdGenerationVersion, UtxoOutPoint, }, - primitives::BlockCount, + primitives::{BlockCount, id::WithId}, }; use crypto::{ key::{KeyKind, PrivateKey}, @@ -163,7 +164,20 @@ 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(); + // 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/lib.rs b/chainstate/types/src/lib.rs index 71bb575614..a92631c4cf 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, DuplicateSealEvidence, SealIndexEntry}, }; mod ancestor; diff --git a/chainstate/types/src/seal.rs b/chainstate/types/src/seal.rs new file mode 100644 index 0000000000..26b8557b32 --- /dev/null +++ b/chainstate/types/src/seal.rs @@ -0,0 +1,295 @@ +// 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::{Block, PoolId, block::ConsensusData, block::signed_block_header::SignedBlockHeader}, + primitives::{BlockHeight, H256, Id}, +}; +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. +/// +/// 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: 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. + pool_id: PoolId, + /// The 32-byte VRF output from the block's consensus data. + vrf_output: H256, +} + +impl BlockSeal { + /// 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 + } +} + +/// An index entry of a seal: the blocks known to carry it. +/// +/// 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, + 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. +/// +/// 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, + 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::*; + use common::chain::{ + GenBlock, + block::{ + BlockHeader, consensus_data::PoSData, consensus_data::PoWData, + signed_block_header::BlockHeaderSignature, 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()); + + // 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); + } + + #[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()); + 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); + } + + #[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); + } +} diff --git a/node-lib/src/config_files/chainstate/mod.rs b/node-lib/src/config_files/chainstate/mod.rs index 4c6336329a..637cc8ddf2 100644 --- a/node-lib/src/config_files/chainstate/mod.rs +++ b/node-lib/src/config_files/chainstate/mod.rs @@ -45,6 +45,11 @@ 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. + /// Defaults to true. + pub pos_seal_duplication_tracking: Option, } impl From for ChainstateConfig { @@ -56,6 +61,7 @@ impl From for ChainstateConfig { max_tip_age, enable_heavy_checks, allow_checkpoints_mismatch, + pos_seal_duplication_tracking, } = config_file; ChainstateConfig { @@ -65,6 +71,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..aa0e75f51f 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,9 @@ 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 { storage_backend,