From f03ba542e1c7320349892c4663b435000b9d0ecf Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:17:24 +0200 Subject: [PATCH] feat(state): per-component fingerprint dump for cross-node drift diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read-only `sentrix state fingerprint` subcommand splits the single state fingerprint into per-component hashes (accounts / EVM code / EVM storage / total_minted / total_burned / SRC-20 / NFT). Run per node (STOPPED) at the same height and diff the output to localize WHICH component diverges — chasing the post-#782 determinism drift. Pure diagnostic: state_components() is not on the consensus path. Also gitignore the local docs-site/ docusaurus preview build. --- .gitignore | 3 + bin/sentrix/src/commands/state.rs | 25 ++++++++ bin/sentrix/src/main.rs | 6 ++ crates/sentrix-core/src/block_executor.rs | 71 +++++++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/.gitignore b/.gitignore index 5e0ad8b1..56735096 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,6 @@ __pycache__/ .cur[a-z]or/ target-docker/ *.proptest-regressions + +# ── Local docs preview build (node_modules + build cache only) ── +docs-site/ diff --git a/bin/sentrix/src/commands/state.rs b/bin/sentrix/src/commands/state.rs index 82ffe4fc..0f43dc9c 100644 --- a/bin/sentrix/src/commands/state.rs +++ b/bin/sentrix/src/commands/state.rs @@ -350,6 +350,31 @@ fn render_json(r: &PreflightReport) -> String { } /// `sentrix state preflight [--json]` — read-only activation readiness report. +/// Dump per-component state fingerprints for the chain.db at SENTRIX_DATA_DIR. +/// Run against each node's data dir (node STOPPED) and diff the output to find +/// WHICH state component diverges across nodes at the same height. Added +/// 2026-06-04 to chase the post-#782 determinism drift. +pub fn cmd_state_fingerprint() -> anyhow::Result<()> { + let storage = Storage::open(&get_db_path())?; + let bc = storage + .load_blockchain()? + .ok_or_else(|| anyhow::anyhow!("Chain not initialized."))?; + + let c = sentrix::core::block_executor::state_components(&bc); + let hx = |b: &[u8; 32]| b.iter().map(|x| format!("{x:02x}")).collect::(); + + println!("height {}", bc.height()); + println!("account_count {}", c.account_count); + println!("total_minted {}", c.total_minted); + println!("total_burned {}", c.total_burned); + println!("accounts_fp {}", hx(&c.accounts_fp)); + println!("contract_code_fp {}", hx(&c.contract_code_fp)); + println!("contract_stor_fp {}", hx(&c.contract_storage_fp)); + println!("src20_fp {}", hx(&c.src20_fp)); + println!("nft_fp {}", hx(&c.nft_fp)); + Ok(()) +} + pub fn cmd_state_preflight(json: bool) -> anyhow::Result<()> { let storage = Storage::open(&get_db_path())?; let bc = storage diff --git a/bin/sentrix/src/main.rs b/bin/sentrix/src/main.rs index 4f16f809..dd80f599 100644 --- a/bin/sentrix/src/main.rs +++ b/bin/sentrix/src/main.rs @@ -625,6 +625,11 @@ enum StateCommands { #[arg(long)] json: bool, }, + /// Dump per-component state fingerprints (accounts / EVM code / EVM + /// storage / total_minted / total_burned / SRC-20 / NFT) for the chain.db + /// at SENTRIX_DATA_DIR. Run per node (STOPPED) and diff to localize which + /// component diverges across nodes — drift diagnosis. + Fingerprint, } #[derive(Subcommand)] @@ -894,6 +899,7 @@ async fn main() -> anyhow::Result<()> { } StateCommands::Verify { input } => commands::state::cmd_state_verify(&input)?, StateCommands::Preflight { json } => commands::state::cmd_state_preflight(json)?, + StateCommands::Fingerprint => commands::state::cmd_state_fingerprint()?, }, Commands::Mempool { action } => match action { diff --git a/crates/sentrix-core/src/block_executor.rs b/crates/sentrix-core/src/block_executor.rs index db9919dc..ba9fbfa9 100644 --- a/crates/sentrix-core/src/block_executor.rs +++ b/crates/sentrix-core/src/block_executor.rs @@ -1967,6 +1967,77 @@ fn compute_state_fingerprint(bc: &Blockchain) -> ([u8; 32], [u8; 32]) { (acc_fp, fp) } +/// Per-component state fingerprints for cross-node drift diagnosis. +/// +/// `compute_state_fingerprint` folds everything into one root, which tells +/// you THAT two nodes diverged but not WHERE. This splits each component out +/// (accounts / EVM code / EVM storage / mint / burn / SRC-20 / NFT) so a diff +/// across nodes at the same height points straight at the divergent subsystem. +/// Added 2026-06-04 chasing the post-#782 state-determinism drift. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateComponents { + /// balance + nonce + code_hash + storage_root over sorted accounts. + pub accounts_fp: [u8; 32], + /// hash over sorted EVM contract bytecode. + pub contract_code_fp: [u8; 32], + /// hash over sorted EVM contract storage slots. + pub contract_storage_fp: [u8; 32], + pub total_minted: u64, + pub total_burned: u64, + /// SRC-20 native registry canonical hash. + pub src20_fp: [u8; 32], + /// NFT native registry canonical hash. + pub nft_fp: [u8; 32], + /// account count (cheap sanity signal alongside the hashes). + pub account_count: usize, +} + +/// Compute the per-component fingerprints — see [`StateComponents`]. +pub fn state_components(bc: &Blockchain) -> StateComponents { + use sha2::{Digest, Sha256}; + + let mut accounts: Vec<(&String, &sentrix_primitives::account::Account)> = + bc.accounts.accounts.iter().collect(); + accounts.sort_by(|a, b| a.0.cmp(b.0)); + let account_count = accounts.len(); + let mut acc_h = Sha256::new(); + for (addr, account) in accounts { + acc_h.update(addr.as_bytes()); + acc_h.update(account.balance.to_be_bytes()); + acc_h.update(account.nonce.to_be_bytes()); + acc_h.update(account.code_hash); + acc_h.update(account.storage_root); + } + + let mut codes: Vec<(&String, &Vec)> = bc.accounts.contract_code.iter().collect(); + codes.sort_by(|a, b| a.0.cmp(b.0)); + let mut code_h = Sha256::new(); + for (k, v) in codes { + code_h.update(k.as_bytes()); + let h: [u8; 32] = Sha256::digest(v).into(); + code_h.update(h); + } + + let mut storage: Vec<(&String, &Vec)> = bc.accounts.contract_storage.iter().collect(); + storage.sort_by(|a, b| a.0.cmp(b.0)); + let mut stor_h = Sha256::new(); + for (k, v) in storage { + stor_h.update(k.as_bytes()); + stor_h.update(v); + } + + StateComponents { + accounts_fp: acc_h.finalize().into(), + contract_code_fp: code_h.finalize().into(), + contract_storage_fp: stor_h.finalize().into(), + total_minted: bc.total_minted, + total_burned: bc.accounts.total_burned, + src20_fp: bc.contracts.canonical_hash(), + nft_fp: bc.nft_registry.canonical_hash(), + account_count, + } +} + // ── Tests ───────────────────────────────────────────────── #[cfg(test)] mod tests {