diff --git a/crates/nu-state/src/accessor.rs b/crates/nu-state/src/accessor.rs index 92c8c56..b6a1ba0 100644 --- a/crates/nu-state/src/accessor.rs +++ b/crates/nu-state/src/accessor.rs @@ -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; + fn get_node(&self, node_id: &str) -> Option; + 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::(&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::(&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::(&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 { + let key = format!("node_temp:{temp_id}"); + self.get::(&key).ok().flatten() } - fn inc_nonce(&self, address: &str) { - let key = format!("account:{address}"); - let mut account = self - .get::(&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 { + let key = format!("node:{node_id}"); + self.get::(&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); + } } } diff --git a/crates/nu-state/src/lib.rs b/crates/nu-state/src/lib.rs index 43777b9..661f21b 100644 --- a/crates/nu-state/src/lib.rs +++ b/crates/nu-state/src/lib.rs @@ -6,3 +6,4 @@ pub mod db; pub use accessor::StateAccessor; pub use db::StateDb; +pub use story_node::{NodeStatus, StoryNodeState, WeightedVote}; diff --git a/crates/nu-vm/src/engine.rs b/crates/nu-vm/src/engine.rs index b2e710d..e730995 100644 --- a/crates/nu-vm/src/engine.rs +++ b/crates/nu-vm/src/engine.rs @@ -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, - pub applied: u32, - pub failed: u32, + pub receipts: Vec, + 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", } } diff --git a/crates/nu-vm/src/errors.rs b/crates/nu-vm/src/errors.rs index 6dd98b1..b88cebe 100644 --- a/crates/nu-vm/src/errors.rs +++ b/crates/nu-vm/src/errors.rs @@ -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), } diff --git a/crates/nu-vm/src/executor.rs b/crates/nu-vm/src/executor.rs index df03301..ba9af9d 100644 --- a/crates/nu-vm/src/executor.rs +++ b/crates/nu-vm/src/executor.rs @@ -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(()) +}