feat(vm): implement NodeSubmit, VoteRegister, VoteCast, StakeOp execution

This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 11:30:05 +03:00
parent a81b1851ce
commit 2c6db93043
5 changed files with 313 additions and 67 deletions

View file

@ -1,53 +1,97 @@
use crate::{account::AccountState, db::StateDb};
use crate::{
account::AccountState,
db::StateDb,
story_node::{StoryNodeState, WeightedVote},
};
/// Read/write access to account state.
/// `set_balance` and `inc_nonce` take `&self` because StateDb uses RocksDB's
/// interior mutability — writes do not require exclusive access at the Rust level.
pub trait StateAccessor {
fn get_balance(&self, address: &str) -> u64;
fn get_nonce(&self, address: &str) -> u64;
fn set_balance(&self, address: &str, balance: u64);
fn inc_nonce(&self, address: &str);
// --- Account ---
fn get_account(&self, address: &str) -> AccountState;
fn set_account(&self, account: &AccountState);
fn get_balance(&self, address: &str) -> u64 {
self.get_account(address).balance
}
fn get_nonce(&self, address: &str) -> u64 {
self.get_account(address).nonce
}
fn set_balance(&self, address: &str, balance: u64) {
let mut a = self.get_account(address);
a.balance = balance;
self.set_account(&a);
}
fn inc_nonce(&self, address: &str) {
let mut a = self.get_account(address);
a.nonce += 1;
self.set_account(&a);
}
// --- Story node ---
fn get_node_by_temp(&self, temp_id: &str) -> Option<StoryNodeState>;
fn get_node(&self, node_id: &str) -> Option<StoryNodeState>;
fn set_node(&self, node: &StoryNodeState);
// --- Helpers used by multiple executors ---
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);
a.locked_until = until_ms;
self.set_account(&a);
}
fn unlock_stake(&self, address: &str, amount: u64) {
let mut a = self.get_account(address);
a.locked = a.locked.saturating_sub(amount);
self.set_account(&a);
}
fn add_nft(&self, address: &str, nft_id: &str) {
let mut a = self.get_account(address);
if !a.nft_ids.contains(&nft_id.to_string()) {
a.nft_ids.push(nft_id.to_string());
}
self.set_account(&a);
}
fn record_vote(&self, temp_id: &str, vote: WeightedVote) {
if let Some(mut node) = self.get_node_by_temp(temp_id) {
node.votes.push(vote);
self.set_node(&node);
}
}
}
impl StateAccessor for StateDb {
fn get_balance(&self, address: &str) -> u64 {
fn get_account(&self, address: &str) -> AccountState {
let key = format!("account:{address}");
self.get::<AccountState>(&key)
.ok()
.flatten()
.map(|a| a.balance)
.unwrap_or(0)
.unwrap_or_else(|| AccountState::new(address.to_string()))
}
fn get_nonce(&self, address: &str) -> u64 {
let key = format!("account:{address}");
self.get::<AccountState>(&key)
.ok()
.flatten()
.map(|a| a.nonce)
.unwrap_or(0)
fn set_account(&self, account: &AccountState) {
let key = format!("account:{}", account.address);
let _ = self.put(&key, account);
}
fn set_balance(&self, address: &str, balance: u64) {
let key = format!("account:{address}");
let mut account = self
.get::<AccountState>(&key)
.ok()
.flatten()
.unwrap_or_else(|| AccountState::new(address.to_string()));
account.balance = balance;
let _ = self.put(&key, &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()
}
fn inc_nonce(&self, address: &str) {
let key = format!("account:{address}");
let mut account = self
.get::<AccountState>(&key)
.ok()
.flatten()
.unwrap_or_else(|| AccountState::new(address.to_string()));
account.nonce += 1;
let _ = self.put(&key, &account);
fn get_node(&self, node_id: &str) -> Option<StoryNodeState> {
let key = format!("node:{node_id}");
self.get::<StoryNodeState>(&key).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);
if !node.node_id.is_empty() {
let canon_key = format!("node:{}", node.node_id);
let _ = self.put(&canon_key, node);
}
}
}

View file

@ -6,3 +6,4 @@ pub mod db;
pub use accessor::StateAccessor;
pub use db::StateDb;
pub use story_node::{NodeStatus, StoryNodeState, WeightedVote};

View file

@ -1,7 +1,10 @@
use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
use nu_state::StateAccessor;
use crate::executor::{execute_token_transfer, ExecutionContext};
use crate::executor::{
execute_node_submit, execute_stake_op, execute_token_transfer, execute_vote_cast,
execute_vote_register, ExecutionContext,
};
pub struct BlockResult {
pub receipts: Vec<TxReceipt>,
@ -9,14 +12,7 @@ pub struct BlockResult {
pub failed: u32,
}
/// Executes all transactions in a block against the given state.
/// Each tx is validated and applied atomically; failures produce a receipt
/// but do not roll back already-applied txs.
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 mut receipts = Vec::with_capacity(block.transactions.len());
let mut applied = 0u32;
let mut failed = 0u32;
@ -42,10 +38,31 @@ fn execute_tx(
TxPayload::TokenTransfer { to, amount } => {
execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce)
}
// Remaining variants return "not yet implemented" — applied in later Faz 1 tasks
other => {
let name = payload_name(other);
Err(crate::errors::VmError::Unknown(format!("{name} execution not yet implemented")))
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,
)
}
TxPayload::VoteRegister { node_id, stake_lock: _ } => {
execute_vote_register(&ctx, &tx.sender, tx.nonce, tx.fee, node_id)
}
TxPayload::VoteCast { node_id, approve } => {
execute_vote_cast(&ctx, &tx.sender, tx.nonce, tx.fee, node_id, *approve)
}
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::NftTransfer { .. }
| TxPayload::CollectionClaim { .. } => {
let name = payload_name(&tx.payload);
Err(crate::errors::VmError::Unknown(format!("{name} not yet implemented")))
}
};

View file

@ -18,6 +18,14 @@ pub enum VmError {
StakeTooLow,
#[error("double spend detected")]
DoubleSpend,
#[error("node not found: {0}")]
NodeNotFound(String),
#[error("node is not in voting state")]
NodeNotVoting,
#[error("already voted on this node")]
AlreadyVoted,
#[error("story not found: {0}")]
StoryNotFound(String),
#[error("unknown: {0}")]
Unknown(String),
}

View file

@ -1,5 +1,9 @@
use crate::errors::VmError;
use nu_state::StateAccessor;
use crate::rewards::{SHELL_PER_NUT, NODE_REWARD_NUT};
use nu_state::{
StateAccessor,
story_node::{NodeStatus, StoryNodeState, WeightedVote},
};
pub struct ExecutionContext<'a> {
pub state: &'a dyn StateAccessor,
@ -7,6 +11,11 @@ pub struct ExecutionContext<'a> {
pub now_ms: i64,
}
// entry_fee = 25% of reward = 25 NUT = 25_000 Shell
const ENTRY_FEE_SHELL: u64 = (NODE_REWARD_NUT / 4) * SHELL_PER_NUT;
// vote_stake_lock = 10% of reward = 10 NUT = 10_000 Shell
const VOTE_LOCK_SHELL: u64 = (NODE_REWARD_NUT / 10) * SHELL_PER_NUT;
pub fn execute_token_transfer(
ctx: &ExecutionContext,
sender: &str,
@ -31,3 +40,170 @@ pub fn execute_token_transfer(
ctx.state.inc_nonce(sender);
Ok(())
}
/// Validates entry fee, creates a Pending StoryNodeState.
/// Canonical node_id is assigned at approval — temp_id used until then.
pub fn execute_node_submit(
ctx: &ExecutionContext,
sender: &str,
nonce: u64,
fee: u64,
story_id: &str,
parent_node_id: &str,
content_hash: &str,
temp_id: &str,
) -> Result<(), VmError> {
let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
}
let total = ENTRY_FEE_SHELL.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?;
let balance = ctx.state.get_balance(sender);
if balance < total {
return Err(VmError::InsufficientBalance { need: total, have: balance });
}
// Deduct entry fee + tx fee
ctx.state.set_balance(sender, balance - total);
ctx.state.inc_nonce(sender);
let node = StoryNodeState {
node_id: String::new(), // assigned at approval
temp_id: temp_id.to_string(),
story_id: story_id.to_string(),
parent_id: parent_node_id.to_string(),
author: sender.to_string(),
content_hash: content_hash.to_string(),
status: NodeStatus::Pending,
submitted_at: ctx.now_ms,
vote_open_at: 0,
vote_end_at: 0,
votes: Vec::new(),
nft_id: String::new(),
entry_fee: ENTRY_FEE_SHELL,
};
ctx.state.set_node(&node);
Ok(())
}
/// Locks stake so the voter can cast a vote on the given node.
/// Stake is released when the voting period ends (approval or rejection).
pub fn execute_vote_register(
ctx: &ExecutionContext,
sender: &str,
nonce: u64,
fee: u64,
node_temp_id: &str,
) -> Result<(), VmError> {
let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
}
let node = ctx.state.get_node_by_temp(node_temp_id)
.ok_or_else(|| VmError::NodeNotFound(node_temp_id.to_string()))?;
if node.status != NodeStatus::VotingOpen {
return Err(VmError::NodeNotVoting);
}
// Check not already registered
if node.votes.iter().any(|v| v.voter == sender) {
return Err(VmError::AlreadyVoted);
}
let total = VOTE_LOCK_SHELL.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?;
let balance = ctx.state.get_balance(sender);
if balance < total {
return Err(VmError::InsufficientBalance { need: total, have: balance });
}
ctx.state.set_balance(sender, balance - fee);
ctx.state.lock_stake(sender, VOTE_LOCK_SHELL, node.vote_end_at);
ctx.state.inc_nonce(sender);
Ok(())
}
/// Records the voter's choice. Weight computed from staked + PoN score.
/// Validates voter was registered (has a pending vote entry).
pub fn execute_vote_cast(
ctx: &ExecutionContext,
sender: &str,
nonce: u64,
fee: u64,
node_temp_id: &str,
approve: bool,
) -> Result<(), VmError> {
let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
}
let node = ctx.state.get_node_by_temp(node_temp_id)
.ok_or_else(|| VmError::NodeNotFound(node_temp_id.to_string()))?;
if node.status != NodeStatus::VotingOpen {
return Err(VmError::NodeNotVoting);
}
// Voter must have registered (stake locked) — check no existing approve/reject cast
let already_cast = node.votes.iter().any(|v| v.voter == sender && v.weight > 0.0);
if already_cast {
return Err(VmError::AlreadyVoted);
}
let balance = ctx.state.get_balance(sender);
if balance < fee {
return Err(VmError::InsufficientBalance { need: fee, have: balance });
}
let account = ctx.state.get_account(sender);
let weight = (account.staked as f64).sqrt() * account.pon_score;
ctx.state.set_balance(sender, balance - fee);
ctx.state.inc_nonce(sender);
ctx.state.record_vote(node_temp_id, WeightedVote { voter: sender.to_string(), approve, weight });
Ok(())
}
/// Stake or unstake NUT tokens.
pub fn execute_stake_op(
ctx: &ExecutionContext,
sender: &str,
nonce: u64,
fee: u64,
stake: bool,
amount: u64,
) -> Result<(), VmError> {
let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
}
let mut account = ctx.state.get_account(sender);
let total_fee_deduct = fee;
if stake {
let total = amount.checked_add(total_fee_deduct).ok_or(VmError::Unknown("overflow".into()))?;
if account.balance < total {
return Err(VmError::InsufficientBalance { need: total, have: account.balance });
}
account.balance -= total;
account.staked += amount;
} else {
if account.balance < total_fee_deduct {
return Err(VmError::InsufficientBalance { need: total_fee_deduct, have: account.balance });
}
if account.staked < amount {
return Err(VmError::InsufficientBalance { need: amount, have: account.staked });
}
account.balance -= total_fee_deduct;
account.staked -= amount;
account.balance += amount;
}
account.nonce += 1;
ctx.state.set_account(&account);
Ok(())
}