iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 952bb74b8fb97e2521f5f2527abfc26cb27b0f14
parent 8cd0c5b3f1689313f7f0ccbd94c34ec77b2e62db
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu,  3 Sep 2026 00:32:24 +0200

Trust locally verified chain across releases

Diffstat:
Msrc/adapters/chain_store.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/adapters/chain_store/compact.rs | 7+++++++
Msrc/domain/ledger_apply.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/ledger_chain.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/main.rs | 23+++++++++++------------
Msrc/main_tests.rs | 36++++++++++++++++++++++++++++++++++++
6 files changed, 231 insertions(+), 43 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -33,7 +33,25 @@ CREATE TABLE IF NOT EXISTS chain_verification ( ); "#; -const CURRENT_VERIFIER_VERSION: &str = env!("CARGO_PKG_VERSION"); +// This identifies the consensus rules, not the application release. UI, packaging, and +// other non-consensus releases must not invalidate a chain that this node already verified. +// Bump this value only when historical validation semantics change, and add the old ruleset to +// `revalidation_from_height` with the first affected block height. +const CURRENT_CONSENSUS_RULESET: &str = "iuna-consensus-v1"; + +// v0.4.10 introduced the verification marker and wrote the package version into it. Its +// validator is identical to the first stable consensus ruleset, so it can be migrated safely. +const LEGACY_EQUIVALENT_VERIFIER_VERSIONS: &[&str] = &["0.4.10"]; + +struct ConsensusRulesetMigration { + from_ruleset: &'static str, + revalidate_from_height: u64, +} + +// When a future release changes consensus validation, bump CURRENT_CONSENSUS_RULESET and add a +// direct migration for every still-supported older ruleset. The height is the first block whose +// validity can differ under the new rules. +const CONSENSUS_RULESET_MIGRATIONS: &[ConsensusRulesetMigration] = &[]; #[derive(Clone, Debug)] pub struct SqliteChainStore { @@ -43,7 +61,9 @@ pub struct SqliteChainStore { #[derive(Debug)] pub struct LoadedChainSnapshot { pub snapshot: ChainSnapshot, - pub verified_by_current_version: bool, + /// First block that must be validated again. `None` means the entire persisted chain is + /// already trusted under the current consensus ruleset. + pub revalidation_from_height: Option<u64>, } impl SqliteChainStore { @@ -133,21 +153,20 @@ impl SqliteChainStore { let Some((snapshot, tip_hash)) = snapshot else { return Ok(None); }; - let verified_by_current_version = connection + let stored_ruleset = connection .query_row( r#" -SELECT 1 FROM chain_verification -WHERE id = 1 AND tip_hash = ?1 AND verifier_version = ?2 +SELECT verifier_version FROM chain_verification +WHERE id = 1 AND tip_hash = ?1 "#, - params![tip_hash, CURRENT_VERIFIER_VERSION], - |_| Ok(()), + params![tip_hash], + |row| row.get::<_, String>(0), ) .optional() - .context("failed to inspect chain verification status")? - .is_some(); + .context("failed to inspect chain verification status")?; Ok(Some(LoadedChainSnapshot { snapshot, - verified_by_current_version, + revalidation_from_height: revalidation_from_height(stored_ruleset.as_deref()), })) }) } @@ -200,7 +219,7 @@ ON CONFLICT(id) DO UPDATE SET verifier_version = excluded.verifier_version, verified_at_ms = excluded.verified_at_ms "#, - params![tip_hash, CURRENT_VERIFIER_VERSION, updated_at_ms], + params![tip_hash, CURRENT_CONSENSUS_RULESET, updated_at_ms], ) .context("failed to persist chain verification status")?; } else { @@ -386,6 +405,21 @@ fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> { .map(|block| (block.height, block.hash.clone())) } +fn revalidation_from_height(stored_ruleset: Option<&str>) -> Option<u64> { + match stored_ruleset { + Some(CURRENT_CONSENSUS_RULESET) => None, + Some(version) if LEGACY_EQUIVALENT_VERIFIER_VERSIONS.contains(&version) => None, + Some(ruleset) => CONSENSUS_RULESET_MIGRATIONS + .iter() + .find(|migration| migration.from_ruleset == ruleset) + .map(|migration| migration.revalidate_from_height) + // Unknown markers are untrusted. + .or(Some(1)), + // A missing marker means the snapshot was not persisted as validated. + None => Some(1), + } +} + fn unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -557,14 +591,14 @@ VALUES (1, 0, 'bad-tip', ?1, 0) } #[test] - fn verified_snapshot_is_trusted_only_for_current_version_and_tip() { + fn verified_snapshot_is_trusted_only_for_current_ruleset_and_tip() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); let snapshot = test_snapshot("chain-store-verification-status"); store.save_verified(&snapshot).unwrap(); let loaded = store.load_with_verification_status().unwrap().unwrap(); - assert!(loaded.verified_by_current_version); + assert_eq!(loaded.revalidation_from_height, None); store .with_connection_mut(|connection| { @@ -576,19 +610,19 @@ VALUES (1, 0, 'bad-tip', ?1, 0) }) .unwrap(); let loaded = store.load_with_verification_status().unwrap().unwrap(); - assert!(!loaded.verified_by_current_version); + assert_eq!(loaded.revalidation_from_height, Some(1)); store .with_connection_mut(|connection| { connection.execute( "UPDATE chain_verification SET verifier_version = ?1, tip_hash = 'other-tip'", - [super::CURRENT_VERIFIER_VERSION], + [super::CURRENT_CONSENSUS_RULESET], )?; Ok(()) }) .unwrap(); let loaded = store.load_with_verification_status().unwrap().unwrap(); - assert!(!loaded.verified_by_current_version); + assert_eq!(loaded.revalidation_from_height, Some(1)); } #[test] @@ -602,18 +636,20 @@ VALUES (1, 0, 'bad-tip', ?1, 0) .load_with_verification_status() .unwrap() .unwrap() - .verified_by_current_version + .revalidation_from_height + .is_none() ); store.save(&snapshot).unwrap(); assert!(store.contains_chain().unwrap()); assert!( - !store + store .load_with_verification_status() .unwrap() .unwrap() - .verified_by_current_version + .revalidation_from_height + .is_some() ); } @@ -634,6 +670,27 @@ VALUES (1, 0, 'bad-tip', ?1, 0) let loaded = reopened.load_with_verification_status().unwrap().unwrap(); assert_eq!(loaded.snapshot, snapshot); - assert!(!loaded.verified_by_current_version); + assert_eq!(loaded.revalidation_from_height, Some(1)); + } + + #[test] + fn v0410_verification_marker_migrates_without_historical_revalidation() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let snapshot = test_snapshot("chain-store-legacy-ruleset-marker"); + store.save_verified(&snapshot).unwrap(); + store + .with_connection_mut(|connection| { + connection.execute( + "UPDATE chain_verification SET verifier_version = '0.4.10'", + [], + )?; + Ok(()) + }) + .unwrap(); + + let loaded = store.load_with_verification_status().unwrap().unwrap(); + + assert_eq!(loaded.revalidation_from_height, None); } } diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -68,6 +68,13 @@ impl CompactBlockContext { self.tables = tables; Ok(()) } + + pub(crate) fn append_trusted_block(&mut self, block: &Block) -> Result<()> { + let mut writer = CompactWriter::default(); + encode_block_body(&mut writer, block, &mut self.tables)?; + self.tables.register_protocol_id(&block.hash); + Ok(()) + } } #[derive(Default)] diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -96,7 +96,7 @@ impl Ledger { &mut utxo_lineage, &mut lineage_values, &mut lineage_owners, - &signing_domain, + Some(&signing_domain), )?; } let expected_reward = block_reward(&block.transactions, 0)?; @@ -156,6 +156,51 @@ impl Ledger { Ok(()) } + /// Rebuild derived state from a block that this node previously validated and persisted. + /// This is deliberately private to snapshot restoration: network and newly produced blocks + /// must always use one of the validating apply paths above. + pub(super) fn apply_trusted_block_at(&mut self, block: Block) -> Result<()> { + debug_assert!(self.pending.is_empty() && self.orphans.is_empty()); + + let reward_committee = self.burn_committee_for_block(&block); + let certified_parent = self + .block_certifies_parent(&block, reward_committee.len()) + .then(|| FinalityCheckpoint { + height: self.tip().height, + hash: self.tip().hash.clone(), + }); + for transaction in &block.transactions { + apply_transaction_with_lineage( + transaction, + block.height, + &mut self.utxos, + &mut self.utxo_lineage, + &mut self.lineage_values, + &mut self.lineage_owners, + None, + )?; + } + + let mined_signatures = block + .transactions + .iter() + .map(|transaction| transaction.signature().to_string()) + .collect::<BTreeSet<_>>(); + let parent = self.tip().clone(); + apply_finalizer_ticket_effects(&parent, &block, &mut self.tickets)?; + self.tickets + .extend(tickets_created_by_block(&block, &self.launch_profile)?); + credit_reward_outputs(&mut self.utxos, &block, &reward_committee)?; + self.compact_block_context.append_trusted_block(&block)?; + self.mined_transaction_ids.extend(mined_signatures); + self.chain.push(block); + if let Some(checkpoint) = certified_parent { + self.objective_finality_checkpoint = Some(checkpoint); + } + self.vdf_rounds = self.next_vdf_rounds_after_tip(); + Ok(()) + } + fn ensure_block_transactions_are_not_replays(&self, block: &Block) -> Result<()> { if block.height < TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT { return Ok(()); @@ -297,9 +342,11 @@ fn apply_transaction_with_lineage( utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>, lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>, lineage_owners: &mut super::LineageOwnerValues, - signing_domain: &super::TransactionSigningDomain, + signing_domain: Option<&super::TransactionSigningDomain>, ) -> Result<()> { - transaction.verify_signature(signing_domain)?; + if let Some(signing_domain) = signing_domain { + transaction.verify_signature(signing_domain)?; + } if matches!(transaction, Transaction::Mine { .. }) { let output = transaction.outputs().remove(0); ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -108,15 +108,35 @@ impl Ledger { } pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> { - let verify_vdf = !(cfg!(feature = "e2e") - && snapshot.launch_profile.profile_id == LaunchProfile::local_testnet().profile_id); - Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, u64::MAX) + Self::from_persisted_snapshot_revalidating_from(snapshot, Some(1)) } - /// Restore state from a local snapshot whose VDF proofs were already verified by this - /// software version. All other block validation still runs while rebuilding the ledger. + /// Restore a local snapshot and only revalidate blocks at or above the supplied height. + /// `None` trusts every persisted block while rebuilding its derived in-memory state. + pub fn from_persisted_snapshot_revalidating_from( + snapshot: ChainSnapshot, + revalidate_from_height: Option<u64>, + ) -> Result<Self> { + let verify_vdf_from_height = if cfg!(feature = "e2e") + && snapshot.launch_profile.profile_id == LaunchProfile::local_testnet().profile_id + { + None + } else { + revalidate_from_height + }; + let trusted_before_height = revalidate_from_height.unwrap_or(u64::MAX); + Self::from_snapshot_with_revalidation_policy( + snapshot, + Some(trusted_before_height), + revalidate_from_height, + verify_vdf_from_height, + u64::MAX, + ) + } + + /// Restore state from a local snapshot already trusted under the current consensus ruleset. pub fn from_locally_verified_snapshot(snapshot: ChainSnapshot) -> Result<Self> { - Self::from_snapshot_with_vdf_policy(snapshot, false, u64::MAX) + Self::from_persisted_snapshot_revalidating_from(snapshot, None) } pub(crate) fn from_preverified_snapshot(snapshot: ChainSnapshot) -> Result<Self> { @@ -132,6 +152,22 @@ impl Ledger { verify_vdf: bool, now_ms: u64, ) -> Result<Self> { + Self::from_snapshot_with_revalidation_policy( + snapshot, + None, + Some(0), + verify_vdf.then_some(0), + now_ms, + ) + } + + fn from_snapshot_with_revalidation_policy( + snapshot: ChainSnapshot, + trusted_before_height: Option<u64>, + revalidate_from_height: Option<u64>, + verify_vdf_from_height: Option<u64>, + now_ms: u64, + ) -> Result<Self> { let ChainSnapshot { genesis_allocations, vdf_rounds, @@ -187,8 +223,14 @@ impl Ledger { )?; for block in blocks.into_iter().skip(1) { - if verify_vdf { - ledger.apply_block_at(block, now_ms)?; + if trusted_before_height.is_some_and(|height| block.height < height) { + ledger.apply_trusted_block_at(block)?; + } else if revalidate_from_height.is_some_and(|height| block.height >= height) { + if verify_vdf_from_height.is_some_and(|height| block.height >= height) { + ledger.apply_block_at(block, now_ms)?; + } else { + ledger.apply_preverified_block_at(block, now_ms)?; + } } else { ledger.apply_preverified_block_at(block, now_ms)?; } diff --git a/src/main.rs b/src/main.rs @@ -513,19 +513,18 @@ async fn initialize_ledger( }); } let height = snapshot_height(&snapshot); - let ledger = if loaded.verified_by_current_version { - println!( - "local chain was verified by version {}; skipping VDF reverification", - env!("CARGO_PKG_VERSION") - ); - Ledger::from_locally_verified_snapshot(snapshot) - } else { - println!( - "verifying local chain for version {}...", - env!("CARGO_PKG_VERSION") - ); - Ledger::from_persisted_snapshot(snapshot) + match loaded.revalidation_from_height { + Some(from_height) => println!( + "validating local chain from height {from_height} for the current consensus ruleset..." + ), + None => println!( + "local chain is trusted under the current consensus ruleset; skipping historical validation" + ), } + let ledger = Ledger::from_persisted_snapshot_revalidating_from( + snapshot, + loaded.revalidation_from_height, + ) .with_context(|| { format!( "failed to load chain database {}", diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -991,6 +991,42 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { } #[tokio::test] +async fn startup_rebuilds_state_from_a_locally_trusted_chain() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let wallet = Wallet::from_seed("trusted-persisted-chain-owner"); + let persisted = ledger_with_one_mined_block(&wallet); + store.save_verified(&persisted.snapshot()).unwrap(); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let resumed = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) + .await + .unwrap(); + + assert_eq!(resumed.status(), persisted.status()); + assert_eq!( + resumed.balance_of(wallet.address()), + persisted.balance_of(wallet.address()) + ); + assert_eq!(resumed.snapshot(), persisted.snapshot()); +} + +#[test] +fn consensus_migration_revalidates_only_from_its_activation_height() { + let wallet = Wallet::from_seed("consensus-revalidation-boundary-owner"); + let mut snapshot = ledger_with_one_mined_block(&wallet).snapshot(); + snapshot.blocks[1].leader_proof.as_mut().unwrap().signature = "0".repeat(128); + + assert!(Ledger::from_persisted_snapshot_revalidating_from(snapshot.clone(), Some(1)).is_err()); + let trusted_prefix = + Ledger::from_persisted_snapshot_revalidating_from(snapshot, Some(2)).unwrap(); + assert_eq!(trusted_prefix.height(), 1); +} + +#[tokio::test] async fn local_testnet_requests_reset_for_persisted_normal_launch_profile() { let dir = tempdir().unwrap(); let chain_path = dir.path().join("chain.sqlite3");