diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml index 312005a1..8f78aad5 100644 --- a/examples/rust/Cargo.toml +++ b/examples/rust/Cargo.toml @@ -13,6 +13,11 @@ path = "src/watch_account_receipts.rs" name = "watch_events" path = "src/watch_events.rs" +[[bin]] +name = "podctl" +path = "src/podctl.rs" + + [dependencies] anyhow = "1.0.95" futures = "0.3.31" @@ -22,4 +27,8 @@ pod-examples-solidity = { path = "../solidity/bindings" } tokio = { version = "1", features = ["full"] } env_logger = "*" hex = "0.4.3" +clap = { version = "4", features = ["derive"] } +serde_json = "1" + + diff --git a/examples/rust/src/podctl.rs b/examples/rust/src/podctl.rs new file mode 100644 index 00000000..cf68f6c7 --- /dev/null +++ b/examples/rust/src/podctl.rs @@ -0,0 +1,226 @@ +//! A minimal sample CLI for the Pod SDK demonstrating four commands: +//! - balance: read balance (wei) of an address +//! - transfer: send value using the ENV wallet +//! - committee: print the current committee +//! - logs: tail verifiable logs (requires WS RPC; optionally verify) +//! +//! Run from `examples/rust/`: +//! export POD_RPC_URL=https://rpc.v2.pod.network +//! # POD_PRIVATE_KEY only needed for `transfer` +//! +//! cargo run --bin podctl -- --help +//! cargo run --bin podctl -- balance 0x
+//! cargo run --bin podctl -- committee +//! cargo run --bin podctl -- transfer --to 0x --amount 1000 +//! export POD_RPC_URL=wss:// # required for logs +//! cargo run --bin podctl -- logs --address 0x --limit 3 + +use std::str::FromStr; + +use anyhow::{Result, anyhow}; +use clap::{Parser, Subcommand}; +use futures::StreamExt; + +use pod_sdk::{ + Address, LogFilterBuilder, Provider, U256, alloy_primitives::FixedBytes, + provider::PodProviderBuilder, +}; + +#[derive(Parser)] +#[command(name = "podctl", version, about = "Pod Network sample CLI")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Print balance (wei) of an address + Balance { address: String }, + + /// Transfer from ENV wallet to a recipient + Transfer { + /// Recipient address (0x + 40 hex) + #[arg(long)] + to: String, + /// Amount in wei (decimal string, e.g. 1000) + #[arg(long)] + amount: String, + }, + + /// Show current committee + Committee { + /// Print as JSON (default: true) + #[arg(long, default_value_t = true)] + json: bool, + }, + + /// Tail verifiable logs (requires WS RPC; optionally verify with committee) + Logs { + /// Contract address (0x + 40 hex) + #[arg(long)] + address: String, + /// topic0 (keccak256 signature, 0x + 64 hex), optional + #[arg(long)] + topic0: Option, + /// Verify each log against current committee + #[arg(long, default_value_t = false)] + verify: bool, + /// Print at most N logs (0 = infinite stream) + #[arg(long, default_value_t = 0)] + limit: usize, + }, +} + +/// Helper: strict address parsing with a friendly error. +fn parse_address(s: &str) -> Result
{ + Address::from_str(s).map_err(|_| anyhow!("Invalid address `{s}`. Expected: 0x + 40 hex chars.")) +} + +#[tokio::main] +async fn main() -> Result<()> { + // Parse CLI FIRST so `--help` exits before any env-required setup. + let cli = Cli::parse(); + + match cli.cmd { + // -------------------------- + // podctl balance
+ // -------------------------- + Cmd::Balance { address } => { + // Read-only provider: only needs POD_RPC_URL + let rpc_url = std::env::var("POD_RPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string()); + let provider = PodProviderBuilder::with_recommended_settings() + .on_url(&rpc_url) + .await + .map_err(|e| anyhow!("provider error: {e:?}"))?; + + let addr = parse_address(&address)?; + let wei = provider.get_balance(addr).await?; + println!("{wei}"); + } + + // ----------------------------------------------------- + // podctl transfer --to
--amount + // ----------------------------------------------------- + Cmd::Transfer { to, amount } => { + // Signing provider: requires POD_PRIVATE_KEY in ENV + let provider = PodProviderBuilder::with_recommended_settings() + .from_env() + .await + .map_err(|e| anyhow!("wallet/provider error: {e:?}"))?; + + let to = parse_address(&to)?; + let amt = U256::from_str(&amount) + .map_err(|_| anyhow!("Invalid amount `{amount}`. Use a decimal integer (wei)."))?; + + let receipt = provider + .transfer(to, amt) + .await + .map_err(|e| anyhow!("transfer error: {e:?}"))?; + + println!("{}", serde_json::to_string_pretty(&receipt)?); + } + + // ------------------------- + // podctl committee [--json] + // ------------------------- + Cmd::Committee { json } => { + // Read-only provider: only needs POD_RPC_URL + let rpc_url = std::env::var("POD_RPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string()); + let provider = PodProviderBuilder::with_recommended_settings() + .on_url(&rpc_url) + .await + .map_err(|e| anyhow!("provider error: {e:?}"))?; + + let committee = provider.get_committee().await?; + if json { + println!("{}", serde_json::to_string_pretty(&committee)?); + } else { + println!("{committee:#?}"); + } + } + + // ---------------------------------------------------------------- + // podctl logs --address 0x [--topic0 0x<64hex>] [--verify] + // ---------------------------------------------------------------- + Cmd::Logs { + address, + topic0, + verify, + limit, + } => { + // Require WebSocket RPC for subscriptions; bail early on HTTP. + let rpc_url = std::env::var("POD_RPC_URL").unwrap_or_default(); + let is_ws = rpc_url.starts_with("ws://") || rpc_url.starts_with("wss://"); + if !is_ws { + eprintln!( + "logs: WebSocket RPC required (ws:// or wss://). \ +Current POD_RPC_URL='{rpc_url}'. Use balance/committee/transfer with HTTP, \ +or switch to a WS endpoint to use logs." + ); + return Ok(()); + } + + // Read-only provider over the given WS URL + let provider = PodProviderBuilder::with_recommended_settings() + .on_url(&rpc_url) + .await + .map_err(|e| anyhow!("provider error: {e:?}"))?; + + // Validate address (should be a contract that emits events) + let addr = parse_address(&address)?; + + // Build filter + let mut builder = LogFilterBuilder::new().address(addr).min_attestations(1); + + if let Some(t0) = topic0 { + let t0_trim = t0.trim(); + let sig: FixedBytes<32> = t0_trim + .parse() + .map_err(|_| anyhow!("Invalid topic0 `{t0_trim}`. Expected 0x + 64 hex."))?; + builder = builder.event_signature(sig); + } + + if limit > 0 { + builder = builder.limit(limit); + } + + let filter = builder.build(); + + // Optionally fetch committee for verification + let maybe_committee = if verify { + Some(provider.get_committee().await?) + } else { + None + }; + + // Subscribe and stream + let sub = provider + .subscribe_verifiable_logs(&filter) + .await + .map_err(|e| anyhow!("subscribe error: {e:?}"))?; + let mut stream = sub.into_stream(); + + let mut count = 0usize; + while let Some(log) = stream.next().await { + if let Some(c) = &maybe_committee { + let verified = log.verify(c).is_ok(); // Result<(), E> -> bool + println!("verified={verified} log={log:#?}"); + } else { + println!("{log:#?}"); + } + + if limit > 0 { + count += 1; + if count >= limit { + break; + } + } + } + } + } + + Ok(()) +}