feat(consensus): slashing, skip tracking, scheduler auto-tx injection into block loop
This commit is contained in:
parent
9837edfb1f
commit
ef3d18ef56
6 changed files with 195 additions and 33 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
62
crates/nu-consensus/src/slashing.rs
Normal file
62
crates/nu-consensus/src/slashing.rs
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue