From 2cbbcb00f0fd84412d101be67027baf7763b1dac81e10f689a061aea343947b3 Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Fri, 24 Apr 2026 15:02:31 +0300 Subject: [PATCH] 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 --- Cargo.lock | 12 ++++++ Cargo.toml | 1 + src/commands/story.rs | 91 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 483384f..e396edb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "uuid", ] [[package]] @@ -1909,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" diff --git a/Cargo.toml b/Cargo.toml index aa9b56f..1571dac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ argon2 = "0.5" aes-gcm = "0.10" rpassword = "7" sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } diff --git a/src/commands/story.rs b/src/commands/story.rs index 3f398f2..5a19761 100644 --- a/src/commands/story.rs +++ b/src/commands/story.rs @@ -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 { + 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?) +}