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>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 15:02:31 +03:00
parent 2335932753
commit 2cbbcb00f0
3 changed files with 94 additions and 10 deletions

12
Cargo.lock generated
View file

@ -1036,6 +1036,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tokio", "tokio",
"uuid",
] ]
[[package]] [[package]]
@ -1909,6 +1910,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 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]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View file

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

View file

@ -1,7 +1,11 @@
use anyhow::Result; use anyhow::Result;
use clap::Subcommand; use clap::Subcommand;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use sha2::{Digest, Sha256};
use serde_json::json; use serde_json::json;
use crate::rpc::Client; use uuid::Uuid;
use crate::{keystore, rpc::Client};
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum StoryCmd { pub enum StoryCmd {
@ -20,20 +24,23 @@ pub enum StoryCmd {
}, },
/// List nodes currently open for voting /// List nodes currently open for voting
Pending, Pending,
/// Submit a new story node (stub — requires wallet integration) /// Submit a new story node
Submit { Submit {
#[arg(long)] story_id: String, #[arg(long)] story_id: String,
#[arg(long)] parent_id: String, #[arg(long)] parent_id: String,
#[arg(long)] ipfs_hash: 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 { Register {
#[arg(long)] node_id: String, #[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 { Vote {
#[arg(long)] node_id: String, #[arg(long)] node_id: String,
#[arg(long)] approve: bool, #[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?; let result = rpc.call("nu_listPendingVotes", vec![]).await?;
println!("{}", serde_json::to_string_pretty(&result)?); println!("{}", serde_json::to_string_pretty(&result)?);
} }
StoryCmd::Submit { story_id, parent_id, ipfs_hash } => { StoryCmd::Submit { story_id, parent_id, ipfs_hash, from } => {
println!("TODO: submit node story={story_id} parent={parent_id} hash={ipfs_hash} (requires wallet — Faz 1)"); 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 } => { StoryCmd::Register { node_id, from } => {
println!("TODO: register vote for node={node_id} (Faz 1)"); 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 } => { StoryCmd::Vote { node_id, approve, from } => {
println!("TODO: vote node={node_id} approve={approve} (Faz 1)"); 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(()) 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?)
}