From ec355b40d2e35b7a2e576cd5a6bb75469eeb9389616176b431016aebadd65ac5 Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Fri, 24 Apr 2026 15:06:27 +0300 Subject: [PATCH] feat(vm): implement NftTransfer and CollectionClaim execution - NftTransfer: validates ownership, NFT not in collection, deducts fee, updates owner field + nft_ids on sender/recipient accounts - CollectionClaim: validates lineage with validate_lineage(), all NFTs owned by sender and unclaimed; sets collection_id on all NFTs in path - Engine: wire both variants; NftMint remains internal-only (returns error) Co-Authored-By: Claude Sonnet 4.6 --- crates/nu-vm/src/engine.rs | 18 +++--- crates/nu-vm/src/executor.rs | 103 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/crates/nu-vm/src/engine.rs b/crates/nu-vm/src/engine.rs index f5a27e9..0b0e136 100644 --- a/crates/nu-vm/src/engine.rs +++ b/crates/nu-vm/src/engine.rs @@ -2,8 +2,8 @@ use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt}; use nu_state::StateAccessor; use crate::executor::{ - execute_node_approve, execute_node_reject, execute_node_submit, - execute_stake_op, execute_token_transfer, execute_validator_register, + execute_collection_claim, execute_node_approve, execute_node_reject, execute_node_submit, + execute_nft_transfer, execute_stake_op, execute_token_transfer, execute_validator_register, execute_vote_cast, execute_vote_register, execute_voting_open, ExecutionContext, }; @@ -68,11 +68,15 @@ fn execute_tx( TxPayload::NodeReject { node_id } => { execute_node_reject(&ctx, node_id) } - // Faz 2 later - TxPayload::NftMint { .. } - | TxPayload::NftTransfer { .. } - | TxPayload::CollectionClaim { .. } => { - Err(crate::errors::VmError::Unknown(format!("{} not yet implemented", payload_name(&tx.payload)))) + TxPayload::NftTransfer { nft_id, to } => { + execute_nft_transfer(&ctx, &tx.sender, tx.nonce, tx.fee, nft_id, to) + } + TxPayload::CollectionClaim { nft_ids } => { + execute_collection_claim(&ctx, &tx.sender, tx.nonce, tx.fee, nft_ids) + } + // Minted automatically via NodeApprove — not user-submitted + TxPayload::NftMint { .. } => { + Err(crate::errors::VmError::Unknown("NftMint not a user tx".into())) } }; diff --git a/crates/nu-vm/src/executor.rs b/crates/nu-vm/src/executor.rs index 207372b..f40237e 100644 --- a/crates/nu-vm/src/executor.rs +++ b/crates/nu-vm/src/executor.rs @@ -335,6 +335,109 @@ pub fn execute_node_approve( Ok(()) } +/// Transfer an NFT from sender to recipient. +pub fn execute_nft_transfer( + ctx: &ExecutionContext, + sender: &str, + nonce: u64, + fee: u64, + nft_id: &str, + to: &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 mut nft = ctx.state.get_nft(nft_id) + .ok_or_else(|| VmError::Unknown(format!("NFT {nft_id} not found")))?; + + if nft.owner != sender { + return Err(VmError::Unknown(format!("sender does not own NFT {nft_id}"))); + } + if !nft.collection_id.is_empty() { + return Err(VmError::Unknown("NFT is part of a collection — cannot transfer".into())); + } + + let balance = ctx.state.get_balance(sender); + if balance < fee { + return Err(VmError::InsufficientBalance { need: fee, have: balance }); + } + + nft.owner = to.to_string(); + ctx.state.set_nft(&nft); + + // Update NFT id lists on both accounts + let mut sender_acct = ctx.state.get_account(sender); + sender_acct.nft_ids.retain(|id| id != nft_id); + sender_acct.balance -= fee; + sender_acct.nonce += 1; + ctx.state.set_account(&sender_acct); + + let mut recipient_acct = ctx.state.get_account(to); + recipient_acct.nft_ids.push(nft_id.to_string()); + ctx.state.set_account(&recipient_acct); + + Ok(()) +} + +/// Claim an exclusive collection from a valid lineage of NFTs. +/// All NFTs must be owned by sender and form a valid prefix-extension path. +pub fn execute_collection_claim( + ctx: &ExecutionContext, + sender: &str, + nonce: u64, + fee: u64, + nft_ids: &[String], +) -> Result<(), VmError> { + use nu_state::nft::validate_lineage; + + let expected_nonce = ctx.state.get_nonce(sender); + if nonce != expected_nonce { + return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce }); + } + if nft_ids.is_empty() { + return Err(VmError::Unknown("nft_ids cannot be empty".into())); + } + if !validate_lineage(nft_ids) { + return Err(VmError::Unknown("NFT ids do not form a valid lineage".into())); + } + + let balance = ctx.state.get_balance(sender); + if balance < fee { + return Err(VmError::InsufficientBalance { need: fee, have: balance }); + } + + // Validate ownership and free status + let mut nfts = Vec::with_capacity(nft_ids.len()); + for id in nft_ids { + let nft = ctx.state.get_nft(id) + .ok_or_else(|| VmError::Unknown(format!("NFT {id} not found")))?; + if nft.owner != sender { + return Err(VmError::Unknown(format!("sender does not own NFT {id}"))); + } + if !nft.collection_id.is_empty() { + return Err(VmError::Unknown(format!("NFT {id} already in a collection"))); + } + nfts.push(nft); + } + + // collection_id = last nft_id in path (the deepest node) + let collection_id = nft_ids.last().unwrap().clone(); + + for mut nft in nfts { + nft.collection_id = collection_id.clone(); + ctx.state.set_nft(&nft); + } + + let mut account = ctx.state.get_account(sender); + account.balance -= fee; + account.nonce += 1; + ctx.state.set_account(&account); + + Ok(()) +} + /// Auto-tx: reject node, burn entry fee, unlock stakes. pub fn execute_node_reject( ctx: &ExecutionContext,