Compare commits

..

5 commits

Author SHA256 Message Date
16a4999fdf feat(nft): add nft transfer and collection claim CLI commands
- nu nft transfer --nft-id <id> --to <addr>: 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 <noreply@anthropic.com>
2026-04-24 15:10:39 +03:00
e6616d1cf9 chore: update CHANGELOG for v0.4.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:03:41 +03:00
5d4a4c97c1 feat(node): implement stake/unstake/register commands with tx signing
- NodeCmd::Stake/Unstake: sends StakeOp tx signed with keystore key
- NodeCmd::Register: sends ValidatorRegister tx
- Extract load_key() and send_tx() local helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:03:14 +03:00
2cbbcb00f0 feat(story): implement submit/register/vote commands with tx signing
- NodeSubmit: generates UUID temp_id, signs and broadcasts via nu_sendRawTx
- VoteRegister: locks 10,000 Shell stake for node voting
- VoteCast: signs approve/reject vote
- Extract load_key() and send_tx() helpers shared across story commands
- Add uuid and sha2 dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:02:31 +03:00
2335932753 feat(wallet): implement wallet send with k256 signing
- Loads encrypted keystore, derives sender address from verifying key
- Fetches current nonce via nu_getAccount RPC
- Builds TokenTransfer payload, signs SHA-256(tx_id) with secp256k1
- Broadcasts via nu_sendRawTx, prints returned tx_id
- Add sha2 dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:58:03 +03:00
9 changed files with 301 additions and 18 deletions

View file

@ -7,6 +7,17 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
## [0.4.0] — 2026-04-24
### Added
- `nu wallet send --to <addr> --amount <shell>`: k256 imzalı TokenTransfer tx gönderir
- `nu story submit`: NodeSubmit tx; UUID temp_id üretir, entry_fee=25_000 Shell
- `nu story register`: VoteRegister tx; stake_lock=10_000 Shell
- `nu story vote --node-id <id> --approve <bool>`: VoteCast tx gönderir
- `nu node stake / unstake`: StakeOp tx gönderir
- `nu node register --stake <amount>`: ValidatorRegister tx gönderir
- Tüm tx-gönderme komutlarında: keystore'dan key yükle → nonce'ı RPC'den al → SHA-256(payload) imzala → nu_sendRawTx
## [0.3.0] — 2026-04-24
### Added

13
Cargo.lock generated
View file

@ -1034,7 +1034,9 @@ dependencies = [
"rpassword",
"serde",
"serde_json",
"sha2",
"tokio",
"uuid",
]
[[package]]
@ -1908,6 +1910,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"

View file

@ -21,3 +21,5 @@ dirs = "5"
argon2 = "0.5"
aes-gcm = "0.10"
rpassword = "7"
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }

View file

@ -1,4 +1,5 @@
pub mod wallet;
pub mod node;
pub mod nft;
pub mod story;
pub mod genesis;

80
src/commands/nft.rs Normal file
View file

@ -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<String>,
#[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<serde_json::Value> {
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?)
}

View file

@ -1,7 +1,9 @@
use anyhow::Result;
use clap::Subcommand;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use sha2::{Digest, Sha256};
use serde_json::json;
use crate::rpc::Client;
use crate::{keystore, rpc::Client};
#[derive(Subcommand)]
pub enum NodeCmd {
@ -15,13 +17,20 @@ pub enum NodeCmd {
Account {
address: String,
},
/// Register as validator (stub — Faz 1)
/// Stake Shell tokens (StakeOp)
Stake {
#[arg(long)] amount: u64,
#[arg(long, default_value = "default")] from: String,
},
/// Unstake from validator set (stub — Faz 1)
/// Unstake Shell tokens (StakeOp)
Unstake {
#[arg(long)] amount: u64,
#[arg(long, default_value = "default")] from: String,
},
/// Register as a validator (ValidatorRegister)
Register {
#[arg(long)] stake: u64,
#[arg(long, default_value = "default")] from: String,
},
}
@ -49,8 +58,64 @@ pub async fn run(cmd: NodeCmd, rpc: &Client) -> Result<()> {
println!("Nonce : {}", account["nonce"].as_u64().unwrap_or(0));
println!("PoN : {}", account["pon_score"].as_f64().unwrap_or(1.0));
}
NodeCmd::Stake { amount } => println!("TODO: stake {amount} Shell (Faz 1)"),
NodeCmd::Unstake { amount } => println!("TODO: unstake {amount} Shell (Faz 1)"),
NodeCmd::Stake { amount, from } => {
let result = stake_op(rpc, &from, true, amount).await?;
println!("Staked {} Shell: {}", amount, result["tx_id"].as_str().unwrap_or("?"));
}
NodeCmd::Unstake { amount, from } => {
let result = stake_op(rpc, &from, false, amount).await?;
println!("Unstaked {} Shell: {}", amount, result["tx_id"].as_str().unwrap_or("?"));
}
NodeCmd::Register { stake, 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!({ "ValidatorRegister": { "stake": stake } });
let result = send_tx(&key, &sender, nonce, payload, rpc).await?;
println!("Registered as validator: {}", result["tx_id"].as_str().unwrap_or("?"));
}
}
Ok(())
}
async fn stake_op(rpc: &Client, label: &str, stake: bool, amount: u64) -> Result<serde_json::Value> {
let (key, sender) = load_key(label)?;
let account = rpc.call("nu_getAccount", vec![json!(sender)]).await?;
let nonce: u64 = account["nonce"].as_u64().unwrap_or(0);
let payload = json!({ "StakeOp": { "stake": stake, "amount": amount } });
send_tx(&key, &sender, nonce, payload, rpc).await
}
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<serde_json::Value> {
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?)
}

