363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
use crate::errors::VmError;
|
|
use crate::rewards::{community_split, genesis_split, SHELL_PER_NUT, NODE_REWARD_NUT};
|
|
use nu_state::{
|
|
nft::NftState,
|
|
story_node::{NodeStatus, StoryNodeState, WeightedVote},
|
|
validator::ValidatorState,
|
|
StateAccessor,
|
|
};
|
|
|
|
pub struct ExecutionContext<'a> {
|
|
pub state: &'a dyn StateAccessor,
|
|
pub block_height: u64,
|
|
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,
|
|
to: &str,
|
|
amount: u64,
|
|
fee: u64,
|
|
nonce: 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 balance = ctx.state.get_balance(sender);
|
|
let total = amount.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?;
|
|
if balance < total {
|
|
return Err(VmError::InsufficientBalance { need: total, have: balance });
|
|
}
|
|
|
|
let recipient_balance = ctx.state.get_balance(to);
|
|
ctx.state.set_balance(sender, balance - total);
|
|
ctx.state.set_balance(to, recipient_balance + amount);
|
|
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(())
|
|
}
|
|
|
|
/// 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(())
|
|
}
|