From 16a4999fdf511c178557b91a71019cb5f4f91e57730e0c4749b3cea067c13d1e Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Fri, 24 Apr 2026 15:10:39 +0300 Subject: [PATCH] feat(nft): add nft transfer and collection claim CLI commands - nu nft transfer --nft-id --to : signed NftTransfer tx - nu nft claim --nft-ids 0,1,11,115: signed CollectionClaim tx with comma-separated path Co-Authored-By: Claude Sonnet 4.6 --- src/commands/mod.rs | 1 + src/commands/nft.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 ++++ 3 files changed, 87 insertions(+) create mode 100644 src/commands/nft.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2f35406..454d892 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod wallet; pub mod node; +pub mod nft; pub mod story; pub mod genesis; diff --git a/src/commands/nft.rs b/src/commands/nft.rs new file mode 100644 index 0000000..f943f41 --- /dev/null +++ b/src/commands/nft.rs @@ -0,0 +1,80 @@ +use anyhow::Result; +use clap::Subcommand; +use k256::ecdsa::{signature::Signer, Signature, SigningKey}; +use sha2::{Digest, Sha256}; +use serde_json::json; + +use crate::{keystore, rpc::Client}; + +#[derive(Subcommand)] +pub enum NftCmd { + /// Transfer an NFT to another address + Transfer { + #[arg(long)] nft_id: String, + #[arg(long)] to: String, + #[arg(long, default_value = "default")] from: String, + }, + /// Claim an exclusive collection from a lineage of NFTs you own + Claim { + /// NFT ids forming the lineage path, from root to deepest (e.g. 0,1,11,115) + #[arg(long, value_delimiter = ',')] nft_ids: Vec, + #[arg(long, default_value = "default")] from: String, + }, +} + +pub async fn run(cmd: NftCmd, rpc: &Client) -> Result<()> { + match cmd { + NftCmd::Transfer { nft_id, to, from } => { + let (key, sender) = load_key(&from)?; + let account = rpc.call("nu_getAccount", vec![json!(sender)]).await?; + let nonce: u64 = account["nonce"].as_u64().unwrap_or(0); + + let payload = json!({ "NftTransfer": { "nft_id": nft_id, "to": to } }); + let result = send_tx(&key, &sender, nonce, payload, rpc).await?; + println!("Transferred NFT {nft_id}: {}", result["tx_id"].as_str().unwrap_or("?")); + } + NftCmd::Claim { nft_ids, from } => { + let (key, sender) = load_key(&from)?; + let account = rpc.call("nu_getAccount", vec![json!(sender)]).await?; + let nonce: u64 = account["nonce"].as_u64().unwrap_or(0); + + let payload = json!({ "CollectionClaim": { "nft_ids": nft_ids } }); + let result = send_tx(&key, &sender, nonce, payload, rpc).await?; + println!("Collection claimed: {}", result["tx_id"].as_str().unwrap_or("?")); + } + } + Ok(()) +} + +fn load_key(label: &str) -> Result<(SigningKey, String)> { + let dir = crate::config::keystore_dir(); + let path = dir.join(format!("{label}.key")); + let password = keystore::prompt_password(false)?; + let key_bytes = keystore::load_encrypted(&path, &password)?; + let key = SigningKey::from_bytes(key_bytes.as_slice().into())?; + let sender = hex::encode(key.verifying_key().to_sec1_bytes()); + Ok((key, sender)) +} + +async fn send_tx( + key: &SigningKey, + sender: &str, + nonce: u64, + payload: serde_json::Value, + rpc: &Client, +) -> Result { + let payload_bytes = serde_json::to_vec(&payload)?; + let tx_id = hex::encode(Sha256::digest(&payload_bytes)); + let sig: Signature = key.sign(tx_id.as_bytes()); + let sig_bytes = sig.to_bytes().to_vec(); + + let tx = json!({ + "tx_id": tx_id, + "sender": sender, + "nonce": nonce, + "fee": 0, + "sig": sig_bytes, + "payload": payload, + }); + Ok(rpc.call("nu_sendRawTx", vec![tx]).await?) +} diff --git a/src/main.rs b/src/main.rs index 60bca1b..216aba6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,11 @@ enum Command { #[command(subcommand)] action: commands::story::StoryCmd, }, + /// NFT operations + Nft { + #[command(subcommand)] + action: commands::nft::NftCmd, + }, /// Genesis operations (dev only) Genesis { #[command(subcommand)] @@ -50,6 +55,7 @@ async fn main() -> Result<()> { Command::Wallet { action } => commands::wallet::run(action).await?, Command::Node { action } => commands::node::run(action, &rpc).await?, Command::Story { action } => commands::story::run(action, &rpc).await?, + Command::Nft { action } => commands::nft::run(action, &rpc).await?, Command::Genesis{ action } => commands::genesis::run(action, &rpc).await?, }