diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index 026e3aa..0b36df4 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -17,35 +17,90 @@ limitations under the License. # Spraay Crypto Payments Agent -An AI agent that queries cryptocurrency data across 15 blockchains using the -[Spraay x402 gateway](https://gateway.spraay.app). The agent can check gateway -health, list supported chains and routes, look up wallet balances, and get -token prices - all through natural language. +An AI agent that executes cryptocurrency payments and queries blockchain data +across 15 chains using the [Spraay x402 gateway](https://gateway.spraay.app). +The agent can perform **batch payments to up to 200 recipients in one atomic +transaction**, check balances, get token prices, create escrow contracts, and +discover robotic task execution services. ## Overview -This example demonstrates how to build a crypto query agent using NeMo +This example demonstrates how to build a crypto payment agent using NeMo Agent Toolkit with custom tools that interact with the Spraay x402 protocol -gateway. The agent uses a ReAct pattern to reason about queries and execute -them via HTTP API calls. +gateway. The centerpiece is **batch payments** implementing the +[Batch Payments for Agents (BPA) 1.0](https://docs.spraay.app/bpa/1.0/) spec, +with free validation and estimation endpoints that make the demo fully runnable +in CI without any funded wallet. ### What is x402? The [x402 protocol](https://www.x402.org) enables AI agents to pay for API services using USDC micropayments over HTTP. When an agent calls a paid endpoint, the server returns HTTP 402 (Payment Required) with payment details. -The agent signs a USDC transaction, resends the request with payment proof, -and the server executes the operation. +The agent can optionally sign a USDC transaction, resend the request with +payment proof, and the server executes the operation. Without payment +credentials, an x402 client receives a payment quote instead of the result +(dry-run mode). ### Supported Chains -Base, Ethereum, Arbitrum, Polygon, BNB Chain, Avalanche, Solana, -Bitcoin, Stacks, Unichain, Plasma, BOB, Bittensor, Stellar, XRP Ledger. +**Primary:** Base, Ethereum, Solana + +**Multi-chain:** Arbitrum, Polygon, Optimism, BNB Chain, Avalanche, Bitcoin, +Stacks, Unichain, Plasma, BOB, Bittensor, Stellar, XRP Ledger + +## Tools + +All tools are registered in the `spraay` function group and share a single +gateway client. + +### Free Tools (No Payment Required) + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `spraay__health` | `GET /health` | Gateway health check | +| `spraay__routes` | `GET /.well-known/x402.json` | List all available routes (x402 discovery) | +| `spraay__chains` | `GET /free/chain-status` | List supported blockchains with status | +| `spraay__price` | `GET /free/prices?tokens=...` | Get current token prices | +| `spraay__batch_validate` | `POST /free/validate-batch` | Validate batch payment recipients | +| `spraay__batch_estimate` | `GET /free/estimate-batch` | Estimate gas + fees for batch payment | + +### Paid Tools (x402 Protocol — Require `EVM_PRIVATE_KEY`) + +The paid tools register **only when `EVM_PRIVATE_KEY` is set**. Without the key +they are not exposed to the agent at all, so this example is safe to run in CI +with no wallet. + +| Tool | Endpoint | Price | Description | +|------|----------|-------|-------------| +| `spraay__balance` | `GET /api/v1/balances` | $0.005 | Check token balances for any wallet | +| `spraay__batch_send` | `POST /api/v1/batch/execute` | $0.02 | Execute batch payment to up to 200 recipients | +| `spraay__escrow_create` | `POST /api/v1/escrow/create` | $0.10 | Create escrow contract with conditions | +| `spraay__rtp_discover` | `GET /api/v1/robots/list` | $0.005 | Discover RTP robots by capability/chain/price | + +**Tool registration modes:** +- **Free-only (default):** Without `EVM_PRIVATE_KEY` set, only the six free + tools above register. The paid tools are not exposed to the agent, so no + wallet, signing, or funds are involved — safe for CI and demos. To preview a + batch payment with zero funds, use the free `batch_validate` and + `batch_estimate` tools. +- **Full set (with key):** With `EVM_PRIVATE_KEY` set, the four paid tools + register alongside the free ones. Paid requests are then routed through the + [x402 SDK](https://pypi.org/project/x402/) payment transport: when the gateway + returns `402 Payment Required` (x402Version 2), the SDK signs an EIP-3009 + `TransferWithAuthorization` for USDC on Base (the `exact` scheme), attaches it + as the `X-PAYMENT` header, and retries. The gateway's facilitator settles the + USDC transfer on-chain and returns the real API result together with an + `X-PAYMENT-RESPONSE` header containing the settlement transaction hash + (surfaced in the tool output under `settlement`). **This moves real funds** — + `spraay__batch_send` broadcasts a real batch payment to every recipient in the + list. Preview with the free `batch_validate` / `batch_estimate` tools and test + with a single small recipient first. ## Prerequisites - Python 3.11+ -- [uv](https://docs.astral.sh/uv/) package manager +- NeMo Agent Toolkit >= 1.4.0 (`pip install nvidia-nat[langchain]`) - NVIDIA API key from [NVIDIA build portal](https://build.nvidia.com) ## Setup @@ -59,7 +114,7 @@ cd examples/spraay_crypto_payments 2. Install dependencies: ```bash -uv pip install -e . +pip install -e . ``` 3. Set environment variables: @@ -69,69 +124,265 @@ export NVIDIA_API_KEY= export SPRAAY_GATEWAY_URL=https://gateway.spraay.app # optional, this is the default ``` -## Running the Example +4. (Optional) To register and live-execute the paid tools, set your EVM private + key. Without it, only the six free tools register: -### Check gateway health +```bash +export EVM_PRIVATE_KEY= # NEVER commit or share this +``` + +**Security note:** The `EVM_PRIVATE_KEY` is only used to sign x402 payment +headers for USDC transfers on Base. It is never logged, echoed, or sent to any +service other than the blockchain. If not set, the paid tools are not +registered and only the free tools are available. + +## Sample Runs + +### 1. Free Query: Check Supported Chains ```bash nat run \ --config_file configs/config.yml \ - --input "Is the Spraay gateway healthy?" + --input "What blockchains does Spraay support?" +``` + +**Output:** + +```json +{ + "chains": { + "base": { + "chain": "base", + "name": "Base", + "chainId": 8453, + "status": "online" + }, + "ethereum": { + "chain": "ethereum", + "name": "Ethereum", + "chainId": 1, + "status": "online" + }, + "arbitrum": { + "chain": "arbitrum", + "name": "Arbitrum One", + "chainId": 42161, + "status": "online" + } + // ... additional chains omitted (Polygon, Optimism, Avalanche, + // BNB Chain, and others); the live gateway returns all supported chains + } +} ``` -### List supported chains +### 2. Free Query: Get Token Prices ```bash nat run \ --config_file configs/config.yml \ - --input "What blockchains does Spraay support?" + --input "What is the current price of ETH and USDC?" ``` -### Get token price +**Output:** + +```json +{ + "prices": { + "ETH": { + "usd": 1798.01 + }, + "USDC": { + "usd": 0.999902 + } + } +} +``` + +### 3. Free Batch Flow: Validate + Estimate a 3-Recipient USDC Batch + +This is the **headline demo** — fully runnable in CI with zero funds required. + +**Step 1: Validate** ```bash nat run \ --config_file configs/config.yml \ - --input "What is the current price of ETH on Base?" + --input 'Validate a batch payment: send USDC on base to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8:1.0, 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb:2.0, 0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199:3.0' ``` -## Expected Output - +**Output:** + +```json +{ + "valid": true, + "errors": [], + "warnings": [], + "summary": { + "chain": "base", + "token": "USDC", + "recipientCount": 3, + "uniqueAddresses": 3, + "totalAmount": 6.0, + "bpaVersion": "1.0" + } +} ``` -$ nat run --config_file configs/config.yml --input "Is the Spraay gateway healthy?" -Configuration Summary: --------------------- -Workflow Type: react_agent -Number of Functions: 5 -Number of LLMs: 1 +**Step 2: Estimate Gas + Fees** -Agent's thoughts: -Thought: The user wants to check if the Spraay gateway is healthy. -I should use the spraay__health tool. -Action: spraay__health -Action Input: check health +```bash +nat run \ + --config_file configs/config.yml \ + --input "Estimate gas for 3 recipients on base with USDC" +``` -Observation: { - "status": "ok", - "version": "3.6.0", - "uptime": "..." +**Output:** + +```json +{ + "estimate": { + "chain": "base", + "recipients": 3, + "protocolFeeBps": 30, + "estimatedGasUSD": 0.003, + "bpaVersion": "1.0" + }, + "note": "Protocol fee: 0.3% of total amount" } +``` + +### 4. (Optional) Live Mode — Moves Real Funds + +> ⚠️ **Live mode moves real money.** With `EVM_PRIVATE_KEY` set, `batch_send` +> broadcasts a real USDC batch payment to every recipient, and the x402 gateway +> fee ($0.02 for batch send) is charged as a real USDC transfer on Base. Use a +> dedicated wallet funded with only what you intend to spend. Test with a +> single small recipient first. + +To execute a batch payment for real, set a **funded** Base wallet key and run +the batch-send input directly (bypassing the agent's tool selection so exactly +one payment is attempted): + +```bash +export EVM_PRIVATE_KEY=0x... # Funded Base wallet; never commit or share this +nat run \ + --config_file configs/config.yml \ + --input 'Use the batch_send tool with this exact JSON: {"recipients":[{"to":"0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8","amount":"0.01"}],"token":"USDC","chain":"base","sender":"0xYOUR_FUNDED_WALLET_ADDRESS"}' +``` -Thought: The gateway is healthy and running. -Final Answer: Yes, the Spraay x402 gateway is healthy. It is running -version 3.6.0 and reporting an "ok" status. ------------------------------- -Workflow Result: ['Yes, the Spraay x402 gateway is healthy...'] +Set `sender` to the public address of the wallet derived from `EVM_PRIVATE_KEY` +(the batch tool otherwise fills in a zero-address placeholder that is only valid +for dry-run quotes). + +What happens under the hood: + +1. The gateway responds `402 Payment Required` (x402Version 2) with the USDC + `exact` payment terms on Base (`eip155:8453`). +2. The x402 SDK signs an EIP-3009 `TransferWithAuthorization` for the fee using + your key, resends the request with the `X-PAYMENT` header, and the gateway's + facilitator settles it on-chain. +3. The tool returns the settled API result plus a `settlement` object with the + transaction hash. + +If the wallet holds insufficient USDC, the gateway rejects the payment and the +tool returns a structured `mode: live` error explaining why — no partial charge. + +**Requirements for a successful live run:** + +- The paying wallet (derived from `EVM_PRIVATE_KEY`) must hold USDC on Base to + cover both the gateway fee and the amounts being sent to recipients. +- Install the live-mode dependencies (included by `pip install -e .`, which + pulls `x402[evm,httpx]`). + +#### Deterministic first live test (no agent, no LLM) + +For the most predictable first live run, use the standalone `batch_send` smoke +script, which lives in the Spraay gateway repo: +[`scripts/live_batch_send_smoke.py`](https://github.com/plagtech/spraay-x402-gateway/blob/main/scripts/live_batch_send_smoke.py). +It drives `batch_send` directly through the client — no ReAct agent, no +`NVIDIA_API_KEY`, and exactly one payment attempt — and requires you to type +`yes` before moving funds. It derives `sender` from your key automatically, +prints a summary, prompts for confirmation (skip with `--yes`), and reports the +settlement transaction hash. Run it first with no key (dry-run quote), then with +a funded `EVM_PRIVATE_KEY`. See its `--help` for multi-recipient +(`--to addr:amount`), token, chain, and gateway options. + +#### Verified live run + +A real run of this smoke test against the production gateway +(`gateway.spraay.app`) — the flat batch payload parsed correctly and the x402 +gateway fee settled on the Base production network (`eip155:8453`): + +```text +================================================================ +Spraay batch_send smoke test +================================================================ + mode : LIVE (moves real funds) + gateway : https://gateway.spraay.app + token/chain : USDC on base + sender : 0x6dc474a4EC7Bc5eA509755179317F3d95B93dc91 + recipients : 1 + -> 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 0.33 USDC + total send : 0.33 USDC (plus the $0.02 x402 gateway fee) +================================================================ +This will move REAL funds. Type 'yes' to proceed: yes + +Submitting batch to https://gateway.spraay.app/api/v1/batch/execute ... + +{ + "mode": "live", + "result": { + "success": true, + "contract": "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC", + "token": { + "symbol": "USDC", + "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "decimals": 6, + "isETH": false + }, + "batch": { + "recipientCount": 1, + "totalAmount": "0.33", + "fee": "0.00099", + "feePercent": "0.3%", + "totalWithFee": "0.33099" + }, + "transaction": { + "to": "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC", + "data": "0xfb83b683000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000ad62f03c7514bb8c51f1ea70c2b75c37404695c80000000000000000000000000000000000000000000000000000000000050910", + "value": "0", + "chainId": 8453 + }, + "approvalRequired": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "spender": "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC", + "amount": "330990", + "amountFormatted": "0.33099" + } + }, + "settlement": { + "success": true, + "payer": "0x6dc474a4EC7Bc5eA509755179317F3d95B93dc91", + "transaction": "0x4a3fdb079beb6b87ca0798b4e6be98496f45fffaed8ed0227987e0c1af54fdd7", + "network": "eip155:8453" + } +} ``` +Settlement transaction hash (verify on-chain at `basescan.org`): +`0x4a3fdb079beb6b87ca0798b4e6be98496f45fffaed8ed0227987e0c1af54fdd7` +(Base, `eip155:8453`) + +The `$0.02` fee is the x402 charge for `/api/v1/batch/execute`; the batch itself +is returned as non-custodial `calldata` for the sender to broadcast. + ## Architecture ``` NeMo Agent Toolkit | v -ReAct Agent (Llama 3.1) +ReAct Agent (Llama 3.1 70B) | v spraay function group (shared client) @@ -139,17 +390,23 @@ spraay function group (shared client) +-- spraay__health +-- spraay__routes +-- spraay__chains - +-- spraay__balance +-- spraay__price + +-- spraay__batch_validate (FREE) + +-- spraay__batch_estimate (FREE) + +-- spraay__batch_send (PAID $0.02) + +-- spraay__balance (PAID $0.005) + +-- spraay__escrow_create (PAID $0.10) + +-- spraay__rtp_discover (PAID $0.005) | v -HTTP + x402 +HTTP + x402 Protocol | v Spraay x402 Gateway (gateway.spraay.app) - - 84+ paid endpoints + - 190+ endpoints - 15 blockchains - USDC micropayments + - BPA 1.0 batch payments ``` ## Files @@ -158,13 +415,23 @@ Spraay x402 Gateway (gateway.spraay.app) |------|-------------| | `configs/config.yml` | NeMo Agent Toolkit workflow configuration | | `src/spraay_crypto_payments/register.py` | Tool registration with `@register_function_group` | -| `src/spraay_crypto_payments/spraay_client.py` | Async HTTP client for the Spraay gateway | +| `src/spraay_crypto_payments/spraay_client.py` | Async HTTP client with x402 support | | `src/spraay_crypto_payments/__init__.py` | Package init | | `pyproject.toml` | Project dependencies and NAT entry points | +## Related Examples + +This example builds on the repository's existing x402 integration work: + +- **[x402_payment_tool](../x402_payment_tool/)** (PR #17): Generic x402 + payment negotiation tool where an agent autonomously evaluates a 402 response + and executes payment. The current example differs by providing domain-specific + tools (batch payments, escrow, RTP) rather than generic x402 handling. + ## Links - [Spraay Gateway Docs](https://docs.spraay.app) +- [Batch Payments for Agents (BPA) 1.0 Spec](https://docs.spraay.app/bpa/1.0/) - [x402 Protocol](https://www.x402.org) - [Spraay MCP Server](https://smithery.ai/server/@plagtech/spraay-x402-mcp) - [NeMo Agent Toolkit Docs](https://docs.nvidia.com/nemo/agent-toolkit/latest/) diff --git a/examples/spraay_crypto_payments/configs/config.yml b/examples/spraay_crypto_payments/configs/config.yml index 264ab94..1d250e5 100644 --- a/examples/spraay_crypto_payments/configs/config.yml +++ b/examples/spraay_crypto_payments/configs/config.yml @@ -18,10 +18,20 @@ # # This agent uses the Spraay x402 gateway to execute cryptocurrency # payments across 15 blockchains via USDC micropayments. +# +# FREE tools (no payment required): +# - health, routes, chains, price +# - batch_validate, batch_estimate (batch payment preview - runnable in CI!) +# +# PAID tools (x402 protocol): +# - balance ($0.005), batch_send ($0.02), escrow_create ($0.10), rtp_discover ($0.005) +# - Registered ONLY when EVM_PRIVATE_KEY is set. With the key, calls execute +# live and move real USDC (preview first with the free batch_validate / +# batch_estimate tools). +# - Without EVM_PRIVATE_KEY: only the free tools are registered (safe for CI/demos) function_groups: # All Spraay gateway tools share a single client (one gateway connection). - # Free query tools - no x402 payment required. spraay: _type: spraay gateway_url: ${SPRAAY_GATEWAY_URL:-https://gateway.spraay.app} @@ -34,12 +44,9 @@ llms: workflow: _type: react_agent - tool_names: - - spraay__health - - spraay__routes - - spraay__chains - - spraay__balance - - spraay__price + # Reference the whole spraay function group. The group registers its free + # tools always, and its paid tools only when EVM_PRIVATE_KEY is set. + tool_names: [spraay] llm_name: nim_llm verbose: true parse_agent_response_max_retries: 3 diff --git a/examples/spraay_crypto_payments/pyproject.toml b/examples/spraay_crypto_payments/pyproject.toml index 24e730e..fd2e1a2 100644 --- a/examples/spraay_crypto_payments/pyproject.toml +++ b/examples/spraay_crypto_payments/pyproject.toml @@ -9,8 +9,13 @@ readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11,<3.14" dependencies = [ - "nvidia-nat>=1.3.0", - "httpx>=0.27.0", + "nvidia-nat[langchain]~=1.4", + "httpx>=0.28.1", + # x402 payment SDK for live-mode execution. The [evm] extra pulls eth-account + # and web3 for EIP-3009 USDC signing; [httpx] enables the async payment + # transport. Dry-run mode uses none of this (x402 is imported lazily). + "x402[evm,httpx]>=2.15.0,<3.0.0", + "eth-account>=0.13.0", ] [build-system] diff --git a/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py b/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py index 7925a2e..e88ed2f 100644 --- a/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py +++ b/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py @@ -20,6 +20,7 @@ """ import logging +import os from pydantic import Field @@ -29,6 +30,7 @@ from nat.data_models.function import FunctionGroupBaseConfig from .spraay_client import SpraayClient +from .spraay_client import to_batch_execute_payload logger = logging.getLogger(__name__) @@ -77,9 +79,24 @@ async def routes(query: str = "") -> str: query: Unused; the endpoint is fixed. Provided for agent compatibility. Returns: - JSON string with the list of gateway routes. + JSON string with a compact summary of gateway routes (x402 discovery manifest). """ - return await client.get("/v1/routes") + import json as json_mod + result = await client.get("/.well-known/x402.json") + try: + # Return compact summary to keep agent context small + data = json_mod.loads(result) + resources = data.get("resources", []) + summary = { + "total_resources": len(resources), + "supported_chains": data.get("supportedChains", []), + "network": data.get("network"), + "sample_resources": resources[:10], # First 10 + "note": f"Showing 10 of {len(resources)} resources. Full manifest at /.well-known/x402.json", + } + return json_mod.dumps(summary, indent=2) + except Exception: + return result # Return raw if parsing fails async def chains(query: str = "") -> str: """List all supported blockchains on the Spraay gateway. @@ -90,14 +107,17 @@ async def chains(query: str = "") -> str: query: Unused; the endpoint is fixed. Provided for agent compatibility. Returns: - JSON string with the list of supported chains. + JSON string with the list of supported chains and their status. """ - return await client.get("/v1/chains") + return await client.get("/free/chain-status") async def balance(query: str) -> str: """Check the token balance of a wallet address on a specific blockchain. - This is a free query - no x402 USDC payment is required. + This is a PAID endpoint ($0.005 via x402). + - Without EVM_PRIVATE_KEY: returns payment quote (dry-run). No funds move. + - With EVM_PRIVATE_KEY: pays the $0.005 fee as a real USDC transfer on + Base (x402 exact scheme) and returns the balance. Args: query: A string containing the wallet address and optionally @@ -106,7 +126,7 @@ async def balance(query: str) -> str: '0xAd62...c8' (defaults to Base/USDC) Returns: - JSON string with the wallet balance. + JSON string with the wallet balance or payment quote. """ parts = query.strip().split() address = parts[0] if parts else query.strip() @@ -124,7 +144,7 @@ async def balance(query: str) -> str: token = parts[idx + 1].upper() return await client.get( - "/v1/balance", + "/api/v1/balances", params={ "address": address, "chain": chain, "token": token }, @@ -136,35 +156,255 @@ async def price(query: str) -> str: This is a free query - no x402 USDC payment is required. Args: - query: A string containing the token symbol and optionally - the chain, e.g.: - 'ETH on base' - 'USDC' (defaults to Base) + query: A string containing the token symbol(s), e.g.: + 'ETH' + 'ETH,USDC,SOL' (comma-separated for multiple tokens) Returns: - JSON string with the current token price. + JSON string with the current token price(s). """ - parts = query.strip().split() - token = parts[0].upper() if parts else "ETH" - chain = "base" - - lower_parts = [p.lower() for p in parts] - if "on" in lower_parts: - idx = lower_parts.index("on") - if idx + 1 < len(parts): - chain = parts[idx + 1].lower() + # Parse tokens from query (comma-separated or space-separated) + tokens = query.strip().replace(" ", ",").upper() + if not tokens: + tokens = "ETH,USDC" return await client.get( - "/v1/price", - params={ - "token": token, "chain": chain - }, + "/free/prices", + params={"tokens": tokens}, ) + async def batch_validate(query: str) -> str: + """Validate a batch payment recipient list before sending. + + This is a FREE endpoint - no x402 payment required. Use this to check + recipient addresses, amounts, and batch structure before executing. + + Args: + query: JSON string or natural language describing the batch, e.g.: + '{"recipients":[{"to":"0xAd62...","amount":"1.5"}],"token":"USDC","chain":"base"}' + 'validate sending USDC on base to 0xAd62...:1.5, 0xDef...:2.0' + + Returns: + JSON string with validation results (valid, errors, warnings, summary). + """ + import json as json_mod + try: + # Try parsing as JSON first + data = json_mod.loads(query) + except Exception: + # Parse natural language: "validate sending USDC on base to addr1:amt1, addr2:amt2" + parts = query.lower().replace("validate", "").replace("sending", "").strip().split() + token = "USDC" + chain = "base" + recipients = [] + + # Extract token and chain + for i, part in enumerate(parts): + if part.upper() in ["USDC", "ETH", "DAI"]: + token = part.upper() + elif part == "on" and i + 1 < len(parts): + chain = parts[i + 1] + elif part == "to" and i + 1 < len(parts): + # Parse "addr1:amt1, addr2:amt2" + recipient_str = " ".join(parts[i + 1:]) + for rec in recipient_str.split(","): + if ":" in rec: + addr, amt = rec.strip().split(":") + recipients.append({"to": addr.strip(), "amount": amt.strip()}) + break + + data = {"recipients": recipients, "token": token, "chain": chain} + + return await client.post("/free/validate-batch", data) + + async def batch_estimate(query: str) -> str: + """Estimate gas and total cost for a batch payment before executing. + + This is a FREE endpoint - no x402 payment required. Returns protocol + fee (0.3%), estimated gas in USD, and total cost breakdown. + + Args: + query: Query string with recipient count, chain, and token, e.g.: + 'recipients=3&chain=base&token=USDC' + 'estimate 5 recipients on base with USDC' + + Returns: + JSON string with cost estimate (gas, fees, total). + """ + # Parse query into parameters + params = {} + if "=" in query and "&" in query: + # Direct parameter format + for param in query.split("&"): + if "=" in param: + key, val = param.split("=", 1) + params[key.strip()] = val.strip() + else: + # Natural language: "estimate 5 recipients on base with USDC" + parts = query.split() + params = {"chain": "base", "token": "USDC"} + for i, part in enumerate(parts): + if part.isdigit(): + params["recipients"] = part + elif part == "on" and i + 1 < len(parts): + params["chain"] = parts[i + 1] + elif part.upper() in ["USDC", "ETH", "DAI"]: + params["token"] = part.upper() + + return await client.get("/free/estimate-batch", params) + + async def batch_send(query: str) -> str: + """Execute a batch payment to up to 200 recipients in one atomic transaction. + + This is a PAID endpoint ($0.02 via x402). Implements BPA 1.0 spec. + - Without EVM_PRIVATE_KEY: returns a payment quote only (dry-run). No + funds move. + - With EVM_PRIVATE_KEY: MOVES REAL FUNDS. Signs the $0.02 x402 gateway + fee as a USDC EIP-3009 transfer on Base, submits the batch, and the + gateway broadcasts a real payment to every recipient in the list. The + result includes the settlement transaction hash under "settlement". + + Supports Base, Ethereum, Solana, and other chains. Protocol fee: 0.3%. + + Args: + query: JSON or natural language describing the batch, e.g.: + 'send USDC on base to 0xAd62...:1.5, 0xDef...:2.0' + '{"recipients":[{"to":"0x...","amount":"1.5"}],"token":"USDC","chain":"base","sender":"0x..."}' + + Returns: + JSON string with transaction result or payment quote. + """ + import json as json_mod + try: + # Try parsing as JSON first + data = json_mod.loads(query) + except Exception: + # Parse natural language: "send USDC on base to addr1:amt1, addr2:amt2" + parts = query.lower().replace("send", "").strip().split() + token = "USDC" + chain = "base" + recipients = [] + + # Extract token and chain + for i, part in enumerate(parts): + if part.upper() in ["USDC", "ETH", "DAI", "SOL"]: + token = part.upper() + elif part == "on" and i + 1 < len(parts): + chain = parts[i + 1] + elif part == "to" and i + 1 < len(parts): + # Parse "addr1:amt1, addr2:amt2" + recipient_str = " ".join(parts[i + 1:]) + for rec in recipient_str.split(","): + if ":" in rec: + addr, amt = rec.strip().split(":") + recipients.append({"to": addr.strip(), "amount": amt.strip()}) + break + + data = {"recipients": recipients, "token": token, "chain": chain} + + # Add sender field if not present (required by BPA 1.0 spec) + if "sender" not in data: + data["sender"] = "0x0000000000000000000000000000000000000000" # Placeholder for dry-run + + # The paid execute endpoint requires parallel recipients/amounts arrays + # in raw base units (see its 402 bazaar schema), not the {to, amount} + # decimal objects the free validate/estimate endpoints accept. + payload = to_batch_execute_payload(data) + + return await client.post("/api/v1/batch/execute", payload) + + async def escrow_create(query: str) -> str: + """Create an escrow contract for conditional payment release. + + This is a PAID endpoint ($0.10 via x402). + - Without EVM_PRIVATE_KEY: returns payment quote (dry-run). No funds move. + - With EVM_PRIVATE_KEY: pays the $0.10 fee as a real USDC transfer on + Base (x402 exact scheme) and creates the escrow. + + Args: + query: JSON describing the escrow, e.g.: + '{"amount":"100","token":"USDC","chain":"base","beneficiary":"0x...","condition":"api_verified"}' + + Returns: + JSON string with escrow contract details or payment quote. + """ + import json as json_mod + try: + data = json_mod.loads(query) + except Exception: + return json_mod.dumps( + {"error": "Invalid escrow format. Expected JSON with amount, token, chain, beneficiary, condition."}) + + return await client.post("/api/v1/escrow/create", data) + + async def rtp_discover(query: str) -> str: + """Discover available RTP (Robot Task Protocol) robots. + + This is a PAID endpoint ($0.005 via x402). Filter by capability, + chain, price range, or status. + - Without EVM_PRIVATE_KEY: returns payment quote (dry-run). No funds move. + - With EVM_PRIVATE_KEY: pays the $0.005 fee as a real USDC transfer on + Base (x402 exact scheme) and returns the robot list. + + Args: + query: Filter query, e.g.: + 'capability=pick&chain=base' + 'find robots with pick capability under $5' + + Returns: + JSON string with available robots or payment quote. + """ + # Parse query into parameters + params = {} + if "=" in query and "&" in query: + # Direct parameter format + for param in query.split("&"): + if "=" in param: + key, val = param.split("=", 1) + params[key.strip()] = val.strip() + else: + # Natural language parsing + parts = query.lower().split() + for i, part in enumerate(parts): + if "pick" in part or "place" in part: + params["capability"] = part + elif part == "under" and i + 1 < len(parts): + # Extract price like "$5" or "5" + price_str = parts[i + 1].replace("$", "") + params["max_price"] = price_str + + return await client.get("/api/v1/robots/list", params) + + # Paid tools move real USDC, so they are only exposed when an EVM signing + # key is configured. NAT's per-function ``filter_fn`` is the idiomatic hook + # for conditional membership: the function is added to the group, but the + # group filters it out of every accessor unless the predicate passes. Here + # the predicate is a constant captured at build time (EVM_PRIVATE_KEY set), + # so paid tools simply do not appear in the agent's toolset without a key. + paid_tools_enabled = bool(os.environ.get("EVM_PRIVATE_KEY")) + + async def _requires_evm_key(_name: str) -> bool: + return paid_tools_enabled + + if not paid_tools_enabled: + logger.info("EVM_PRIVATE_KEY not set; registering free Spraay tools only. Paid " + "tools (balance, batch_send, escrow_create, rtp_discover) are " + "skipped. Set EVM_PRIVATE_KEY to enable them.") + + # Free info & discovery tools (always registered). group.add_function("health", health, description=health.__doc__) group.add_function("routes", routes, description=routes.__doc__) group.add_function("chains", chains, description=chains.__doc__) - group.add_function("balance", balance, description=balance.__doc__) group.add_function("price", price, description=price.__doc__) + # Free batch preview tools (always registered - runnable in CI without funds). + group.add_function("batch_validate", batch_validate, description=batch_validate.__doc__) + group.add_function("batch_estimate", batch_estimate, description=batch_estimate.__doc__) + + # Paid tools (registered only when EVM_PRIVATE_KEY is set). + group.add_function("balance", balance, description=balance.__doc__, filter_fn=_requires_evm_key) + group.add_function("batch_send", batch_send, description=batch_send.__doc__, filter_fn=_requires_evm_key) + group.add_function("escrow_create", escrow_create, description=escrow_create.__doc__, filter_fn=_requires_evm_key) + group.add_function("rtp_discover", rtp_discover, description=rtp_discover.__doc__, filter_fn=_requires_evm_key) + yield group diff --git a/examples/spraay_crypto_payments/src/spraay_crypto_payments/spraay_client.py b/examples/spraay_crypto_payments/src/spraay_crypto_payments/spraay_client.py index 758d90b..f3575a9 100644 --- a/examples/spraay_crypto_payments/src/spraay_crypto_payments/spraay_client.py +++ b/examples/spraay_crypto_payments/src/spraay_crypto_payments/spraay_client.py @@ -17,33 +17,351 @@ Provides async HTTP methods for interacting with the Spraay x402 protocol gateway, which enables AI agents to execute cryptocurrency payments across 15 blockchains using USDC micropayments. + +Two modes: + +* **Dry-run mode** (no ``EVM_PRIVATE_KEY``): paid endpoints return a structured + payment quote parsed from the gateway's HTTP 402 response. No wallet, no + signing, no funds move. Safe for CI and demos. +* **Live mode** (``EVM_PRIVATE_KEY`` set): paid requests are routed through the + x402 SDK's payment transport. When the gateway responds ``402 Payment + Required`` (x402Version 2), the SDK signs an EIP-3009 ``TransferWithAuthorization`` + for USDC on Base (the ``exact`` scheme), attaches it as the ``X-PAYMENT`` + header, and retries. The gateway's facilitator settles the USDC transfer + on-chain and returns the real API result plus an ``X-PAYMENT-RESPONSE`` + header containing the settlement transaction hash. + +The private key is read once from the environment, used only to construct the +in-memory signer, and is never logged, echoed, or included in any return value. """ +import base64 import json import logging +import os +from decimal import Decimal import httpx logger = logging.getLogger(__name__) +# Base-unit decimals for the tokens the Spraay gateway documents at +# GET /api/v1/tokens. The paid /api/v1/batch/execute endpoint requires each +# amount in raw base units (e.g. 1.5 USDC -> "1500000"), unlike the free +# validate/estimate endpoints which accept human-decimal amounts. +_TOKEN_DECIMALS = { + "ETH": 18, + "WETH": 18, + "USDC": 6, + "USDT": 6, + "EURC": 6, + "DAI": 18, +} +# Standard ERC-20 default for any token not in the map above. +_DEFAULT_TOKEN_DECIMALS = 18 + + +def to_batch_execute_payload(data: dict) -> dict: + """Convert a BPA batch into the shape /api/v1/batch/execute requires. + + The free ``/free/validate-batch`` and ``/free/estimate-batch`` endpoints + accept recipients as ``[{"to": addr, "amount": "1.5"}, ...]`` with + human-decimal amounts. The paid ``/api/v1/batch/execute`` endpoint (per the + 402 response's ``extensions.bazaar`` schema) instead requires two parallel + arrays — a flat ``recipients`` address list and an ``amounts`` list in raw + base units — plus ``token`` and ``sender``. + + This converts the former into the latter. If ``recipients`` is already a + flat list of address strings (already converted), the payload is returned + unchanged. + + Args: + data: Batch dict with ``recipients`` (list of ``{"to", "amount"}``), + ``token``, ``sender``, and optionally ``chain``. + + Returns: + A new dict with ``recipients`` (addresses) and ``amounts`` (raw + base-unit strings) arrays, preserving ``token``, ``sender`` and any + other top-level keys. + + Raises: + ValueError: if a recipient is missing an address or amount, or an + amount is not a valid number. + """ + recipients = data.get("recipients", []) + + # Already in the flat-array form (or empty) — nothing to convert. + if not recipients or not isinstance(recipients[0], dict): + return dict(data) + + token = str(data.get("token", "USDC")).upper() + decimals = _TOKEN_DECIMALS.get(token, _DEFAULT_TOKEN_DECIMALS) + scale = Decimal(10)**decimals + + addresses: list[str] = [] + amounts: list[str] = [] + for rec in recipients: + addr = rec.get("to") + amount = rec.get("amount") + if not addr: + raise ValueError("Batch recipient is missing a 'to' address.") + if amount is None or amount == "": + raise ValueError(f"Batch recipient {addr} is missing an amount.") + try: + raw = int((Decimal(str(amount)) * scale).to_integral_value()) + except Exception as e: + raise ValueError(f"Invalid amount {amount!r} for recipient {addr}: {e}") from e + addresses.append(addr) + amounts.append(str(raw)) + + payload = dict(data) + payload["recipients"] = addresses + payload["amounts"] = amounts + return payload + class SpraayClient: - """Async HTTP client for the Spraay x402 gateway.""" + """Async HTTP client for the Spraay x402 gateway with x402 payment support.""" def __init__(self, gateway_url: str, timeout: int = 30): self.gateway_url = gateway_url.rstrip("/") self.timeout = timeout + # Optional private key for x402 payment execution (never logged/echoed). + self.private_key = os.environ.get("EVM_PRIVATE_KEY") + # Lazily-built x402 client + signer, cached across requests. Only ever + # constructed in live mode; the dry-run path never imports x402. + self._x402_client = None + self._payer_address: str | None = None + + def _parse_402_response(self, endpoint: str, response_body: dict) -> dict: + """Parse a 402 Payment Required response into a structured dry-run result. + + Args: + endpoint: The endpoint that returned 402 + response_body: The x402 JSON response body + + Returns: + Structured dict with mode, endpoint, payment_required details, and note + """ + try: + accepts = response_body.get("accepts", []) + if not accepts: + return { + "mode": "dry_run", + "endpoint": endpoint, + "error": "No payment options in 402 response", + } + + # Use the first EVM payment option (Base/USDC) + payment_option = accepts[0] + amount_raw = int(payment_option.get("amount", 0)) + # Convert 6-decimal USDC to dollars (5000 = $0.005) + price_usd = amount_raw / 1_000_000 + + return { + "mode": "dry_run", + "endpoint": endpoint, + "payment_required": { + "price": f"${price_usd:.3f}", + "amount_usdc_raw": amount_raw, + "asset": "USDC", + "network": payment_option.get("network", "eip155:8453"), + "pay_to": payment_option.get("payTo"), + }, + "note": "Set EVM_PRIVATE_KEY environment variable to execute for real.", + } + except Exception as e: + logger.error("Failed to parse 402 response: %s", e) + return { + "mode": "dry_run", + "endpoint": endpoint, + "error": f"Failed to parse payment requirements: {e}", + } + + def _get_x402_client(self): + """Lazily build and cache the x402 payment client from EVM_PRIVATE_KEY. + + Constructs an eth-account signer and registers the EVM ``exact`` payment + scheme (V2 ``eip155:*`` wildcard + legacy V1 networks). The resulting + x402 client is reused for every live request. + + Returns: + An ``x402Client`` configured to sign USDC payments. + + Raises: + RuntimeError: if the x402/eth-account dependencies are missing or the + private key is invalid. The error message never contains the key. + """ + if self._x402_client is not None: + return self._x402_client + + try: + from eth_account import Account + from x402 import x402Client + from x402.mechanisms.evm.exact import register_exact_evm_client + from x402.mechanisms.evm.signers import EthAccountSigner + except ImportError as e: + raise RuntimeError("Live mode requires the x402 SDK with EVM + httpx extras. " + "Install with: uv pip install -e . (pulls x402[evm,httpx]).") from e + + key = self.private_key or "" + if not key.startswith("0x"): + key = "0x" + key + try: + account = Account.from_key(key) + except Exception: + # Do not chain or echo the original error — it could reference the + # malformed key material. + raise RuntimeError("EVM_PRIVATE_KEY is not a valid EVM private key.") from None + + client = x402Client() + register_exact_evm_client(client, EthAccountSigner(account)) + self._x402_client = client + self._payer_address = account.address + return client + + @staticmethod + def _safe_json(response: "httpx.Response"): + """Return the response body as parsed JSON, or raw text if not JSON.""" + try: + return response.json() + except Exception: + return response.text + + @staticmethod + def _decode_payment_response(response: "httpx.Response"): + """Decode the base64 X-PAYMENT-RESPONSE settlement header, if present. + + The gateway returns settlement details (success flag, transaction hash, + network, payer) in this header after a successful x402 payment. + """ + raw = response.headers.get("x-payment-response") or response.headers.get("payment-response") + if not raw: + return None + try: + return json.loads(base64.b64decode(raw)) + except Exception: + return raw + + async def _live_request( + self, + method: str, + path: str, + *, + params: dict | None = None, + json_data: dict | None = None, + ) -> str: + """Execute a paid request in live mode via the x402 payment transport. + + Routes the request through an httpx client wrapped with the x402 SDK's + payment transport. A 402 from the gateway is handled automatically: the + SDK signs a USDC EIP-3009 authorization, retries with the X-PAYMENT + header, and the gateway settles on-chain. This moves real funds. + + Args: + method: "GET" or "POST". + path: Gateway endpoint path. + params: Query parameters (GET). + json_data: JSON body (POST). + + Returns: + JSON string with the settled API result and settlement details, or a + structured error. The private key is never included. + """ + try: + x402_client = self._get_x402_client() + from x402.http.clients.httpx import PaymentError + from x402.http.clients.httpx import wrapHttpxWithPayment + except RuntimeError as e: + return json.dumps({"mode": "live", "error": str(e), "path": path}, indent=2) + except ImportError as e: + return json.dumps({ + "mode": "live", + "error": f"Live mode dependencies missing: {e}", + "path": path, + }, + indent=2) + + url = f"{self.gateway_url}{path}" + headers = {"Content-Type": "application/json"} + try: + async with wrapHttpxWithPayment(x402_client, timeout=self.timeout) as http: + if method == "GET": + response = await http.get(url, params=params, headers=headers) + else: + response = await http.post(url, json=json_data, headers=headers) + + if response.status_code == 402: + # The payment was signed and submitted but the gateway did + # not accept it. Most common cause: the paying wallet holds + # insufficient USDC, or the payment window expired. + return json.dumps( + { + "mode": "live", + "error": ("Payment was signed and submitted but the gateway rejected it " + "(HTTP 402). Check that the paying wallet holds enough USDC on the " + "target chain to cover the payment plus the transfer amount."), + "gateway_response": self._safe_json(response), + "path": path, + }, + indent=2) + + response.raise_for_status() + settlement = self._decode_payment_response(response) + if settlement is None: + # No payment was required (e.g. a free endpoint hit in live + # mode) — return the raw body, identical to dry-run mode. + return json.dumps(self._safe_json(response), indent=2) + # A payment settled: surface the result plus the settlement + # details (transaction hash, payer, network). + return json.dumps({ + "mode": "live", + "result": self._safe_json(response), + "settlement": settlement, + }, + indent=2) + except PaymentError as e: + logger.error("x402 payment failed for %s", path) + return json.dumps({ + "mode": "live", + "error": f"x402 payment execution failed: {e}", + "path": path, + }, + indent=2) + except httpx.HTTPStatusError as e: + logger.error("Spraay gateway HTTP error: %s %s", e.response.status_code, path) + return json.dumps( + { + "mode": "live", + "error": f"HTTP {e.response.status_code}", + "response_body": self._safe_json(e.response), + "path": path, + }, + indent=2) + except Exception as e: + logger.error("Live x402 request failed for %s", path) + return json.dumps({"mode": "live", "error": str(e), "path": path}, indent=2) async def get(self, path: str, params: dict | None = None) -> str: """Make a GET request to the Spraay gateway. + Handles both free and paid (x402) endpoints. For paid endpoints: + - Dry-run mode (no EVM_PRIVATE_KEY): returns payment quote as JSON + - Live mode (EVM_PRIVATE_KEY set): executes x402 payment flow + Args: - path: API endpoint path (e.g., '/health', '/v1/chains'). + path: API endpoint path (e.g., '/health', '/free/prices'). params: Optional query parameters. Returns: - JSON string with the gateway response. + JSON string with the gateway response or payment quote. """ + # Live mode: route paid endpoints through the x402 payment transport, + # which signs and settles automatically. (Free endpoints never hit 402, + # so they pass straight through.) + if self.private_key: + return await self._live_request("GET", path, params=params) + try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( @@ -51,11 +369,24 @@ async def get(self, path: str, params: dict | None = None) -> str: params=params, headers={"Content-Type": "application/json"}, ) + + # Handle 402 Payment Required (dry-run: return structured quote) + if response.status_code == 402: + body = response.json() + dry_run_result = self._parse_402_response(path, body) + return json.dumps(dry_run_result, indent=2) + response.raise_for_status() return json.dumps(response.json(), indent=2) except httpx.HTTPStatusError as e: - logger.error("Spraay gateway HTTP error: %s %s", e.response.status_code, path) - return json.dumps({"error": f"HTTP {e.response.status_code}", "path": path}) + if e.response.status_code != 402: # 402 already handled above + logger.error("Spraay gateway HTTP error: %s %s", e.response.status_code, path) + return json.dumps({ + "error": f"HTTP {e.response.status_code}", + "response_body": self._safe_json(e.response), + "path": path, + }) + raise # Re-raise if we missed a 402 except Exception as e: logger.error("Spraay gateway request failed: %s", e) return json.dumps({"error": str(e)}) @@ -63,13 +394,23 @@ async def get(self, path: str, params: dict | None = None) -> str: async def post(self, path: str, data: dict) -> str: """Make a POST request to the Spraay gateway. + Handles both free and paid (x402) endpoints. For paid endpoints: + - Dry-run mode (no EVM_PRIVATE_KEY): returns payment quote as JSON + - Live mode (EVM_PRIVATE_KEY set): executes x402 payment flow + Args: - path: API endpoint path (e.g., '/v1/batch-send'). + path: API endpoint path (e.g., '/free/validate-batch', '/api/v1/batch/execute'). data: JSON body to send. Returns: - JSON string with the gateway response. + JSON string with the gateway response or payment quote. """ + # Live mode: route paid endpoints through the x402 payment transport, + # which signs and settles automatically. In live mode a POST to a paid + # endpoint such as /api/v1/batch/execute moves real funds. + if self.private_key: + return await self._live_request("POST", path, json_data=data) + try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( @@ -77,11 +418,24 @@ async def post(self, path: str, data: dict) -> str: json=data, headers={"Content-Type": "application/json"}, ) + + # Handle 402 Payment Required (dry-run: return structured quote) + if response.status_code == 402: + body = response.json() + dry_run_result = self._parse_402_response(path, body) + return json.dumps(dry_run_result, indent=2) + response.raise_for_status() return json.dumps(response.json(), indent=2) except httpx.HTTPStatusError as e: - logger.error("Spraay gateway HTTP error: %s %s", e.response.status_code, path) - return json.dumps({"error": f"HTTP {e.response.status_code}", "path": path}) + if e.response.status_code != 402: # 402 already handled above + logger.error("Spraay gateway HTTP error: %s %s", e.response.status_code, path) + return json.dumps({ + "error": f"HTTP {e.response.status_code}", + "response_body": self._safe_json(e.response), + "path": path, + }) + raise # Re-raise if we missed a 402 except Exception as e: logger.error("Spraay gateway request failed: %s", e) return json.dumps({"error": str(e)})