feat(consensus): slashing, ValidatorState, scheduler auto-tx, NodeApprove/Reject execution

This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 14:14:55 +03:00
parent ef3d18ef56
commit 015e521ae2
8 changed files with 267 additions and 37 deletions

View file

@ -7,6 +7,18 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
## [0.5.0] — 2026-04-24
### Added
- `nu-consensus/src/slashing.rs``slash_double_sign` (%10 stake, PoN→0.5, 30 slot ban), `slash_invalid_block` (%5 stake, PoN→0.7), `record_skip`/`record_block`, `try_unban`
- `nu-consensus/src/types.rs``ValidatorRecord`'a `skip_count`, `ban_until_slot` alanları; `MIN_VALIDATOR_STAKE` Shell cinsine güncellendi (1_000_000)
- `nu-state/src/validator.rs``ValidatorState` struct (on-chain validator kayıt)
- `nu-state/src/db.rs``scan_prefix<T>` — prefix ile tüm kayıtları iterate eder
- `nu-state/src/accessor.rs``get_nft/set_nft`, `get_validator/set_validator` trait metodları
- `nu-vm/executor.rs``execute_validator_register`, `execute_voting_open`, `execute_node_approve` (NFT mint + ödül dağıtımı), `execute_node_reject` (entry fee yakım + stake unlock)
- `block_loop.rs` — her slot'ta `generate_scheduler_txs` çalışır; Pending→VotingOpen ve VotingOpen→Approved/Rejected auto-tx'leri mempool'a eklenir
- `DEV_WALLET` env variable desteği — hardcode kaldırıldı
## [0.4.0] — 2026-04-24 ## [0.4.0] — 2026-04-24
### Added ### Added

1
Cargo.lock generated
View file

