Skip to content
Merged
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
104 changes: 86 additions & 18 deletions crates/sentrix-core/src/blockchain_trie_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ use sentrix_primitives::error::{SentrixError, SentrixResult};
use sentrix_primitives::transaction::{PROTOCOL_TREASURY, TOKEN_OP_ADDRESS};
use sentrix_storage::MdbxStorage;
use sentrix_trie::address::{
account_value_bytes, address_to_key, pending_rewards_value_bytes,
account_value_bytes, address_to_key, liveness_value_bytes, pending_rewards_value_bytes,
total_minted_key, total_minted_value_bytes, validator_liveness_key,
validator_pending_rewards_key,
};

/// SIP-6 Phase 1c snapshot row for a single validator's liveness +
/// jail state — captured before the `state_trie` mut-borrow, written
/// in Phase 2c. Tuple: (address, signed_count, missed_count,
/// jail_until, is_jailed).
type LivenessSnapshotRow = (String, u64, u64, u64, bool);
use sentrix_trie::tree::SentrixTrie;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -442,28 +449,47 @@ impl Blockchain {
// Borrow of `accounts` ends after collect().

// Phase 1c — SIP-6 Bug A (post-fork only): snapshot every
// validator's `pending_rewards` into the trie inputs BEFORE
// we re-borrow `self.state_trie` mutably. Pre-fork this is an
// empty Vec, so the legacy trie write path is bit-identical.
// validator's off-trie consensus state into trie inputs BEFORE
// we re-borrow `self.state_trie` mutably. Pre-fork these are
// empty `Vec`s / `None`, so the legacy trie write path is
// bit-identical.
//
// Sorted by address for cross-validator determinism (the
// HashMap iteration order is per-process, but a sorted Vec is
// identical everywhere — same property the balance write path
// gets from its BTreeSet above).
let pending_rewards_updates: Vec<(String, u64)> =
if Self::is_state_in_trie_height(block_index) {
let mut rows: Vec<(String, u64)> = self
.stake_registry
.validators
.iter()
.map(|(addr, val)| (addr.clone(), val.pending_rewards))
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
rows
} else {
Vec::new()
};
// Borrow of `stake_registry` ends after collect()+sort.
let pending_rewards_updates: Vec<(String, u64)>;
let liveness_updates: Vec<LivenessSnapshotRow>;
let total_minted_snapshot: Option<u64>;
if Self::is_state_in_trie_height(block_index) {
let mut rewards: Vec<(String, u64)> = self
.stake_registry
.validators
.iter()
.map(|(addr, val)| (addr.clone(), val.pending_rewards))
.collect();
rewards.sort_by(|a, b| a.0.cmp(&b.0));

let mut liveness: Vec<LivenessSnapshotRow> = self
.stake_registry
.validators
.iter()
.map(|(addr, val)| {
let (signed, missed) = self.slashing.liveness.get_stats(addr);
(addr.clone(), signed, missed, val.jail_until, val.is_jailed)
})
.collect();
liveness.sort_by(|a, b| a.0.cmp(&b.0));

pending_rewards_updates = rewards;
liveness_updates = liveness;
total_minted_snapshot = Some(self.total_minted);
} else {
pending_rewards_updates = Vec::new();
liveness_updates = Vec::new();
total_minted_snapshot = None;
}
// Borrow of `stake_registry` / `slashing` / `total_minted` ends here.

if trace {
eprintln!("[trie-trace] update_trie_for_block at h={block_index}");
Expand Down Expand Up @@ -545,6 +571,48 @@ impl Blockchain {
}
}

// Phase 2c — SIP-6 Bug A (post-fork only): commit per-validator
// liveness snapshot (signed_count, missed_count, jail_until,
// is_jailed) under `validator_liveness_key`. Drift on any of
// these fields silently changed active_set / jail evaluation
// pre-fork (mainnet halt #9 class). Post-fork divergence
// surfaces in state_root at the block where it first appears.
//
// Unlike pending_rewards we always insert (never delete) —
// a value of (0,0,0,false) is a legitimate "validator
// registered, no signing yet, not jailed" state distinct from
// "validator not in registry"; using delete would conflate them.
for (addr, signed, missed, jail_until, is_jailed) in &liveness_updates {
let key = validator_liveness_key(addr);
let value = liveness_value_bytes(*signed, *missed, *jail_until, *is_jailed);
trie.insert(&key, &value)?;
if trace {
eprintln!(
"[trie-trace] liveness {addr} signed={signed} missed={missed} \
jail_until={jail_until} is_jailed={is_jailed} → root={}",
hex::encode(trie.root_hash())
);
}
}

// Phase 2d — SIP-6 Bug A (post-fork only): commit the global
// `Blockchain.total_minted` counter under a fixed key
// (`total_minted_key`). Drift here meant validators disagreed
// on circulating supply for tokenomics gating (halving math,
// ClaimRewards budget) — surfaced on apply paths that read it
// back later. Same insert-always semantics as Phase 2c.
if let Some(total) = total_minted_snapshot {
let key = total_minted_key();
let value = total_minted_value_bytes(total);
trie.insert(&key, &value)?;
if trace {
eprintln!(
"[trie-trace] total_minted={total} → root={}",
hex::encode(trie.root_hash())
);
}
}

let root = trie.commit(block_index)?;
if trace {
eprintln!(
Expand Down
151 changes: 151 additions & 0 deletions crates/sentrix-trie/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,78 @@ pub fn pending_rewards_value_decode(bytes: &[u8]) -> Option<u64> {
Some(u64::from_be_bytes(bytes[0..8].try_into().ok()?))
}

/// SIP-6 Bug A — trie key for a validator's liveness + jail state.
///
/// Distinct domain (`"sentrix/v1/liveness/"`) so it can't collide with
/// the balance or pending_rewards namespaces.
pub fn validator_liveness_key(address: &str) -> NodeHash {
const DOMAIN: &[u8] = b"sentrix/v1/liveness/";
let addr = address.trim_start_matches("0x").to_lowercase();
let bytes = hex::decode(&addr).unwrap_or_else(|_| addr.as_bytes().to_vec());
let mut h = Sha256::new();
h.update(DOMAIN);
h.update(&bytes);
h.finalize().into()
}

/// Encode a validator's liveness + jail snapshot for trie value storage.
///
/// Layout (25 bytes, fixed):
/// - 0..8: `signed_count` (u64 BE) — from `LivenessTracker.get_stats`
/// - 8..16: `missed_count` (u64 BE) — from `LivenessTracker.get_stats`
/// - 16..24: `jail_until` (u64 BE) — `ValidatorStake.jail_until`
/// - 24: `is_jailed` (0 or 1) — `ValidatorStake.is_jailed`
pub fn liveness_value_bytes(
signed_count: u64,
missed_count: u64,
jail_until: u64,
is_jailed: bool,
) -> [u8; 25] {
let mut v = [0u8; 25];
v[0..8].copy_from_slice(&signed_count.to_be_bytes());
v[8..16].copy_from_slice(&missed_count.to_be_bytes());
v[16..24].copy_from_slice(&jail_until.to_be_bytes());
v[24] = u8::from(is_jailed);
v
}

/// Decode trie value bytes produced by [`liveness_value_bytes`].
/// Returns `None` if the slice is shorter than 25 bytes.
pub fn liveness_value_decode(bytes: &[u8]) -> Option<(u64, u64, u64, bool)> {
if bytes.len() < 25 {
return None;
}
let signed = u64::from_be_bytes(bytes[0..8].try_into().ok()?);
let missed = u64::from_be_bytes(bytes[8..16].try_into().ok()?);
let jail_until = u64::from_be_bytes(bytes[16..24].try_into().ok()?);
let is_jailed = bytes[24] != 0;
Some((signed, missed, jail_until, is_jailed))
}

/// SIP-6 Bug A — trie key for the global `Blockchain.total_minted`
/// counter. Single fixed key (no address); writes always overwrite the
/// same slot post-fork so state_root captures supply directly.
pub fn total_minted_key() -> NodeHash {
const DOMAIN: &[u8] = b"sentrix/v1/total_minted";
let mut h = Sha256::new();
h.update(DOMAIN);
h.finalize().into()
}

/// Encode `total_minted` (u64 sentri) as 8 BE bytes for trie storage.
pub fn total_minted_value_bytes(total: u64) -> [u8; 8] {
total.to_be_bytes()
}

/// Decode trie value bytes produced by [`total_minted_value_bytes`].
/// Returns `None` if the slice is shorter than 8 bytes.
pub fn total_minted_value_decode(bytes: &[u8]) -> Option<u64> {
if bytes.len() < 8 {
return None;
}
Some(u64::from_be_bytes(bytes[0..8].try_into().ok()?))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -179,4 +251,83 @@ mod tests {
assert!(pending_rewards_value_decode(&[0u8; 4]).is_none());
assert!(pending_rewards_value_decode(&[]).is_none());
}

// ── SIP-6 liveness + total_minted trie key tests ────────────────

#[test]
fn test_liveness_key_deterministic_and_case_insensitive() {
let a = validator_liveness_key("0xDEADBEEF");
let b = validator_liveness_key("0xdeadbeef");
let c = validator_liveness_key("0xdeadbeef");
assert_eq!(a, b);
assert_eq!(b, c);
}

/// Critical: liveness key MUST differ from balance, pending_rewards
/// keys for the same address — otherwise different state components
/// collide at the same trie slot.
#[test]
fn test_liveness_key_distinct_from_other_keys() {
let addr = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let bal = address_to_key(addr);
let rew = validator_pending_rewards_key(addr);
let liv = validator_liveness_key(addr);
assert_ne!(bal, liv);
assert_ne!(rew, liv);
assert_ne!(bal, rew);
}

#[test]
fn test_liveness_value_roundtrip() {
let encoded = liveness_value_bytes(1_234, 5, 999_999, true);
assert_eq!(encoded.len(), 25);
let (s, m, ju, ij) = liveness_value_decode(&encoded).unwrap();
assert_eq!(s, 1_234);
assert_eq!(m, 5);
assert_eq!(ju, 999_999);
assert!(ij);

// is_jailed=false roundtrip
let encoded = liveness_value_bytes(0, 0, 0, false);
let (_, _, _, ij) = liveness_value_decode(&encoded).unwrap();
assert!(!ij);
}

#[test]
fn test_liveness_value_decode_short_returns_none() {
assert!(liveness_value_decode(&[0u8; 24]).is_none());
assert!(liveness_value_decode(&[]).is_none());
}

#[test]
fn test_total_minted_key_deterministic() {
assert_eq!(total_minted_key(), total_minted_key());
}

#[test]
fn test_total_minted_key_distinct_from_per_address_keys() {
// Sanity: the global total_minted slot must not collide with
// any per-address namespace (balance / pending_rewards / liveness).
// We can't enumerate all addresses, but verify a representative
// sample doesn't accidentally hash to the same key.
let tm = total_minted_key();
for addr in &[
"0x0000000000000000000000000000000000000000",
"0xffffffffffffffffffffffffffffffffffffffff",
"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"0x0000000000000000000000000000000000000002", // PROTOCOL_TREASURY
] {
assert_ne!(tm, address_to_key(addr));
assert_ne!(tm, validator_pending_rewards_key(addr));
assert_ne!(tm, validator_liveness_key(addr));
}
}

#[test]
fn test_total_minted_value_roundtrip() {
let v = 31_500_000_000_000_000u64; // 315M SRX × 1e8 sentri
let encoded = total_minted_value_bytes(v);
assert_eq!(encoded.len(), 8);
assert_eq!(total_minted_value_decode(&encoded), Some(v));
}
}
6 changes: 4 additions & 2 deletions crates/sentrix-trie/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ pub mod tree;

// Re-export commonly used types
pub use address::{
account_value_bytes, account_value_decode, address_to_key,
pending_rewards_value_bytes, pending_rewards_value_decode, validator_pending_rewards_key,
account_value_bytes, account_value_decode, address_to_key, liveness_value_bytes,
liveness_value_decode, pending_rewards_value_bytes, pending_rewards_value_decode,
total_minted_key, total_minted_value_bytes, total_minted_value_decode,
validator_liveness_key, validator_pending_rewards_key,
};
pub use node::{NodeHash, TrieNode};
pub use proof::MerkleProof;
Expand Down
Loading