From 6cf523fe06eed340ae93215f25e3c1923edf6885 Mon Sep 17 00:00:00 2001 From: abhicris Date: Tue, 21 Apr 2026 06:45:17 +0530 Subject: [PATCH] feat: agent-deposit command + GitHub Actions CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a first-class `agent-deposit` verb for Create Protocol AgentDeposit on Arbitrum so an AI agent can introspect and fund itself from a single binary: arbitrum-cli agent-deposit
--action balance|deposit|withdraw|registered Reads (balance, registered) go through eth_call. Writes return unsigned calldata — the CLI is strictly key-less; agents sign via switchboard or any wallet and broadcast with `exec eth_sendRawTransaction`. Contract address is resolved per-chain (Arbitrum One / Sepolia) with a one-line swap when Phase 1 lands on mainnet. Also wires up CI (fmt + check + clippy + test) since none existed. 11 unit tests cover calldata encoding, action parsing, chain resolution, and response shape. See README "Create Protocol agent-deposit" section for usage. --- .github/workflows/ci.yml | 46 ++++++ README.md | 26 ++- src/agent_deposit.rs | 340 +++++++++++++++++++++++++++++++++++++++ src/commands.rs | 85 +++++++++- src/main.rs | 41 +++++ 5 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/agent_deposit.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4fa92c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + check: + name: cargo check + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + - name: cargo check + run: cargo check --all-targets + + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: cargo test + run: cargo test --all-targets diff --git a/README.md b/README.md index bed56ab..232507c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ --- -**[Quick Start](#quick-start)** · **[Commands](#commands)** · **[Agent mode](#agent-mode)** · **[MCP](#mcp-server)** +**[Quick Start](#quick-start)** · **[Commands](#commands)** · **[Agent mode](#agent-mode)** · **[Create Protocol](#create-protocol-agent-deposit)** · **[MCP](#mcp-server)** --- @@ -60,6 +60,7 @@ arbitrum-cli block latest --human | `gas` | Current gas price + block number | | `watch blocks` | Stream new blocks (polling) | | `exec --params '[...]'` | Generic RPC passthrough | +| `agent-deposit
--action ...` | Create Protocol AgentDeposit — balance, deposit, withdraw, registered | | `mcp` | Start MCP server for AI agents | | `info` | List supported Arbitrum chains | @@ -78,6 +79,29 @@ arbitrum-cli exec eth_getLogs --params '[{"fromBlock":"latest","address":"0x..." Use `--human` for colored terminal output when you're debugging. +## Create Protocol agent-deposit + +Works out of the box with [Create Protocol](https://createprotocol.org) AgentDeposit on Arbitrum. Create Protocol is an AI agent economy where agents register, deposit USDC, execute tasks, and earn fees — `arbitrum-cli` is the tool an agent uses to see and move its own on-chain funds. + +```bash +# How much USDC does this agent have on deposit? +arbitrum-cli agent-deposit 0xC75020d5f669F5D15Afcb81b0e5F6d21bCDa9664 --action balance + +# Is this agent registered in Phase 1? +arbitrum-cli agent-deposit 0xC75020d5f669F5D15Afcb81b0e5F6d21bCDa9664 --action registered + +# Prepare an unsigned deposit tx (1 USDC = 1_000_000 raw, 6 decimals). +# The CLI never holds keys — sign + broadcast externally. +arbitrum-cli agent-deposit 0xC750... --action deposit --amount 1000000 + +# Same shape for withdraw +arbitrum-cli agent-deposit 0xC750... --action withdraw --amount 500000 +``` + +The AgentDeposit contract address is resolved automatically per chain (Arbitrum One, Arbitrum Sepolia). Override with `--contract 0x…` for forks or staging deployments. + +Phase 1 of Create Protocol is live on Sepolia with Arbitrum One redeployment imminent — see [createprotocol.org](https://createprotocol.org). + ## MCP server Expose arbitrum-cli as a [Model Context Protocol](https://modelcontextprotocol.io) server so Claude, Cursor, or any MCP-compatible agent can call Arbitrum directly. diff --git a/src/agent_deposit.rs b/src/agent_deposit.rs new file mode 100644 index 0000000..576d798 --- /dev/null +++ b/src/agent_deposit.rs @@ -0,0 +1,340 @@ +//! AgentDeposit — Create Protocol agent funding primitive. +//! +//! AgentDeposit is the core contract in [Create Protocol] Phase 1: agents +//! register, deposit USDC, spend it on compute/tasks, and the protocol +//! distributes fees. This module wraps the read-only surface (balance + agent +//! metadata) so an AI agent can introspect its own on-chain state using only +//! this CLI. +//! +//! Write actions (`deposit`, `withdraw`) currently return unsigned calldata — +//! agents sign with their own key/switchboard and broadcast via `exec +//! eth_sendRawTransaction` (or any wallet). This keeps the CLI read-safe by +//! default; no keys ever touch this binary. +//! +//! **Contract addresses.** AgentDeposit is deployed on Sepolia today; Arbitrum +//! One redeployment lands with Phase 1. To wire the real address, update the +//! [`agent_deposit_address`] match — it's a one-line swap per chain. +//! +//! [Create Protocol]: https://createprotocol.org + +use crate::rpc::{hex_to_u64, rpc_call}; +use eyre::{eyre, Result}; +use serde_json::{json, Value}; + +/// ABI selectors for the Create Protocol AgentDeposit contract. +/// +/// These match the deployed Sepolia ABI (`AgentDeposit.sol`). Kept as string +/// constants so they're easy to eyeball against an ABI file or a block +/// explorer. +pub mod selectors { + /// `balanceOf(address)` — USDC balance the agent has on deposit. + pub const BALANCE_OF: &str = "0x70a08231"; + /// `deposit(uint256)` — pull USDC from caller into the agent's balance. + pub const DEPOSIT: &str = "0xb6b55f25"; + /// `withdraw(uint256)` — push USDC back to caller. + pub const WITHDRAW: &str = "0x2e1a7d4d"; + /// `isRegistered(address)` — whether the address is a known agent. + pub const IS_REGISTERED: &str = "0xc3c5a547"; +} + +/// Resolve the AgentDeposit contract address for a given chain id. +/// +/// Returns `None` until Create Protocol Phase 1 lands on that chain. Swapping +/// in a real deployment is one line per arm. +/// +/// - Arbitrum One (42161): placeholder — Phase 1 deploy pending +/// - Arbitrum Sepolia (421614): placeholder — mirrors staging deploy +/// - All other chains: unsupported (AgentDeposit is Arbitrum-first) +pub fn agent_deposit_address(chain_id: u64) -> Option<&'static str> { + match chain_id { + // TODO(create-protocol): replace with real Arbitrum One deployment + 42161 => Some("0x0000000000000000000000000000000000000000"), + // TODO(create-protocol): replace with real Arbitrum Sepolia deployment + 421614 => Some("0x0000000000000000000000000000000000000000"), + _ => None, + } +} + +/// What the user asked us to do with the AgentDeposit contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Read: `balanceOf(agent)` — returns USDC on deposit (raw + decimal). + Balance, + /// Prepare unsigned calldata for `deposit(amount)`. + Deposit, + /// Prepare unsigned calldata for `withdraw(amount)`. + Withdraw, + /// Read: `isRegistered(agent)` — has the agent joined Phase 1 registry? + Registered, +} + +impl Action { + pub fn parse(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "balance" => Ok(Action::Balance), + "deposit" => Ok(Action::Deposit), + "withdraw" => Ok(Action::Withdraw), + "registered" | "is-registered" => Ok(Action::Registered), + other => Err(eyre!( + "unknown agent-deposit action '{}'. Try: balance | deposit | withdraw | registered", + other + )), + } + } +} + +/// Encode `selector(address)` calldata (one 32-byte word). +/// +/// Pads the 20-byte address into a 32-byte left-zero-padded word. Accepts +/// `0x…` and bare hex. Validates length + hex. +pub fn encode_address_call(selector: &str, address: &str) -> Result { + let addr_hex = address.trim_start_matches("0x"); + if addr_hex.len() != 40 { + return Err(eyre!( + "address must be 20 bytes (40 hex chars); got {} chars", + addr_hex.len() + )); + } + hex::decode(addr_hex).map_err(|e| eyre!("address is not valid hex: {}", e))?; + + let sel = selector.trim_start_matches("0x"); + if sel.len() != 8 { + return Err(eyre!( + "selector must be 4 bytes (8 hex chars); got {}", + sel.len() + )); + } + hex::decode(sel).map_err(|e| eyre!("selector is not valid hex: {}", e))?; + + Ok(format!("0x{}{:0>64}", sel, addr_hex.to_lowercase())) +} + +/// Encode `selector(uint256)` calldata. +/// +/// The amount is a raw on-chain integer (e.g., USDC has 6 decimals, so +/// $1.00 = 1_000_000). We keep the CLI honest — no hidden decimal scaling. +pub fn encode_uint256_call(selector: &str, amount: u128) -> Result { + let sel = selector.trim_start_matches("0x"); + if sel.len() != 8 { + return Err(eyre!( + "selector must be 4 bytes (8 hex chars); got {}", + sel.len() + )); + } + hex::decode(sel).map_err(|e| eyre!("selector is not valid hex: {}", e))?; + + Ok(format!("0x{}{:0>64x}", sel, amount)) +} + +/// Decode a 32-byte hex word as a u128 (fits USDC amounts up to ~3.4e20 raw). +pub fn decode_uint_result(hex_word: &str) -> Result { + let stripped = hex_word.trim_start_matches("0x"); + if stripped.is_empty() { + return Ok(0); + } + u128::from_str_radix(stripped, 16).map_err(|e| eyre!("invalid hex uint: {}", e)) +} + +/// Fetch the chain id from the RPC endpoint so we can resolve the right +/// AgentDeposit deployment without asking the user. +pub async fn fetch_chain_id(rpc: &str) -> Result { + let result = rpc_call(rpc, "eth_chainId", json!([])).await?; + let hex = result + .as_str() + .ok_or_else(|| eyre!("eth_chainId returned non-string"))?; + hex_to_u64(hex) +} + +/// Call `balanceOf(agent)` on AgentDeposit. Returns raw USDC (6 decimals). +pub async fn read_balance(rpc: &str, contract: &str, agent: &str) -> Result { + let data = encode_address_call(selectors::BALANCE_OF, agent)?; + let result = rpc_call( + rpc, + "eth_call", + json!([{"to": contract, "data": data}, "latest"]), + ) + .await?; + let raw = result.as_str().unwrap_or("0x0"); + decode_uint_result(raw) +} + +/// Call `isRegistered(agent)`. A non-zero return means the agent is in the +/// Phase 1 registry. +pub async fn read_registered(rpc: &str, contract: &str, agent: &str) -> Result { + let data = encode_address_call(selectors::IS_REGISTERED, agent)?; + let result = rpc_call( + rpc, + "eth_call", + json!([{"to": contract, "data": data}, "latest"]), + ) + .await?; + let raw = result.as_str().unwrap_or("0x0"); + Ok(decode_uint_result(raw).unwrap_or(0) != 0) +} + +/// Build the JSON response for the CLI `agent-deposit` command. Kept in this +/// module so the shape is guaranteed stable across commands.rs and tests. +pub fn format_balance_response( + agent: &str, + contract: &str, + chain_id: u64, + balance_raw: u128, +) -> Value { + // AgentDeposit settles in USDC — 6 decimals. Scaling stays local to + // presentation; the raw value is always the source of truth. + const USDC_DECIMALS: u32 = 6; + let balance_human = balance_raw as f64 / 10f64.powi(USDC_DECIMALS as i32); + json!({ + "agent": agent, + "contract": contract, + "chain_id": chain_id, + "action": "balance", + "balance_raw": balance_raw.to_string(), + "balance_usdc": balance_human, + }) +} + +/// Build the JSON response for an unsigned write (deposit / withdraw). +pub fn format_unsigned_tx( + action: &str, + agent: &str, + contract: &str, + chain_id: u64, + amount_raw: u128, + data: &str, +) -> Value { + json!({ + "agent": agent, + "contract": contract, + "chain_id": chain_id, + "action": action, + "amount_raw": amount_raw.to_string(), + "tx": { + "to": contract, + "from": agent, + "data": data, + "value": "0x0", + }, + "note": "Unsigned tx. Sign with your agent key (e.g., via kcolbchain switchboard) and broadcast via eth_sendRawTransaction.", + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_balance_of_call() { + let data = encode_address_call( + selectors::BALANCE_OF, + "0xC75020d5f669F5D15Afcb81b0e5F6d21bCDa9664", + ) + .expect("encode"); + assert_eq!( + data, + "0x70a08231000000000000000000000000c75020d5f669f5d15afcb81b0e5f6d21bcda9664" + ); + assert_eq!(data.len(), 2 + 8 + 64); + } + + #[test] + fn encodes_is_registered_call() { + let data = encode_address_call( + selectors::IS_REGISTERED, + "0x0000000000000000000000000000000000000001", + ) + .expect("encode"); + assert!(data.starts_with("0xc3c5a547")); + assert!(data.ends_with("0000000000000000000000000000000000000001")); + } + + #[test] + fn encodes_deposit_amount() { + // 1 USDC = 1_000_000 (6 decimals) + let data = encode_uint256_call(selectors::DEPOSIT, 1_000_000).expect("encode"); + assert_eq!( + data, + "0xb6b55f2500000000000000000000000000000000000000000000000000000000000f4240" + ); + } + + #[test] + fn encodes_withdraw_amount() { + let data = encode_uint256_call(selectors::WITHDRAW, 42).expect("encode"); + assert_eq!( + data, + "0x2e1a7d4d000000000000000000000000000000000000000000000000000000000000002a" + ); + } + + #[test] + fn rejects_bad_address_length() { + let err = encode_address_call(selectors::BALANCE_OF, "0xdeadbeef").unwrap_err(); + assert!(err.to_string().contains("20 bytes")); + } + + #[test] + fn rejects_bad_hex() { + let err = encode_address_call( + selectors::BALANCE_OF, + "0xZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + ) + .unwrap_err(); + assert!(err.to_string().to_lowercase().contains("hex")); + } + + #[test] + fn decodes_uint_result() { + assert_eq!(decode_uint_result("0x0").unwrap(), 0); + assert_eq!(decode_uint_result("0x").unwrap(), 0); + assert_eq!(decode_uint_result("0xf4240").unwrap(), 1_000_000); + } + + #[test] + fn action_parses_aliases() { + assert_eq!(Action::parse("balance").unwrap(), Action::Balance); + assert_eq!(Action::parse("Deposit").unwrap(), Action::Deposit); + assert_eq!(Action::parse("WITHDRAW").unwrap(), Action::Withdraw); + assert_eq!(Action::parse("registered").unwrap(), Action::Registered); + assert_eq!(Action::parse("is-registered").unwrap(), Action::Registered); + assert!(Action::parse("yeet").is_err()); + } + + #[test] + fn known_chains_resolve_address() { + // Both arms return Some — the value is placeholder until Phase 1, + // but the resolver contract must remain stable. + assert!(agent_deposit_address(42161).is_some()); + assert!(agent_deposit_address(421614).is_some()); + assert!(agent_deposit_address(1).is_none()); + } + + #[test] + fn balance_response_scales_usdc_decimals() { + let v = format_balance_response( + "0xC75020d5f669F5D15Afcb81b0e5F6d21bCDa9664", + "0x0000000000000000000000000000000000000000", + 42161, + 2_500_000, // 2.5 USDC raw + ); + assert_eq!(v["balance_raw"], "2500000"); + assert_eq!(v["balance_usdc"], 2.5); + assert_eq!(v["action"], "balance"); + } + + #[test] + fn unsigned_tx_response_has_expected_shape() { + let v = format_unsigned_tx( + "deposit", + "0xC75020d5f669F5D15Afcb81b0e5F6d21bCDa9664", + "0x0000000000000000000000000000000000000000", + 42161, + 1_000_000, + "0xb6b55f2500000000000000000000000000000000000000000000000000000000000f4240", + ); + assert_eq!(v["tx"]["value"], "0x0"); + assert_eq!(v["tx"]["to"], "0x0000000000000000000000000000000000000000"); + assert_eq!(v["amount_raw"], "1000000"); + assert!(v["note"].as_str().unwrap().contains("Unsigned")); + } +} diff --git a/src/commands.rs b/src/commands.rs index 3bef56e..985f3c6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,7 @@ +use crate::agent_deposit::{ + self, agent_deposit_address, encode_address_call, encode_uint256_call, fetch_chain_id, + format_balance_response, format_unsigned_tx, read_balance, read_registered, selectors, Action, +}; use crate::output::{emit, Mode}; use crate::rpc::{hex_to_u64, rpc_call, wei_hex_to_eth}; use eyre::{eyre, Result}; @@ -155,8 +159,8 @@ pub async fn watch(rpc: &str, target: &str, mode: Mode) -> Result<()> { // ── exec (generic RPC passthrough — agent-friendly) ── pub async fn exec(rpc: &str, method: &str, params: &str, mode: Mode) -> Result<()> { - let params_val: Value = serde_json::from_str(params) - .map_err(|e| eyre!("Invalid params JSON: {}", e))?; + let params_val: Value = + serde_json::from_str(params).map_err(|e| eyre!("Invalid params JSON: {}", e))?; let result = rpc_call(rpc, method, params_val).await?; let out = json!({ "method": method, @@ -194,6 +198,83 @@ pub fn info(mode: Mode) -> Result<()> { Ok(()) } +// ── agent-deposit (Create Protocol) ── +// +// Phase 1 of Create Protocol is an agent-economy on Arbitrum: agents register, +// deposit USDC, execute tasks, earn fees. This command gives the CLI a +// first-class verb for the AgentDeposit contract so an LLM can do the full +// loop (check balance → prepare deposit/withdraw tx) from one binary. +// +// Reads go through `eth_call` and return decoded JSON. Writes return unsigned +// calldata — the CLI is strictly key-less; agents sign with switchboard or +// any wallet and broadcast via `exec eth_sendRawTransaction`. +pub async fn agent_deposit( + rpc: &str, + address: &str, + action_str: &str, + amount: Option, + contract_override: Option<&str>, + mode: Mode, +) -> Result<()> { + let action = Action::parse(action_str)?; + + // Resolve contract: explicit override wins, else look up by chain id. We + // call eth_chainId either way so the emitted JSON records which chain + // the caller is actually talking to — avoids silent testnet/mainnet + // confusion when an agent is driving. + let chain_id = fetch_chain_id(rpc).await?; + let contract = match contract_override { + Some(c) => c.to_string(), + None => agent_deposit_address(chain_id) + .ok_or_else(|| { + eyre!( + "No AgentDeposit deployment registered for chain id {}. \ + Pass --contract
or use an Arbitrum RPC.", + chain_id + ) + })? + .to_string(), + }; + + match action { + Action::Balance => { + let raw = read_balance(rpc, &contract, address).await?; + let out = format_balance_response(address, &contract, chain_id, raw); + emit(mode, "Agent Deposit Balance", &out); + } + Action::Registered => { + let is_reg = read_registered(rpc, &contract, address).await?; + let out = json!({ + "agent": address, + "contract": contract, + "chain_id": chain_id, + "action": "registered", + "registered": is_reg, + }); + emit(mode, "Agent Registration", &out); + } + Action::Deposit | Action::Withdraw => { + let amt = amount.ok_or_else(|| { + eyre!("--amount is required for deposit/withdraw (raw units; USDC = 6 decimals)") + })?; + let (selector, label, action_tag) = match action { + Action::Deposit => (selectors::DEPOSIT, "Agent Deposit (unsigned)", "deposit"), + Action::Withdraw => (selectors::WITHDRAW, "Agent Withdraw (unsigned)", "withdraw"), + _ => unreachable!(), + }; + let data = encode_uint256_call(selector, amt)?; + let out = format_unsigned_tx(action_tag, address, &contract, chain_id, amt, &data); + emit(mode, label, &out); + } + } + + // Silence "unused import" if the module-private helpers change shape in + // the future — these re-exports document the full surface used here. + let _ = (agent_deposit::selectors::BALANCE_OF, encode_address_call); + + Ok(()) +} + // ── mcp (stub) ── pub async fn mcp(_rpc: &str, bind: &str) -> Result<()> { // MCP server stub — production version would expose tools via stdio or SSE diff --git a/src/main.rs b/src/main.rs index c3a0d39..0abcfe7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; +mod agent_deposit; mod commands; mod output; mod rpc; @@ -89,6 +90,30 @@ enum Commands { bind: String, }, + /// Interact with Create Protocol AgentDeposit on Arbitrum + /// + /// Read agent balance / registration state, or produce unsigned calldata + /// for deposit/withdraw (sign + broadcast externally — the CLI never + /// touches keys). + AgentDeposit { + /// Agent wallet address (the EOA that registers + deposits) + address: String, + + /// What to do: balance | deposit | withdraw | registered + #[arg(long, default_value = "balance")] + action: String, + + /// Amount in raw on-chain units (USDC = 6 decimals; 1 USDC = 1000000). + /// Required for deposit / withdraw. + #[arg(long)] + amount: Option, + + /// Override the AgentDeposit contract address (advanced; defaults to + /// the deployment registered for the connected chain). + #[arg(long)] + contract: Option, + }, + /// Print supported chains and info Info, } @@ -119,6 +144,22 @@ async fn main() -> eyre::Result<()> { commands::exec(&rpc_url, &method, ¶ms, out_mode).await? } Commands::Mcp { bind } => commands::mcp(&rpc_url, &bind).await?, + Commands::AgentDeposit { + address, + action, + amount, + contract, + } => { + commands::agent_deposit( + &rpc_url, + &address, + &action, + amount, + contract.as_deref(), + out_mode, + ) + .await? + } Commands::Info => commands::info(out_mode)?, }