From a4202868eef719bdc6462ebbdaa452e7de2b182c Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:35:29 +0200 Subject: [PATCH 1/2] fix(consensus): accept justified peer block on #1e by default (no env) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-node SENTRIX_OBSERVER_TOLERANT_STATE_ROOT env gate (#795) with a uniform, default behavior for ALL nodes: on a #1e state_root mismatch for a Peer block, ACCEPT it (stamp the proposer's root) iff the block carries a justification — which means it already passed the 2/3 justification gate in add_block_impl before pass-2 (an absent/insufficient justification is rejected there). A no-justification Peer block still hits the strict #1e reject. Why: consensus is on block_hash (2/3 justification), not state_root. The chain's state-commitment is imperfect (recurring state_root), so a node that falls behind and catches up via sync recomputes a different root and the strict #1e REJECTED the canonical block → the validator got stuck syncing → chain stalled until halt-all (val1, 2026-06-06). The env gate made only the fullnode tolerant, so validators kept stalling; making it the default fixes the stuck-sync at the root and removes the val-vs-fullnode divergence. Safety: this only changes the accept/reject decision on already-canonical (justification-gated) blocks; the stamped root is identical either way, so no computed-state change and no fork gate needed. The accept inherits the chain's existing justification trust level (the add_block_impl gate) — no weaker. NOTE: cryptographic per-signature justification verification (STRICT_JUSTIFICATION_HEIGHT) is a separate, currently-broken path (recover_signer disagrees with the producer's signing payload — validated 2026-06-06: enabling it rejects legitimate blocks); fixing that to enable strict verification fleet-wide is a tracked follow-up. Tests: justified Peer block + tampered root past fork height → ACCEPTED, root stamped; the no-justification counterpart → REJECTED (the updated C-03 rollback test). Both use the pad-past-STATE_ROOT_FORK_HEIGHT harness. sentrix-core 264 passed; clippy --all-targets -D warnings clean. --- Cargo.lock | 40 +++--- Cargo.toml | 2 +- crates/sentrix-core/src/block_executor.rs | 150 ++++++++++++++++++---- 3 files changed, 143 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd090559..16f90f6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5178,7 +5178,7 @@ dependencies = [ [[package]] name = "sentrix" -version = "2.2.40" +version = "2.2.41" dependencies = [ "aes-gcm", "alloy-consensus", @@ -5226,7 +5226,7 @@ dependencies = [ [[package]] name = "sentrix-bft" -version = "2.2.40" +version = "2.2.41" dependencies = [ "bincode", "hex", @@ -5254,7 +5254,7 @@ dependencies = [ [[package]] name = "sentrix-core" -version = "2.2.40" +version = "2.2.41" dependencies = [ "alloy-consensus", "alloy-eips", @@ -5286,7 +5286,7 @@ dependencies = [ [[package]] name = "sentrix-evm" -version = "2.2.40" +version = "2.2.41" dependencies = [ "alloy-primitives", "hex", @@ -5301,7 +5301,7 @@ dependencies = [ [[package]] name = "sentrix-faucet" -version = "2.2.40" +version = "2.2.41" dependencies = [ "anyhow", "axum", @@ -5325,14 +5325,14 @@ dependencies = [ [[package]] name = "sentrix-fork-heights" -version = "2.2.40" +version = "2.2.41" dependencies = [ "tracing", ] [[package]] name = "sentrix-grpc" -version = "2.2.40" +version = "2.2.41" dependencies = [ "async-stream", "bincode", @@ -5351,7 +5351,7 @@ dependencies = [ [[package]] name = "sentrix-network" -version = "2.2.40" +version = "2.2.41" dependencies = [ "async-trait", "bincode", @@ -5369,7 +5369,7 @@ dependencies = [ [[package]] name = "sentrix-nft" -version = "2.2.40" +version = "2.2.41" dependencies = [ "hex", "serde", @@ -5380,7 +5380,7 @@ dependencies = [ [[package]] name = "sentrix-node" -version = "2.2.40" +version = "2.2.41" dependencies = [ "anyhow", "axum", @@ -5406,14 +5406,14 @@ dependencies = [ [[package]] name = "sentrix-precompiles" -version = "2.2.40" +version = "2.2.41" dependencies = [ "alloy-primitives", ] [[package]] name = "sentrix-primitives" -version = "2.2.40" +version = "2.2.41" dependencies = [ "hex", "proptest", @@ -5428,7 +5428,7 @@ dependencies = [ [[package]] name = "sentrix-prom-exporter" -version = "2.2.40" +version = "2.2.41" dependencies = [ "http-body-util", "hyper", @@ -5457,7 +5457,7 @@ dependencies = [ [[package]] name = "sentrix-rpc" -version = "2.2.40" +version = "2.2.41" dependencies = [ "alloy-consensus", "alloy-eips", @@ -5490,14 +5490,14 @@ dependencies = [ [[package]] name = "sentrix-rpc-types" -version = "2.2.40" +version = "2.2.41" dependencies = [ "serde_json", ] [[package]] name = "sentrix-staking" -version = "2.2.40" +version = "2.2.41" dependencies = [ "sentrix-primitives", "serde", @@ -5507,7 +5507,7 @@ dependencies = [ [[package]] name = "sentrix-storage" -version = "2.2.40" +version = "2.2.41" dependencies = [ "bincode", "libmdbx", @@ -5522,7 +5522,7 @@ dependencies = [ [[package]] name = "sentrix-trie" -version = "2.2.40" +version = "2.2.41" dependencies = [ "bincode", "blake3", @@ -5539,7 +5539,7 @@ dependencies = [ [[package]] name = "sentrix-wallet" -version = "2.2.40" +version = "2.2.41" dependencies = [ "aes-gcm", "argon2", @@ -5559,7 +5559,7 @@ dependencies = [ [[package]] name = "sentrix-wire" -version = "2.2.40" +version = "2.2.41" dependencies = [ "bincode", "secp256k1 0.31.1", diff --git a/Cargo.toml b/Cargo.toml index b2c33c6d..237e6a5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [".", "crates/sentrix-primitives", "crates/sentrix-wallet", "crates/se # `version.workspace = true`. Same goes for edition/license/repository so # they can't drift across crates. [workspace.package] -version = "2.2.40" +version = "2.2.41" edition = "2024" license = "BUSL-1.1" repository = "https://github.com/sentrix-labs/sentrix" diff --git a/crates/sentrix-core/src/block_executor.rs b/crates/sentrix-core/src/block_executor.rs index 2f1b859d..466809b0 100644 --- a/crates/sentrix-core/src/block_executor.rs +++ b/crates/sentrix-core/src/block_executor.rs @@ -1727,30 +1727,42 @@ impl Blockchain { return Ok(()); } - // Observer-tolerant accept (gated, default OFF). An observer/ - // fullnode applies EVERY block via add_block_from_peer (Peer) and - // strictly rejecting a #1e here halts it on canonical data: the - // block already passed the strict 2/3-precommit justification - // verification earlier in add_block_impl, so it IS the network- - // agreed block (consensus is on block_hash, not state_root). The - // mismatch is the chain's known imperfect state-commitment - // (recurring/oscillating state_root) that validators already - // tolerate via the apply-from-stash path. With - // SENTRIX_OBSERVER_TOLERANT_STATE_ROOT=1 set, accept the block and - // stamp the proposer's (canonical) received root so the observer's - // chain stays consistent with the committed roots; its local - // accounts diverge from that root (the same pre-existing imperfection - // every node has), so served state is no worse than a validator's. - // Default OFF → validators keep the strict #1e reject below. Only an - // observer node sets this env. + // #1e on a JUSTIFIED Peer block → ACCEPT (stamp the + // proposer's canonical root). Consensus is on + // block_hash, not state_root, and any Peer block that + // reaches here already passed the 2/3 justification + // gate in add_block_impl (the `Some(j)` block at + // ~232: an absent/insufficient justification is + // rejected there, before pass-2). So the block IS + // network-canonical; the local-recompute mismatch is + // the chain's known imperfect state-commitment + // (recurring/oscillating state_root) that the + // apply-from-stash path already tolerates. Strictly + // rejecting it halts the node on canonical data — the + // lagging-validator stuck-sync (val1, 2026-06-06, + // recovered only by halt-all). This is the DEFAULT + // for ALL nodes now, replacing the per-node + // SENTRIX_OBSERVER_TOLERANT env whose validator-vs- + // fullnode divergence caused that stall. The node's + // local accounts diverge from the stamped root (the + // same imperfection every node carries), so served + // state is no worse than any validator's. A Peer + // block with NO justification is NOT accepted here — + // it falls through to the strict reject below. + // + // (Cryptographic per-signature justification + // verification — STRICT_JUSTIFICATION_HEIGHT — is a + // separate, currently-broken path: recover_signer + // disagrees with the producer's signing payload, so + // it stays off; this accept inherits the chain's + // existing justification trust level, no weaker.) if self.source_for_current_add == BlockSource::Peer - && std::env::var_os("SENTRIX_OBSERVER_TOLERANT_STATE_ROOT") - .is_some_and(|v| v == "1") + && last.justification.is_some() { tracing::debug!( - "observer-tolerant: #1e at block {} (received {} vs computed \ - {}) — accepting justified canonical block, stamping received \ - root (local state diverges; chain state-commitment imperfect)", + "#1e accept at block {} (received {} vs computed {}) — \ + justified canonical block, stamping received root \ + (local state diverges; chain state-commitment imperfect)", block_index, hex::encode(received_root), hex::encode(computed_root), @@ -2729,7 +2741,6 @@ mod tests { #[test] fn test_c03_pass2_failure_restores_staking_state() { use sentrix_primitives::block::{Block, STATE_ROOT_FORK_HEIGHT}; - use sentrix_primitives::justification::BlockJustification; use sentrix_staking::staking::ValidatorStake; use sentrix_storage::MdbxStorage; use std::sync::Arc; @@ -2794,19 +2805,18 @@ mod tests { let mut block = Block::new(height, prev_hash, vec![coinbase], "v1".into()); block.timestamp = 1_777_000_001; // Tamper the declared root so #1e fires AFTER the reward bundle ran. + // Leave the block UNJUSTIFIED: a justified #1e block is now accepted + // (canonical-block tolerance), so to still exercise the Pass-2 reject + + // rollback we use the no-justification path, which #1e rejects. block.state_root = Some([0xAB; 32]); block.hash = block.calculate_hash(); - let mut just = BlockJustification::new(height, 0, block.hash.clone()); - just.add_precommit("v1".into(), vec![], 1000); - just.add_precommit("v2".into(), vec![], 1000); - just.add_precommit("v3".into(), vec![], 1000); - block.justification = Some(just); + block.justification = None; let pending_before = bc.stake_registry.validators.get("v1").unwrap().pending_rewards; let err = bc .add_block_from_peer(block) - .expect_err("tampered state_root must be rejected (#1e)"); + .expect_err("tampered state_root on an unjustified block must be rejected (#1e)"); assert!( format!("{err:?}").contains("state_root mismatch"), "expected #1e state_root mismatch, got: {err:?}" @@ -2825,6 +2835,90 @@ mod tests { } } + /// A JUSTIFIED Peer block with a tampered (mismatching) state_root past the + /// fork height is ACCEPTED on #1e and the proposer's root stamped — + /// consensus is on block_hash and the block carries a 2/3 justification. + /// This is the lagging-validator catch-up fix (val1 stuck-sync 2026-06-06). + /// The no-justification counterpart staying rejected is covered by + /// `test_c03_pass2_failure_restores_staking_state` above. + #[test] + fn test_justified_peer_block_accepted_on_state_root_mismatch() { + use sentrix_primitives::block::{Block, STATE_ROOT_FORK_HEIGHT}; + use sentrix_primitives::justification::BlockJustification; + use sentrix_staking::staking::ValidatorStake; + use sentrix_storage::MdbxStorage; + use std::sync::Arc; + use tempfile::TempDir; + + let mut bc = setup(); + bc.voyager_activated = true; + for addr in ["v1", "v2", "v3", "v4"] { + bc.stake_registry.validators.insert( + addr.to_string(), + ValidatorStake { + address: addr.to_string(), + self_stake: 1000, + total_delegated: 0, + commission_rate: 1000, + max_commission_rate: 2000, + is_jailed: false, + jail_until: 0, + is_tombstoned: false, + blocks_signed: 0, + blocks_missed: 0, + pending_rewards: 0, + registration_height: 0, + last_commission_change_height: 0, + }, + ); + } + bc.stake_registry.active_set = vec!["v1".into(), "v2".into(), "v3".into(), "v4".into()]; + + // Pad past STATE_ROOT_FORK_HEIGHT so the #1e check enforces. + let pad_height = STATE_ROOT_FORK_HEIGHT + 1; + let prev = bc.latest_block().unwrap().hash.clone(); + let mut pad = Block::new( + pad_height, + prev, + vec![Transaction::new_coinbase("v1".into(), 0, pad_height, 1_777_000_000)], + "v1".into(), + ); + pad.timestamp = 1_777_000_000; + bc.chain.push(pad); + + let dir = TempDir::new().unwrap(); + let mdbx = Arc::new(MdbxStorage::open(dir.path()).unwrap()); + bc.init_trie(mdbx).unwrap(); + + let height = bc.height() + 1; + let prev_hash = bc.latest_block().unwrap().hash.clone(); + let reward = bc.get_block_reward(); + let coinbase = Transaction::new_coinbase("v1".into(), reward, height, 1_777_000_002); + let mut block = Block::new(height, prev_hash, vec![coinbase], "v1".into()); + block.timestamp = 1_777_000_002; + // Tampered root → forces #1e; justification present → must be accepted. + block.state_root = Some([0xAB; 32]); + block.hash = block.calculate_hash(); + let mut just = BlockJustification::new(height, 0, block.hash.clone()); + just.add_precommit("v1".into(), vec![], 1000); + just.add_precommit("v2".into(), vec![], 1000); + just.add_precommit("v3".into(), vec![], 1000); + block.justification = Some(just); + + let h_before = bc.height(); + let result = bc.add_block_from_peer(block); + assert!( + result.is_ok(), + "justified Peer block must be accepted despite #1e: {result:?}" + ); + assert_eq!(bc.height(), h_before + 1, "justified block must commit"); + assert_eq!( + bc.latest_block().unwrap().state_root, + Some([0xAB; 32]), + "proposer's (received) root must be stamped on #1e accept" + ); + } + #[test] fn test_add_block_succeeds_without_trie() { // update_trie_for_block returning Ok(None) must not fail add_block. From c20425912ae7892812636b906df76b4d86fbca59 Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:50:55 +0200 Subject: [PATCH 2/2] fix(consensus): plumb Pass-1 justification-verified flag into #1e accept (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit #801 Major: keying the #1e accept off block.justification.is_some() was imprecise — add_block_impl SKIPS the 2/3 gate when SENTRIX_REPLAY_BYPASS_AUTHZ is set or voyager_mode_for(height) is false, so a peer block could carry an arbitrary (unvalidated) justification blob, reach Pass-2, and be accepted on #1e without ever passing BFT validation. Fix: a per-add transient `current_add_justification_verified` (serde-skipped), reset at the top of add_block_impl and set true ONLY at the end of the gate block — i.e. only when the gate actually RAN and PASSED. Pass-2's #1e accept now reads that flag instead of justification presence, so it tolerates a state_root mismatch strictly on gate-verified blocks; replay / pre-voyager / no-justification blocks fall through to the strict reject. Tests unchanged + still green (justified→accept goes through the gate→flag set; unjustified→reject, gate skipped→flag false). sentrix-core suite + clippy --all-targets -D warnings clean. --- crates/sentrix-core/src/block_executor.rs | 27 +++++++++++++++-------- crates/sentrix-core/src/blockchain.rs | 9 ++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/sentrix-core/src/block_executor.rs b/crates/sentrix-core/src/block_executor.rs index 466809b0..62be181b 100644 --- a/crates/sentrix-core/src/block_executor.rs +++ b/crates/sentrix-core/src/block_executor.rs @@ -166,6 +166,8 @@ impl Blockchain { } 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; let expected_index = self.height() + 1; let expected_prev = self.latest_block()?.hash.clone(); @@ -368,6 +370,11 @@ impl Blockchain { j.signer_count(), ))); } + // Gate ran AND passed (no early-return above) → record that this + // block's 2/3 justification was actually verified, for Pass-2's + // #1e accept. A justification blob alone is insufficient — it could + // have bypassed this gate via replay / pre-voyager (CodeRabbit #801). + self.current_add_justification_verified = true; } // C-04: validate coinbase amount AND recipient. Amount must equal the @@ -1727,14 +1734,16 @@ impl Blockchain { return Ok(()); } - // #1e on a JUSTIFIED Peer block → ACCEPT (stamp the - // proposer's canonical root). Consensus is on - // block_hash, not state_root, and any Peer block that - // reaches here already passed the 2/3 justification - // gate in add_block_impl (the `Some(j)` block at - // ~232: an absent/insufficient justification is - // rejected there, before pass-2). So the block IS - // network-canonical; the local-recompute mismatch is + // #1e on a Peer block whose 2/3 justification was + // VERIFIED in Pass-1 → ACCEPT (stamp the proposer's + // canonical root). `current_add_justification_verified` + // is set only when the gate in add_block_impl (~232) + // actually ran AND passed — NOT merely when a + // justification blob is present, which could have + // bypassed the gate via replay / pre-voyager + // (CodeRabbit #801). Consensus is on block_hash, not + // state_root, so a gate-verified block IS network- + // canonical; the local-recompute mismatch is // the chain's known imperfect state-commitment // (recurring/oscillating state_root) that the // apply-from-stash path already tolerates. Strictly @@ -1757,7 +1766,7 @@ impl Blockchain { // it stays off; this accept inherits the chain's // existing justification trust level, no weaker.) if self.source_for_current_add == BlockSource::Peer - && last.justification.is_some() + && self.current_add_justification_verified { tracing::debug!( "#1e accept at block {} (received {} vs computed {}) — \ diff --git a/crates/sentrix-core/src/blockchain.rs b/crates/sentrix-core/src/blockchain.rs index 2c277b55..5ef359f5 100644 --- a/crates/sentrix-core/src/blockchain.rs +++ b/crates/sentrix-core/src/blockchain.rs @@ -138,6 +138,14 @@ pub struct Blockchain { /// Pass 2. Backlog #1e. Not persisted. #[serde(skip, default = "default_block_source")] pub(crate) source_for_current_add: crate::block_executor::BlockSource, + /// Per-add transient: true only when Pass-1's 2/3 justification gate + /// actually RAN and PASSED for the current block. Pass-2's #1e accept reads + /// this so it tolerates a state_root mismatch only on a block whose + /// justification was verified — never one that bypassed the gate (replay + /// `SENTRIX_REPLAY_BYPASS_AUTHZ`, or pre-voyager heights). Presence of a + /// `justification` blob alone is NOT sufficient (CodeRabbit #801). + #[serde(skip)] + pub(crate) current_add_justification_verified: bool, /// Rolling tracker for state_root divergences from peers. /// @@ -243,6 +251,7 @@ impl Blockchain { epoch_manager: sentrix_staking::epoch::EpochManager::new(), slashing: sentrix_staking::slashing::SlashingEngine::new(), source_for_current_add: crate::block_executor::BlockSource::SelfProduced, + current_add_justification_verified: false, divergence_tracker: DivergenceTracker::default(), voyager_activated: false, evm_activated: false,