diff --git a/crates/sentrix-core/src/block_executor.rs b/crates/sentrix-core/src/block_executor.rs index 6f140ecc..2eb8c879 100644 --- a/crates/sentrix-core/src/block_executor.rs +++ b/crates/sentrix-core/src/block_executor.rs @@ -165,6 +165,27 @@ impl Blockchain { result } + /// SIP-5 / Bug B: compute the `state_root` a candidate block would produce + /// without mutating this chain. + /// + /// The proposer calls this at propose time to stamp the resulting + /// `state_root` into the block before hashing and signing, so the hash the + /// quorum votes on already commits the post-execution state. Validators + /// call it to re-derive and verify a proposal's `state_root` before + /// prevoting (see SIP-5). + /// + /// Runs the real self-produced apply on a throwaway clone — the strict + /// justification gate is peer-only, so a propose-time block that has no + /// justification yet still applies — then reads the stamped root and + /// discards the clone. Cost is ~one extra block apply (see SIP-5 §Cost). + pub fn speculative_apply_for_state_root(&self, block: &Block) -> SentrixResult<[u8; 32]> { + let mut probe = self.clone(); + probe.add_block(block.clone())?; + probe.latest_block()?.state_root.ok_or_else(|| { + SentrixError::Internal("speculative apply produced no state_root".to_string()) + }) + } + fn add_block_impl(&mut self, block: Block) -> SentrixResult<()> { // Reset per-add: only the justification gate below sets this true. self.current_add_justification_verified = false; @@ -1670,7 +1691,22 @@ impl Blockchain { last.index ))); } - // Self-produced: stamp and recompute hash (V7-C-01). + // Self-produced. + if Self::is_speculative_apply_height(last.index) { + // Post-fork (SIP-5 / Bug B): the proposer stamps + // state_root at propose time and the quorum signs a hash + // that already commits it, so a self-produced block must + // arrive with state_root = Some. A None here means the + // propose-time stamp was skipped — reject rather than + // recompute the hash, which would diverge from the hash the + // quorum voted on (the Bug B inconsistency this fork closes). + return Err(SentrixError::ChainValidationFailed(format!( + "self-produced block {} has state_root=None past \ + SPECULATIVE_APPLY_HEIGHT (SIP-5)", + last.index + ))); + } + // Pre-fork: stamp and recompute hash (V7-C-01). last.state_root = Some(computed_root); last.hash = last.calculate_hash(); } diff --git a/crates/sentrix-core/src/blockchain.rs b/crates/sentrix-core/src/blockchain.rs index 5ef359f5..baab1d75 100644 --- a/crates/sentrix-core/src/blockchain.rs +++ b/crates/sentrix-core/src/blockchain.rs @@ -378,6 +378,12 @@ impl Blockchain { crate::fork_heights::is_strict_justification_height(height) } + /// SIP-5 / Bug B: at or after the speculative-apply fork, the block hash + /// commits the post-execution `state_root` from proposal time. + pub fn is_speculative_apply_height(height: u64) -> bool { + crate::fork_heights::is_speculative_apply_height(height) + } + pub fn is_state_in_trie_height(height: u64) -> bool { crate::fork_heights::is_state_in_trie_height(height) } diff --git a/crates/sentrix-fork-heights/src/lib.rs b/crates/sentrix-fork-heights/src/lib.rs index ce6fb55c..a968dfc8 100644 --- a/crates/sentrix-fork-heights/src/lib.rs +++ b/crates/sentrix-fork-heights/src/lib.rs @@ -200,6 +200,29 @@ const NATIVE_STATE_IN_TRIE_HEIGHT_DEFAULT: u64 = u64::MAX; /// simul-start). See `audits/reward-distribution-flow-audit-2026-04-27.md`. const REWARD_APPLY_PATH_HEIGHT_DEFAULT: u64 = u64::MAX; +/// Bug B fix — speculative apply at propose so the block hash commits the +/// post-execution `state_root` (SIP-5, drafted 2026-05-31). +/// +/// Pre-fork: a self-produced block is built with `state_root = None` and +/// hashed as `H1`; the engine collects a quorum over `H1`; the apply path +/// then stamps `state_root` and recomputes the hash to `H2`, while the +/// embedded justification still references `H1`. Stored blocks are +/// internally inconsistent (`block.hash != justification.block_hash`), +/// which blocks safe activation of `STRICT_JUSTIFICATION_HEIGHT`. Surfaced +/// on 2026-05-31 testnet h=5,817,132. +/// +/// Post-fork: the proposer applies the block speculatively, stamps the +/// resulting `state_root` into the block before hashing/signing, and +/// validators re-derive the `state_root` and refuse to prevote on a +/// proposal that diverges. The post-apply recompute is skipped — the hash +/// the quorum signed already commits the state root. See +/// [SIP-5](https://github.com/sentrix-labs/SIPs/blob/main/sips/sip-5.md). +/// +/// Default `u64::MAX` on both nets — consensus-changing, activated via +/// `SPECULATIVE_APPLY_HEIGHT=` env after a halt-all + state-root +/// alignment pre-flight + simul-start, testnet first. +const SPECULATIVE_APPLY_HEIGHT_DEFAULT: u64 = u64::MAX; + // ── Runtime readers (env → u64, default to compile-time default) ────── fn configured_chain_id() -> u64 { @@ -274,6 +297,22 @@ pub fn get_tokenomics_v2_height() -> u64 { ) } +/// Bug B / SIP-5: read the speculative-apply fork height from env, default +/// `u64::MAX` (disabled). Post-fork the block hash commits the +/// post-execution `state_root` from proposal time. +pub fn get_speculative_apply_height() -> u64 { + read_height( + "SPECULATIVE_APPLY_HEIGHT", + SPECULATIVE_APPLY_HEIGHT_DEFAULT, + ) +} + +/// Is the given height at or after the SIP-5 speculative-apply fork? +pub fn is_speculative_apply_height(height: u64) -> bool { + let fork = get_speculative_apply_height(); + fork != u64::MAX && height >= fork +} + /// BFT-gate-relax: read fork height from env, default `u64::MAX` /// (disabled — keeps current `active >= MIN_BFT_VALIDATORS` gate). /// Post-fork: `active >= ⌈2/3 × total⌉` (= 3 for 4-validator network).