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(())
+}
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..d9adbbad
--- /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);
+ 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);
+ }
+}