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:
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");