From 37dadc458d3af68b9f80ba9f871a63b3f2a4d0e6 Mon Sep 17 00:00:00 2001 From: Satya Kwok Date: Wed, 29 Apr 2026 01:16:09 +0200 Subject: [PATCH 1/2] docs: dual-explorer note for Blockscout sidecar at blockscout.sentrixchain.com scan.sentrixchain.com remains the primary explorer with full native-op coverage. Blockscout sidecar adds EVM-standard UX (ERC-20 holders, source verification, EIP-3091 URLs) for listing platforms + EVM tooling that expects that shape. --- docs/operations/METAMASK.md | 2 ++ docs/operations/NETWORKS.md | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/operations/METAMASK.md b/docs/operations/METAMASK.md index 699e8a5..1fbc707 100644 --- a/docs/operations/METAMASK.md +++ b/docs/operations/METAMASK.md @@ -29,6 +29,8 @@ Sentrix is fully MetaMask-compatible on both networks. Mainnet (chain ID 7119) a Mainnet supports `eth_sendRawTransaction` and Solidity contract deployment since the 2026-04-25 Voyager activation. Use mainnet for production deployments and testnet for development. +> **Two parallel block explorers.** The custom `scan.sentrixchain.com` is the primary UX (covers Sentrix's native TokenOp / StakingOp events + validator pages + label system). For EVM-standard tooling — ERC-20 token holders, smart-contract source verification, EIP-3091 URLs — use `blockscout.sentrixchain.com`. Either URL works in MetaMask's "Block Explorer URL" field; pick the one matching the feature you'll use most. Listing platforms (CoinGecko / CoinMarketCap) and wallets that expect Blockscout-style endpoints should use the Blockscout URL. + ## Get Test SRX Faucet: https://faucet.sentrixchain.com (or use a funded testnet wallet directly). diff --git a/docs/operations/NETWORKS.md b/docs/operations/NETWORKS.md index c814572..f2e9652 100644 --- a/docs/operations/NETWORKS.md +++ b/docs/operations/NETWORKS.md @@ -6,7 +6,8 @@ |-|-| | Chain ID | 7119 (0x1bcf) | | RPC | https://rpc.sentrixchain.com | -| Explorer | https://scan.sentrixchain.com | +| Explorer (custom) | https://scan.sentrixchain.com — native TokenOps + StakingOps + validator UX | +| Explorer (Blockscout) | https://blockscout.sentrixchain.com — EVM-standard UX (ERC-20 transfers, source verification) | | P2P port | 30303 | | API port | 8545 | | Block time | 1s | @@ -61,6 +62,13 @@ Add network manually: | Symbol | SRX | SRX | | Explorer | https://scan.sentrixchain.com | https://scan.sentrixchain.com (toggle Testnet) | +> Sentrix runs **two parallel explorers**. `scan.sentrixchain.com` (custom) is the +> primary UX for native protocol features — TokenOp / StakingOp events, validator +> pages, label system. `blockscout.sentrixchain.com` (Blockscout v8) is the +> EVM-standard sidecar for ERC-20 holders, contract source verification, and +> EIP-3091-style URLs that wallets and listing sites expect. Either URL works in +> the explorer field; pick by the feature you need. + ### ethers.js ```js From 91f725b7cc6044ab1f166cbfb548c32c66879cec Mon Sep 17 00:00:00 2001 From: Satya Kwok Date: Wed, 29 Apr 2026 09:18:39 +0200 Subject: [PATCH 2/2] refactor(staking): extract LivenessTracker + DoubleSignDetector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slashing.rs was 954 LOC carrying three independent concerns: - LivenessTracker (sliding-window downtime detector) + LIVENESS_WINDOW / MIN_SIGNED_PER_WINDOW / DOWNTIME_SLASH_BP / DOWNTIME_JAIL_BLOCKS constants - DoubleSignEvidence + DoubleSignDetector + DOUBLE_SIGN_SLASH_BP - SlashingEngine (the orchestrator) + tests Pulled out the first two into slashing/{liveness,double_sign}.rs. slashing.rs drops to 728 LOC, focused on SlashingEngine + tests. Pure mechanical extraction — public API at sentrix_staking::slashing::* unchanged via re-exports. Phase 3 follow-on of the workspace audit. --- crates/sentrix-staking/src/slashing.rs | 248 +----------------- .../src/slashing/double_sign.rs | 120 +++++++++ .../sentrix-staking/src/slashing/liveness.rs | 133 ++++++++++ 3 files changed, 264 insertions(+), 237 deletions(-) create mode 100644 crates/sentrix-staking/src/slashing/double_sign.rs create mode 100644 crates/sentrix-staking/src/slashing/liveness.rs diff --git a/crates/sentrix-staking/src/slashing.rs b/crates/sentrix-staking/src/slashing.rs index 2a49c2c..45fdeea 100644 --- a/crates/sentrix-staking/src/slashing.rs +++ b/crates/sentrix-staking/src/slashing.rs @@ -23,248 +23,22 @@ // // Rationale for each constant inline below. +// LivenessTracker + DoubleSignDetector live in `slashing/liveness.rs` +// and `slashing/double_sign.rs` respectively. Re-exported here so +// downstream crates' `use sentrix_staking::slashing::*` keeps working. +mod double_sign; +mod liveness; + +pub use double_sign::{DOUBLE_SIGN_SLASH_BP, DoubleSignDetector, DoubleSignEvidence}; +pub use liveness::{ + DOWNTIME_JAIL_BLOCKS, DOWNTIME_SLASH_BP, LIVENESS_WINDOW, LivenessTracker, MIN_SIGNED_PER_WINDOW, +}; + use crate::staking::StakeRegistry; use sentrix_primitives::transaction::JailEvidence; use sentrix_primitives::{SentrixError, SentrixResult}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -// ── Constants ──────────────────────────────────────────────── - -/// Rolling window for liveness tracking, in blocks. -/// -/// At 1s block time = ~4 hours. Long enough to absorb normal operational -/// downtime (a weekly 10-minute deploy is 0.07% of the window; even a -/// 30-minute emergency recovery is 12.5%). Short enough that a -/// persistently offline validator still gets jailed within a half-day. -/// -/// Comparable chains: -/// - Tendermint default: 100 (≈100s — demo-tight) -/// - Cosmos Hub: 10_000 (≈16.7h @ 6s block time) -/// - Osmosis: 30_000 (≈41.7h @ 5s) -/// - Sei: 10_000 (≈1.1h @ 400ms) -/// - Sentrix (here): 14_400 (≈4h @ 1s) -/// -/// Sentrix lands between Sei's tight-on-fast-blocks approach and Cosmos's -/// generous-long-window approach, scaled for our 1s block cadence. -pub const LIVENESS_WINDOW: u64 = 14_400; - -/// Minimum signed blocks required per window for a validator to stay out -/// of jail. Expressed as an absolute block count, not a fraction, so the -/// math stays integer-friendly. -/// -/// 4_320 / 14_400 = 30% — validator must sign at least 30% of blocks in -/// any rolling 4-hour window. Translated to downtime tolerance: up to -/// ~70% of the window (≈2.8 hours) can be missed before jailing. That -/// covers: -/// - Weekly 10-minute deploy → ~0.07% downtime (absorbed) -/// - Emergency 30-min recovery → 12.5% downtime (absorbed) -/// - Extended 2-hour debugging → 50% downtime (absorbed) -/// - Full 3-hour outage in 4h → 75% downtime (jailed) -/// -/// Cosmos Hub uses 5% (generous, built for massive validator sets). -/// We go stricter because Sentrix's 21-validator target means each -/// individual validator carries proportionally more responsibility — -/// one flapping validator on a 21-node network is ~5% of producing -/// capacity lost, which is significant. -pub const MIN_SIGNED_PER_WINDOW: u64 = 4_320; - -/// Stake slashed on a liveness-downtime jail, in basis points. -/// -/// 10 BP = 0.1% of stake. Gentle-but-not-zero: operators notice (a -/// self-stake of 15_000 SRX becomes 14_985 SRX) without losing a life- -/// changing amount. Cosmos Hub uses 1 BP (0.01%) which is symbolic; -/// we go 10× stricter because individual reliability matters more at -/// Sentrix's smaller validator count. -/// -/// Compare to `DOUBLE_SIGN_SLASH_BP` (2000 BP / 20%) for equivocation — -/// malicious behavior is punished 200× harder than negligence. -pub const DOWNTIME_SLASH_BP: u16 = 10; - -/// Blocks jailed after a liveness failure. -/// -/// 600 blocks = 10 minutes @ 1s block time. Matches Cosmos Hub's -/// `downtime_jail_duration`. Long enough that the operator has to -/// actively notice + investigate + file an unjail tx (can't just -/// hot-reset and pretend nothing happened). Short enough that a -/// legitimately-flapping validator recovers quickly after the root -/// cause is fixed. -pub const DOWNTIME_JAIL_BLOCKS: u64 = 600; - -/// Stake slashed on a proven equivocation (double-sign), in basis points. -/// -/// 2000 BP = 20%. Unchanged from v2.1.6. Double-signing is provably -/// malicious (not accidental), so punishment is deliberately harsh. -/// Matches Cosmos Hub, Osmosis, Sei, and most BFT chains' standard. -/// Usually followed by tombstone (permanent ban) so the validator -/// can't re-enter the active set. -pub const DOUBLE_SIGN_SLASH_BP: u16 = 2000; - -// ── Liveness Tracker ───────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct LivenessTracker { - /// Per-validator sliding window: height → signed (true/false) - /// We store only the last LIVENESS_WINDOW entries per validator. - records: HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct LivenessRecord { - height: u64, - signed: bool, -} - -impl LivenessTracker { - pub fn new() -> Self { - Self::default() - } - - /// Record that a validator signed (or missed) a block at this height - pub fn record(&mut self, validator: &str, height: u64, signed: bool) { - let entries = self.records.entry(validator.to_string()).or_default(); - entries.push(LivenessRecord { height, signed }); - - // Trim to window size - if entries.len() > LIVENESS_WINDOW as usize { - let excess = entries.len() - LIVENESS_WINDOW as usize; - entries.drain(..excess); - } - } - - /// Check if validator has fallen below the minimum signed threshold - pub fn is_downtime(&self, validator: &str) -> bool { - let entries = match self.records.get(validator) { - Some(e) => e, - None => return false, - }; - - // Only check once we have a full window - if entries.len() < LIVENESS_WINDOW as usize { - return false; - } - - let signed_count = entries.iter().filter(|r| r.signed).count() as u64; - signed_count < MIN_SIGNED_PER_WINDOW - } - - /// Get signed/missed counts for a validator - pub fn get_stats(&self, validator: &str) -> (u64, u64) { - let entries = match self.records.get(validator) { - Some(e) => e, - None => return (0, 0), - }; - let signed = entries.iter().filter(|r| r.signed).count() as u64; - let missed = entries.iter().filter(|r| !r.signed).count() as u64; - (signed, missed) - } - /// Clear records for a validator (used after slashing to reset window) - pub fn reset(&mut self, validator: &str) { - self.records.remove(validator); - } -} - -// ── Double-Sign Evidence ───────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DoubleSignEvidence { - pub validator: String, - pub height: u64, - pub block_hash_a: String, - pub block_hash_b: String, - pub signature_a: String, - pub signature_b: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DoubleSignDetector { - /// Recent block signatures: (validator, height) → block_hash - /// We keep a sliding window of recent blocks for detection - recent_blocks: HashMap<(String, u64), String>, - /// Max entries before cleanup - max_entries: usize, - /// Processed evidence hashes (prevent double-processing) - processed: Vec, -} - -impl DoubleSignDetector { - pub fn new() -> Self { - Self { - recent_blocks: HashMap::new(), - max_entries: 10_000, - processed: Vec::new(), - } - } - - /// Record a block signature. Returns evidence if double-sign detected. - pub fn record_block( - &mut self, - validator: &str, - height: u64, - block_hash: &str, - signature: &str, - ) -> Option { - let key = (validator.to_string(), height); - - if let Some(existing_hash) = self.recent_blocks.get(&key) { - if existing_hash != block_hash { - return Some(DoubleSignEvidence { - validator: validator.to_string(), - height, - block_hash_a: existing_hash.clone(), - block_hash_b: block_hash.to_string(), - signature_a: String::new(), // filled by caller if available - signature_b: signature.to_string(), - }); - } - return None; // same hash, not a double-sign - } - - self.recent_blocks.insert(key, block_hash.to_string()); - - // Cleanup old entries - if self.recent_blocks.len() > self.max_entries { - let cutoff_height = height.saturating_sub(LIVENESS_WINDOW * 10); - self.recent_blocks.retain(|(_v, h), _| *h > cutoff_height); - } - - None - } - - /// Verify and process external evidence submission - pub fn process_evidence(&mut self, evidence: &DoubleSignEvidence) -> SentrixResult { - // Basic validation - if evidence.block_hash_a == evidence.block_hash_b { - return Err(SentrixError::InvalidTransaction( - "evidence hashes must differ".into(), - )); - } - if evidence.validator.is_empty() { - return Err(SentrixError::InvalidTransaction( - "evidence missing validator".into(), - )); - } - - // Check not already processed - let evidence_id = format!( - "{}:{}:{}:{}", - evidence.validator, evidence.height, evidence.block_hash_a, evidence.block_hash_b - ); - if self.processed.contains(&evidence_id) { - return Ok(false); // already processed - } - - self.processed.push(evidence_id); - - // Cap processed list - if self.processed.len() > 1000 { - self.processed.drain(..500); - } - - Ok(true) - } -} // ── Slashing Engine ────────────────────────────────────────── diff --git a/crates/sentrix-staking/src/slashing/double_sign.rs b/crates/sentrix-staking/src/slashing/double_sign.rs new file mode 100644 index 0000000..2587f07 --- /dev/null +++ b/crates/sentrix-staking/src/slashing/double_sign.rs @@ -0,0 +1,120 @@ +// slashing/double_sign.rs — equivocation evidence + detector. +// +// `DoubleSignEvidence` is the on-the-wire shape used by +// `StakingOp::SubmitEvidence`. `DoubleSignDetector` is the per-node +// observer that flags two distinct block hashes signed by the same +// validator at the same height — the standard Tendermint-style +// equivocation predicate. + +use super::liveness::LIVENESS_WINDOW; +use sentrix_primitives::{SentrixError, SentrixResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Stake slashed on a proven equivocation (double-sign), in basis points. +/// +/// 2000 BP = 20%. Unchanged from v2.1.6. Double-signing is provably +/// malicious (not accidental), so punishment is deliberately harsh. +/// Matches Cosmos Hub, Osmosis, Sei, and most BFT chains' standard. +/// Usually followed by tombstone (permanent ban) so the validator +/// can't re-enter the active set. +pub const DOUBLE_SIGN_SLASH_BP: u16 = 2000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DoubleSignEvidence { + pub validator: String, + pub height: u64, + pub block_hash_a: String, + pub block_hash_b: String, + pub signature_a: String, + pub signature_b: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DoubleSignDetector { + /// Recent block signatures: (validator, height) → block_hash + /// We keep a sliding window of recent blocks for detection + recent_blocks: HashMap<(String, u64), String>, + /// Max entries before cleanup + max_entries: usize, + /// Processed evidence hashes (prevent double-processing) + processed: Vec, +} + +impl DoubleSignDetector { + pub fn new() -> Self { + Self { + recent_blocks: HashMap::new(), + max_entries: 10_000, + processed: Vec::new(), + } + } + + /// Record a block signature. Returns evidence if double-sign detected. + pub fn record_block( + &mut self, + validator: &str, + height: u64, + block_hash: &str, + signature: &str, + ) -> Option { + let key = (validator.to_string(), height); + + if let Some(existing_hash) = self.recent_blocks.get(&key) { + if existing_hash != block_hash { + return Some(DoubleSignEvidence { + validator: validator.to_string(), + height, + block_hash_a: existing_hash.clone(), + block_hash_b: block_hash.to_string(), + signature_a: String::new(), // filled by caller if available + signature_b: signature.to_string(), + }); + } + return None; // same hash, not a double-sign + } + + self.recent_blocks.insert(key, block_hash.to_string()); + + // Cleanup old entries + if self.recent_blocks.len() > self.max_entries { + let cutoff_height = height.saturating_sub(LIVENESS_WINDOW * 10); + self.recent_blocks.retain(|(_v, h), _| *h > cutoff_height); + } + + None + } + + /// Verify and process external evidence submission + pub fn process_evidence(&mut self, evidence: &DoubleSignEvidence) -> SentrixResult { + // Basic validation + if evidence.block_hash_a == evidence.block_hash_b { + return Err(SentrixError::InvalidTransaction( + "evidence hashes must differ".into(), + )); + } + if evidence.validator.is_empty() { + return Err(SentrixError::InvalidTransaction( + "evidence missing validator".into(), + )); + } + + // Check not already processed + let evidence_id = format!( + "{}:{}:{}:{}", + evidence.validator, evidence.height, evidence.block_hash_a, evidence.block_hash_b + ); + if self.processed.contains(&evidence_id) { + return Ok(false); // already processed + } + + self.processed.push(evidence_id); + + // Cap processed list + if self.processed.len() > 1000 { + self.processed.drain(..500); + } + + Ok(true) + } +} diff --git a/crates/sentrix-staking/src/slashing/liveness.rs b/crates/sentrix-staking/src/slashing/liveness.rs new file mode 100644 index 0000000..f74ef49 --- /dev/null +++ b/crates/sentrix-staking/src/slashing/liveness.rs @@ -0,0 +1,133 @@ +// slashing/liveness.rs — sliding-window liveness tracker for downtime +// detection. +// +// Each validator gets a per-address Vec sized to the +// last LIVENESS_WINDOW blocks. `is_downtime` returns true once the +// window is full AND the validator has signed fewer than +// MIN_SIGNED_PER_WINDOW of those blocks. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Rolling window for liveness tracking, in blocks. +/// +/// At 1s block time = ~4 hours. Long enough to absorb normal operational +/// downtime (a weekly 10-minute deploy is 0.07% of the window; even a +/// 30-minute emergency recovery is 12.5%). Short enough that a +/// persistently offline validator still gets jailed within a half-day. +/// +/// Comparable chains: +/// - Tendermint default: 100 (≈100s — demo-tight) +/// - Cosmos Hub: 10_000 (≈16.7h @ 6s block time) +/// - Osmosis: 30_000 (≈41.7h @ 5s) +/// - Sei: 10_000 (≈1.1h @ 400ms) +/// - Sentrix (here): 14_400 (≈4h @ 1s) +/// +/// Sentrix lands between Sei's tight-on-fast-blocks approach and Cosmos's +/// generous-long-window approach, scaled for our 1s block cadence. +pub const LIVENESS_WINDOW: u64 = 14_400; + +/// Minimum signed blocks required per window for a validator to stay out +/// of jail. Expressed as an absolute block count, not a fraction, so the +/// math stays integer-friendly. +/// +/// 4_320 / 14_400 = 30% — validator must sign at least 30% of blocks in +/// any rolling 4-hour window. Translated to downtime tolerance: up to +/// ~70% of the window (≈2.8 hours) can be missed before jailing. That +/// covers: +/// - Weekly 10-minute deploy → ~0.07% downtime (absorbed) +/// - Emergency 30-min recovery → 12.5% downtime (absorbed) +/// - Extended 2-hour debugging → 50% downtime (absorbed) +/// - Full 3-hour outage in 4h → 75% downtime (jailed) +/// +/// Cosmos Hub uses 5% (generous, built for massive validator sets). +/// We go stricter because Sentrix's 21-validator target means each +/// individual validator carries proportionally more responsibility — +/// one flapping validator on a 21-node network is ~5% of producing +/// capacity lost, which is significant. +pub const MIN_SIGNED_PER_WINDOW: u64 = 4_320; + +/// Stake slashed on a liveness-downtime jail, in basis points. +/// +/// 10 BP = 0.1% of stake. Gentle-but-not-zero: operators notice (a +/// self-stake of 15_000 SRX becomes 14_985 SRX) without losing a life- +/// changing amount. Cosmos Hub uses 1 BP (0.01%) which is symbolic; +/// we go 10× stricter because individual reliability matters more at +/// Sentrix's smaller validator count. +/// +/// Compare to `DOUBLE_SIGN_SLASH_BP` (2000 BP / 20%) for equivocation — +/// malicious behavior is punished 200× harder than negligence. +pub const DOWNTIME_SLASH_BP: u16 = 10; + +/// Blocks jailed after a liveness failure. +/// +/// 600 blocks = 10 minutes @ 1s block time. Matches Cosmos Hub's +/// `downtime_jail_duration`. Long enough that the operator has to +/// actively notice + investigate + file an unjail tx (can't just +/// hot-reset and pretend nothing happened). Short enough that a +/// legitimately-flapping validator recovers quickly after the root +/// cause is fixed. +pub const DOWNTIME_JAIL_BLOCKS: u64 = 600; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LivenessTracker { + /// Per-validator sliding window: height → signed (true/false) + /// We store only the last LIVENESS_WINDOW entries per validator. + records: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LivenessRecord { + height: u64, + signed: bool, +} + +impl LivenessTracker { + pub fn new() -> Self { + Self::default() + } + + /// Record that a validator signed (or missed) a block at this height + pub fn record(&mut self, validator: &str, height: u64, signed: bool) { + let entries = self.records.entry(validator.to_string()).or_default(); + entries.push(LivenessRecord { height, signed }); + + // Trim to window size + if entries.len() > LIVENESS_WINDOW as usize { + let excess = entries.len() - LIVENESS_WINDOW as usize; + entries.drain(..excess); + } + } + + /// Check if validator has fallen below the minimum signed threshold + pub fn is_downtime(&self, validator: &str) -> bool { + let entries = match self.records.get(validator) { + Some(e) => e, + None => return false, + }; + + // Only check once we have a full window + if entries.len() < LIVENESS_WINDOW as usize { + return false; + } + + let signed_count = entries.iter().filter(|r| r.signed).count() as u64; + signed_count < MIN_SIGNED_PER_WINDOW + } + + /// Get signed/missed counts for a validator + pub fn get_stats(&self, validator: &str) -> (u64, u64) { + let entries = match self.records.get(validator) { + Some(e) => e, + None => return (0, 0), + }; + let signed = entries.iter().filter(|r| r.signed).count() as u64; + let missed = entries.iter().filter(|r| !r.signed).count() as u64; + (signed, missed) + } + + /// Clear records for a validator (used after slashing to reset window) + pub fn reset(&mut self, validator: &str) { + self.records.remove(validator); + } +}