From ef3d18ef56751cbe0071c2ef91f534533b1aa4a37df689a936302cd20a137197 Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Fri, 24 Apr 2026 12:31:46 +0300 Subject: [PATCH] feat(consensus): slashing, skip tracking, scheduler auto-tx injection into block loop --- crates/nu-consensus/src/lib.rs | 1 + crates/nu-consensus/src/slashing.rs | 62 +++++++++++++++++++++++ crates/nu-consensus/src/types.rs | 48 ++++++++++-------- crates/nu-state/src/db.rs | 22 ++++++++- crates/nu-state/src/story_node.rs | 19 ++++++-- src/block_loop.rs | 76 +++++++++++++++++++++++++---- 6 files changed, 195 insertions(+), 33 deletions(-) create mode 100644 crates/nu-consensus/src/slashing.rs diff --git a/crates/nu-consensus/src/lib.rs b/crates/nu-consensus/src/lib.rs index f28c92c..1aea95e 100644 --- a/crates/nu-consensus/src/lib.rs +++ b/crates/nu-consensus/src/lib.rs @@ -1,5 +1,6 @@ pub mod types; pub mod scheduler; +pub mod slashing; pub mod validator_set; pub mod pon_score; pub mod slot; diff --git a/crates/nu-consensus/src/slashing.rs b/crates/nu-consensus/src/slashing.rs new file mode 100644 index 0000000..4ddd15a --- /dev/null +++ b/crates/nu-consensus/src/slashing.rs @@ -0,0 +1,62 @@ +use crate::types::{ValidatorRecord, PON_SCORE_MIN, MAX_SKIP_COUNT}; + +pub const SLASH_DOUBLE_SIGN_STAKE_PCT: u64 = 10; +pub const SLASH_INVALID_BLOCK_STAKE_PCT: u64 = 5; +pub const SLASH_DOUBLE_SIGN_BAN_SLOTS: u32 = 30; + +pub struct SlashResult { + pub slashed_amount: u64, + pub burn_amount: u64, // %50 + pub reporter_reward: u64, // %50 +} + +/// Double signing: %10 stake, PoN → 0.5, 30 slot ban. +pub fn slash_double_sign(record: &mut ValidatorRecord, current_slot: u32) -> SlashResult { + let slashed = record.stake * SLASH_DOUBLE_SIGN_STAKE_PCT / 100; + record.stake -= slashed; + record.pon_score = PON_SCORE_MIN; + record.is_active = false; + record.ban_until_slot = current_slot + SLASH_DOUBLE_SIGN_BAN_SLOTS; + record.slash_count += 1; + split(slashed) +} + +/// Invalid block: %5 stake, PoN → 0.7. +pub fn slash_invalid_block(record: &mut ValidatorRecord) -> SlashResult { + let slashed = record.stake * SLASH_INVALID_BLOCK_STAKE_PCT / 100; + record.stake -= slashed; + record.pon_score = (0.7_f64).max(PON_SCORE_MIN); + record.slash_count += 1; + split(slashed) +} + +/// Record a slot skip. Returns true if deactivation threshold reached. +pub fn record_skip(record: &mut ValidatorRecord) -> bool { + record.skip_count += 1; + record.consecutive_blocks = 0; + if record.skip_count >= MAX_SKIP_COUNT { + record.is_active = false; + record.skip_count = 0; + return true; + } + false +} + +/// Called when validator successfully produces a block. +pub fn record_block(record: &mut ValidatorRecord) { + record.skip_count = 0; + record.consecutive_blocks += 1; +} + +/// Lift ban if ban_until_slot has passed. +pub fn try_unban(record: &mut ValidatorRecord, current_slot: u32) { + if record.ban_until_slot > 0 && current_slot >= record.ban_until_slot { + record.is_active = true; + record.ban_until_slot = 0; + } +} + +fn split(amount: u64) -> SlashResult { + let half = amount / 2; + SlashResult { slashed_amount: amount, burn_amount: half, reporter_reward: amount - half } +} diff --git a/crates/nu-consensus/src/types.rs b/crates/nu-consensus/src/types.rs index 0c581a8..5f4fbaf 100644 --- a/crates/nu-consensus/src/types.rs +++ b/crates/nu-consensus/src/types.rs @@ -1,26 +1,30 @@ use serde::{Deserialize, Serialize}; -pub const SLOT_DURATION_MS: u64 = 6_000; -pub const ROUND_SIZE: u32 = 21; +pub const SLOT_DURATION_MS: u64 = 6_000; +pub const ROUND_SIZE: u32 = 21; pub const MAX_CONSECUTIVE_BLOCKS: u32 = 5; -pub const MIN_VALIDATOR_STAKE: u64 = 1_000_000_000; // 1000 NUT in micro-units -pub const PON_SCORE_INIT: f64 = 1.0; -pub const PON_SCORE_MAX: f64 = 1.8; -pub const PON_SCORE_MIN: f64 = 0.5; -pub const PON_SCORE_WIN: f64 = 0.02; -pub const PON_SCORE_LOSE: f64 = -0.05; +// 1_000 NUT × 1_000 Shell/NUT = 1_000_000 Shell +pub const MIN_VALIDATOR_STAKE: u64 = 1_000_000; +pub const PON_SCORE_INIT: f64 = 1.0; +pub const PON_SCORE_MAX: f64 = 1.8; +pub const PON_SCORE_MIN: f64 = 0.5; +pub const PON_SCORE_WIN: f64 = 0.02; +pub const PON_SCORE_LOSE: f64 = -0.05; pub const PON_SCORE_HONEST_BLOCK: f64 = 0.01; -pub const WHALE_CAP_RATIO: f64 = 0.05; +pub const WHALE_CAP_RATIO: f64 = 0.05; +pub const MAX_SKIP_COUNT: u32 = 10; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidatorRecord { - pub address: String, - pub stake: u64, - pub pon_score: f64, - pub is_active: bool, - pub last_block: u64, - pub slash_count: u32, + pub address: String, + pub stake: u64, + pub pon_score: f64, + pub is_active: bool, + pub last_block: u64, + pub slash_count: u32, + pub skip_count: u32, pub consecutive_blocks: u32, + pub ban_until_slot: u32, // 0 = not banned } impl ValidatorRecord { @@ -28,11 +32,17 @@ impl ValidatorRecord { Self { address, stake, - pon_score: PON_SCORE_INIT, - is_active: true, - last_block: 0, - slash_count: 0, + pon_score: PON_SCORE_INIT, + is_active: true, + last_block: 0, + slash_count: 0, + skip_count: 0, consecutive_blocks: 0, + ban_until_slot: 0, } } + + pub fn is_banned(&self, current_slot: u32) -> bool { + self.ban_until_slot > current_slot + } } diff --git a/crates/nu-state/src/db.rs b/crates/nu-state/src/db.rs index ad8b784..33c132f 100644 --- a/crates/nu-state/src/db.rs +++ b/crates/nu-state/src/db.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use rocksdb::{DB, Options}; +use rocksdb::{DB, IteratorMode, Options}; use serde::{de::DeserializeOwned, Serialize}; pub struct StateDb { @@ -16,7 +16,7 @@ impl StateDb { pub fn get(&self, key: &str) -> Result> { match self.db.get(key.as_bytes())? { Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), - None => Ok(None), + None => Ok(None), } } @@ -30,4 +30,22 @@ impl StateDb { self.db.delete(key.as_bytes())?; Ok(()) } + + /// Iterates all keys with the given prefix, deserializing each value. + /// Skips entries that fail to deserialize. + pub fn scan_prefix(&self, prefix: &str) -> Vec { + let mut results = Vec::new(); + let iter = self.db.iterator(IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward)); + for item in iter { + if let Ok((key, value)) = item { + if !key.starts_with(prefix.as_bytes()) { + break; + } + if let Ok(v) = serde_json::from_slice::(&value) { + results.push(v); + } + } + } + results + } } diff --git a/crates/nu-state/src/story_node.rs b/crates/nu-state/src/story_node.rs index c81c7f2..f394912 100644 --- a/crates/nu-state/src/story_node.rs +++ b/crates/nu-state/src/story_node.rs @@ -1,5 +1,8 @@ use serde::{Deserialize, Serialize}; +const VOTING_OPEN_DELAY_MS: i64 = 7 * 24 * 60 * 60 * 1_000; // 7 days +const VOTING_PERIOD_MS: i64 = 3 * 24 * 60 * 60 * 1_000; // 3 days + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum NodeStatus { Pending, @@ -17,8 +20,8 @@ pub struct WeightedVote { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoryNodeState { - pub node_id: String, // canonical; empty until approved - pub temp_id: String, // client UUID + pub node_id: String, + pub temp_id: String, pub story_id: String, pub parent_id: String, pub author: String, @@ -28,7 +31,7 @@ pub struct StoryNodeState { pub vote_open_at: i64, pub vote_end_at: i64, pub votes: Vec, - pub nft_id: String, // set on approval + pub nft_id: String, pub entry_fee: u64, } @@ -44,4 +47,14 @@ impl StoryNodeState { pub fn is_approved(&self) -> bool { self.approve_votes_weight() > self.reject_votes_weight() } + + pub fn ready_for_voting(&self, now_ms: i64) -> bool { + self.status == NodeStatus::Pending + && now_ms >= self.submitted_at + VOTING_OPEN_DELAY_MS + } + + pub fn ready_for_finalization(&self, now_ms: i64) -> bool { + self.status == NodeStatus::VotingOpen + && now_ms >= self.vote_end_at + } } diff --git a/src/block_loop.rs b/src/block_loop.rs index dde88fb..81da6ea 100644 --- a/src/block_loop.rs +++ b/src/block_loop.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::{interval, Duration}; -use nu_block::{builder::BlockBuilder, types::Block}; +use nu_block::{ + builder::BlockBuilder, + types::{Block, RawTransaction, TxPayload}, +}; use nu_consensus::slot::current_slot; use nu_mempool::Mempool; use nu_state::StateDb; @@ -13,6 +16,7 @@ use crate::p2p::P2pSender; const MAX_TX_PER_BLOCK: usize = 500; const BLOCK_INTERVAL_MS: u64 = 6_000; +const SCHEDULER_ADDR: &str = "0x0000000000000000000000000000SCHEDULER"; pub struct BlockLoopConfig { pub validator_addr: String, @@ -20,14 +24,14 @@ pub struct BlockLoopConfig { } pub async fn run( - config: BlockLoopConfig, - db: Arc>, - mempool: Arc>, - p2p: Option, + config: BlockLoopConfig, + db: Arc>, + mempool: Arc>, + p2p: Option, ) { - let mut ticker = interval(Duration::from_millis(BLOCK_INTERVAL_MS)); + let mut ticker = interval(Duration::from_millis(BLOCK_INTERVAL_MS)); let mut height: u64 = 1; - let mut prev_hash = "0".repeat(64); + let mut prev_hash = "0".repeat(64); tracing::info!( chain_id = %config.chain_id, @@ -41,6 +45,18 @@ pub async fn run( let slot = current_slot(); let now_ms = chrono::Utc::now().timestamp_millis(); + // Scheduler: inject auto-txs for pending nodes + { + let db_guard = db.lock().await; + let auto_txs = generate_scheduler_txs(&db_guard, now_ms); + if !auto_txs.is_empty() { + let mut pool = mempool.lock().await; + for tx in auto_txs { + pool.insert(tx, now_ms); + } + } + } + let pending = { let mut pool = mempool.lock().await; pool.select_for_block(MAX_TX_PER_BLOCK, now_ms) @@ -85,10 +101,9 @@ pub async fn run( let hash = block_hash(&block); - let block_key = format!("block:{height}"); { let db_guard = db.lock().await; - if let Err(e) = db_guard.put(&block_key, &block) { + if let Err(e) = db_guard.put(&format!("block:{height}"), &block) { tracing::error!(height, "failed to persist block: {e}"); } } @@ -111,6 +126,49 @@ pub async fn run( } } +/// Scans pending nodes and produces VotingOpen / NodeApprove / NodeReject auto-txs. +fn generate_scheduler_txs(db: &StateDb, now_ms: i64) -> Vec { + use nu_state::story_node::StoryNodeState; + use sha2::{Digest, Sha256}; + + let mut txs = Vec::new(); + + let nodes: Vec = db.scan_prefix("node_temp:"); + + for node in nodes { + let payload = if node.ready_for_voting(now_ms) { + Some(TxPayload::VotingOpen { node_id: node.temp_id.clone() }) + } else if node.ready_for_finalization(now_ms) { + if node.is_approved() { + let canonical_id = node.temp_id.clone(); // simplified — real impl derives from tree depth + Some(TxPayload::NodeApprove { + temp_id: node.temp_id.clone(), + canonical_id, + }) + } else { + Some(TxPayload::NodeReject { node_id: node.temp_id.clone() }) + } + } else { + None + }; + + if let Some(payload) = payload { + let body = serde_json::to_vec(&payload).unwrap_or_default(); + let tx_id = hex::encode(Sha256::digest(&body)); + txs.push(RawTransaction { + tx_id, + sender: SCHEDULER_ADDR.to_string(), + nonce: 0, + fee: 0, + sig: vec![], + payload, + }); + } + } + + txs +} + fn block_hash(block: &Block) -> String { use sha2::{Digest, Sha256}; let data = serde_json::to_vec(&block.header).unwrap_or_default();