View file

@ -1,7 +1,11 @@
use anyhow::Result;
use clap::Subcommand;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use sha2::{Digest, Sha256};
use serde_json::json;
use crate::rpc::Client;
use uuid::Uuid;
use crate::{keystore, rpc::Client};
#[derive(Subcommand)]
pub enum StoryCmd {
@ -20,20 +24,23 @@ pub enum StoryCmd {
},
/// List nodes currently open for voting
Pending,
/// Submit a new story node (stub — requires wallet integration)
/// Submit a new story node
Submit {
#[arg(long)] story_id: String,
#[arg(long)] parent_id: String,
#[arg(long)] ipfs_hash: String,
#[arg(long, default_value = "default")] from: String,
},
/// Register to vote on a node (stub — Faz 1)
/// Register to vote on a node (locks stake)
Register {
#[arg(long)] node_id: String,
#[arg(long, default_value = "default")] from: String,
},
/// Cast a vote (stub — Faz 1)
/// Cast a vote on a node
Vote {
#[arg(long)] node_id: String,
#[arg(long)] approve: bool,
#[arg(long, default_value = "default")] from: String,
},
}
@ -55,15 +62,79 @@ pub async fn run(cmd: StoryCmd, rpc: &Client) -> Result<()> {
let result = rpc.call("nu_listPendingVotes", vec![]).await?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
StoryCmd::Submit { story_id, parent_id, ipfs_hash } => {
println!("TODO: submit node story={story_id} parent={parent_id} hash={ipfs_hash} (requires wallet — Faz 1)");
StoryCmd::Submit { story_id, parent_id, ipfs_hash, 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 temp_id = Uuid::new_v4().to_string();
let payload = json!({
"NodeSubmit": {
"story_id": story_id,
"parent_node_id": parent_id,
"content_hash": ipfs_hash,
"temp_id": temp_id,
"entry_fee": 25_000u64,
}
});
let result = send_tx(&key, &sender, nonce, payload, rpc).await?;
println!("Submitted: tx={} temp_id={temp_id}", result["tx_id"].as_str().unwrap_or("?"));
}
StoryCmd::Register { node_id } => {
println!("TODO: register vote for node={node_id} (Faz 1)");
StoryCmd::Register { node_id, 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!({
"VoteRegister": { "node_id": node_id, "stake_lock": 10_000u64 }
});
let result = send_tx(&key, &sender, nonce, payload, rpc).await?;
println!("Registered: {}", result["tx_id"].as_str().unwrap_or("?"));
}
StoryCmd::Vote { node_id, approve } => {
println!("TODO: vote node={node_id} approve={approve} (Faz 1)");
StoryCmd::Vote { node_id, approve, 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!({
"VoteCast": { "node_id": node_id, "approve": approve }
});
let result = send_tx(&key, &sender, nonce, payload, rpc).await?;
println!("Vote cast: {}", 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<serde_json::Value> {
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?)
}

View file

@ -1,7 +1,8 @@
use anyhow::Result;
use clap::Subcommand;
use k256::ecdsa::SigningKey;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use crate::keystore;
@ -17,7 +18,7 @@ pub enum WalletCmd {
#[arg(long, default_value = "default")]
label: String,
},
/// Send NUT tokens (stub — Faz 1 tx signing)
/// Send Shell tokens to another address
Send {
#[arg(long)] to: String,
#[arg(long)] amount: u64,
@ -57,7 +58,40 @@ pub async fn run(cmd: WalletCmd) -> Result<()> {
}
WalletCmd::Send { to, amount, from } => {
println!("TODO: nu wallet send --to {to} --amount {amount} Shell (from: {from}) — Faz 1");
let rpc_url = std::env::var("NU_RPC_URL")
.unwrap_or_else(|_| "http://localhost:8545".to_string());
let client = crate::rpc::Client::new(&rpc_url);
let dir = crate::config::keystore_dir();
let path = dir.join(format!("{from}.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());
let account = client.call("nu_getAccount", vec![serde_json::json!(sender)]).await?;
let nonce: u64 = account["nonce"].as_u64().unwrap_or(0);
let payload = serde_json::json!({
"TokenTransfer": { "to": to, "amount": amount }
});
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 = serde_json::json!({
"tx_id": tx_id,
"sender": sender,
"nonce": nonce,
"fee": 0,
"sig": sig_bytes,
"payload": payload,
});
let result = client.call("nu_sendRawTx", vec![tx]).await?;
println!("Sent: {}", result["tx_id"].as_str().unwrap_or("?"));
}
}
Ok(())

View file

@ -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?,
}