Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion crates/sentrix-core/src/block_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
6 changes: 6 additions & 0 deletions crates/sentrix-core/src/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
39 changes: 39 additions & 0 deletions crates/sentrix-fork-heights/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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 {
Expand Down Expand Up @@ -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).
Expand Down
Loading