feat(consensus): slashing, skip tracking, scheduler auto-tx injection into block loop

This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 12:31:46 +03:00
parent 9837edfb1f
commit ef3d18ef56
6 changed files with 195 additions and 33 deletions

View file

@ -1,5 +1,6 @@
pub mod types; pub mod types;
pub mod scheduler; pub mod scheduler;
pub mod slashing;
pub mod validator_set; pub mod validator_set;
pub mod pon_score; pub mod pon_score;
pub mod slot; pub mod slot;

View file

@ -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 }
}

View file

@ -3,7 +3,8 @@ use serde::{Deserialize, Serialize};
pub const SLOT_DURATION_MS: u64 = 6_000; pub const SLOT_DURATION_MS: u64 = 6_000;
pub const ROUND_SIZE: u32 = 21; pub const ROUND_SIZE: u32 = 21;
pub const MAX_CONSECUTIVE_BLOCKS: u32 = 5; pub const MAX_CONSECUTIVE_BLOCKS: u32 = 5;
pub const MIN_VALIDATOR_STAKE: u64 = 1_000_000_000; // 1000 NUT in micro-units // 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_INIT: f64 = 1.0;
pub const PON_SCORE_MAX: f64 = 1.8; pub const PON_SCORE_MAX: f64 = 1.8;
pub const PON_SCORE_MIN: f64 = 0.5; pub const PON_SCORE_MIN: f64 = 0.5;
@ -11,6 +12,7 @@ pub const PON_SCORE_WIN: f64 = 0.02;
pub const PON_SCORE_LOSE: f64 = -0.05; pub const PON_SCORE_LOSE: f64 = -0.05;
pub const PON_SCORE_HONEST_BLOCK: f64 = 0.01; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatorRecord { pub struct ValidatorRecord {
@ -20,7 +22,9 @@ pub struct ValidatorRecord {
pub is_active: bool, pub is_active: bool,
pub last_block: u64, pub last_block: u64,
pub slash_count: u32, pub slash_count: u32,
pub skip_count: u32,
pub consecutive_blocks: u32, pub consecutive_blocks: u32,
pub ban_until_slot: u32, // 0 = not banned
} }
impl ValidatorRecord { impl ValidatorRecord {
@ -32,7 +36,13 @@ impl ValidatorRecord {
is_active: true, is_active: true,
last_block: 0, last_block: 0,
slash_count: 0, slash_count: 0,
skip_count: 0,
consecutive_blocks: 0, consecutive_blocks: 0,
ban_until_slot: 0,
} }
} }
pub fn is_banned(&self, current_slot: u32) -> bool {
self.ban_until_slot > current_slot
}
} }

View file

@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use rocksdb::{DB, Options}; use rocksdb::{DB, IteratorMode, Options};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
pub struct StateDb { pub struct StateDb {
@ -30,4 +30,22 @@ impl StateDb {
self.db.delete(key.as_bytes())?; self.db.delete(key.as_bytes())?;
Ok(()) Ok(())
} }
/// Iterates all keys with the given prefix, deserializing each value.
/// Skips entries that fail to deserialize.
pub fn scan_prefix<T: DeserializeOwned>(&self, prefix: &str) -> Vec<T> {
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::<T>(&value) {
results.push(v);
}
}
}
results
}
} }

View file

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum NodeStatus { pub enum NodeStatus {
Pending, Pending,
@ -17,8 +20,8 @@ pub struct WeightedVote {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoryNodeState { pub struct StoryNodeState {
pub node_id: String, // canonical; empty until approved pub node_id: String,
pub temp_id: String, // client UUID pub temp_id: String,
pub story_id: String, pub story_id: String,
pub parent_id: String, pub parent_id: String,
pub author: String, pub author: String,
@ -28,7 +31,7 @@ pub struct StoryNodeState {
pub vote_open_at: i64, pub vote_open_at: i64,
pub vote_end_at: i64, pub vote_end_at: i64,
pub votes: Vec<WeightedVote>, pub votes: Vec<WeightedVote>,
pub nft_id: String, // set on approval pub nft_id: String,
pub entry_fee: u64, pub entry_fee: u64,
} }
@ -44,4 +47,14 @@ impl StoryNodeState {
pub fn is_approved(&self) -> bool { pub fn is_approved(&self) -> bool {
self.approve_votes_weight() > self.reject_votes_weight() 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
}
} }

View file

@ -3,7 +3,10 @@ use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::{interval, Duration}; 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_consensus::slot::current_slot;
use nu_mempool::Mempool; use nu_mempool::Mempool;
use nu_state::StateDb; use nu_state::StateDb;
@ -13,6 +16,7 @@ use crate::p2p::P2pSender;
const MAX_TX_PER_BLOCK: usize = 500; const MAX_TX_PER_BLOCK: usize = 500;
const BLOCK_INTERVAL_MS: u64 = 6_000; const BLOCK_INTERVAL_MS: u64 = 6_000;
const SCHEDULER_ADDR: &str = "0x0000000000000000000000000000SCHEDULER";
pub struct BlockLoopConfig { pub struct BlockLoopConfig {
pub validator_addr: String, pub validator_addr: String,
@ -41,6 +45,18 @@ pub async fn run(
let slot = current_slot(); let slot = current_slot();
let now_ms = chrono::Utc::now().timestamp_millis(); 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 pending = {
let mut pool = mempool.lock().await; let mut pool = mempool.lock().await;
pool.select_for_block(MAX_TX_PER_BLOCK, now_ms) pool.select_for_block(MAX_TX_PER_BLOCK, now_ms)
@ -85,10 +101,9 @@ pub async fn run(
let hash = block_hash(&block); let hash = block_hash(&block);
let block_key = format!("block:{height}");
{ {
let db_guard = db.lock().await; 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}"); 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<RawTransaction> {
use nu_state::story_node::StoryNodeState;
use sha2::{Digest, Sha256};
let mut txs = Vec::new();
let nodes: Vec<StoryNodeState> = 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 { fn block_hash(block: &Block) -> String {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let data = serde_json::to_vec(&block.header).unwrap_or_default(); let data = serde_json::to_vec(&block.header).unwrap_or_default();