From 4976e947e15966cf543a87f4f83786b7a50562b8 Mon Sep 17 00:00:00 2001 From: plagtech Date: Fri, 10 Jul 2026 23:14:39 -0700 Subject: [PATCH 1/5] fix: update Spraay gateway endpoint paths; feat: add batch payments, escrow, and RTP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes broken endpoint paths in the merged spraay_crypto_payments example (PR #20) and adds batch payment, escrow, and RTP (Robot Task Protocol) tools with x402 payment support. Fixes: - routes: GET /v1/routes → GET /.well-known/x402.json (x402 discovery) - chains: GET /v1/chains → GET /free/chain-status - price: GET /v1/price → GET /free/prices?tokens=... - balance: GET /v1/balance → GET /api/v1/balances (paid $0.005) - All four endpoints were returning 404 on live gateway (verified 2026-07-10) New FREE tools (runnable in CI with zero funds): - batch_validate: POST /free/validate-batch - validate recipient list - batch_estimate: GET /free/estimate-batch - gas + fee preview New PAID tools (x402 protocol, dry-run by default): - batch_send: POST /api/v1/batch/execute ($0.02) - batch payments to up to 200 recipients in one atomic transaction (BPA 1.0 spec) - escrow_create: POST /api/v1/escrow/create ($0.10) - escrow contracts - rtp_discover: GET /api/v1/robots/list ($0.005) - discover RTP robots Implementation: - Enhanced spraay_client.py with x402-aware request handling - Dry-run mode (no EVM_PRIVATE_KEY): paid tools return payment quotes - Live mode (EVM_PRIVATE_KEY set): executes x402 payment flow - All tools use shared SpraayClient via FunctionGroup pattern - Updated config.yml with all new tool names and explanatory comments - Rewrote README with tool tables, sample runs, and security notes Lead with batch payments (up to 200 recipients per tx). Primary chains: Base, Ethereum, Solana. Multi-chain secondary. Related: PR #17 (x402_payment_tool) established x402 payment examples in this repo. This PR extends that pattern with domain-specific tools. Batch Payments for Agents (BPA) 1.0: https://docs.spraay.app/bpa/1.0/ Signed-off-by: plagtech --- examples/spraay_crypto_payments/README.md | 251 ++++++++++++++--- .../spraay_crypto_payments/configs/config.yml | 20 +- .../src/spraay_crypto_payments/register.py | 261 ++++++++++++++++-- .../spraay_crypto_payments/spraay_client.py | 113 +++++++- 4 files changed, 564 insertions(+), 81 deletions(-) diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index 026e3aa..8d05d67 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -17,30 +17,68 @@ 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, the agent receives a payment quote (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, Dry-Run by Default) + +| 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 | + +**Paid Tool Modes:** +- **Dry-run (default):** Without `EVM_PRIVATE_KEY` set, paid tools return + structured payment quotes showing the required USDC amount, network, and + payment address. Safe for CI and demos. +- **Live mode:** With `EVM_PRIVATE_KEY` set, the agent executes the full x402 + payment flow and receives the actual API result. ## Prerequisites @@ -69,69 +107,178 @@ export NVIDIA_API_KEY= export SPRAAY_GATEWAY_URL=https://gateway.spraay.app # optional, this is the default ``` -## Running the Example +4. (Optional) For live execution of paid tools, set your EVM private key: + +```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, paid tools operate in dry-run +mode and return payment quotes. -### Check gateway health +## 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" + } + } +} ``` -### 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" + } +} +``` + +**Step 2: Estimate Gas + Fees** + +```bash +nat run \ + --config_file configs/config.yml \ + --input "Estimate gas for 3 recipients on base with USDC" +``` +**Output:** + +```json +{ + "estimate": { + "chain": "base", + "recipients": 3, + "protocolFeeBps": 30, + "estimatedGasUSD": 0.003, + "bpaVersion": "1.0" + }, + "note": "Protocol fee: 0.3% of total amount" +} ``` -$ 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 +### 4. Dry-Run Paid Tool: Batch Send Payment Quote -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 +Without `EVM_PRIVATE_KEY`, `batch_send` returns the x402 payment quote: -Observation: { - "status": "ok", - "version": "3.6.0", - "uptime": "..." +```bash +nat run \ + --config_file configs/config.yml \ + --input "Send 1 USDC on base to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" +``` + +**Output:** + +```json +{ + "mode": "dry_run", + "endpoint": "/api/v1/batch/execute", + "payment_required": { + "price": "$0.020", + "amount_usdc_raw": 20000, + "asset": "USDC", + "network": "eip155:8453", + "pay_to": "0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" + }, + "note": "Set EVM_PRIVATE_KEY environment variable to execute for real." } +``` + +### 5. (Optional) Live Mode Snippet -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...'] +To execute a batch payment for real (requires funded wallet): + +```bash +export EVM_PRIVATE_KEY=0x... # Your funded Base wallet private key +nat run \ + --config_file configs/config.yml \ + --input "Send 1 USDC on base to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" ``` +The agent will sign the x402 payment ($0.02 USDC) and execute the batch +transaction, returning the transaction hash and status. + ## Architecture ``` NeMo Agent Toolkit | v -ReAct Agent (Llama 3.1) +ReAct Agent (Llama 3.1 70B) | v spraay function group (shared client) @@ -139,17 +286,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 +311,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..84ac2d8 100644 --- a/examples/spraay_crypto_payments/configs/config.yml +++ b/examples/spraay_crypto_payments/configs/config.yml @@ -18,10 +18,18 @@ # # 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, dry-run by default): +# - balance ($0.005), batch_send ($0.02), escrow_create ($0.10), rtp_discover ($0.005) +# - Set EVM_PRIVATE_KEY environment variable to execute paid tools for real +# - Without EVM_PRIVATE_KEY: paid tools return payment quotes (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} @@ -35,11 +43,19 @@ llms: workflow: _type: react_agent tool_names: + # Free info & discovery tools - spraay__health - spraay__routes - spraay__chains - - spraay__balance - spraay__price + # Batch payments (FREE validate/estimate, PAID execute) + - spraay__batch_validate + - spraay__batch_estimate + - spraay__batch_send + # Paid tools (dry-run by default) + - spraay__balance + - spraay__escrow_create + - spraay__rtp_discover llm_name: nim_llm verbose: true parse_agent_response_max_retries: 3 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..a243c37 100644 --- a/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py +++ b/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py @@ -77,9 +77,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 +105,16 @@ 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) + - With EVM_PRIVATE_KEY: executes payment and returns balance Args: query: A string containing the wallet address and optionally @@ -106,7 +123,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 +141,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 +153,227 @@ 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 payment quote (dry-run) + - With EVM_PRIVATE_KEY: executes payment and returns transaction hash + + 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 + + return await client.post("/api/v1/batch/execute", data) + + 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) + - With EVM_PRIVATE_KEY: executes payment and creates 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 (Robotic 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) + - With EVM_PRIVATE_KEY: executes payment and returns 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) + + # Register all tools with the function group 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__) + # Batch payment tools (lead with these - batch_validate and batch_estimate are FREE) + group.add_function("batch_validate", batch_validate, description=batch_validate.__doc__) + group.add_function("batch_estimate", batch_estimate, description=batch_estimate.__doc__) + group.add_function("batch_send", batch_send, description=batch_send.__doc__) + + # Paid tools (dry-run by default) + group.add_function("balance", balance, description=balance.__doc__) + group.add_function("escrow_create", escrow_create, description=escrow_create.__doc__) + group.add_function("rtp_discover", rtp_discover, description=rtp_discover.__doc__) + 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..9da7b9d 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,10 +17,14 @@ 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. + +Supports both dry-run mode (returns payment quotes) and live mode (executes +x402 payment flow when EVM_PRIVATE_KEY is set). """ import json import logging +import os import httpx @@ -28,21 +32,72 @@ 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") + + 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}", + } 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. """ try: async with httpx.AsyncClient(timeout=self.timeout) as client: @@ -51,11 +106,29 @@ async def get(self, path: str, params: dict | None = None) -> str: params=params, headers={"Content-Type": "application/json"}, ) + + # Handle 402 Payment Required + if response.status_code == 402: + body = response.json() + if not self.private_key: + # Dry-run: return structured payment quote + dry_run_result = self._parse_402_response(path, body) + return json.dumps(dry_run_result, indent=2) + else: + # Live mode: execute x402 payment (basic implementation) + # For production, integrate full x402 payment signing + return json.dumps({ + "error": "Live x402 payment execution not yet implemented", + "payment_quote": self._parse_402_response(path, body), + }, 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}", "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,12 +136,16 @@ 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. """ try: async with httpx.AsyncClient(timeout=self.timeout) as client: @@ -77,11 +154,29 @@ async def post(self, path: str, data: dict) -> str: json=data, headers={"Content-Type": "application/json"}, ) + + # Handle 402 Payment Required + if response.status_code == 402: + body = response.json() + if not self.private_key: + # Dry-run: return structured payment quote + dry_run_result = self._parse_402_response(path, body) + return json.dumps(dry_run_result, indent=2) + else: + # Live mode: execute x402 payment (basic implementation) + # For production, integrate full x402 payment signing + return json.dumps({ + "error": "Live x402 payment execution not yet implemented", + "payment_quote": self._parse_402_response(path, body), + }, 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}", "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)}) From 8bf1c9f5cf0524aa665443ab2a82d4369ad2e766 Mon Sep 17 00:00:00 2001 From: plagtech Date: Sat, 11 Jul 2026 03:00:04 -0700 Subject: [PATCH 2/5] Add live batch_send smoke test and document a verified live run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/live_batch_send_smoke.py: standalone, deterministic batch_send x402 smoke test (dry-run + live), no ReAct agent and no NVIDIA_API_KEY, with an explicit confirmation before moving funds. - README: document the smoke script and add a "Verified live run" block with the real Base mainnet settlement (tx 0x4a3fdb...54fdd7, block 48487033) — the 0.02 USDC x402 fee for /api/v1/batch/execute. - register.py: correct the RTP expansion to "Robot Task Protocol". - spraay_client.py: build the flat /api/v1/batch/execute payload (parallel recipients/amounts arrays in raw base units) and add live x402 signing; pyproject.toml: live-mode extras. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: plagtech --- examples/spraay_crypto_payments/README.md | 147 +++++++- .../spraay_crypto_payments/pyproject.toml | 7 +- .../scripts/live_batch_send_smoke.py | 251 ++++++++++++++ .../src/spraay_crypto_payments/register.py | 33 +- .../spraay_crypto_payments/spraay_client.py | 313 ++++++++++++++++-- 5 files changed, 702 insertions(+), 49 deletions(-) create mode 100644 examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index 8d05d67..7353215 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -76,9 +76,17 @@ gateway client. **Paid Tool Modes:** - **Dry-run (default):** Without `EVM_PRIVATE_KEY` set, paid tools return structured payment quotes showing the required USDC amount, network, and - payment address. Safe for CI and demos. -- **Live mode:** With `EVM_PRIVATE_KEY` set, the agent executes the full x402 - payment flow and receives the actual API result. + payment address. No wallet, no signing, no funds move. Safe for CI and demos. +- **Live mode:** With `EVM_PRIVATE_KEY` set, paid requests are 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` in live mode broadcasts a real batch payment to + every recipient in the list. ## Prerequisites @@ -258,19 +266,139 @@ nat run \ } ``` -### 5. (Optional) Live Mode Snippet +### 5. (Optional) Live Mode — Moves Real Funds -To execute a batch payment for real (requires funded wallet): +> ⚠️ **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... # Your funded Base wallet private key +export EVM_PRIVATE_KEY=0x... # Funded Base wallet; never commit or share this nat run \ --config_file configs/config.yml \ - --input "Send 1 USDC on base to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" + --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"}' +``` + +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 `uv 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 smoke script. 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. Run it first with no key (dry-run quote), then with a +funded key: + +```bash +# 1. Dry-run: prints the x402 quote, no funds move. +python scripts/live_batch_send_smoke.py + +# 2. Live: signs + settles the $0.02 fee and submits the batch (REAL funds). +export EVM_PRIVATE_KEY=0x... # funded Base wallet +python scripts/live_batch_send_smoke.py \ + --to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 --amount 0.01 ``` -The agent will sign the x402 payment ($0.02 USDC) and execute the batch -transaction, returning the transaction hash and status. +The script derives `sender` from your key automatically, prints a summary, +prompts for confirmation (skip with `--yes`), and reports the settlement +transaction hash. See `--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 **Base mainnet** (`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" + } +} +``` + +Verify on-chain: + + +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 @@ -313,6 +441,7 @@ Spraay x402 Gateway (gateway.spraay.app) | `src/spraay_crypto_payments/register.py` | Tool registration with `@register_function_group` | | `src/spraay_crypto_payments/spraay_client.py` | Async HTTP client with x402 support | | `src/spraay_crypto_payments/__init__.py` | Package init | +| `scripts/live_batch_send_smoke.py` | Standalone batch_send smoke test (dry-run + live) | | `pyproject.toml` | Project dependencies and NAT entry points | ## Related Examples diff --git a/examples/spraay_crypto_payments/pyproject.toml b/examples/spraay_crypto_payments/pyproject.toml index 24e730e..8a63704 100644 --- a/examples/spraay_crypto_payments/pyproject.toml +++ b/examples/spraay_crypto_payments/pyproject.toml @@ -10,7 +10,12 @@ license = { text = "Apache-2.0" } requires-python = ">=3.11,<3.14" dependencies = [ "nvidia-nat>=1.3.0", - "httpx>=0.27.0", + "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/scripts/live_batch_send_smoke.py b/examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py new file mode 100644 index 0000000..ece02db --- /dev/null +++ b/examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py @@ -0,0 +1,251 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Standalone smoke test for the Spraay batch_send x402 flow. + +Drives ``SpraayClient`` directly against the gateway's ``/api/v1/batch/execute`` +endpoint, bypassing the ReAct agent entirely. No LLM, no NVIDIA_API_KEY, and no +tool-selection ambiguity — exactly one batch payment is attempted, so this is +the most deterministic way to run a first live test. + +Modes (identical to the tools): + +* **Dry-run** (no ``EVM_PRIVATE_KEY``): prints the x402 payment quote. No funds + move. Run this first to sanity-check connectivity and your recipient list. +* **Live** (``EVM_PRIVATE_KEY`` set): signs and settles the x402 gateway fee and + submits the batch. THIS MOVES REAL FUNDS. Requires an explicit confirmation + (type ``yes`` at the prompt, or pass ``--yes``). + +Examples +-------- +Dry-run the default single-recipient batch:: + + python scripts/live_batch_send_smoke.py + +Live send 0.01 USDC on Base to one address (requires a funded wallet):: + + export EVM_PRIVATE_KEY=0x... # funded Base wallet; never commit/share + python scripts/live_batch_send_smoke.py \ + --to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 --amount 0.01 --yes + +Multiple recipients:: + + python scripts/live_batch_send_smoke.py \ + --to 0xAAA...:0.01 --to 0xBBB...:0.02 --yes + +The private key is read only from the environment, used only to build the +in-memory signer, and is never printed by this script. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys + +# Allow running straight from the example directory without `pip install -e .`. +_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from spraay_crypto_payments.spraay_client import SpraayClient, to_batch_execute_payload # noqa: E402 + +# Zero-address placeholder used for dry-run quotes (mirrors the batch_send tool). +_PLACEHOLDER_SENDER = "0x0000000000000000000000000000000000000000" + + +def _parse_recipients(values: list[str]) -> list[dict]: + """Parse --to values into recipient dicts. + + Each value is either a bare address (paired with --amount) or the compact + ``address:amount`` form. Returns a list of {"to", "amount"} dicts. + """ + recipients: list[dict] = [] + for value in values: + if ":" in value: + addr, _, amount = value.partition(":") + recipients.append({"to": addr.strip(), "amount": amount.strip()}) + else: + recipients.append({"to": value.strip(), "amount": None}) + return recipients + + +def _derive_sender(private_key: str) -> str | None: + """Derive the payer's public address from the private key, or None. + + Never logs or returns the key itself — only the derived public address. + """ + try: + from eth_account import Account + except ImportError: + return None + key = private_key if private_key.startswith("0x") else "0x" + private_key + try: + return Account.from_key(key).address + except Exception: + # Invalid key — let SpraayClient surface the structured error at send time. + return None + + +def _build_batch(args: argparse.Namespace, private_key: str | None) -> dict: + """Construct the /api/v1/batch/execute request body.""" + recipients = _parse_recipients(args.to) + # Fill in the shared --amount for any bare addresses. + for rec in recipients: + if rec["amount"] is None: + if args.amount is None: + raise SystemExit( + f"Recipient {rec['to']} has no amount. Pass --amount, or use " + f"the address:amount form." + ) + rec["amount"] = args.amount + + data: dict = { + "recipients": recipients, + "token": args.token, + "chain": args.chain, + } + + # In live mode the sender must be the paying wallet, not the dry-run + # placeholder. Prefer an explicit --sender, else derive it from the key. + if args.sender: + data["sender"] = args.sender + elif private_key: + derived = _derive_sender(private_key) + data["sender"] = derived or _PLACEHOLDER_SENDER + else: + data["sender"] = _PLACEHOLDER_SENDER + + return data + + +def _confirm(data: dict, live: bool, assume_yes: bool) -> bool: + """Print a summary and, in live mode, require explicit confirmation.""" + total = 0.0 + for rec in data["recipients"]: + try: + total += float(rec["amount"]) + except (TypeError, ValueError): + total = float("nan") + break + + print("=" * 64) + print("Spraay batch_send smoke test") + print("=" * 64) + print(f" mode : {'LIVE (moves real funds)' if live else 'dry-run (quote only)'}") + print(f" gateway : {data.get('_gateway', '(default)')}") + print(f" token/chain : {data['token']} on {data['chain']}") + print(f" sender : {data['sender']}") + print(f" recipients : {len(data['recipients'])}") + for rec in data["recipients"]: + print(f" -> {rec['to']} {rec['amount']} {data['token']}") + print(f" total send : {total} {data['token']} (plus the $0.02 x402 gateway fee)") + print("=" * 64) + + if not live: + return True + if assume_yes: + print("Confirmation skipped via --yes. Proceeding with a REAL payment.\n") + return True + try: + answer = input("This will move REAL funds. Type 'yes' to proceed: ").strip().lower() + except EOFError: + answer = "" + return answer == "yes" + + +async def _run(args: argparse.Namespace) -> int: + private_key = os.environ.get("EVM_PRIVATE_KEY") + live = bool(private_key) + + data = _build_batch(args, private_key) + summary = dict(data, _gateway=args.gateway) + + if not _confirm(summary, live, args.yes): + print("Aborted - no request sent.") + return 1 + + client = SpraayClient(gateway_url=args.gateway, timeout=args.timeout) + print("\nSubmitting batch to", f"{args.gateway}/api/v1/batch/execute", "...\n") + # The execute endpoint requires parallel recipients/amounts arrays in raw + # base units (see its 402 bazaar schema), not the {to, amount} decimal + # objects used for display/confirmation above. + payload = to_batch_execute_payload(data) + result = await client.post("/api/v1/batch/execute", payload) + print(result) + + # Best-effort exit code: non-zero if the client reported an error. + try: + parsed = json.loads(result) + except Exception: + return 0 + if isinstance(parsed, dict) and parsed.get("error"): + return 2 + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Deterministic smoke test for the Spraay batch_send x402 flow.", + ) + parser.add_argument( + "--to", + action="append", + default=[], + metavar="ADDRESS[:AMOUNT]", + help="Recipient address, or address:amount. Repeatable. " + "Defaults to one small test recipient.", + ) + parser.add_argument( + "--amount", + default=None, + help="Amount per recipient for bare --to addresses (default: 0.01 when " + "no recipients are given).", + ) + parser.add_argument("--token", default="USDC", help="Token symbol (default: USDC).") + parser.add_argument("--chain", default="base", help="Chain (default: base).") + parser.add_argument( + "--sender", + default=None, + help="Sender/payer address. In live mode, defaults to the address " + "derived from EVM_PRIVATE_KEY.", + ) + parser.add_argument( + "--gateway", + default=os.environ.get("SPRAAY_GATEWAY_URL", "https://gateway.spraay.app"), + help="Gateway base URL (default: $SPRAAY_GATEWAY_URL or gateway.spraay.app).", + ) + parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout seconds (default: 60).") + parser.add_argument( + "--yes", + action="store_true", + help="Skip the live-mode confirmation prompt (use with care).", + ) + return parser + + +def main() -> int: + args = _build_parser().parse_args() + if not args.to: + # Default: a single tiny test payment to a well-known example address. + args.to = ["0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8"] + if args.amount is None: + args.amount = "0.01" + return asyncio.run(_run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) 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 a243c37..bd64c11 100644 --- a/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py +++ b/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py @@ -29,6 +29,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__) @@ -113,8 +114,9 @@ async def balance(query: str) -> str: """Check the token balance of a wallet address on a specific blockchain. This is a PAID endpoint ($0.005 via x402). - - Without EVM_PRIVATE_KEY: returns payment quote (dry-run) - - With EVM_PRIVATE_KEY: executes payment and returns balance + - 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 @@ -254,8 +256,12 @@ 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 payment quote (dry-run) - - With EVM_PRIVATE_KEY: executes payment and returns transaction hash + - 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%. @@ -299,14 +305,20 @@ async def batch_send(query: str) -> str: if "sender" not in data: data["sender"] = "0x0000000000000000000000000000000000000000" # Placeholder for dry-run - return await client.post("/api/v1/batch/execute", data) + # 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) - - With EVM_PRIVATE_KEY: executes payment and creates escrow + - 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.: @@ -324,12 +336,13 @@ async def escrow_create(query: str) -> str: return await client.post("/api/v1/escrow/create", data) async def rtp_discover(query: str) -> str: - """Discover available RTP (Robotic Task Protocol) robots. + """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) - - With EVM_PRIVATE_KEY: executes payment and returns robot list + - 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.: 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 9da7b9d..3c1fd95 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 @@ -18,18 +18,107 @@ gateway, which enables AI agents to execute cryptocurrency payments across 15 blockchains using USDC micropayments. -Supports both dry-run mode (returns payment quotes) and live mode (executes -x402 payment flow when EVM_PRIVATE_KEY is set). +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 with x402 payment support.""" @@ -37,8 +126,12 @@ class SpraayClient: 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) + # 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. @@ -85,6 +178,166 @@ def _parse_402_response(self, endpoint: str, response_body: dict) -> dict: "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, 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. @@ -99,6 +352,12 @@ async def get(self, path: str, params: dict | None = None) -> str: Returns: 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( @@ -107,27 +366,22 @@ async def get(self, path: str, params: dict | None = None) -> str: headers={"Content-Type": "application/json"}, ) - # Handle 402 Payment Required + # Handle 402 Payment Required (dry-run: return structured quote) if response.status_code == 402: body = response.json() - if not self.private_key: - # Dry-run: return structured payment quote - dry_run_result = self._parse_402_response(path, body) - return json.dumps(dry_run_result, indent=2) - else: - # Live mode: execute x402 payment (basic implementation) - # For production, integrate full x402 payment signing - return json.dumps({ - "error": "Live x402 payment execution not yet implemented", - "payment_quote": self._parse_402_response(path, body), - }, indent=2) + 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: 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}", "path": 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) @@ -147,6 +401,12 @@ async def post(self, path: str, data: dict) -> str: Returns: 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( @@ -155,27 +415,22 @@ async def post(self, path: str, data: dict) -> str: headers={"Content-Type": "application/json"}, ) - # Handle 402 Payment Required + # Handle 402 Payment Required (dry-run: return structured quote) if response.status_code == 402: body = response.json() - if not self.private_key: - # Dry-run: return structured payment quote - dry_run_result = self._parse_402_response(path, body) - return json.dumps(dry_run_result, indent=2) - else: - # Live mode: execute x402 payment (basic implementation) - # For production, integrate full x402 payment signing - return json.dumps({ - "error": "Live x402 payment execution not yet implemented", - "payment_quote": self._parse_402_response(path, body), - }, indent=2) + 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: 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}", "path": 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) From a21271ac2307b04a0d007e5538bbffae8168622e Mon Sep 17 00:00:00 2001 From: plagtech Date: Sat, 11 Jul 2026 09:49:43 -0700 Subject: [PATCH 3/5] Address PR #27 review: gate paid tools on EVM_PRIVATE_KEY - config.yml: reference the spraay function group (tool_names: [spraay]) instead of listing tools individually, matching the kaggle_mcp example. - register.py: register paid tools (balance, batch_send, escrow_create, rtp_discover) only when EVM_PRIVATE_KEY is set, via NAT's per-function filter_fn hook; free tools always register. Log one info line when paid tools are skipped. - Move live_batch_send_smoke.py to the spraay-x402-gateway repo; link to its new home from the README and drop the local copy. - README: document both registration modes and remove the now-unreachable keyless dry-run sample; keep the verified-live settlement details. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: plagtech --- examples/spraay_crypto_payments/README.md | 106 +++----- .../spraay_crypto_payments/configs/config.yml | 25 +- .../scripts/live_batch_send_smoke.py | 251 ------------------ .../src/spraay_crypto_payments/register.py | 32 ++- 4 files changed, 72 insertions(+), 342 deletions(-) delete mode 100644 examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index 7353215..b060c11 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -39,7 +39,8 @@ 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 can optionally sign a USDC transaction, resend the request with payment proof, and the server executes the operation. Without payment -credentials, the agent receives a payment quote (dry-run mode). +credentials, an x402 client receives a payment quote instead of the result +(dry-run mode). ### Supported Chains @@ -64,7 +65,11 @@ gateway client. | `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, Dry-Run by Default) +### 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 | |------|----------|-------|-------------| @@ -73,20 +78,24 @@ gateway client. | `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 | -**Paid Tool Modes:** -- **Dry-run (default):** Without `EVM_PRIVATE_KEY` set, paid tools return - structured payment quotes showing the required USDC amount, network, and - payment address. No wallet, no signing, no funds move. Safe for CI and demos. -- **Live mode:** With `EVM_PRIVATE_KEY` set, paid requests are 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` in live mode broadcasts a real batch payment to - every recipient in the list. +**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 @@ -115,7 +124,8 @@ export NVIDIA_API_KEY= export SPRAAY_GATEWAY_URL=https://gateway.spraay.app # optional, this is the default ``` -4. (Optional) For live execution of paid tools, set your EVM private key: +4. (Optional) To register and live-execute the paid tools, set your EVM private + key. Without it, only the six free tools register: ```bash export EVM_PRIVATE_KEY= # NEVER commit or share this @@ -123,8 +133,8 @@ 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, paid tools operate in dry-run -mode and return payment quotes. +service other than the blockchain. If not set, the paid tools are not +registered and only the free tools are available. ## Sample Runs @@ -239,34 +249,7 @@ nat run \ } ``` -### 4. Dry-Run Paid Tool: Batch Send Payment Quote - -Without `EVM_PRIVATE_KEY`, `batch_send` returns the x402 payment quote: - -```bash -nat run \ - --config_file configs/config.yml \ - --input "Send 1 USDC on base to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" -``` - -**Output:** - -```json -{ - "mode": "dry_run", - "endpoint": "/api/v1/batch/execute", - "payment_required": { - "price": "$0.020", - "amount_usdc_raw": 20000, - "asset": "USDC", - "network": "eip155:8453", - "pay_to": "0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8" - }, - "note": "Set EVM_PRIVATE_KEY environment variable to execute for real." -} -``` - -### 5. (Optional) Live Mode — Moves Real Funds +### 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 @@ -311,26 +294,16 @@ tool returns a structured `mode: live` error explaining why — no partial charg #### Deterministic first live test (no agent, no LLM) -For the most predictable first live run, use the standalone smoke script. It -drives `batch_send` directly through the client — no ReAct agent, no +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. Run it first with no key (dry-run quote), then with a -funded key: - -```bash -# 1. Dry-run: prints the x402 quote, no funds move. -python scripts/live_batch_send_smoke.py - -# 2. Live: signs + settles the $0.02 fee and submits the batch (REAL funds). -export EVM_PRIVATE_KEY=0x... # funded Base wallet -python scripts/live_batch_send_smoke.py \ - --to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 --amount 0.01 -``` - -The script derives `sender` from your key automatically, prints a summary, -prompts for confirmation (skip with `--yes`), and reports the settlement -transaction hash. See `--help` for multi-recipient (`--to addr:amount`), token, -chain, and gateway options. +`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 @@ -441,7 +414,6 @@ Spraay x402 Gateway (gateway.spraay.app) | `src/spraay_crypto_payments/register.py` | Tool registration with `@register_function_group` | | `src/spraay_crypto_payments/spraay_client.py` | Async HTTP client with x402 support | | `src/spraay_crypto_payments/__init__.py` | Package init | -| `scripts/live_batch_send_smoke.py` | Standalone batch_send smoke test (dry-run + live) | | `pyproject.toml` | Project dependencies and NAT entry points | ## Related Examples diff --git a/examples/spraay_crypto_payments/configs/config.yml b/examples/spraay_crypto_payments/configs/config.yml index 84ac2d8..1d250e5 100644 --- a/examples/spraay_crypto_payments/configs/config.yml +++ b/examples/spraay_crypto_payments/configs/config.yml @@ -23,10 +23,12 @@ # - health, routes, chains, price # - batch_validate, batch_estimate (batch payment preview - runnable in CI!) # -# PAID tools (x402 protocol, dry-run by default): +# PAID tools (x402 protocol): # - balance ($0.005), batch_send ($0.02), escrow_create ($0.10), rtp_discover ($0.005) -# - Set EVM_PRIVATE_KEY environment variable to execute paid tools for real -# - Without EVM_PRIVATE_KEY: paid tools return payment quotes (safe for CI/demos) +# - 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). @@ -42,20 +44,9 @@ llms: workflow: _type: react_agent - tool_names: - # Free info & discovery tools - - spraay__health - - spraay__routes - - spraay__chains - - spraay__price - # Batch payments (FREE validate/estimate, PAID execute) - - spraay__batch_validate - - spraay__batch_estimate - - spraay__batch_send - # Paid tools (dry-run by default) - - spraay__balance - - spraay__escrow_create - - spraay__rtp_discover + # 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/scripts/live_batch_send_smoke.py b/examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py deleted file mode 100644 index ece02db..0000000 --- a/examples/spraay_crypto_payments/scripts/live_batch_send_smoke.py +++ /dev/null @@ -1,251 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Standalone smoke test for the Spraay batch_send x402 flow. - -Drives ``SpraayClient`` directly against the gateway's ``/api/v1/batch/execute`` -endpoint, bypassing the ReAct agent entirely. No LLM, no NVIDIA_API_KEY, and no -tool-selection ambiguity — exactly one batch payment is attempted, so this is -the most deterministic way to run a first live test. - -Modes (identical to the tools): - -* **Dry-run** (no ``EVM_PRIVATE_KEY``): prints the x402 payment quote. No funds - move. Run this first to sanity-check connectivity and your recipient list. -* **Live** (``EVM_PRIVATE_KEY`` set): signs and settles the x402 gateway fee and - submits the batch. THIS MOVES REAL FUNDS. Requires an explicit confirmation - (type ``yes`` at the prompt, or pass ``--yes``). - -Examples --------- -Dry-run the default single-recipient batch:: - - python scripts/live_batch_send_smoke.py - -Live send 0.01 USDC on Base to one address (requires a funded wallet):: - - export EVM_PRIVATE_KEY=0x... # funded Base wallet; never commit/share - python scripts/live_batch_send_smoke.py \ - --to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 --amount 0.01 --yes - -Multiple recipients:: - - python scripts/live_batch_send_smoke.py \ - --to 0xAAA...:0.01 --to 0xBBB...:0.02 --yes - -The private key is read only from the environment, used only to build the -in-memory signer, and is never printed by this script. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import sys - -# Allow running straight from the example directory without `pip install -e .`. -_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") -if os.path.isdir(_SRC) and _SRC not in sys.path: - sys.path.insert(0, _SRC) - -from spraay_crypto_payments.spraay_client import SpraayClient, to_batch_execute_payload # noqa: E402 - -# Zero-address placeholder used for dry-run quotes (mirrors the batch_send tool). -_PLACEHOLDER_SENDER = "0x0000000000000000000000000000000000000000" - - -def _parse_recipients(values: list[str]) -> list[dict]: - """Parse --to values into recipient dicts. - - Each value is either a bare address (paired with --amount) or the compact - ``address:amount`` form. Returns a list of {"to", "amount"} dicts. - """ - recipients: list[dict] = [] - for value in values: - if ":" in value: - addr, _, amount = value.partition(":") - recipients.append({"to": addr.strip(), "amount": amount.strip()}) - else: - recipients.append({"to": value.strip(), "amount": None}) - return recipients - - -def _derive_sender(private_key: str) -> str | None: - """Derive the payer's public address from the private key, or None. - - Never logs or returns the key itself — only the derived public address. - """ - try: - from eth_account import Account - except ImportError: - return None - key = private_key if private_key.startswith("0x") else "0x" + private_key - try: - return Account.from_key(key).address - except Exception: - # Invalid key — let SpraayClient surface the structured error at send time. - return None - - -def _build_batch(args: argparse.Namespace, private_key: str | None) -> dict: - """Construct the /api/v1/batch/execute request body.""" - recipients = _parse_recipients(args.to) - # Fill in the shared --amount for any bare addresses. - for rec in recipients: - if rec["amount"] is None: - if args.amount is None: - raise SystemExit( - f"Recipient {rec['to']} has no amount. Pass --amount, or use " - f"the address:amount form." - ) - rec["amount"] = args.amount - - data: dict = { - "recipients": recipients, - "token": args.token, - "chain": args.chain, - } - - # In live mode the sender must be the paying wallet, not the dry-run - # placeholder. Prefer an explicit --sender, else derive it from the key. - if args.sender: - data["sender"] = args.sender - elif private_key: - derived = _derive_sender(private_key) - data["sender"] = derived or _PLACEHOLDER_SENDER - else: - data["sender"] = _PLACEHOLDER_SENDER - - return data - - -def _confirm(data: dict, live: bool, assume_yes: bool) -> bool: - """Print a summary and, in live mode, require explicit confirmation.""" - total = 0.0 - for rec in data["recipients"]: - try: - total += float(rec["amount"]) - except (TypeError, ValueError): - total = float("nan") - break - - print("=" * 64) - print("Spraay batch_send smoke test") - print("=" * 64) - print(f" mode : {'LIVE (moves real funds)' if live else 'dry-run (quote only)'}") - print(f" gateway : {data.get('_gateway', '(default)')}") - print(f" token/chain : {data['token']} on {data['chain']}") - print(f" sender : {data['sender']}") - print(f" recipients : {len(data['recipients'])}") - for rec in data["recipients"]: - print(f" -> {rec['to']} {rec['amount']} {data['token']}") - print(f" total send : {total} {data['token']} (plus the $0.02 x402 gateway fee)") - print("=" * 64) - - if not live: - return True - if assume_yes: - print("Confirmation skipped via --yes. Proceeding with a REAL payment.\n") - return True - try: - answer = input("This will move REAL funds. Type 'yes' to proceed: ").strip().lower() - except EOFError: - answer = "" - return answer == "yes" - - -async def _run(args: argparse.Namespace) -> int: - private_key = os.environ.get("EVM_PRIVATE_KEY") - live = bool(private_key) - - data = _build_batch(args, private_key) - summary = dict(data, _gateway=args.gateway) - - if not _confirm(summary, live, args.yes): - print("Aborted - no request sent.") - return 1 - - client = SpraayClient(gateway_url=args.gateway, timeout=args.timeout) - print("\nSubmitting batch to", f"{args.gateway}/api/v1/batch/execute", "...\n") - # The execute endpoint requires parallel recipients/amounts arrays in raw - # base units (see its 402 bazaar schema), not the {to, amount} decimal - # objects used for display/confirmation above. - payload = to_batch_execute_payload(data) - result = await client.post("/api/v1/batch/execute", payload) - print(result) - - # Best-effort exit code: non-zero if the client reported an error. - try: - parsed = json.loads(result) - except Exception: - return 0 - if isinstance(parsed, dict) and parsed.get("error"): - return 2 - return 0 - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Deterministic smoke test for the Spraay batch_send x402 flow.", - ) - parser.add_argument( - "--to", - action="append", - default=[], - metavar="ADDRESS[:AMOUNT]", - help="Recipient address, or address:amount. Repeatable. " - "Defaults to one small test recipient.", - ) - parser.add_argument( - "--amount", - default=None, - help="Amount per recipient for bare --to addresses (default: 0.01 when " - "no recipients are given).", - ) - parser.add_argument("--token", default="USDC", help="Token symbol (default: USDC).") - parser.add_argument("--chain", default="base", help="Chain (default: base).") - parser.add_argument( - "--sender", - default=None, - help="Sender/payer address. In live mode, defaults to the address " - "derived from EVM_PRIVATE_KEY.", - ) - parser.add_argument( - "--gateway", - default=os.environ.get("SPRAAY_GATEWAY_URL", "https://gateway.spraay.app"), - help="Gateway base URL (default: $SPRAAY_GATEWAY_URL or gateway.spraay.app).", - ) - parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout seconds (default: 60).") - parser.add_argument( - "--yes", - action="store_true", - help="Skip the live-mode confirmation prompt (use with care).", - ) - return parser - - -def main() -> int: - args = _build_parser().parse_args() - if not args.to: - # Default: a single tiny test payment to a well-known example address. - args.to = ["0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8"] - if args.amount is None: - args.amount = "0.01" - return asyncio.run(_run(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) 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 bd64c11..979c072 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 @@ -373,20 +374,37 @@ async def rtp_discover(query: str) -> str: return await client.get("/api/v1/robots/list", params) - # Register all tools with the function group + # 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("price", price, description=price.__doc__) - # Batch payment tools (lead with these - batch_validate and batch_estimate are FREE) + # 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__) - group.add_function("batch_send", batch_send, description=batch_send.__doc__) - # Paid tools (dry-run by default) - group.add_function("balance", balance, description=balance.__doc__) - group.add_function("escrow_create", escrow_create, description=escrow_create.__doc__) - group.add_function("rtp_discover", rtp_discover, description=rtp_discover.__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 From a95c5abe1b2b80bd40fafb1497285afe1dd5f791 Mon Sep 17 00:00:00 2001 From: plagtech Date: Sat, 11 Jul 2026 14:18:13 -0700 Subject: [PATCH 4/5] fix: declare nvidia-nat-langchain dependency required by config The config's react_agent workflow and nim LLM types are provided by nvidia-nat-langchain, which pyproject.toml did not declare. On a clean environment the README's install step succeeded but nat run failed at config validation. Fixed by adding the nvidia-nat[langchain] extra, matching sibling examples. Verified end-to-end via nat run in both modes after the fix (6 tools without EVM_PRIVATE_KEY, 10 with). Also fix the README install step to `pip install -e .` (the prior `uv pip install -e .` errors on bare system Python), matching sibling examples. Add a truncation marker to the Sample 1 chains output so re-testers don't read the abbreviated 3-chain sample as missing chains. Signed-off-by: plagtech --- examples/spraay_crypto_payments/README.md | 8 +++++--- examples/spraay_crypto_payments/pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index b060c11..70609df 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -100,7 +100,7 @@ with no wallet. ## 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 @@ -114,7 +114,7 @@ cd examples/spraay_crypto_payments 2. Install dependencies: ```bash -uv pip install -e . +pip install -e . ``` 3. Set environment variables: @@ -169,6 +169,8 @@ nat run \ "chainId": 42161, "status": "online" } + // ... additional chains omitted (Polygon, Optimism, Avalanche, + // BNB Chain, and others); the live gateway returns all supported chains } } ``` @@ -289,7 +291,7 @@ tool returns a structured `mode: live` error explaining why — no partial charg - 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 `uv pip install -e .`, which +- Install the live-mode dependencies (included by `pip install -e .`, which pulls `x402[evm,httpx]`). #### Deterministic first live test (no agent, no LLM) diff --git a/examples/spraay_crypto_payments/pyproject.toml b/examples/spraay_crypto_payments/pyproject.toml index 8a63704..fd2e1a2 100644 --- a/examples/spraay_crypto_payments/pyproject.toml +++ b/examples/spraay_crypto_payments/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11,<3.14" dependencies = [ - "nvidia-nat>=1.3.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 From f345d588a5a93f46f2e097a109ddc479119eee7d Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 14 Jul 2026 15:04:34 -0700 Subject: [PATCH 5/5] ci: apply repo formatting; fix Vale and link-check failures in README Signed-off-by: plagtech --- examples/spraay_crypto_payments/README.md | 9 ++-- .../src/spraay_crypto_payments/register.py | 10 ++-- .../spraay_crypto_payments/spraay_client.py | 54 ++++++++++--------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/examples/spraay_crypto_payments/README.md b/examples/spraay_crypto_payments/README.md index 70609df..0b36df4 100644 --- a/examples/spraay_crypto_payments/README.md +++ b/examples/spraay_crypto_payments/README.md @@ -311,7 +311,7 @@ a funded `EVM_PRIVATE_KEY`. See its `--help` for multi-recipient 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 **Base mainnet** (`eip155:8453`): +gateway fee settled on the Base production network (`eip155:8453`): ```text ================================================================ @@ -369,11 +369,12 @@ Submitting batch to https://gateway.spraay.app/api/v1/batch/execute ... } ``` -Verify on-chain: - +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. +is returned as non-custodial `calldata` for the sender to broadcast. ## Architecture 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 979c072..e88ed2f 100644 --- a/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py +++ b/examples/spraay_crypto_payments/src/spraay_crypto_payments/register.py @@ -332,7 +332,8 @@ async def escrow_create(query: str) -> str: 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 json_mod.dumps( + {"error": "Invalid escrow format. Expected JSON with amount, token, chain, beneficiary, condition."}) return await client.post("/api/v1/escrow/create", data) @@ -386,10 +387,9 @@ 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.") + 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__) 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 3c1fd95..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 @@ -96,7 +96,7 @@ def to_batch_execute_payload(data: dict) -> dict: token = str(data.get("token", "USDC")).upper() decimals = _TOKEN_DECIMALS.get(token, _DEFAULT_TOKEN_DECIMALS) - scale = Decimal(10) ** decimals + scale = Decimal(10)**decimals addresses: list[str] = [] amounts: list[str] = [] @@ -201,10 +201,8 @@ def _get_x402_client(self): 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 + 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"): @@ -272,7 +270,8 @@ async def _live_request( """ try: x402_client = self._get_x402_client() - from x402.http.clients.httpx import PaymentError, wrapHttpxWithPayment + 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: @@ -280,7 +279,8 @@ async def _live_request( "mode": "live", "error": f"Live mode dependencies missing: {e}", "path": path, - }, indent=2) + }, + indent=2) url = f"{self.gateway_url}{path}" headers = {"Content-Type": "application/json"} @@ -295,16 +295,16 @@ async def _live_request( # 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) + 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) @@ -318,22 +318,26 @@ async def _live_request( "mode": "live", "result": self._safe_json(response), "settlement": settlement, - }, indent=2) + }, + 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) + }, + 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) + 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)