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
62 changes: 59 additions & 3 deletions crates/sentrix-core/src/blockchain_trie_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,30 @@ 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, liveness_value_bytes, pending_rewards_value_bytes,
total_minted_key, total_minted_value_bytes, validator_liveness_key,
validator_pending_rewards_key,
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,
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);

/// SIP-6 Phase 1c snapshot of `EpochManager.current_epoch` — captured
/// before the `state_trie` mut-borrow, written in Phase 2e as a
/// single 80-byte commitment under `epoch_state_key`. Field order
/// mirrors [`sentrix_trie::address::epoch_state_value_bytes`].
struct EpochSnapshot {
epoch_number: u64,
start_height: u64,
end_height: u64,
total_staked: u64,
total_rewards: u64,
total_blocks_produced: u64,
validator_set: Vec<String>,
}
use sentrix_trie::tree::SentrixTrie;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -461,6 +475,7 @@ impl Blockchain {
let pending_rewards_updates: Vec<(String, u64)>;
let liveness_updates: Vec<LivenessSnapshotRow>;
let total_minted_snapshot: Option<u64>;
let epoch_snapshot: Option<EpochSnapshot>;
if Self::is_state_in_trie_height(block_index) {
let mut rewards: Vec<(String, u64)> = self
.stake_registry
Expand All @@ -481,13 +496,24 @@ impl Blockchain {
.collect();
liveness.sort_by(|a, b| a.0.cmp(&b.0));

let epoch = &self.epoch_manager.current_epoch;
pending_rewards_updates = rewards;
liveness_updates = liveness;
total_minted_snapshot = Some(self.total_minted);
epoch_snapshot = Some(EpochSnapshot {
epoch_number: epoch.epoch_number,
start_height: epoch.start_height,
end_height: epoch.end_height,
total_staked: epoch.total_staked,
total_rewards: epoch.total_rewards,
total_blocks_produced: epoch.total_blocks_produced,
validator_set: epoch.validator_set.clone(),
});
} else {
pending_rewards_updates = Vec::new();
liveness_updates = Vec::new();
total_minted_snapshot = None;
epoch_snapshot = None;
}
// Borrow of `stake_registry` / `slashing` / `total_minted` ends here.

Expand Down Expand Up @@ -613,6 +639,36 @@ impl Blockchain {
}
}

// Phase 2e — SIP-6 Bug A (post-fork only): commit
// `EpochManager.current_epoch` as a single 80-byte snapshot
// (epoch_number + start/end_height + total_staked +
// total_rewards + total_blocks_produced + validator_set_hash).
// Closes the last off-trie consensus state class — drift on
// any epoch field (active-set rotation, accumulators) surfaces
// as state_root mismatch at the block where it first appears.
if let Some(snap) = epoch_snapshot {
let key = epoch_state_key();
let value = epoch_state_value_bytes(
snap.epoch_number,
snap.start_height,
snap.end_height,
snap.total_staked,
snap.total_rewards,
snap.total_blocks_produced,
&snap.validator_set,
);
trie.insert(&key, &value)?;
if trace {
eprintln!(
"[trie-trace] epoch_state epoch={} blocks={} rewards={} → root={}",
snap.epoch_number,
snap.total_blocks_produced,
snap.total_rewards,
hex::encode(trie.root_hash())
);
}
}

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

/// 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
/// block (post-fork). Drift on any field surfaces as state_root
/// mismatch immediately.
pub fn epoch_state_key() -> NodeHash {
const DOMAIN: &[u8] = b"sentrix/v1/epoch_state";
let mut h = Sha256::new();
h.update(DOMAIN);
h.finalize().into()
}

/// Encode the current epoch's commitment-relevant fields for trie
/// value storage. Fixed 80 bytes:
///
/// - 0..8: `epoch_number` (u64 BE)
/// - 8..16: `start_height` (u64 BE)
/// - 16..24: `end_height` (u64 BE)
/// - 24..32: `total_staked` (u64 BE)
/// - 32..40: `total_rewards` (u64 BE)
/// - 40..48: `total_blocks_produced` (u64 BE)
/// - 48..80: SHA-256 of the sorted validator-set addresses (32 bytes)
///
/// `validator_set` is sorted by address before hashing so cross-host
/// commitments match regardless of the source iteration order.
pub fn epoch_state_value_bytes(
epoch_number: u64,
start_height: u64,
end_height: u64,
total_staked: u64,
total_rewards: u64,
total_blocks_produced: u64,
validator_set: &[String],
) -> [u8; 80] {
let mut v = [0u8; 80];
v[0..8].copy_from_slice(&epoch_number.to_be_bytes());
v[8..16].copy_from_slice(&start_height.to_be_bytes());
v[16..24].copy_from_slice(&end_height.to_be_bytes());
v[24..32].copy_from_slice(&total_staked.to_be_bytes());
v[32..40].copy_from_slice(&total_rewards.to_be_bytes());
v[40..48].copy_from_slice(&total_blocks_produced.to_be_bytes());

let mut sorted: Vec<&String> = validator_set.iter().collect();
sorted.sort();
let mut h = Sha256::new();
for addr in sorted {
h.update(addr.as_bytes());
h.update(b"\n");
}
let validator_set_hash: [u8; 32] = h.finalize().into();
v[48..80].copy_from_slice(&validator_set_hash);
v
}

/// Decode the 6 scalar fields from trie value bytes produced by
/// [`epoch_state_value_bytes`]. The validator-set hash is the last 32
/// bytes (verbatim from the trie value — the original set can't be
/// recovered from a SHA-256). Returns `None` if shorter than 80 bytes.
#[allow(clippy::type_complexity)]
pub fn epoch_state_value_decode(bytes: &[u8]) -> Option<(u64, u64, u64, u64, u64, u64, [u8; 32])> {
if bytes.len() < 80 {
return None;
}
let epoch_number = u64::from_be_bytes(bytes[0..8].try_into().ok()?);
let start_height = u64::from_be_bytes(bytes[8..16].try_into().ok()?);
let end_height = u64::from_be_bytes(bytes[16..24].try_into().ok()?);
let total_staked = u64::from_be_bytes(bytes[24..32].try_into().ok()?);
let total_rewards = u64::from_be_bytes(bytes[32..40].try_into().ok()?);
let total_blocks_produced = u64::from_be_bytes(bytes[40..48].try_into().ok()?);
let mut validator_set_hash = [0u8; 32];
validator_set_hash.copy_from_slice(&bytes[48..80]);
Some((
epoch_number,
start_height,
end_height,
total_staked,
total_rewards,
total_blocks_produced,
validator_set_hash,
))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -330,4 +412,67 @@ mod tests {
assert_eq!(encoded.len(), 8);
assert_eq!(total_minted_value_decode(&encoded), Some(v));
}

// ── SIP-6 epoch_state trie tests ────────────────────────────────

#[test]
fn test_epoch_state_key_deterministic_and_distinct() {
let k = epoch_state_key();
assert_eq!(k, epoch_state_key());
// Must not collide with the other system-level / per-addr keys.
assert_ne!(k, total_minted_key());
assert_ne!(k, address_to_key("0xdeadbeef"));
assert_ne!(k, validator_pending_rewards_key("0xdeadbeef"));
assert_ne!(k, validator_liveness_key("0xdeadbeef"));
}

#[test]
fn test_epoch_state_value_roundtrip() {
let validators = vec![
"0xaaaa".to_string(),
"0xbbbb".to_string(),
"0xcccc".to_string(),
];
let encoded =
epoch_state_value_bytes(7, 201_600, 230_399, 1_000_000, 42_000, 28_800, &validators);
assert_eq!(encoded.len(), 80);
let (en, sh, eh, ts, tr, tb, _hash) = epoch_state_value_decode(&encoded).unwrap();
assert_eq!(en, 7);
assert_eq!(sh, 201_600);
assert_eq!(eh, 230_399);
assert_eq!(ts, 1_000_000);
assert_eq!(tr, 42_000);
assert_eq!(tb, 28_800);
}

/// Critical: validator_set hash MUST be independent of iteration
/// order — same set in different orders must produce identical
/// trie value bytes. Otherwise HashMap-derived sets fork the chain
/// across validators.
#[test]
fn test_epoch_state_value_validator_set_order_independent() {
let order1 = vec![
"0xaaaa".to_string(),
"0xbbbb".to_string(),
"0xcccc".to_string(),
];
let order2 = vec![
"0xcccc".to_string(),
"0xaaaa".to_string(),
"0xbbbb".to_string(),
];
let v1 = epoch_state_value_bytes(7, 0, 0, 0, 0, 0, &order1);
let v2 = epoch_state_value_bytes(7, 0, 0, 0, 0, 0, &order2);
assert_eq!(
v1, v2,
"validator_set hash must be order-independent — otherwise \
HashMap-iteration drift forks the chain"
);
}

#[test]
fn test_epoch_state_value_decode_short_returns_none() {
assert!(epoch_state_value_decode(&[0u8; 79]).is_none());
assert!(epoch_state_value_decode(&[]).is_none());
}
}
9 changes: 5 additions & 4 deletions crates/sentrix-trie/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ pub mod tree;

// Re-export commonly used types
pub use address::{
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,
account_value_bytes, account_value_decode, address_to_key, epoch_state_key,
epoch_state_value_bytes, epoch_state_value_decode, 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