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 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 15:06:27 +03:00
parent 3711bc450c
commit ec355b40d2
2 changed files with 114 additions and 7 deletions

View file

@ -2,8 +2,8 @@ use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
use nu_state::StateAccessor; use nu_state::StateAccessor;
use crate::executor::{ use crate::executor::{
execute_node_approve, execute_node_reject, execute_node_submit, execute_collection_claim, execute_node_approve, execute_node_reject, execute_node_submit,
execute_stake_op, execute_token_transfer, execute_validator_register, execute_nft_transfer, execute_stake_op, execute_token_transfer, execute_validator_register,
execute_vote_cast, execute_vote_register, execute_voting_open, execute_vote_cast, execute_vote_register, execute_voting_open,
ExecutionContext, ExecutionContext,
}; };
@ -68,11 +68,15 @@ fn execute_tx(
TxPayload::NodeReject { node_id } => { TxPayload::NodeReject { node_id } => {
execute_node_reject(&ctx, node_id) execute_node_reject(&ctx, node_id)
} }
// Faz 2 later TxPayload::NftTransfer { nft_id, to } => {
TxPayload::NftMint { .. } execute_nft_transfer(&ctx, &tx.sender, tx.nonce, tx.fee, nft_id, to)
| TxPayload::NftTransfer { .. } }
| TxPayload::CollectionClaim { .. } => { TxPayload::CollectionClaim { nft_ids } => {
Err(crate::errors::VmError::Unknown(format!("{} not yet implemented", payload_name(&tx.payload)))) 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()))
} }
}; };

View file

@ -335,6 +335,109 @@ pub fn execute_node_approve(
Ok(()) 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. /// Auto-tx: reject node, burn entry fee, unlock stakes.
pub fn execute_node_reject( pub fn execute_node_reject(
ctx: &ExecutionContext, ctx: &ExecutionContext,