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]
## [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
### Added

1
Cargo.lock generated
View file

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

View file

@ -1,7 +1,9 @@
use crate::{
account::AccountState,
db::StateDb,
nft::NftState,
story_node::{StoryNodeState, WeightedVote},
validator::ValidatorState,
};
pub trait StateAccessor {
@ -31,7 +33,15 @@ pub trait StateAccessor {
fn get_node(&self, node_id: &str) -> Option<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) {
let mut a = self.get_account(address);
a.locked = a.locked.saturating_add(amount);
@ -63,35 +73,44 @@ pub trait StateAccessor {
impl StateAccessor for StateDb {
fn get_account(&self, address: &str) -> AccountState {
let key = format!("account:{address}");
self.get::<AccountState>(&key)
self.get::<AccountState>(&format!("account:{address}"))
.ok()
.flatten()
.unwrap_or_else(|| AccountState::new(address.to_string()))
}
fn set_account(&self, account: &AccountState) {
let key = format!("account:{}", account.address);
let _ = self.put(&key, account);
let _ = self.put(&format!("account:{}", account.address), account);
}
fn get_node_by_temp(&self, temp_id: &str) -> Option<StoryNodeState> {
let key = format!("node_temp:{temp_id}");
self.get::<StoryNodeState>(&key).ok().flatten()
self.get::<StoryNodeState>(&format!("node_temp:{temp_id}")).ok().flatten()
}
fn get_node(&self, node_id: &str) -> Option<StoryNodeState> {
let key = format!("node:{node_id}");
self.get::<StoryNodeState>(&key).ok().flatten()
self.get::<StoryNodeState>(&format!("node:{node_id}")).ok().flatten()
}
fn set_node(&self, node: &StoryNodeState) {
// Index by temp_id (always set) and canonical node_id (set after approval)
let temp_key = format!("node_temp:{}", node.temp_id);
let _ = self.put(&temp_key, node);
let _ = self.put(&format!("node_temp:{}", node.temp_id), node);
if !node.node_id.is_empty() {
let canon_key = format!("node:{}", node.node_id);
let _ = self.put(&canon_key, node);
let _ = self.put(&format!("node:{}", node.node_id), 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 account;
pub mod story_node;
pub mod nft;
pub mod db;
pub mod nft;
pub mod story_node;
pub mod validator;
pub use accessor::StateAccessor;
pub use db::StateDb;
pub use nft::NftState;
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

@ -11,3 +11,4 @@ thiserror.workspace = true
tracing.workspace = true
nu-state = { path = "../nu-state" }
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 crate::executor::{
execute_node_submit, execute_stake_op, execute_token_transfer, execute_vote_cast,
execute_vote_register, ExecutionContext,
execute_node_approve, execute_node_reject, execute_node_submit,
execute_stake_op, execute_token_transfer, execute_validator_register,
execute_vote_cast, execute_vote_register, execute_voting_open,
ExecutionContext,
};
pub struct BlockResult {
@ -13,12 +15,15 @@ pub struct 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 applied = 0u32;
let mut failed = 0u32;
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; }
receipts.push(receipt);
}
@ -31,6 +36,7 @@ fn execute_tx(
state: &dyn StateAccessor,
block_height: u64,
now_ms: i64,
dev_wallet: &str,
) -> TxReceipt {
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)
}
TxPayload::NodeSubmit { story_id, parent_node_id, content_hash, temp_id, entry_fee: _ } => {
execute_node_submit(
&ctx, &tx.sender, tx.nonce, tx.fee,
story_id, parent_node_id, content_hash, temp_id,
)
execute_node_submit(&ctx, &tx.sender, tx.nonce, tx.fee, story_id, parent_node_id, content_hash, temp_id)
}
TxPayload::VoteRegister { node_id, stake_lock: _ } => {
execute_vote_register(&ctx, &tx.sender, tx.nonce, tx.fee, node_id)
@ -53,16 +56,23 @@ fn execute_tx(
TxPayload::StakeOp { stake, amount } => {
execute_stake_op(&ctx, &tx.sender, tx.nonce, tx.fee, *stake, *amount)
}
// Faz 2: consensus-driven auto txs
TxPayload::NodeApprove { .. }
| TxPayload::NftMint { .. }
| TxPayload::NodeReject { .. }
| TxPayload::VotingOpen { .. }
| TxPayload::ValidatorRegister { .. }
TxPayload::ValidatorRegister { stake } => {
execute_validator_register(&ctx, &tx.sender, tx.nonce, tx.fee, *stake)
}
TxPayload::VotingOpen { node_id } => {
execute_voting_open(&ctx, node_id)
}
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::CollectionClaim { .. } => {
let name = payload_name(&tx.payload);
Err(crate::errors::VmError::Unknown(format!("{name} not yet implemented")))
Err(crate::errors::VmError::Unknown(format!("{} not yet implemented", payload_name(&tx.payload))))
}
};

View file

@ -1,8 +1,10 @@
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::{
StateAccessor,
nft::NftState,
story_node::{NodeStatus, StoryNodeState, WeightedVote},
validator::ValidatorState,
StateAccessor,
};
pub struct ExecutionContext<'a> {
@ -207,3 +209,155 @@ pub fn execute_stake_op(
ctx.state.set_account(&account);
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(())
}