From 8dc6419beb4639cf6554d91c58e7ac246b62a620 Mon Sep 17 00:00:00 2001 From: JosephTran Date: Fri, 15 Aug 2025 02:57:14 +0000 Subject: [PATCH 1/3] examples(rust): add `podctl` CLI (balance/transfer/committee/logs) Adds a minimal CLI under examples/rust as a bin target. Features: balance, committee, transfer, logs (WS required; optional verify). Read-only cmds use on_url; transfer uses from_env. Logs detect HTTP and exit with a hint. Clippy/fmt clean. Follow-ups: HTTP eth_getLogs helper; extra subcommands. --- examples/rust/Cargo.toml | 9 ++ examples/rust/src/podctl.rs | 226 ++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 examples/rust/src/podctl.rs 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(()) +} From 26a8c0481f9034bbb35c29eee5c21ecbde9e7c74 Mon Sep 17 00:00:00 2001 From: JosephTran Date: Sun, 17 Aug 2025 05:04:43 +0200 Subject: [PATCH 2/3] feat: add AttestationVerifier and TimedAttestationVerifier, remove unfinished RegistryTimedAttestationVerifier --- .../src/verifier/AttestationVerifier.sol | 20 +++++++++ solidity-sdk/src/verifier/ExtendedECDSA.sol | 24 +++++++++++ solidity-sdk/src/verifier/PodRegistry.sol | 1 + .../src/verifier/TimedAttestationVerifier.sol | 20 +++++++++ solidity-sdk/test/AttestationVerifier.t.sol | 40 +++++++++++++++++ .../test/TimedAttestationVerifier.t.sol | 43 +++++++++++++++++++ 6 files changed, 148 insertions(+) create mode 100644 solidity-sdk/src/verifier/AttestationVerifier.sol create mode 100644 solidity-sdk/src/verifier/ExtendedECDSA.sol create mode 100644 solidity-sdk/src/verifier/TimedAttestationVerifier.sol create mode 100644 solidity-sdk/test/AttestationVerifier.t.sol create mode 100644 solidity-sdk/test/TimedAttestationVerifier.t.sol diff --git a/solidity-sdk/src/verifier/AttestationVerifier.sol b/solidity-sdk/src/verifier/AttestationVerifier.sol new file mode 100644 index 00000000..f58d7542 --- /dev/null +++ b/solidity-sdk/src/verifier/AttestationVerifier.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./ExtendedECDSA.sol"; + +contract AttestationVerifier { + using ExtendedECDSA for bytes32; + + /// @notice Verifies the attestation and returns true if valid + /// @param attestation Encoded as: abi.encode(signer, message, signature) + function verifyAttestation(bytes calldata attestation) external pure returns (bool) { + (address expectedSigner, bytes32 messageHash, bytes memory signature) = + abi.decode(attestation, (address, bytes32, bytes)); + + // Recover signer from message and signature + address recovered = messageHash.recover(signature); + + return recovered == expectedSigner; + } +} diff --git a/solidity-sdk/src/verifier/ExtendedECDSA.sol b/solidity-sdk/src/verifier/ExtendedECDSA.sol new file mode 100644 index 00000000..f9ab5d55 --- /dev/null +++ b/solidity-sdk/src/verifier/ExtendedECDSA.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ExtendedECDSA - Adds recover(bytes) support to Pod ECDSA library +library ExtendedECDSA { + function recover(bytes32 hash, bytes memory signature) internal pure returns (address) { + require(signature.length == 65, "ECDSA: invalid signature length"); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + + require(uint256(s) <= 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, "ECDSA: invalid s value"); + require(v == 27 || v == 28, "ECDSA: invalid v value"); + + return ecrecover(hash, v, r, s); + } +} diff --git a/solidity-sdk/src/verifier/PodRegistry.sol b/solidity-sdk/src/verifier/PodRegistry.sol index af4cf3ea..4fd35be1 100644 --- a/solidity-sdk/src/verifier/PodRegistry.sol +++ b/solidity-sdk/src/verifier/PodRegistry.sol @@ -61,4 +61,5 @@ contract PodRegistry is IPodRegistry, Ownable { function getFaultTolerance() external view returns (uint8) { return validatorCount / 3; } + } diff --git a/solidity-sdk/src/verifier/TimedAttestationVerifier.sol b/solidity-sdk/src/verifier/TimedAttestationVerifier.sol new file mode 100644 index 00000000..c6c88747 --- /dev/null +++ b/solidity-sdk/src/verifier/TimedAttestationVerifier.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./ExtendedECDSA.sol"; + +contract TimedAttestationVerifier { + using ExtendedECDSA for bytes32; + + /// @notice Verifies a timed attestation and returns true if valid and not expired + /// @param attestation Encoded as: abi.encode(signer, messageHash, signature, deadline) + function verifyTimedAttestation(bytes calldata attestation) external view returns (bool) { + (address signer, bytes32 messageHash, bytes memory signature, uint256 deadline) = + abi.decode(attestation, (address, bytes32, bytes, uint256)); + + require(block.timestamp <= deadline, "attestation expired"); + + address recovered = messageHash.recover(signature); + return recovered == signer; + } +} diff --git a/solidity-sdk/test/AttestationVerifier.t.sol b/solidity-sdk/test/AttestationVerifier.t.sol new file mode 100644 index 00000000..ad471c25 --- /dev/null +++ b/solidity-sdk/test/AttestationVerifier.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/verifier/AttestationVerifier.sol"; +import "../src/verifier/ECDSA.sol"; + +contract AttestationVerifierTest is Test { + AttestationVerifier verifier; + address signer; + uint256 privateKey; + + function setUp() public { + verifier = new AttestationVerifier(); + privateKey = 0xA11CE; + signer = vm.addr(privateKey); + } + + function testValidAttestation() public view { + bytes32 message = keccak256("Hello Pod"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, message); + bytes memory signature = abi.encodePacked(r, s, v); + + bytes memory attestation = abi.encode(signer, message, signature); + bool result = verifier.verifyAttestation(attestation); + + assertTrue(result, "Attestation should be valid"); + } + + function testInvalidSignature() public view { + bytes32 message = keccak256("Hello Pod"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey + 1, message); // sai signer + bytes memory signature = abi.encodePacked(r, s, v); + + bytes memory attestation = abi.encode(signer, message, signature); + bool result = verifier.verifyAttestation(attestation); + + assertFalse(result, "Should fail"); + } +} diff --git a/solidity-sdk/test/TimedAttestationVerifier.t.sol b/solidity-sdk/test/TimedAttestationVerifier.t.sol new file mode 100644 index 00000000..9279096a --- /dev/null +++ b/solidity-sdk/test/TimedAttestationVerifier.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/verifier/TimedAttestationVerifier.sol"; + +contract TimedAttestationVerifierTest is Test { + TimedAttestationVerifier verifier; + address signer; + uint256 privateKey; + + function setUp() public { + verifier = new TimedAttestationVerifier(); + privateKey = 0xA11CE; + signer = vm.addr(privateKey); + } + + function testValidAndNotExpired() public view { + bytes32 message = keccak256("timed msg"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, message); + bytes memory signature = abi.encodePacked(r, s, v); + + uint256 deadline = block.timestamp + 100; + bytes memory attestation = abi.encode(signer, message, signature, deadline); + + bool result = verifier.verifyTimedAttestation(attestation); + assertTrue(result); + } + + function testExpiredAttestation() public { + bytes32 message = keccak256("timed msg"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, message); + bytes memory signature = abi.encodePacked(r, s, v); + + uint256 deadline = block.timestamp; + vm.warp(block.timestamp + 1); // simulate time passed + + bytes memory attestation = abi.encode(signer, message, signature, deadline); + + vm.expectRevert("attestation expired"); + verifier.verifyTimedAttestation(attestation); + } +} From 2b2948132ec655c1dea91e9e75a0d6c21c934ffe Mon Sep 17 00:00:00 2001 From: Joseph Tran <115780760+Josephtran102@users.noreply.github.com> Date: Sun, 17 Aug 2025 10:15:43 +0700 Subject: [PATCH 3/3] Update AttestationVerifier.t.sol --- solidity-sdk/test/AttestationVerifier.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solidity-sdk/test/AttestationVerifier.t.sol b/solidity-sdk/test/AttestationVerifier.t.sol index ad471c25..d9adbbad 100644 --- a/solidity-sdk/test/AttestationVerifier.t.sol +++ b/solidity-sdk/test/AttestationVerifier.t.sol @@ -29,7 +29,7 @@ contract AttestationVerifierTest is Test { function testInvalidSignature() public view { bytes32 message = keccak256("Hello Pod"); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey + 1, message); // sai signer + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey + 1, message); bytes memory signature = abi.encodePacked(r, s, v); bytes memory attestation = abi.encode(signer, message, signature);