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 {
|
pub trait StateAccessor {
|
||||||
fn get_balance(&self, address: &str) -> u64;
|
// --- Account ---
|
||||||
fn get_nonce(&self, address: &str) -> u64;
|
fn get_account(&self, address: &str) -> AccountState;
|
||||||
fn set_balance(&self, address: &str, balance: u64);
|
fn set_account(&self, account: &AccountState);
|
||||||
fn inc_nonce(&self, address: &str);
|
|
||||||
|
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 {
|
impl StateAccessor for StateDb {
|
||||||
fn get_balance(&self, address: &str) -> u64 {
|
fn get_account(&self, address: &str) -> AccountState {
|
||||||
let key = format!("account:{address}");
|
let key = format!("account:{address}");
|
||||||
self.get::<AccountState>(&key)
|
self.get::<AccountState>(&key)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|a| a.balance)
|
.unwrap_or_else(|| AccountState::new(address.to_string()))
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_nonce(&self, address: &str) -> u64 {
|
fn set_account(&self, account: &AccountState) {
|
||||||
let key = format!("account:{address}");
|
let key = format!("account:{}", account.address);
|
||||||
self.get::<AccountState>(&key)
|
let _ = self.put(&key, account);
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|a| a.nonce)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_balance(&self, address: &str, balance: u64) {
|
fn get_node_by_temp(&self, temp_id: &str) -> Option<StoryNodeState> {
|
||||||
let key = format!("account:{address}");
|
let key = format!("node_temp:{temp_id}");
|
||||||
let mut account = self
|
self.get::<StoryNodeState>(&key).ok().flatten()
|
||||||
.get::<AccountState>(&key)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| AccountState::new(address.to_string()));
|
|
||||||
account.balance = balance;
|
|
||||||
let _ = self.put(&key, &account);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inc_nonce(&self, address: &str) {
|
fn get_node(&self, node_id: &str) -> Option<StoryNodeState> {
|
||||||
let key = format!("account:{address}");
|
let key = format!("node:{node_id}");
|
||||||
let mut account = self
|
self.get::<StoryNodeState>(&key).ok().flatten()
|
||||||
.get::<AccountState>(&key)
|
}
|
||||||
.ok()
|
|
||||||
.flatten()
|
fn set_node(&self, node: &StoryNodeState) {
|
||||||
.unwrap_or_else(|| AccountState::new(address.to_string()));
|
// Index by temp_id (always set) and canonical node_id (set after approval)
|
||||||
account.nonce += 1;
|
let temp_key = format!("node_temp:{}", node.temp_id);
|
||||||
let _ = self.put(&key, &account);
|
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 accessor::StateAccessor;
|
||||||
pub use db::StateDb;
|
pub use db::StateDb;
|
||||||
|
pub use story_node::{NodeStatus, StoryNodeState, WeightedVote};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
|
use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
|
||||||
use nu_state::StateAccessor;
|
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 struct BlockResult {
|
||||||
pub receipts: Vec<TxReceipt>,
|
pub receipts: Vec<TxReceipt>,
|
||||||
|
|
@ -9,14 +12,7 @@ pub struct BlockResult {
|
||||||
pub failed: u32,
|
pub failed: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes all transactions in a block against the given state.
|
pub fn execute_block(block: &Block, state: &dyn StateAccessor, now_ms: i64) -> BlockResult {
|
||||||
/// 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 {
|
|
||||||
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;
|
||||||
|
|
@ -42,10 +38,31 @@ fn execute_tx(
|
||||||
TxPayload::TokenTransfer { to, amount } => {
|
TxPayload::TokenTransfer { to, amount } => {
|
||||||
execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce)
|
execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce)
|
||||||
}
|
}
|
||||||
// Remaining variants return "not yet implemented" — applied in later Faz 1 tasks
|
TxPayload::NodeSubmit { story_id, parent_node_id, content_hash, temp_id, entry_fee: _ } => {
|
||||||
other => {
|
execute_node_submit(
|
||||||
let name = payload_name(other);
|
&ctx, &tx.sender, tx.nonce, tx.fee,
|
||||||
Err(crate::errors::VmError::Unknown(format!("{name} execution not yet implemented")))
|
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")))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,7 +81,7 @@ fn payload_name(payload: &TxPayload) -> &'static str {
|
||||||
TxPayload::NftTransfer { .. } => "NftTransfer",
|
TxPayload::NftTransfer { .. } => "NftTransfer",
|
||||||
TxPayload::CollectionClaim { .. } => "CollectionClaim",
|
TxPayload::CollectionClaim { .. } => "CollectionClaim",
|
||||||
TxPayload::StakeOp { .. } => "StakeOp",
|
TxPayload::StakeOp { .. } => "StakeOp",
|
||||||
TxPayload::ValidatorRegister { .. }=> "ValidatorRegister",
|
TxPayload::ValidatorRegister { .. } => "ValidatorRegister",
|
||||||
TxPayload::NodeApprove { .. } => "NodeApprove",
|
TxPayload::NodeApprove { .. } => "NodeApprove",
|
||||||
TxPayload::NftMint { .. } => "NftMint",
|
TxPayload::NftMint { .. } => "NftMint",
|
||||||
TxPayload::NodeReject { .. } => "NodeReject",
|
TxPayload::NodeReject { .. } => "NodeReject",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ pub enum VmError {
|
||||||
StakeTooLow,
|
StakeTooLow,
|
||||||
#[error("double spend detected")]
|
#[error("double spend detected")]
|
||||||
DoubleSpend,
|
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}")]
|
#[error("unknown: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
use crate::errors::VmError;
|
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 struct ExecutionContext<'a> {
|
||||||
pub state: &'a dyn StateAccessor,
|
pub state: &'a dyn StateAccessor,
|
||||||
|
|
@ -7,6 +11,11 @@ pub struct ExecutionContext<'a> {
|
||||||
pub now_ms: i64,
|
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(
|
pub fn execute_token_transfer(
|
||||||
ctx: &ExecutionContext,
|
ctx: &ExecutionContext,
|
||||||
sender: &str,
|
sender: &str,
|
||||||
|
|
@ -31,3 +40,170 @@ pub fn execute_token_transfer(
|
||||||
ctx.state.inc_nonce(sender);
|
ctx.state.inc_nonce(sender);
|
||||||
Ok(())
|
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