From 96b4dfa77bf793bc5b6fcce7cee29272502f0c53 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Sun, 1 Feb 2026 10:12:30 +0800 Subject: [PATCH] Initial setup: Rust CLI tool for blockchain node - clap 4 CLI with wallet, tx, mine, block, chain commands - reqwest HTTP client (REST API only, no lib dependency) - Colored output with owo-colors + comfy-table - Mining progress bar with indicatif --- .gitignore | 4 ++ Cargo.toml | 24 +++++++ README.md | 125 ++++++++++++++++++++++++++++++++++++ src/client.rs | 76 ++++++++++++++++++++++ src/commands/blocks.rs | 85 ++++++++++++++++++++++++ src/commands/mining.rs | 47 ++++++++++++++ src/commands/mod.rs | 36 +++++++++++ src/commands/node.rs | 56 ++++++++++++++++ src/commands/transaction.rs | 69 ++++++++++++++++++++ src/commands/wallet.rs | 53 +++++++++++++++ src/config.rs | 9 +++ src/display.rs | 28 ++++++++ src/error.rs | 1 + src/main.rs | 37 +++++++++++ 14 files changed, 650 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/client.rs create mode 100644 src/commands/blocks.rs create mode 100644 src/commands/mining.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/node.rs create mode 100644 src/commands/transaction.rs create mode 100644 src/commands/wallet.rs create mode 100644 src/config.rs create mode 100644 src/display.rs create mode 100644 src/error.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c326ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +*.swp +*.swo +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d5a1709 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "blockchain-cli" +version = "0.1.0" +edition = "2021" +authors = ["Alex"] +license = "MIT" +description = "CLI tool for interacting with the blockchain node via REST API" + +[[bin]] +name = "blockchain-cli" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +owo-colors = "4" +comfy-table = "7" +indicatif = "0.17" +anyhow = "1" +dirs = "6" +chrono = "0.4" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebcd645 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# blockchain-cli + +Rust command-line tool for interacting with the blockchain node. Communicates entirely via REST API - no direct library dependency on `blockchain-core`. + +## Architecture + +``` +blockchain-cli/ +└── src/ + ├── main.rs # Clap parsing + command dispatch + ├── commands/ + │ ├── mod.rs # Command enum (subcommands) + │ ├── wallet.rs # create, balance, list + │ ├── transaction.rs # send, list-pending + │ ├── mining.rs # mine (with progress bar) + │ ├── blocks.rs # list, get + │ └── node.rs # info, validate + ├── client.rs # NodeClient (reqwest HTTP wrapper) + ├── display.rs # Colored/formatted terminal output + ├── config.rs # Node URL, wallet directory + └── error.rs # CLI error types (anyhow) +``` + +## Commands + +``` +USAGE: + blockchain-cli [OPTIONS] + +COMMANDS: + wallet Wallet management + tx Transaction operations + mine Mine a new block + block Block explorer + chain Chain information + +OPTIONS: + --node Node URL [default: http://localhost:3000] + -h, --help Print help + -V, --version Print version +``` + +### Wallet + +```bash +# Create a new wallet (keypair saved locally) +blockchain-cli wallet create + +# Check balance +blockchain-cli wallet balance
+``` + +### Transactions + +```bash +# Send tokens +blockchain-cli tx send + +# View pending transactions +blockchain-cli tx pending +``` + +### Mining + +```bash +# Mine pending transactions (with progress indicator) +blockchain-cli mine +``` + +### Blocks + +```bash +# List recent blocks +blockchain-cli block list [--limit 10] + +# Get block details +blockchain-cli block get +``` + +### Chain + +```bash +# Chain info (height, difficulty, latest hash) +blockchain-cli chain info + +# Validate entire chain +blockchain-cli chain validate +``` + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| CLI framework | clap 4 (derive) | Industry standard, excellent UX | +| HTTP client | reqwest | Async, well-maintained | +| Output | owo-colors + comfy-table | Colored text + formatted tables | +| Progress | indicatif | Mining progress bar | +| Errors | anyhow | Convenient error chaining for CLI | +| Node coupling | REST API only | Fully decoupled from core lib | + +## Quick Start + +```bash +# Build +cargo build --release + +# Start blockchain-node first (see blockchain-core repo) +# Then use the CLI: +blockchain-cli chain info +blockchain-cli wallet create +blockchain-cli mine +``` + +## Configuration + +The CLI looks for wallets in `~/.blockchain-cli/wallets/`. Node URL defaults to `http://localhost:3000` and can be overridden with `--node`. + +## Related Repos + +- [blockchain-core](../blockchain-core) - Core library + REST API node +- [blockchain-flutter](../blockchain-flutter) - Flutter mobile/web app + +## License + +MIT diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..0ac4322 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; +use serde::de::DeserializeOwned; + +/// HTTP client wrapper for the blockchain node API. +pub struct NodeClient { + base_url: String, + client: reqwest::Client, +} + +impl NodeClient { + pub fn new(base_url: &str) -> Self { + NodeClient { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + } + } + + pub async fn get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .client + .get(&url) + .send() + .await + .context("failed to connect to node")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("API error {}: {}", status, body); + } + + resp.json().await.context("failed to parse response") + } + + pub async fn post( + &self, + path: &str, + body: &B, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .client + .post(&url) + .json(body) + .send() + .await + .context("failed to connect to node")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("API error {}: {}", status, body); + } + + resp.json().await.context("failed to parse response") + } + + pub async fn post_empty(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .client + .post(&url) + .send() + .await + .context("failed to connect to node")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("API error {}: {}", status, body); + } + + resp.json().await.context("failed to parse response") + } +} diff --git a/src/commands/blocks.rs b/src/commands/blocks.rs new file mode 100644 index 0000000..76d09fa --- /dev/null +++ b/src/commands/blocks.rs @@ -0,0 +1,85 @@ +use clap::Subcommand; + +use crate::client::NodeClient; +use crate::display; +use crate::error::Result; + +#[derive(Subcommand)] +pub enum BlockCommand { + /// List recent blocks + List { + /// Number of blocks to show + #[arg(short, long, default_value = "10")] + limit: usize, + }, + /// Get block by hash + Get { + /// Block hash + hash: String, + }, +} + +pub async fn execute(command: BlockCommand, client: &NodeClient) -> Result<()> { + match command { + BlockCommand::List { limit } => list(client, limit).await, + BlockCommand::Get { hash } => get(client, &hash).await, + } +} + +async fn list(client: &NodeClient, limit: usize) -> Result<()> { + let path = format!("/api/blocks?limit={}", limit); + let resp: serde_json::Value = client.get(&path).await?; + + let blocks = resp["blocks"].as_array().unwrap(); + display::print_header(&format!("Blocks ({} total)", resp["total"].as_u64().unwrap_or(0))); + + let rows: Vec<(&str, String)> = blocks + .iter() + .map(|b| { + let hash = b["header"]["hash"].as_str().unwrap_or("?"); + let index = b["header"]["index"].as_u64().unwrap_or(0); + let txs = b["transactions"].as_array().map(|a| a.len()).unwrap_or(0); + // Return static str by leaking - acceptable for CLI display + let label = format!("#{}", index); + let value = format!("{} ({} txs)", &hash[..16], txs); + (Box::leak(label.into_boxed_str()) as &str, value) + }) + .collect(); + + display::print_key_value_table(rows); + Ok(()) +} + +async fn get(client: &NodeClient, hash: &str) -> Result<()> { + let path = format!("/api/blocks/{}", hash); + let block: serde_json::Value = client.get(&path).await?; + + display::print_header("Block Details"); + display::print_info( + "Index", + &block["header"]["index"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info("Hash", block["header"]["hash"].as_str().unwrap_or("?")); + display::print_info( + "Previous", + block["header"]["previous_hash"].as_str().unwrap_or("?"), + ); + display::print_info( + "Nonce", + &block["header"]["nonce"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info( + "Difficulty", + &block["header"]["difficulty"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info( + "Transactions", + &block["transactions"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0) + .to_string(), + ); + + Ok(()) +} diff --git a/src/commands/mining.rs b/src/commands/mining.rs new file mode 100644 index 0000000..9fc1e81 --- /dev/null +++ b/src/commands/mining.rs @@ -0,0 +1,47 @@ +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::client::NodeClient; +use crate::display; +use crate::error::Result; + +pub async fn execute(client: &NodeClient, miner_address: &str) -> Result<()> { + display::print_header("Mining new block..."); + + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + pb.set_message("Mining in progress..."); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + + let body = serde_json::json!({ "miner_address": miner_address }); + let block: serde_json::Value = client.post("/api/mine", &body).await?; + + pb.finish_and_clear(); + + display::print_success("Block mined successfully!"); + display::print_info( + "Index", + &block["header"]["index"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info( + "Hash", + block["header"]["hash"].as_str().unwrap_or("?"), + ); + display::print_info( + "Nonce", + &block["header"]["nonce"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info( + "Transactions", + &block["transactions"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0) + .to_string(), + ); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..f4e3f84 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,36 @@ +pub mod blocks; +pub mod mining; +pub mod node; +pub mod transaction; +pub mod wallet; + +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Commands { + /// Wallet management + Wallet { + #[command(subcommand)] + command: wallet::WalletCommand, + }, + /// Transaction operations + Tx { + #[command(subcommand)] + command: transaction::TxCommand, + }, + /// Mine a new block + Mine { + /// Miner address to receive block reward + miner_address: String, + }, + /// Block explorer + Block { + #[command(subcommand)] + command: blocks::BlockCommand, + }, + /// Chain information + Chain { + #[command(subcommand)] + command: node::ChainCommand, + }, +} diff --git a/src/commands/node.rs b/src/commands/node.rs new file mode 100644 index 0000000..99a4b98 --- /dev/null +++ b/src/commands/node.rs @@ -0,0 +1,56 @@ +use clap::Subcommand; + +use crate::client::NodeClient; +use crate::display; +use crate::error::Result; + +#[derive(Subcommand)] +pub enum ChainCommand { + /// Display chain information + Info, + /// Validate the entire chain + Validate, +} + +pub async fn execute(command: ChainCommand, client: &NodeClient) -> Result<()> { + match command { + ChainCommand::Info => info(client).await, + ChainCommand::Validate => validate(client).await, + } +} + +async fn info(client: &NodeClient) -> Result<()> { + let info: serde_json::Value = client.get("/api/chain/info").await?; + + display::print_header("Chain Info"); + display::print_info("Length", &info["length"].as_u64().unwrap_or(0).to_string()); + display::print_info( + "Difficulty", + &info["difficulty"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info( + "Latest Hash", + info["latest_hash"].as_str().unwrap_or("?"), + ); + display::print_info( + "Block Reward", + &info["block_reward"].as_u64().unwrap_or(0).to_string(), + ); + + Ok(()) +} + +async fn validate(client: &NodeClient) -> Result<()> { + let result: serde_json::Value = client.post_empty("/api/chain/validate").await?; + + if result["valid"].as_bool().unwrap_or(false) { + display::print_success("Chain is valid!"); + } else { + display::print_error(&format!( + "Chain is invalid: {}", + result["error"].as_str().unwrap_or("unknown error") + )); + } + + Ok(()) +} diff --git a/src/commands/transaction.rs b/src/commands/transaction.rs new file mode 100644 index 0000000..2fde520 --- /dev/null +++ b/src/commands/transaction.rs @@ -0,0 +1,69 @@ +use clap::Subcommand; + +use crate::client::NodeClient; +use crate::display; +use crate::error::Result; + +#[derive(Subcommand)] +pub enum TxCommand { + /// Send tokens + Send { + /// Sender address + from: String, + /// Recipient address + to: String, + /// Amount to send + amount: u64, + }, + /// List pending transactions + Pending, +} + +pub async fn execute(command: TxCommand, client: &NodeClient) -> Result<()> { + match command { + TxCommand::Send { from, to, amount } => send(client, &from, &to, amount).await, + TxCommand::Pending => pending(client).await, + } +} + +async fn send(client: &NodeClient, from: &str, to: &str, amount: u64) -> Result<()> { + // Note: In a real implementation, the CLI would load the private key, + // sign the transaction locally, and send the signed tx. + // For now, this sends unsigned (the signing logic would need the wallet file). + let body = serde_json::json!({ + "from": from, + "to": to, + "amount": amount, + "nonce": 0, + "timestamp": chrono::Utc::now().timestamp_millis(), + "signature": "" + }); + + let tx: serde_json::Value = client.post("/api/transactions", &body).await?; + display::print_header("Transaction Submitted"); + display::print_info("ID", tx["id"].as_str().unwrap_or("?")); + display::print_info("From", from); + display::print_info("To", to); + display::print_info("Amount", &amount.to_string()); + + Ok(()) +} + +async fn pending(client: &NodeClient) -> Result<()> { + let txs: Vec = client.get("/api/transactions/pending").await?; + + display::print_header(&format!("Pending Transactions ({})", txs.len())); + for tx in &txs { + display::print_info( + tx["id"].as_str().unwrap_or("?"), + &format!( + "{} -> {} : {}", + tx["from"].as_str().unwrap_or("?"), + tx["to"].as_str().unwrap_or("?"), + tx["amount"].as_u64().unwrap_or(0) + ), + ); + } + + Ok(()) +} diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs new file mode 100644 index 0000000..e7ec388 --- /dev/null +++ b/src/commands/wallet.rs @@ -0,0 +1,53 @@ +use clap::Subcommand; + +use crate::client::NodeClient; +use crate::display; +use crate::error::Result; + +#[derive(Subcommand)] +pub enum WalletCommand { + /// Create a new wallet + Create, + /// Check wallet balance + Balance { + /// Wallet address + address: String, + }, +} + +pub async fn execute(command: WalletCommand, client: &NodeClient) -> Result<()> { + match command { + WalletCommand::Create => create(client).await, + WalletCommand::Balance { address } => balance(client, &address).await, + } +} + +async fn create(client: &NodeClient) -> Result<()> { + let wallet: serde_json::Value = client.post_empty("/api/wallets").await?; + + display::print_header("New Wallet Created"); + display::print_info("Address", wallet["address"].as_str().unwrap_or("?")); + if let Some(key) = wallet["signing_key_hex"].as_str() { + display::print_info("Private Key", key); + println!("\n {} Save this private key securely!", "⚠".yellow()); + } + + Ok(()) +} + +async fn balance(client: &NodeClient, address: &str) -> Result<()> { + let path = format!("/api/wallets/{}/balance", address); + let info: serde_json::Value = client.get(&path).await?; + + display::print_header("Wallet Balance"); + display::print_info("Address", address); + display::print_info( + "Balance", + &info["balance"].as_u64().unwrap_or(0).to_string(), + ); + display::print_info("Nonce", &info["nonce"].as_u64().unwrap_or(0).to_string()); + + Ok(()) +} + +use owo_colors::OwoColorize; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6cd1810 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,9 @@ +use std::path::PathBuf; + +pub const DEFAULT_NODE_URL: &str = "http://localhost:3000"; + +/// Get the wallet storage directory (~/.blockchain-cli/wallets/). +pub fn wallet_dir() -> PathBuf { + let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join(".blockchain-cli").join("wallets") +} diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..b76fa77 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,28 @@ +use comfy_table::{Cell, Table}; +use owo_colors::OwoColorize; + +pub fn print_header(text: &str) { + println!("\n{}", text.bold().cyan()); + println!("{}", "─".repeat(50).dimmed()); +} + +pub fn print_success(text: &str) { + println!("{} {}", "✓".green(), text); +} + +pub fn print_error(text: &str) { + println!("{} {}", "✗".red(), text); +} + +pub fn print_info(label: &str, value: &str) { + println!(" {}: {}", label.dimmed(), value); +} + +pub fn print_key_value_table(rows: Vec<(&str, String)>) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value"]); + for (key, value) in rows { + table.add_row(vec![Cell::new(key), Cell::new(value)]); + } + println!("{table}"); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..88e89cd --- /dev/null +++ b/src/error.rs @@ -0,0 +1 @@ +pub type Result = anyhow::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2327f9a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,37 @@ +mod client; +mod commands; +mod config; +mod display; +mod error; + +use clap::Parser; +use commands::Commands; + +#[derive(Parser)] +#[command(name = "blockchain-cli")] +#[command(about = "CLI tool for interacting with the blockchain node")] +#[command(version)] +struct Cli { + /// Node URL + #[arg(long, default_value = config::DEFAULT_NODE_URL)] + node: String, + + #[command(subcommand)] + command: Commands, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let client = client::NodeClient::new(&cli.node); + + match cli.command { + Commands::Wallet { command } => commands::wallet::execute(command, &client).await?, + Commands::Tx { command } => commands::transaction::execute(command, &client).await?, + Commands::Mine { miner_address } => commands::mining::execute(&client, &miner_address).await?, + Commands::Block { command } => commands::blocks::execute(command, &client).await?, + Commands::Chain { command } => commands::node::execute(command, &client).await?, + } + + Ok(()) +}