@ -860,6 +860,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"nu-block", "nu-block",
"nu-consensus",
"nu-state", "nu-state",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -1,7 +1,9 @@
use crate::{ use crate::{
account::AccountState, account::AccountState,
db::StateDb, db::StateDb,
nft::NftState,
story_node::{StoryNodeState, WeightedVote}, story_node::{StoryNodeState, WeightedVote},
validator::ValidatorState,
}; };
pub trait StateAccessor { pub trait StateAccessor {
@ -31,7 +33,15 @@ pub trait StateAccessor {
fn get_node(&self, node_id: &str) -> Option<StoryNodeState>; fn get_node(&self, node_id: &str) -> Option<StoryNodeState>;
fn set_node(&self, node: &StoryNodeState); fn set_node(&self, node: &StoryNodeState);
// --- Helpers used by multiple executors --- // --- NFT ---
fn get_nft(&self, nft_id: &str) -> Option<NftState>;
fn set_nft(&self, nft: &NftState);
// --- Validator ---
fn get_validator(&self, address: &str) -> Option<ValidatorState>;
fn set_validator(&self, v: &ValidatorState);
// --- Composite helpers ---
fn lock_stake(&self, address: &str, amount: u64, until_ms: i64) { fn lock_stake(&self, address: &str, amount: u64, until_ms: i64) {
let mut a = self.get_account(address); let mut a = self.get_account(address);
a.locked = a.locked.saturating_add(amount); a.locked = a.locked.saturating_add(amount);
@ -63,35 +73,44 @@ pub trait StateAccessor {
impl StateAccessor for StateDb { impl StateAccessor for StateDb {
fn get_account(&self, address: &str) -> AccountState { fn get_account(&self, address: &str) -> AccountState {
let key = format!("account:{address}"); self.get::<AccountState>(&format!("account:{address}"))
self.get::<AccountState>(&key)
.ok() .ok()
.flatten() .flatten()
.unwrap_or_else(|| AccountState::new(address.to_string())) .unwrap_or_else(|| AccountState::new(address.to_string()))
} }
fn set_account(&self, account: &AccountState) { fn set_account(&self, account: &AccountState) {
let key = format!("account:{}", account.address); let _ = self.put(&format!("account:{}", account.address), account);
let _ = self.put(&key, account);
} }
fn get_node_by_temp(&self, temp_id: &str) -> Option<StoryNodeState> { fn get_node_by_temp(&self, temp_id: &str) -> Option<StoryNodeState> {
let key = format!("node_temp:{temp_id}"); self.get::<StoryNodeState>(&format!("node_temp:{temp_id}")).ok().flatten()
self.get::<StoryNodeState>(&key).ok().flatten()
} }
fn get_node(&self, node_id: &str) -> Option<StoryNodeState> { fn get_node(&self, node_id: &str) -> Option<StoryNodeState> {
let key = format!("node:{node_id}"); self.get::<StoryNodeState>(&format!("node:{node_id}")).ok().flatten()
self.get::<StoryNodeState>(&key).ok().flatten()
} }
fn set_node(&self, node: &StoryNodeState) { fn set_node(&self, node: &StoryNodeState) {
// Index by temp_id (always set) and canonical node_id (set after approval) let _ = self.put(&format!("node_temp:{}", node.temp_id), node);
let temp_key = format!("node_temp:{}", node.temp_id);
let _ = self.put(&temp_key, node);
if !node.node_id.is_empty() { if !node.node_id.is_empty() {
let canon_key = format!("node:{}", node.node_id); let _ = self.put(&format!("node:{}", node.node_id), node);
let _ = self.put(&canon_key, node);
} }
} }
fn get_nft(&self, nft_id: &str) -> Option<NftState> {
self.get::<NftState>(&format!("nft:{nft_id}")).ok().flatten()
}
fn set_nft(&self, nft: &NftState) {
let _ = self.put(&format!("nft:{}", nft.nft_id), nft);
}
fn get_validator(&self, address: &str) -> Option<ValidatorState> {
self.get::<ValidatorState>(&format!("validator:{address}")).ok().flatten()
}
fn set_validator(&self, v: &ValidatorState) {
let _ = self.put(&format!("validator:{}", v.address), v);
}
} }

View file

@ -1,9 +1,12 @@
pub mod accessor; pub mod accessor;
pub mod account; pub mod account;
pub mod story_node;
pub mod nft;
pub mod db; pub mod db;
pub mod nft;
pub mod story_node;
pub mod validator;
pub use accessor::StateAccessor; pub use accessor::StateAccessor;
pub use db::StateDb; pub use db::StateDb;
pub use nft::NftState;
pub use story_node::{NodeStatus, StoryNodeState, WeightedVote}; pub use story_node::{NodeStatus, StoryNodeState, WeightedVote};
pub use validator::ValidatorState;

View file

@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatorState {
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,
}
impl ValidatorState {
pub fn new(address: String, stake: u64) -> Self {
Self {
address,
stake,
pon_score: 1.0,
is_active: true,
last_block: 0,
slash_count: 0,
skip_count: 0,
consecutive_blocks: 0,
ban_until_slot: 0,
}
}
}

View file

@ -9,5 +9,6 @@ serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
nu-state = { path = "../nu-state" } nu-state = { path = "../nu-state" }
nu-block = { path = "../nu-block" } nu-block = { path = "../nu-block" }
nu-consensus = { path = "../nu-consensus" }

View file

@ -2,8 +2,10 @@ use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
use nu_state::StateAccessor; use nu_state::StateAccessor;
use crate::executor::{ use crate::executor::{
execute_node_submit, execute_stake_op, execute_token_transfer, execute_vote_cast, execute_node_approve, execute_node_reject, execute_node_submit,
execute_vote_register, ExecutionContext, execute_stake_op, execute_token_transfer, execute_validator_register,
execute_vote_cast, execute_vote_register, execute_voting_open,
ExecutionContext,
}; };
pub struct BlockResult { pub struct BlockResult {
@ -13,12 +15,15 @@ pub struct BlockResult {
} }
pub fn execute_block(block: &Block, state: &dyn StateAccessor, now_ms: i64) -> BlockResult { pub fn execute_block(block: &Block, state: &dyn StateAccessor, now_ms: i64) -> BlockResult {
let dev_wallet = std::env::var("DEV_WALLET")
.unwrap_or_else(|_| "0xDEV0000000000000000000000000000000000000".to_string());
let mut receipts = Vec::with_capacity(block.transactions.len()); let mut receipts = Vec::with_capacity(block.transactions.len());
let mut applied = 0u32; let mut applied = 0u32;
let mut failed = 0u32; let mut failed = 0u32;
for tx in &block.transactions { for tx in &block.transactions {
let receipt = execute_tx(tx, state, block.header.height, now_ms); let receipt = execute_tx(tx, state, block.header.height, now_ms, &dev_wallet);
if receipt.success { applied += 1; } else { failed += 1; } if receipt.success { applied += 1; } else { failed += 1; }
receipts.push(receipt); receipts.push(receipt);
} }
@ -31,6 +36,7 @@ fn execute_tx(
state: &dyn StateAccessor, state: &dyn StateAccessor,
block_height: u64, block_height: u64,
now_ms: i64, now_ms: i64,
dev_wallet: &str,
) -> TxReceipt { ) -> TxReceipt {
let ctx = ExecutionContext { state, block_height, now_ms }; let ctx = ExecutionContext { state, block_height, now_ms };
@ -39,10 +45,7 @@ fn execute_tx(
execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce) execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce)
} }
TxPayload::NodeSubmit { story_id, parent_node_id, content_hash, temp_id, entry_fee: _ } => { TxPayload::NodeSubmit { story_id, parent_node_id, content_hash, temp_id, entry_fee: _ } => {
execute_node_submit( execute_node_submit(&ctx, &tx.sender, tx.nonce, tx.fee, story_id, parent_node_id, content_hash, temp_id)
&ctx, &tx.sender, tx.nonce, tx.fee,
story_id, parent_node_id, content_hash, temp_id,
)
} }
TxPayload::VoteRegister { node_id, stake_lock: _ } => { TxPayload::VoteRegister { node_id, stake_lock: _ } => {
execute_vote_register(&ctx, &tx.sender, tx.nonce, tx.fee, node_id) execute_vote_register(&ctx, &tx.sender, tx.nonce, tx.fee, node_id)
@ -53,16 +56,23 @@ fn execute_tx(
TxPayload::StakeOp { stake, amount } => { TxPayload::StakeOp { stake, amount } => {
execute_stake_op(&ctx, &tx.sender, tx.nonce, tx.fee, *stake, *amount) execute_stake_op(&ctx, &tx.sender, tx.nonce, tx.fee, *stake, *amount)
} }
// Faz 2: consensus-driven auto txs TxPayload::ValidatorRegister { stake } => {
TxPayload::NodeApprove { .. } execute_validator_register(&ctx, &tx.sender, tx.nonce, tx.fee, *stake)
| TxPayload::NftMint { .. } }
| TxPayload::NodeReject { .. } TxPayload::VotingOpen { node_id } => {
| TxPayload::VotingOpen { .. } execute_voting_open(&ctx, node_id)
| TxPayload::ValidatorRegister { .. } }
TxPayload::NodeApprove { temp_id, canonical_id } => {
execute_node_approve(&ctx, temp_id, canonical_id, dev_wallet)
}
TxPayload::NodeReject { node_id } => {
execute_node_reject(&ctx, node_id)
}
// Faz 2 later
TxPayload::NftMint { .. }
| TxPayload::NftTransfer { .. } | TxPayload::NftTransfer { .. }
| TxPayload::CollectionClaim { .. } => { | TxPayload::CollectionClaim { .. } => {
let name = payload_name(&tx.payload); Err(crate::errors::VmError::Unknown(format!("{} not yet implemented", payload_name(&tx.payload))))
Err(crate::errors::VmError::Unknown(format!("{name} not yet implemented")))
} }
}; };

View file

@ -1,8 +1,10 @@
use crate::errors::VmError; use crate::errors::VmError;
use crate::rewards::{SHELL_PER_NUT, NODE_REWARD_NUT}; use crate::rewards::{community_split, genesis_split, SHELL_PER_NUT, NODE_REWARD_NUT};
use nu_state::{ use nu_state::{
StateAccessor, nft::NftState,
story_node::{NodeStatus, StoryNodeState, WeightedVote}, story_node::{NodeStatus, StoryNodeState, WeightedVote},
validator::ValidatorState,
StateAccessor,
}; };
pub struct ExecutionContext<'a> { pub struct ExecutionContext<'a> {
@ -207,3 +209,155 @@ pub fn execute_stake_op(
ctx.state.set_account(&account); ctx.state.set_account(&account);
Ok(()) Ok(())
} }
/// Register as a validator — locks stake, creates ValidatorState.
pub fn execute_validator_register(
ctx: &ExecutionContext,
sender: &str,
nonce: u64,
fee: u64,
stake: u64,
) -> Result<(), VmError> {
use nu_consensus::types::MIN_VALIDATOR_STAKE;
let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
}
if stake < MIN_VALIDATOR_STAKE {
return Err(VmError::StakeTooLow);
}
let mut account = ctx.state.get_account(sender);
let total = stake.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?;
if account.balance < total {
return Err(VmError::InsufficientBalance { need: total, have: account.balance });
}
account.balance -= total;
account.staked += stake;
account.nonce += 1;
ctx.state.set_account(&account);
let record = ValidatorState::new(sender.to_string(), stake);
ctx.state.set_validator(&record);
Ok(())
}
const BURN_WALLET: &str = "0x000000000000000000000000000000000000DEAD";
const GENESIS_STORY: &str = "genesis";
/// Auto-tx: transition node from Pending → VotingOpen.
pub fn execute_voting_open(
ctx: &ExecutionContext,
node_id: &str,
) -> Result<(), VmError> {
let mut node = ctx.state.get_node_by_temp(node_id)
.ok_or_else(|| VmError::NodeNotFound(node_id.to_string()))?;
if node.status != NodeStatus::Pending {
return Err(VmError::Unknown("node not in Pending state".into()));
}
let voting_period_ms: i64 = 3 * 24 * 60 * 60 * 1_000;
node.status = NodeStatus::VotingOpen;
node.vote_open_at = ctx.now_ms;
node.vote_end_at = ctx.now_ms + voting_period_ms;
ctx.state.set_node(&node);
Ok(())
}
/// Auto-tx: approve node, mint NFT, distribute rewards.
pub fn execute_node_approve(
ctx: &ExecutionContext,
temp_id: &str,
canonical_id: &str,
dev_wallet: &str,
) -> Result<(), VmError> {
let mut node = ctx.state.get_node_by_temp(temp_id)
.ok_or_else(|| VmError::NodeNotFound(temp_id.to_string()))?;
if node.status != NodeStatus::VotingOpen {
return Err(VmError::NodeNotVoting);
}
node.status = NodeStatus::Approved;
node.node_id = canonical_id.to_string();
node.nft_id = canonical_id.to_string();
ctx.state.set_node(&node);
// Mint NFT
let depth = canonical_id.len() as u32;
let nft = NftState {
nft_id: canonical_id.to_string(),
node_id: canonical_id.to_string(),
owner: node.author.clone(),
collection_id: String::new(),
depth,
lineage: vec![],
minted_at: ctx.now_ms,
};
ctx.state.set_nft(&nft);
ctx.state.add_nft(&node.author, canonical_id);
// Unlock voter stakes + update PoN scores
let winning_approve = node.is_approved();
let total_weight: f64 = node.votes.iter().map(|v| v.weight).sum();
let mut voter_reward_pool = 0u64;
if node.story_id == GENESIS_STORY {
let reward = genesis_split();
let dw_bal = ctx.state.get_balance(dev_wallet);
ctx.state.set_balance(dev_wallet, dw_bal + reward);
} else {
let split = community_split();
voter_reward_pool = split.voters;
// Author reward
let author_bal = ctx.state.get_balance(&node.author);
ctx.state.set_balance(&node.author, author_bal + split.author);
// Burn
let burn_bal = ctx.state.get_balance(BURN_WALLET);
ctx.state.set_balance(BURN_WALLET, burn_bal + split.burn);
}
// Distribute voter rewards + unlock stakes
for vote in &node.votes {
ctx.state.unlock_stake(&vote.voter, VOTE_LOCK_SHELL);
if node.story_id != GENESIS_STORY && vote.approve == winning_approve && total_weight > 0.0 {
let share = (vote.weight / total_weight * voter_reward_pool as f64) as u64;
let bal = ctx.state.get_balance(&vote.voter);
ctx.state.set_balance(&vote.voter, bal + share);
}
}
Ok(())
}
/// Auto-tx: reject node, burn entry fee, unlock stakes.
pub fn execute_node_reject(
ctx: &ExecutionContext,
node_id: &str,
) -> Result<(), VmError> {
let mut node = ctx.state.get_node_by_temp(node_id)
.ok_or_else(|| VmError::NodeNotFound(node_id.to_string()))?;
if node.status != NodeStatus::VotingOpen {
return Err(VmError::NodeNotVoting);
}
node.status = NodeStatus::Rejected;
ctx.state.set_node(&node);
// Burn entry fee
let burn_bal = ctx.state.get_balance(BURN_WALLET);
ctx.state.set_balance(BURN_WALLET, burn_bal + node.entry_fee);
// Unlock all voter stakes
for vote in &node.votes {
ctx.state.unlock_stake(&vote.voter, VOTE_LOCK_SHELL);
}
Ok(())
}