diff --git a/crates/sentrix-core/src/blockchain.rs b/crates/sentrix-core/src/blockchain.rs index 06211ca9..1c71e173 100644 --- a/crates/sentrix-core/src/blockchain.rs +++ b/crates/sentrix-core/src/blockchain.rs @@ -373,6 +373,10 @@ impl Blockchain { crate::fork_heights::is_state_in_trie_height(height) } + pub fn is_native_state_in_trie_height(height: u64) -> bool { + crate::fork_heights::is_native_state_in_trie_height(height) + } + pub fn is_bft_gate_relax_height(height: u64) -> bool { crate::fork_heights::is_bft_gate_relax_height(height) } diff --git a/crates/sentrix-core/src/blockchain_trie_ops.rs b/crates/sentrix-core/src/blockchain_trie_ops.rs index 60ef1e87..89b63fdc 100644 --- a/crates/sentrix-core/src/blockchain_trie_ops.rs +++ b/crates/sentrix-core/src/blockchain_trie_ops.rs @@ -26,7 +26,8 @@ use sentrix_primitives::transaction::{PROTOCOL_TREASURY, TOKEN_OP_ADDRESS}; use sentrix_storage::MdbxStorage; use sentrix_trie::address::{ account_value_bytes, address_to_key, epoch_state_key, epoch_state_value_bytes, - liveness_value_bytes, pending_rewards_value_bytes, total_minted_key, total_minted_value_bytes, + liveness_value_bytes, native_nft_registry_key, native_src20_registry_key, + pending_rewards_value_bytes, total_minted_key, total_minted_value_bytes, validator_liveness_key, validator_pending_rewards_key, }; @@ -517,6 +518,22 @@ impl Blockchain { } // Borrow of `stake_registry` / `slashing` / `total_minted` ends here. + // Native-module state commitment. Independent of the SIP-6 + // STATE_IN_TRIE gate above (which is already active on testnet — + // reusing it would retroactively fork testnet's state_root). Capture + // the SRC-20 + NFT registry canonical hashes here, before the trie + // mut-borrow, and commit them in Phase 2f. Pre-fork both are None and + // the state_root is unchanged from today. + let native_src20_hash: Option<[u8; 32]>; + let native_nft_hash: Option<[u8; 32]>; + if Self::is_native_state_in_trie_height(block_index) { + native_src20_hash = Some(self.contracts.canonical_hash()); + native_nft_hash = Some(self.nft_registry.canonical_hash()); + } else { + native_src20_hash = None; + native_nft_hash = None; + } + if trace { eprintln!("[trie-trace] update_trie_for_block at h={block_index}"); eprintln!("[trie-trace] touched (sorted): {} addresses", updates.len()); @@ -669,6 +686,33 @@ impl Blockchain { } } + // Phase 2f — native-module state commitment (post + // NATIVE_STATE_IN_TRIE fork only). Each registry's canonical hash is + // written under a single fixed key, overwritten every block, so the + // state_root reflects all SRC-20 + NFT state. Always-insert (even when + // empty) — an empty registry has a stable canonical hash distinct from + // any populated one, same insert-always semantics as total_minted. + if let Some(h) = native_src20_hash { + trie.insert(&native_src20_registry_key(), &h)?; + if trace { + eprintln!( + "[trie-trace] native_src20 hash={} → root={}", + hex::encode(&h[..8]), + hex::encode(trie.root_hash()) + ); + } + } + if let Some(h) = native_nft_hash { + trie.insert(&native_nft_registry_key(), &h)?; + if trace { + eprintln!( + "[trie-trace] native_nft hash={} → root={}", + hex::encode(&h[..8]), + hex::encode(trie.root_hash()) + ); + } + } + let root = trie.commit(block_index)?; if trace { eprintln!( diff --git a/crates/sentrix-core/src/fork_heights.rs b/crates/sentrix-core/src/fork_heights.rs index c0c2851b..c21f5690 100644 --- a/crates/sentrix-core/src/fork_heights.rs +++ b/crates/sentrix-core/src/fork_heights.rs @@ -175,6 +175,17 @@ const STRICT_JUSTIFICATION_HEIGHT_DEFAULT: u64 = u64::MAX; const STATE_IN_TRIE_HEIGHT_DEFAULT: u64 = u64::MAX; const STATE_IN_TRIE_HEIGHT_TESTNET_DEFAULT: u64 = 6_026_000; +/// Native-module state-root commitment (SRC-20 ContractRegistry + NFT +/// NftRegistry into the trie). Default `u64::MAX` on BOTH mainnet AND +/// testnet — deliberately NOT reusing `STATE_IN_TRIE_HEIGHT` (already +/// active on testnet at 6,026,000): reusing it would retroactively change +/// testnet's state_root and fork the chain. This is a fresh, independent +/// gate that stays disabled everywhere until an explicit activation +/// height is set via env, preceded by halt-all + state-root-alignment +/// pre-flight + simul-start so every validator commits identical native +/// state at the activation block. +const NATIVE_STATE_IN_TRIE_HEIGHT_DEFAULT: u64 = u64::MAX; + // ── Runtime readers (env → u64, default to compile-time default) ────── fn configured_chain_id() -> u64 { @@ -399,6 +410,17 @@ pub fn get_state_in_trie_height() -> u64 { ) } +/// Native-module state-root commitment fork height. Default `u64::MAX` +/// on both nets (see [`NATIVE_STATE_IN_TRIE_HEIGHT_DEFAULT`]). Once +/// pinned via env, the apply path commits the SRC-20 + NFT registry +/// canonical hashes into the trie before state_root. +pub fn get_native_state_in_trie_height() -> u64 { + read_height( + "NATIVE_STATE_IN_TRIE_HEIGHT", + NATIVE_STATE_IN_TRIE_HEIGHT_DEFAULT, + ) +} + // ── Height predicates ──────────────────────────────────── // // Every fork-height predicate has the same shape: @@ -529,6 +551,18 @@ pub fn is_state_in_trie_height(height: u64) -> bool { fork != u64::MAX && height >= fork } +/// Native-module state-root commitment — true once +/// `NATIVE_STATE_IN_TRIE_HEIGHT` activates. Post-fork: the apply path +/// commits the SRC-20 `ContractRegistry` and NFT `NftRegistry` canonical +/// hashes into the state trie before state_root, so divergence in native +/// token/NFT state surfaces as a state_root mismatch instead of relying +/// on deterministic re-execution + the (uncommitted) blob. Pre-fork the +/// state_root is computed exactly as before (native state stays off-trie). +pub fn is_native_state_in_trie_height(height: u64) -> bool { + let fork = get_native_state_in_trie_height(); + fork != u64::MAX && height >= fork +} + /// BFT-gate-relax: is the given height at or after the fork? /// Post-fork: validator-loop's P1 BFT safety gate uses /// `active >= ⌈2/3 × total⌉` instead of `active >= MIN_BFT_VALIDATORS @@ -749,4 +783,45 @@ mod tests { std::env::remove_var("JAIL_CONSENSUS_HEIGHT"); } } + + /// NATIVE_STATE_IN_TRIE must default to disabled (`u64::MAX`) on BOTH + /// mainnet and testnet — unlike SIP-6 STATE_IN_TRIE, which is already + /// armed on testnet. Reusing that gate would have forked testnet; this + /// fresh gate stays closed everywhere until explicitly pinned. + #[test] + fn native_state_in_trie_disabled_by_default_both_nets() { + let _guard = env_test_lock(); + unsafe { + std::env::remove_var("NATIVE_STATE_IN_TRIE_HEIGHT"); + + std::env::set_var("SENTRIX_CHAIN_ID", MAINNET_CHAIN_ID.to_string()); + assert_eq!(get_native_state_in_trie_height(), u64::MAX); + assert!(!is_native_state_in_trie_height(0)); + assert!(!is_native_state_in_trie_height(u64::MAX - 1)); + + std::env::set_var("SENTRIX_CHAIN_ID", TESTNET_CHAIN_ID.to_string()); + assert_eq!( + get_native_state_in_trie_height(), + u64::MAX, + "must stay disabled on testnet too (STATE_IN_TRIE is already armed there)" + ); + assert!(!is_native_state_in_trie_height(6_026_000)); + + std::env::remove_var("SENTRIX_CHAIN_ID"); + } + } + + /// Once pinned, the gate flips open exactly at the activation height. + #[test] + fn native_state_in_trie_activates_at_pinned_height() { + let _guard = env_test_lock(); + unsafe { + std::env::set_var("NATIVE_STATE_IN_TRIE_HEIGHT", "1000"); + assert_eq!(get_native_state_in_trie_height(), 1000); + assert!(!is_native_state_in_trie_height(999)); + assert!(is_native_state_in_trie_height(1000)); + assert!(is_native_state_in_trie_height(1001)); + std::env::remove_var("NATIVE_STATE_IN_TRIE_HEIGHT"); + } + } } diff --git a/crates/sentrix-core/tests/native_module_state_root.rs b/crates/sentrix-core/tests/native_module_state_root.rs new file mode 100644 index 00000000..d85ecc6b --- /dev/null +++ b/crates/sentrix-core/tests/native_module_state_root.rs @@ -0,0 +1,239 @@ +//! Native-module state-root commitment tests (feat/native-module-state-root-commitment). +//! +//! Verifies that, post `NATIVE_STATE_IN_TRIE_HEIGHT` fork, the SRC-20 +//! `ContractRegistry` and NFT `NftRegistry` are committed into the state trie +//! so their state is reflected in `state_root` — and that pre-fork behavior is +//! bit-identical to before (native state stays off-trie). +//! +//! The native registries are mutated directly (no tx) so each test isolates +//! the *commitment* path from the *apply* path and from account/fee changes: +//! two chains share identical account state and differ only in native-module +//! state, so any state_root difference is attributable to the commitment. + +use sentrix_core::blockchain::Blockchain; +use sentrix_core::storage::Storage; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; + +/// Serialises the `NATIVE_STATE_IN_TRIE_HEIGHT` env var across tests in this +/// binary (process-wide state would otherwise race under the parallel runner). +static ENV_GUARD: Mutex<()> = Mutex::new(()); + +fn proposer_addr() -> String { + format!("0x{}", "ab".repeat(20)) +} + +fn deployer_addr() -> String { + format!("0x{}", "cd".repeat(20)) +} + +/// Fresh chain with an initialised trie + one authorised validator. +fn fresh_chain() -> (TempDir, Storage, Blockchain) { + let dir = TempDir::new().expect("tempdir"); + let storage = Storage::open(dir.path().to_str().unwrap()).expect("storage open"); + let mut bc = Blockchain::new("admin".to_string()); + bc.authority + .add_validator_unchecked(proposer_addr(), "V1".to_string(), "pk1".to_string()); + let mdbx = storage.mdbx_arc(); + bc.init_trie(Arc::clone(&mdbx)).unwrap(); + bc.init_storage_handle(Arc::clone(&mdbx)).unwrap(); + (dir, storage, bc) +} + +/// Produce `n` coinbase blocks; returns the trie root at the final height. +fn mine_n(bc: &mut Blockchain, n: u64) -> Option<[u8; 32]> { + let proposer = proposer_addr(); + let mut root = None; + for _ in 0..n { + let block = bc.create_block(&proposer).expect("create_block"); + bc.add_block(block).expect("add_block"); + root = bc.trie_root_at(bc.height()); + } + root +} + +/// Run `f` with `NATIVE_STATE_IN_TRIE_HEIGHT` set to `value` (or removed when +/// `None`), restoring the previous value afterward. Serialised via ENV_GUARD. +fn with_native_fork(value: Option<&str>, f: F) { + let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var("NATIVE_STATE_IN_TRIE_HEIGHT").ok(); + // SAFETY: process-wide env, serialised by ENV_GUARD (edition-2024 contract). + unsafe { + match value { + Some(v) => std::env::set_var("NATIVE_STATE_IN_TRIE_HEIGHT", v), + None => std::env::remove_var("NATIVE_STATE_IN_TRIE_HEIGHT"), + } + } + f(); + unsafe { + match prev { + Some(v) => std::env::set_var("NATIVE_STATE_IN_TRIE_HEIGHT", v), + None => std::env::remove_var("NATIVE_STATE_IN_TRIE_HEIGHT"), + } + } +} + +fn deploy_src20(bc: &mut Blockchain, supply: u64, seed: &str) { + bc.contracts + .deploy(&deployer_addr(), "Tok", "TOK", 8, supply, 0, seed) + .expect("src20 deploy"); +} + +fn deploy_nft(bc: &mut Blockchain, owner: &str, seed: &str) { + let (cid, _) = bc + .nft_registry + .deploy_collection(&deployer_addr(), "C", "C", "u", None, true, true, seed) + .expect("nft deploy"); + bc.nft_registry + .get_collection_mut(&cid) + .unwrap() + .mint(&deployer_addr(), owner, 1, "", None) + .expect("nft mint"); +} + +// ── 1. Pre-fork state_root behaviour is preserved ──────────── + +#[test] +fn pre_fork_native_state_does_not_affect_state_root() { + with_native_fork(None, || { + // Chain A mutates native state; chain B does not. Pre-fork the roots + // must be identical — native state is not committed. + let (_da, _sa, mut a) = fresh_chain(); + let (_db, _sb, mut b) = fresh_chain(); + mine_n(&mut a, 3); + mine_n(&mut b, 3); + + deploy_src20(&mut a, 1_000, "s1"); + deploy_nft(&mut a, &proposer_addr(), "n1"); + + let ra = mine_n(&mut a, 1); + let rb = mine_n(&mut b, 1); + assert_eq!(ra, rb, "pre-fork: native state must not change state_root"); + }); +} + +// ── 2 & 3. Post-fork SRC-20 / NFT changes affect state_root ── + +#[test] +fn post_fork_src20_change_affects_state_root() { + with_native_fork(Some("0"), || { + let (_da, _sa, mut a) = fresh_chain(); + let (_db, _sb, mut b) = fresh_chain(); + mine_n(&mut a, 3); + mine_n(&mut b, 3); + + deploy_src20(&mut a, 1_000, "s1"); // only A + + let ra = mine_n(&mut a, 1); + let rb = mine_n(&mut b, 1); + assert_ne!(ra, rb, "post-fork: SRC-20 state must change state_root"); + }); +} + +#[test] +fn post_fork_nft_change_affects_state_root() { + with_native_fork(Some("0"), || { + let (_da, _sa, mut a) = fresh_chain(); + let (_db, _sb, mut b) = fresh_chain(); + mine_n(&mut a, 3); + mine_n(&mut b, 3); + + deploy_nft(&mut a, &proposer_addr(), "n1"); // only A + + let ra = mine_n(&mut a, 1); + let rb = mine_n(&mut b, 1); + assert_ne!(ra, rb, "post-fork: NFT state must change state_root"); + }); +} + +// ── 4. Replay determinism ──────────────────────────────────── + +#[test] +fn post_fork_replay_produces_identical_state_root() { + with_native_fork(Some("0"), || { + let build = || { + let (_d, _s, mut bc) = fresh_chain(); + mine_n(&mut bc, 3); + deploy_src20(&mut bc, 1_000, "s1"); + deploy_nft(&mut bc, &proposer_addr(), "n1"); + mine_n(&mut bc, 1) + }; + assert_eq!( + build(), + build(), + "same native ops replayed → identical root" + ); + }); +} + +// ── 5 & 6. Different native states → different state_root ───── + +#[test] +fn post_fork_different_src20_supply_differs() { + with_native_fork(Some("0"), || { + let make = |supply: u64| { + let (_d, _s, mut bc) = fresh_chain(); + mine_n(&mut bc, 3); + deploy_src20(&mut bc, supply, "s1"); + mine_n(&mut bc, 1) + }; + assert_ne!(make(1_000), make(2_000)); + }); +} + +#[test] +fn post_fork_different_nft_owner_differs() { + with_native_fork(Some("0"), || { + let make = |owner: &str| { + let (_d, _s, mut bc) = fresh_chain(); + mine_n(&mut bc, 3); + deploy_nft(&mut bc, owner, "n1"); + mine_n(&mut bc, 1) + }; + let alice = format!("0x{}", "11".repeat(20)); + let bob = format!("0x{}", "22".repeat(20)); + assert_ne!(make(&alice), make(&bob)); + }); +} + +// ── 7. HashMap insertion order does not change state_root ───── + +#[test] +fn post_fork_src20_deploy_order_independent() { + with_native_fork(Some("0"), || { + let (_da, _sa, mut a) = fresh_chain(); + let (_db, _sb, mut b) = fresh_chain(); + mine_n(&mut a, 3); + mine_n(&mut b, 3); + + // Same two contracts, opposite deploy order. + deploy_src20(&mut a, 1_000, "s1"); + deploy_src20(&mut a, 2_000, "s2"); + deploy_src20(&mut b, 2_000, "s2"); + deploy_src20(&mut b, 1_000, "s1"); + + let ra = mine_n(&mut a, 1); + let rb = mine_n(&mut b, 1); + assert_eq!(ra, rb, "deploy order must not change state_root"); + }); +} + +// ── 8. Empty native registry commitment is stable ──────────── + +#[test] +fn post_fork_empty_registry_commitment_stable() { + with_native_fork(Some("0"), || { + // Two fresh chains with empty registries must agree post-fork, and the + // empty-registry root must differ from a populated one. + let (_da, _sa, mut a) = fresh_chain(); + let (_db, _sb, mut b) = fresh_chain(); + let ra = mine_n(&mut a, 3); + let rb = mine_n(&mut b, 3); + assert_eq!(ra, rb, "empty native commitment must be deterministic"); + + deploy_src20(&mut a, 1, "s1"); + let ra2 = mine_n(&mut a, 1); + let rb2 = mine_n(&mut b, 1); + assert_ne!(ra2, rb2, "empty vs populated must differ post-fork"); + }); +} diff --git a/crates/sentrix-trie/src/address.rs b/crates/sentrix-trie/src/address.rs index 2ba74a6d..692a04ac 100644 --- a/crates/sentrix-trie/src/address.rs +++ b/crates/sentrix-trie/src/address.rs @@ -146,6 +146,28 @@ pub fn total_minted_value_decode(bytes: &[u8]) -> Option { Some(u64::from_be_bytes(bytes[0..8].try_into().ok()?)) } +/// Native-module state commitment — trie key for the SRC-20 +/// `ContractRegistry`. Single fixed key (no address); the value is the +/// registry's canonical hash, overwritten every block post-fork so the +/// state_root captures all SRC-20 contract state (balances, supply, +/// allowances). Gated by `NATIVE_STATE_IN_TRIE_HEIGHT`. +pub fn native_src20_registry_key() -> NodeHash { + const DOMAIN: &[u8] = b"sentrix/v1/native_src20_registry"; + let mut h = Sha256::new(); + h.update(DOMAIN); + h.finalize().into() +} + +/// Native-module state commitment — trie key for the NFT `NftRegistry`. +/// Single fixed key; value is the registry's canonical hash. Same model +/// and fork gate as [`native_src20_registry_key`]. +pub fn native_nft_registry_key() -> NodeHash { + const DOMAIN: &[u8] = b"sentrix/v1/native_nft_registry"; + let mut h = Sha256::new(); + h.update(DOMAIN); + h.finalize().into() +} + /// SIP-6 Bug A — trie key for the global `EpochManager.current_epoch` /// snapshot. Single fixed key — writes always overwrite, so the /// state_root reflects the current epoch's accumulators at every @@ -500,4 +522,32 @@ mod tests { assert!(epoch_state_value_decode(&[0u8; 79]).is_none()); assert!(epoch_state_value_decode(&[]).is_none()); } + + /// Native-module registry keys must be deterministic, mutually distinct, + /// and distinct from every other fixed/derived trie key — otherwise their + /// commitments would collide with each other or with account/SIP-6 state. + #[test] + fn test_native_registry_keys_deterministic_and_distinct() { + // Deterministic: same call → same key. + assert_eq!(native_src20_registry_key(), native_src20_registry_key()); + assert_eq!(native_nft_registry_key(), native_nft_registry_key()); + + // SRC-20 vs NFT must differ. + assert_ne!( + native_src20_registry_key(), + native_nft_registry_key(), + "SRC-20 and NFT registry keys must not collide" + ); + + // Distinct from the other fixed-key domains. + for other in [total_minted_key(), epoch_state_key()] { + assert_ne!(native_src20_registry_key(), other); + assert_ne!(native_nft_registry_key(), other); + } + + // Distinct from an address-derived key. + let addr_key = address_to_key("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); + assert_ne!(native_src20_registry_key(), addr_key); + assert_ne!(native_nft_registry_key(), addr_key); + } }