feat(vm): implement NodeSubmit, VoteRegister, VoteCast, StakeOp execution
This commit is contained in:
parent
a81b1851ce
commit
2c6db93043
5 changed files with 313 additions and 67 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ pub mod db;
|
|||
|
||||
pub use accessor::StateAccessor;
|
||||
pub use db::StateDb;
|
||||
pub use story_node::{NodeStatus, StoryNodeState, WeightedVote};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,21 @@
|
|||
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>,
|
||||
pub applied: u32,
|
||||
pub failed: u32,
|
||||
pub receipts: Vec<TxReceipt>,
|
||||
pub applied: u32,
|
||||
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;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for tx in &block.transactions {
|
||||
let receipt = execute_tx(tx, state, block.header.height, now_ms);
|
||||
|
|
@ -42,32 +38,53 @@ 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")))
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => TxReceipt { tx_id: tx.tx_id.clone(), success: true, error: String::new() },
|
||||
Ok(()) => TxReceipt { tx_id: tx.tx_id.clone(), success: true, error: String::new() },
|
||||
Err(e) => TxReceipt { tx_id: tx.tx_id.clone(), success: false, error: e.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_name(payload: &TxPayload) -> &'static str {
|
||||
match payload {
|
||||
TxPayload::TokenTransfer { .. } => "TokenTransfer",
|
||||
TxPayload::NodeSubmit { .. } => "NodeSubmit",
|
||||
TxPayload::VoteRegister { .. } => "VoteRegister",
|
||||
TxPayload::VoteCast { .. } => "VoteCast",
|
||||
TxPayload::NftTransfer { .. } => "NftTransfer",
|
||||
TxPayload::CollectionClaim { .. } => "CollectionClaim",
|
||||
TxPayload::StakeOp { .. } => "StakeOp",
|
||||
TxPayload::ValidatorRegister { .. }=> "ValidatorRegister",
|
||||
TxPayload::NodeApprove { .. } => "NodeApprove",
|
||||
TxPayload::NftMint { .. } => "NftMint",
|
||||
TxPayload::NodeReject { .. } => "NodeReject",
|
||||
TxPayload::VotingOpen { .. } => "VotingOpen",
|
||||
TxPayload::TokenTransfer { .. } => "TokenTransfer",
|
||||
TxPayload::NodeSubmit { .. } => "NodeSubmit",
|
||||
TxPayload::VoteRegister { .. } => "VoteRegister",
|
||||
TxPayload::VoteCast { .. } => "VoteCast",
|
||||
TxPayload::NftTransfer { .. } => "NftTransfer",
|
||||
TxPayload::CollectionClaim { .. } => "CollectionClaim",
|
||||
TxPayload::StakeOp { .. } => "StakeOp",
|
||||
TxPayload::ValidatorRegister { .. } => "ValidatorRegister",
|
||||
TxPayload::NodeApprove { .. } => "NodeApprove",
|
||||
TxPayload::NftMint { .. } => "NftMint",
|
||||
TxPayload::NodeReject { .. } => "NodeReject",
|
||||
TxPayload::VotingOpen { .. } => "VotingOpen",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue