diff --git a/.env.example b/.env.example index f718249..a29f324 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,26 @@ OMNICLAW_PRIVATE_KEY=0x... # authority. The policy engine exposes the controlled payment API. OMNICLAW_AGENT_TOKEN=replace-with-agent-token OMNICLAW_AGENT_POLICY_PATH=./policy.json +OMNICLAW_AGENT_STATE_PATH=./wallet-state.json OMNICLAW_OWNER_TOKEN=replace-with-owner-token +# Buyer server rail profile. +# hybrid = Circle direct transfer + x402 paid APIs +# circle = Circle direct transfer only +# x402 = x402 paid APIs only +OMNICLAW_BUYER_MODE=hybrid + +# Credential requirements by mode: +# hybrid: CIRCLE_API_KEY + ENTITY_SECRET + OMNICLAW_PRIVATE_KEY + OMNICLAW_RPC_URL +# circle: CIRCLE_API_KEY + ENTITY_SECRET +# x402: OMNICLAW_PRIVATE_KEY + OMNICLAW_RPC_URL +# x402 Gateway nanopayments also require funded Gateway balance. They do not +# require CIRCLE_API_KEY when the seller provides GatewayWalletBatched metadata. + +# Optional explicit rail overrides. Leave unset to use OMNICLAW_BUYER_MODE. +# OMNICLAW_ENABLE_CIRCLE_TRANSFER=true +# OMNICLAW_ENABLE_X402=true + # Policy hot reload interval in seconds. Use a larger value or disable reload in # production if policies are deployed immutably. OMNICLAW_POLICY_RELOAD_INTERVAL=5 @@ -49,36 +67,41 @@ OMNICLAW_REDIS_URL=redis://localhost:6379/0 # ============================================================================= # Used by `omniclaw-cli` when acting as an agent execution layer. -OMNICLAW_SERVER_URL=http://127.0.0.1:8080 +OMNICLAW_SERVER_URL=http://127.0.0.1:9091 OMNICLAW_TOKEN=replace-with-agent-token # Optional human-readable CLI output. Default output is agent-friendly JSON. OMNICLAW_CLI_HUMAN=0 # ============================================================================= -# Circle Gateway / Nanopayments +# x402 Nanopayment Funding (Circle Gateway) # ============================================================================= -# Required for Circle Gateway nanopayments, Gateway balance, and Circle-backed -# wallet operations. -CIRCLE_API_KEY=replace-with-circle-api-key +# Required for Circle direct transfers and optional Circle Gateway API helper +# operations. For OMNICLAW_BUYER_MODE=x402, leave this blank unless you have +# a real Circle API key for helper operations. +CIRCLE_API_KEY= -# Entity secret for Circle wallet encryption. +# Entity secret for Circle Developer Wallet encryption. Required only when the +# Circle direct transfer rail is enabled. # If your Circle account/API key already has an Entity Secret, set it here. # Circle only allows one active Entity Secret per account/API key, so OmniClaw # only auto-generates/registers a new one when no existing secret is provided or # found in the managed local credential store. ENTITY_SECRET= -OMNICLAW_NANOPAYMENTS_ENABLED=true -OMNICLAW_NANOPAYMENTS_ENV=testnet +# Nanopayment knobs below tune Gateway behavior when x402 uses GatewayWalletBatched. +# Gateway API environment is derived from OMNICLAW_ENV: +# production/prod/mainnet -> mainnet; all other values -> testnet. OMNICLAW_NANOPAYMENTS_AUTO_TOPUP=false OMNICLAW_NANOPAYMENTS_TOPUP_THRESHOLD=1.00 OMNICLAW_NANOPAYMENTS_TOPUP_AMOUNT=10.00 OMNICLAW_NANOPAYMENTS_MICRO_THRESHOLD=1.00 -# Optional Gateway contract override for GatewayWalletBatched metadata. +# Optional Gateway contract overrides. OmniClaw has defaults for supported EVM +# networks; set these only when a network's Gateway metadata changes. CIRCLE_GATEWAY_CONTRACT= +CIRCLE_GATEWAY_USDC_ADDRESS= # ============================================================================= # Production Hardening diff --git a/.gitignore b/.gitignore index 21ad0be..935269f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,11 @@ logs/ tmp/ .runtime/ output/doc/ +examples/agent/buyer/policy.json +examples/agent/buyer/*.state.json +examples/agent/buyer/wallet-state.json +examples/agent/buyer/runtime/ +examples/agent/runtime/ # Private operating workspace # Keep the root internal workspace out of git. diff --git a/Dockerfile.agent b/Dockerfile.agent index 7d1bc10..87995a0 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -23,4 +23,4 @@ EXPOSE 9090 ENV OMNICLAW_AGENT_PORT=9090 -CMD ["uv", "run", "omniclaw", "server", "--host", "0.0.0.0", "--port", "9090"] +CMD ["uv", "run", "uvicorn", "omniclaw.agent.server:app", "--host", "0.0.0.0", "--port", "9090"] diff --git a/README.md b/README.md index a02df6b..f1361c5 100644 --- a/README.md +++ b/README.md @@ -32,27 +32,44 @@ pip install omniclaw Start the policy engine: ```bash -export OMNICLAW_PRIVATE_KEY="0x..." -export OMNICLAW_AGENT_TOKEN="agent-token" -export OMNICLAW_AGENT_POLICY_PATH="./policy.json" -export OMNICLAW_NETWORK="BASE-SEPOLIA" -export OMNICLAW_RPC_URL="https://sepolia.base.org" - -docker compose up --build omniclaw-agent +cp .env.example .env +# Hybrid mode (default): leave OMNICLAW_BUYER_MODE=hybrid and fill CIRCLE_API_KEY, +# ENTITY_SECRET, OMNICLAW_PRIVATE_KEY, OMNICLAW_AGENT_TOKEN, OMNICLAW_OWNER_TOKEN, +# OMNICLAW_NETWORK, and OMNICLAW_RPC_URL. +# x402-only Gateway mode: set OMNICLAW_BUYER_MODE=x402, leave CIRCLE_API_KEY and +# ENTITY_SECRET empty unless you need optional Circle Gateway API helpers, and fill +# OMNICLAW_PRIVATE_KEY, OMNICLAW_AGENT_TOKEN, OMNICLAW_OWNER_TOKEN, +# OMNICLAW_NETWORK, and OMNICLAW_RPC_URL. + +mkdir -p examples/agent/buyer/runtime +cp examples/agent/buyer/policy.example.json examples/agent/buyer/runtime/policy.json +# Edit examples/agent/buyer/runtime/policy.json so the token matches OMNICLAW_AGENT_TOKEN. + +docker compose -f examples/agent/buyer/docker-compose.yml --env-file .env up --build ``` +The policy file is stable configuration. Generated wallet state is written separately +to `examples/agent/buyer/runtime/wallet-state.json`. + +Buyer-facing policy rails are: + +- `circle_transfer` for direct Circle Developer Wallet transfers. +- `x402` for paid API payments. OmniClaw chooses Gateway nanopayment or the standard + x402 payment path internally based on seller accepts, buyer config, and Gateway balance. + Configure the buyer CLI: ```bash -export OMNICLAW_SERVER_URL="http://localhost:8080" -export OMNICLAW_TOKEN="agent-token" +set -a; source .env; set +a +export OMNICLAW_SERVER_URL="http://127.0.0.1:9091" +export OMNICLAW_TOKEN="$OMNICLAW_AGENT_TOKEN" ``` Inspect and pay an x402 endpoint: ```bash -omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli inspect-x402 --recipient "http://127.0.0.1:4023/compute?size=20" +omniclaw-cli pay --recipient "http://127.0.0.1:4023/compute?size=20" --amount 0.10 --idempotency-key job-123 ``` ## Development diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 117aa50..e1622a1 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -16,12 +16,12 @@ client = OmniClaw() await client.pay( wallet_id="wallet-id", recipient="https://paid.example.com/compute", - amount=None, + amount="0.10", idempotency_key="job-123", ) ``` -Use `amount=None` for x402 URLs where the endpoint publishes the amount in `PAYMENT-REQUIRED`. +For x402 URLs, `amount` is the maximum amount the buyer is willing to pay. ### Gateway Buyer Helpers @@ -43,5 +43,17 @@ The local policy engine exposes buyer payment, wallet, ledger, policy, and x402 Start it with: ```bash -docker compose up --build omniclaw-agent +docker compose -f examples/agent/buyer/docker-compose.yml --env-file .env up --build +``` + +Agent requests use: + +```text +Authorization: Bearer +``` + +Privileged guard-bypass and confirmation endpoints also require: + +```text +X-Omniclaw-Owner-Token: ``` diff --git a/docs/POLICY_REFERENCE.md b/docs/POLICY_REFERENCE.md index 9a47391..54f4f58 100644 --- a/docs/POLICY_REFERENCE.md +++ b/docs/POLICY_REFERENCE.md @@ -25,14 +25,16 @@ Hot reload: "wallets": { "primary": { "name": "Primary Wallet", - "wallet_id": "wlt_...", - "address": "0x...", "limits": { "daily_max": "100.00", "per_tx_max": "50.00" }, "recipients": { "mode": "allow_all" + }, + "rails": { + "circle_transfer": true, + "x402": true } } } @@ -53,8 +55,6 @@ Hot reload: "wallets": { "primary": { "name": "Primary Wallet", - "wallet_id": "wlt_...", - "address": "0x...", "limits": { "daily_max": "1000.00", "hourly_max": "200.00", @@ -70,6 +70,10 @@ Hot reload: "addresses": ["0xSeller1...", "0xSeller2..."], "domains": ["api.service-a.com"] }, + "rails": { + "circle_transfer": true, + "x402": true + }, "confirm_threshold": "50.00" } } @@ -105,11 +109,12 @@ Hot reload: | Field | Type | Description | |-------|------|-------------| | `name` | string | Wallet name | -| `wallet_id` | string | Circle Developer Wallet ID (auto-generated if missing) | -| `address` | string | Circle Developer Wallet address (auto-filled if missing) | +| `wallet_id` | string | Optional existing Circle Developer Wallet ID | +| `address` | string | Optional expected Circle Developer Wallet address | | `limits` | object | Spending limits | | `rate_limits` | object | Rate limits | | `recipients` | object | Recipient rules | +| `rails` | object | Buyer payment rails enabled for this wallet | | `confirm_threshold` | decimal | Amount requiring owner confirmation | ### limits @@ -136,6 +141,15 @@ Hot reload: | `addresses` | array | List of allowed/blocked addresses | | `domains` | array | List of allowed/blocked domains (for x402 URLs) | +### rails + +| Field | Type | Description | +|-------|------|-------------| +| `circle_transfer` | boolean | Allow direct Circle Developer Wallet transfers | +| `x402` | boolean | Allow paid API payments. OmniClaw chooses Gateway nanopayment or the standard x402 payment path internally based on seller accepts, buyer config, and Gateway balance. | + +Generated Circle wallet IDs and EOA addresses are stored in `OMNICLAW_AGENT_STATE_PATH`, not in `policy.json`. Keep policy files stable and reviewable. + --- ## Recipient Modes @@ -285,9 +299,9 @@ Agent can pay everyone EXCEPT these addresses/domains. --- -## Environment Override +## SDK Environment Guard Defaults -You can also set limits via environment variables instead of policy.json: +When using the SDK directly, you can set default guard limits via environment variables: ```bash export OMNICLAW_DAILY_BUDGET="100.00" @@ -296,7 +310,8 @@ export OMNICLAW_TX_LIMIT="25.00" export OMNICLAW_RATE_LIMIT_PER_MIN="10" ``` -These override policy.json values if set. +The buyer server applies `policy.json` as its source of truth. Put server-side +agent limits in the policy file. --- @@ -305,3 +320,4 @@ These override policy.json values if set. - Simple: `examples/policy-simple.json` - Advanced: `examples/policy-advanced.json` - Default: `examples/default-policy.json` +- Buyer server template: `examples/agent/buyer/policy.example.json` diff --git a/docs/agent-getting-started.md b/docs/agent-getting-started.md index ef9398c..bb28cf8 100644 --- a/docs/agent-getting-started.md +++ b/docs/agent-getting-started.md @@ -2,34 +2,77 @@ This guide is for agent buyers. -## Start Core +## Start The Buyer Server ```bash -export OMNICLAW_PRIVATE_KEY="0x..." -export OMNICLAW_AGENT_TOKEN="agent-token" -export OMNICLAW_AGENT_POLICY_PATH="./policy.json" -export OMNICLAW_NETWORK="BASE-SEPOLIA" -export OMNICLAW_RPC_URL="https://sepolia.base.org" +cp .env.example .env +# For hybrid mode, fill CIRCLE_API_KEY, ENTITY_SECRET, OMNICLAW_PRIVATE_KEY, +# OMNICLAW_AGENT_TOKEN, OMNICLAW_OWNER_TOKEN, OMNICLAW_NETWORK, and OMNICLAW_RPC_URL. +# For x402-only Gateway mode instead, set OMNICLAW_BUYER_MODE=x402 and leave +# CIRCLE_API_KEY and ENTITY_SECRET empty unless you need optional Circle Gateway +# API helper operations. -docker compose up --build omniclaw-agent +mkdir -p examples/agent/buyer/runtime +cp examples/agent/buyer/policy.example.json examples/agent/buyer/runtime/policy.json +# Edit examples/agent/buyer/runtime/policy.json so the token matches OMNICLAW_AGENT_TOKEN. + +docker compose -f examples/agent/buyer/docker-compose.yml --env-file .env up --build ``` +The buyer server runs in hybrid mode by default: + +- Circle Developer Wallet for direct USDC transfers. +- EOA signer for x402 paid APIs. When the seller advertises `GatewayWalletBatched` + and the buyer has Gateway balance, OmniClaw can use the Gateway nanopayment path. +- Stable policy in `examples/agent/buyer/runtime/policy.json`. +- Generated wallet state in `examples/agent/buyer/runtime/wallet-state.json`. + +Mode requirements: + +| Mode | Credentials | +| --- | --- | +| `hybrid` | `CIRCLE_API_KEY`, `ENTITY_SECRET`, `OMNICLAW_PRIVATE_KEY`, `OMNICLAW_RPC_URL` | +| `circle` | `CIRCLE_API_KEY`, `ENTITY_SECRET` | +| `x402` | `OMNICLAW_PRIVATE_KEY`, `OMNICLAW_RPC_URL`, and funded Gateway balance for `GatewayWalletBatched` nanopayments | + +`CIRCLE_API_KEY` is not required for x402 Gateway nanopayments. It is only needed +for Circle direct transfers and optional Circle Gateway API helper operations. +Gateway is an x402 execution path, not a separate buyer rail. + ## Configure The Buyer CLI ```bash -export OMNICLAW_SERVER_URL="http://localhost:8080" -export OMNICLAW_TOKEN="agent-token" +set -a; source .env; set +a +export OMNICLAW_SERVER_URL="http://127.0.0.1:9091" +export OMNICLAW_TOKEN="$OMNICLAW_AGENT_TOKEN" ``` ## Inspect A Paid URL ```bash -omniclaw-cli can-pay --recipient https://paid.example.com/compute -omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute +omniclaw-cli can-pay --recipient "http://127.0.0.1:4023/compute?size=20" +omniclaw-cli inspect-x402 --recipient "http://127.0.0.1:4023/compute?size=20" +``` + +## Pay A Paid API + +```bash +omniclaw-cli pay --recipient "http://127.0.0.1:4023/compute?size=20" --amount 0.10 --idempotency-key job-123 +``` + +## Direct Transfer + +```bash +omniclaw-cli pay \ + --recipient \ + --amount 1.00 \ + --idempotency-key transfer-123 ``` -## Pay +## x402 Nanopayment Funding ```bash -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli deposit-address +omniclaw-cli balance-detail +omniclaw-cli deposit --amount 10.00 --check-gas ``` diff --git a/docs/agent-skills.md b/docs/agent-skills.md index 557f04d..5541f59 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -7,5 +7,5 @@ Use these flows when an agent needs to inspect or pay a URL through the policy e ```bash omniclaw-cli can-pay --recipient https://paid.example.com/compute omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli pay --recipient https://paid.example.com/compute --amount 0.10 --idempotency-key job-123 ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ff21a55..fd6d21a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -5,7 +5,7 @@ ## Configure ```bash -omniclaw-cli configure --server-url http://localhost:8080 --token agent-token --wallet wallet-id +omniclaw-cli configure --server-url http://127.0.0.1:9091 --token agent-token --wallet wallet-id ``` ## Inspect A Paid URL @@ -18,7 +18,7 @@ omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute ## Pay ```bash -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli pay --recipient https://paid.example.com/compute --amount 0.10 --idempotency-key job-123 omniclaw-cli pay --recipient 0xRecipient --amount 5.00 --idempotency-key job-124 ``` @@ -26,6 +26,8 @@ omniclaw-cli pay --recipient 0xRecipient --amount 5.00 --idempotency-key job-124 ```bash omniclaw-cli wallet list +omniclaw-cli deposit-address +omniclaw-cli balance-detail omniclaw-cli ledger omniclaw-cli intents omniclaw-cli confirmations diff --git a/docs/developer-guide.md b/docs/developer-guide.md index cb0126d..154b578 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -12,33 +12,33 @@ client = OmniClaw() result = await client.pay( wallet_id="wallet-id", recipient="https://paid.example.com/compute", - amount=None, + amount="0.10", idempotency_key="job-123", ) ``` -For x402 URLs, OmniClaw inspects the payment requirements and routes through the buyer rail that is available and allowed by policy. +For x402 URLs, pass the maximum amount you are willing to pay. The buyer server +inspects the payment requirements and rejects the payment if the seller asks for +more than the max amount. ## Run The Policy Engine ```bash -export OMNICLAW_PRIVATE_KEY="0x..." -export OMNICLAW_AGENT_TOKEN="agent-token" -export OMNICLAW_AGENT_POLICY_PATH="./policy.json" -export OMNICLAW_NETWORK="BASE-SEPOLIA" -export OMNICLAW_RPC_URL="https://sepolia.base.org" +cp .env.example .env +# Fill buyer credentials and policy tokens in .env. -docker compose up --build omniclaw-agent +docker compose -f examples/agent/buyer/docker-compose.yml --env-file .env up --build ``` ## Pay With The CLI ```bash -export OMNICLAW_SERVER_URL="http://localhost:8080" -export OMNICLAW_TOKEN="agent-token" +set -a; source .env; set +a +export OMNICLAW_SERVER_URL="http://127.0.0.1:9091" +export OMNICLAW_TOKEN="$OMNICLAW_AGENT_TOKEN" omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli pay --recipient https://paid.example.com/compute --amount 0.10 --idempotency-key job-123 ``` ## Gateway Buyer Funding diff --git a/docs/operator-cli.md b/docs/operator-cli.md index 76bbbda..42eaca5 100644 --- a/docs/operator-cli.md +++ b/docs/operator-cli.md @@ -7,13 +7,13 @@ and query wallet, intent, ledger, and confirmation state. Start the policy engine with Docker Compose: ```bash -docker compose up --build omniclaw-agent +docker compose -f examples/agent/buyer/docker-compose.yml --env-file .env up --build ``` Then configure the CLI: ```bash -omniclaw configure --server-url http://localhost:8080 --token agent-token +omniclaw configure --server-url http://127.0.0.1:9091 --token agent-token ``` ## Buyer CLI @@ -23,5 +23,5 @@ omniclaw configure --server-url http://localhost:8080 --token agent-token ```bash omniclaw-cli can-pay --recipient https://paid.example.com/compute omniclaw-cli inspect-x402 --recipient https://paid.example.com/compute -omniclaw-cli pay --recipient https://paid.example.com/compute --idempotency-key job-123 +omniclaw-cli pay --recipient https://paid.example.com/compute --amount 0.10 --idempotency-key job-123 ``` diff --git a/examples/agent/buyer/docker-compose.yml b/examples/agent/buyer/docker-compose.yml index a28e5f3..98a783c 100644 --- a/examples/agent/buyer/docker-compose.yml +++ b/examples/agent/buyer/docker-compose.yml @@ -15,17 +15,22 @@ services: context: ../../.. dockerfile: Dockerfile.agent command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 9091 - env_file: ../../../.env environment: + - OMNICLAW_STORAGE_BACKEND=redis - OMNICLAW_REDIS_URL=redis://redis:6379/1 - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json + - OMNICLAW_AGENT_STATE_PATH=/config/wallet-state.json - OMNICLAW_LOG_LEVEL=INFO - - OMNICLAW_AGENT_TOKEN=payment-agent-token - - CIRCLE_API_KEY=${BUYER_CIRCLE_API_KEY} - - OMNICLAW_PRIVATE_KEY=${BUYER_OMNICLAW_PRIVATE_KEY} - - ENTITY_SECRET=${BUYER_ENTITY_SECRET} + - OMNICLAW_BUYER_MODE=${OMNICLAW_BUYER_MODE:-hybrid} + - OMNICLAW_NETWORK=${OMNICLAW_NETWORK:-BASE-SEPOLIA} + - OMNICLAW_RPC_URL=${OMNICLAW_RPC_URL:-} + - OMNICLAW_AGENT_TOKEN=${OMNICLAW_AGENT_TOKEN:-} + - OMNICLAW_OWNER_TOKEN=${OMNICLAW_OWNER_TOKEN:-} + - CIRCLE_API_KEY=${CIRCLE_API_KEY:-} + - OMNICLAW_PRIVATE_KEY=${OMNICLAW_PRIVATE_KEY:-} + - ENTITY_SECRET=${ENTITY_SECRET:-} volumes: - - ./policy.json:/config/policy.json + - ./runtime:/config ports: - "9091:9091" depends_on: diff --git a/examples/agent/buyer/policy.example.json b/examples/agent/buyer/policy.example.json new file mode 100644 index 0000000..5e0657b --- /dev/null +++ b/examples/agent/buyer/policy.example.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "tokens": { + "replace-with-agent-token": { + "wallet_alias": "payment-agent", + "active": true, + "label": "Payment Agent" + } + }, + "wallets": { + "payment-agent": { + "name": "Payment Agent", + "limits": { + "daily_max": "100.00", + "per_tx_max": "10.00" + }, + "recipients": { + "mode": "allow_all" + }, + "rails": { + "circle_transfer": true, + "x402": true + } + } + } +} diff --git a/examples/agent/buyer/policy.json b/examples/agent/buyer/policy.json deleted file mode 100644 index eb50384..0000000 --- a/examples/agent/buyer/policy.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "version": "2.0", - "tokens": { - "payment-agent-token": { - "wallet_alias": "payment-agent", - "active": true, - "label": "Payment Agent" - } - }, - "wallets": { - "payment-agent": { - "name": "Payment Agent", - "wallet_id": "fe1409e8-3948-56eb-b847-8c0a72c1b1d4", - "address": "0xcf9baaf6bb37677488c041fdb9166708407ebb3b", - "limits": { - "daily_max": "100.00", - "hourly_max": null, - "per_tx_max": "10.00", - "per_tx_min": null - }, - "rate_limits": null, - "recipients": { - "mode": "whitelist", - "addresses": [ - "0x5a4e248fa08c37b15ea0efdfdf336e92317d5243" - ], - "domains": [ - "api.stripe.com", - "localhost", - "127.0.0.1" - ] - }, - "confirm_threshold": null - } - }, - "limits": { - "daily_max": null, - "hourly_max": null, - "per_tx_max": null, - "per_tx_min": null - }, - "rate_limits": { - "per_minute": null, - "per_hour": null - }, - "recipients": { - "mode": "whitelist", - "addresses": [ - "0x5a4e248fa08c37b15ea0efdfdf336e92317d5243" - ], - "domains": [ - "api.stripe.com", - "localhost", - "127.0.0.1" - ] - }, - "confirm_threshold": null -} diff --git a/examples/agent/docker-compose.yml b/examples/agent/docker-compose.yml index 25a505e..ae1d81e 100644 --- a/examples/agent/docker-compose.yml +++ b/examples/agent/docker-compose.yml @@ -5,13 +5,18 @@ services: dockerfile: Dockerfile.agent command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 8080 environment: - - - - OMNICLAW_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com - - OMNICLAW_NETWORK=ETH-SEPOLIA - - OMNICLAW_STORAGE_BACKEND=memory - - OMNICLAW_AGENT_TOKEN=test-agent-token + - OMNICLAW_NETWORK=${OMNICLAW_NETWORK:-ETH-SEPOLIA} + - OMNICLAW_RPC_URL=${OMNICLAW_RPC_URL:-https://ethereum-sepolia-rpc.publicnode.com} + - OMNICLAW_STORAGE_BACKEND=${OMNICLAW_STORAGE_BACKEND:-memory} + - OMNICLAW_BUYER_MODE=${OMNICLAW_BUYER_MODE:-x402} + - OMNICLAW_AGENT_TOKEN=${OMNICLAW_AGENT_TOKEN:-test-agent-token} + - OMNICLAW_OWNER_TOKEN=${OMNICLAW_OWNER_TOKEN:-} - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json + - OMNICLAW_AGENT_STATE_PATH=/config/wallet-state.json + - CIRCLE_API_KEY=${CIRCLE_API_KEY:-} + - OMNICLAW_PRIVATE_KEY=${OMNICLAW_PRIVATE_KEY:-0x1111111111111111111111111111111111111111111111111111111111111111} + - ENTITY_SECRET=${ENTITY_SECRET:-} volumes: - - ./policy.json:/config/policy.json + - ./runtime:/config ports: - "9080:8080" diff --git a/examples/agent/policy.json b/examples/agent/policy.json index c873f84..73895ea 100644 --- a/examples/agent/policy.json +++ b/examples/agent/policy.json @@ -16,6 +16,10 @@ }, "recipients": { "mode": "allow_all" + }, + "rails": { + "circle_transfer": true, + "x402": true } } } diff --git a/examples/default-policy.json b/examples/default-policy.json index 5444f43..314976e 100644 --- a/examples/default-policy.json +++ b/examples/default-policy.json @@ -16,7 +16,11 @@ }, "recipients": { "mode": "allow_all" + }, + "rails": { + "circle_transfer": true, + "x402": true } } } -} \ No newline at end of file +} diff --git a/examples/machine-to-machine/README.md b/examples/machine-to-machine/README.md index fe628fc..20318ce 100644 --- a/examples/machine-to-machine/README.md +++ b/examples/machine-to-machine/README.md @@ -22,12 +22,13 @@ https://api.paid-resource.example/compute Consumer service: ```bash -export OMNICLAW_SERVER_URL="http://127.0.0.1:8080" -export OMNICLAW_TOKEN="service-agent-token" +set -a; source .env; set +a +export OMNICLAW_SERVER_URL="http://127.0.0.1:9091" +export OMNICLAW_TOKEN="$OMNICLAW_AGENT_TOKEN" omniclaw-cli can-pay --recipient https://api.paid-resource.example/compute omniclaw-cli inspect-x402 --recipient https://api.paid-resource.example/compute -omniclaw-cli pay --recipient https://api.paid-resource.example/compute --idempotency-key batch-042 +omniclaw-cli pay --recipient https://api.paid-resource.example/compute --amount 0.10 --idempotency-key batch-042 ``` ## Service Contract diff --git a/examples/policy-advanced.json b/examples/policy-advanced.json index b866a1a..820b61f 100644 --- a/examples/policy-advanced.json +++ b/examples/policy-advanced.json @@ -10,8 +10,6 @@ "wallets": { "payment-agent": { "name": "Payment Agent", - "wallet_id": "82e93f7f-ad08-5436-b876-d856f6fd36b4", - "address": "0x1cb6540be09f4fc41f8d3aab15dfb136e274a9b3", "limits": { "daily_max": "1000.00", "hourly_max": "200.00", @@ -33,7 +31,11 @@ "127.0.0.1" ] }, + "rails": { + "circle_transfer": true, + "x402": true + }, "confirm_threshold": "50.00" } } -} \ No newline at end of file +} diff --git a/examples/policy-simple.json b/examples/policy-simple.json index e5bc3cc..ef4865e 100644 --- a/examples/policy-simple.json +++ b/examples/policy-simple.json @@ -16,7 +16,11 @@ }, "recipients": { "mode": "allow_all" + }, + "rails": { + "circle_transfer": true, + "x402": true } } } -} \ No newline at end of file +} diff --git a/mcp_server/app/core/config.py b/mcp_server/app/core/config.py index 9055713..32ce036 100644 --- a/mcp_server/app/core/config.py +++ b/mcp_server/app/core/config.py @@ -73,11 +73,15 @@ def validate_payment_secrets(cls, value: SecretStr | None, info: any) -> SecretS @model_validator(mode="after") def validate_auth_configuration(self) -> "Settings": - if self.MCP_REQUIRE_AUTH and self.MCP_AUTH_ENABLED: - if not self.MCP_AUTH_TOKEN and not self.MCP_JWT_SECRET: - raise ValueError( - "MCP authentication is enabled and required, but no MCP_AUTH_TOKEN or MCP_JWT_SECRET is configured." - ) + if ( + self.MCP_REQUIRE_AUTH + and self.MCP_AUTH_ENABLED + and not self.MCP_AUTH_TOKEN + and not self.MCP_JWT_SECRET + ): + raise ValueError( + "MCP authentication is enabled and required, but no MCP_AUTH_TOKEN or MCP_JWT_SECRET is configured." + ) return self model_config = SettingsConfigDict( diff --git a/mcp_server/app/mcp/fastmcp_server.py b/mcp_server/app/mcp/fastmcp_server.py index f14f1b4..07f6844 100644 --- a/mcp_server/app/mcp/fastmcp_server.py +++ b/mcp_server/app/mcp/fastmcp_server.py @@ -83,7 +83,7 @@ async def create_agent_wallet( ) return {"status": "success", **result} except Exception as exc: - raise _fail("create_agent_wallet", exc) + raise _fail("create_agent_wallet", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -104,7 +104,7 @@ async def list_wallets( result = await client.list_wallets(wallet_set_id=wallet_set_id) return {"status": "success", "wallets": result} except Exception as exc: - raise _fail("list_wallets", exc) + raise _fail("list_wallets", exc) from exc @mcp.tool() @@ -117,7 +117,7 @@ async def get_wallet( result = await client.get_wallet(wallet_id) return {"status": "success", "wallet": result} except Exception as exc: - raise _fail("get_wallet", exc) + raise _fail("get_wallet", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -135,7 +135,7 @@ async def check_balance( result = await client.get_wallet_usdc_balance(wallet_id) return {"status": "success", **result} except Exception as exc: - raise _fail("check_balance", exc) + raise _fail("check_balance", exc) from exc @mcp.tool() @@ -148,7 +148,7 @@ async def get_balances( result = await client.get_balances(wallet_id) return {"status": "success", **result} except Exception as exc: - raise _fail("get_balances", exc) + raise _fail("get_balances", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -182,7 +182,7 @@ async def simulate( ) return {"status": "success", "simulation": result} except Exception as exc: - raise _fail("simulate", exc) + raise _fail("simulate", exc) from exc @mcp.tool() @@ -254,7 +254,7 @@ async def pay( ) return {"status": "success", "payment": result} except Exception as exc: - raise _fail("pay", exc) + raise _fail("pay", exc) from exc @mcp.tool() @@ -274,7 +274,7 @@ async def batch_pay( ) return {"status": "success", "batch_result": result} except Exception as exc: - raise _fail("batch_pay", exc) + raise _fail("batch_pay", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -319,7 +319,7 @@ async def create_payment_intent( ) return {"status": "success", "intent": result} except Exception as exc: - raise _fail("create_payment_intent", exc) + raise _fail("create_payment_intent", exc) from exc @mcp.tool() @@ -332,7 +332,7 @@ async def get_payment_intent( result = await client.get_payment_intent(intent_id) return {"status": "success", "intent": result} except Exception as exc: - raise _fail("get_payment_intent", exc) + raise _fail("get_payment_intent", exc) from exc @mcp.tool() @@ -345,7 +345,7 @@ async def confirm_payment_intent( result = await client.confirm_intent(intent_id) return {"status": "success", "payment": result} except Exception as exc: - raise _fail("confirm_payment_intent", exc) + raise _fail("confirm_payment_intent", exc) from exc @mcp.tool() @@ -359,7 +359,7 @@ async def cancel_intent( result = await client.cancel_intent(intent_id, reason=reason) return {"status": "success", "intent": result} except Exception as exc: - raise _fail("cancel_intent", exc) + raise _fail("cancel_intent", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -382,7 +382,7 @@ async def list_transactions( result = await client.list_transactions(wallet_id=wallet_id, blockchain=blockchain) return {"status": "success", **result} except Exception as exc: - raise _fail("list_transactions", exc) + raise _fail("list_transactions", exc) from exc @mcp.tool() @@ -395,7 +395,7 @@ async def sync_transaction( result = await client.sync_transaction(ledger_entry_id) return {"status": "success", **result} except Exception as exc: - raise _fail("sync_transaction", exc) + raise _fail("sync_transaction", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -413,7 +413,7 @@ async def can_pay( result = await client.can_pay(recipient) return {"status": "success", **result} except Exception as exc: - raise _fail("can_pay", exc) + raise _fail("can_pay", exc) from exc @mcp.tool() @@ -426,7 +426,7 @@ async def detect_payment_method( result = await client.detect_method(recipient) return {"status": "success", **result} except Exception as exc: - raise _fail("detect_payment_method", exc) + raise _fail("detect_payment_method", exc) from exc @mcp.tool() @@ -451,7 +451,7 @@ async def trust_lookup( ) return {"status": "success", **result} except Exception as exc: - raise _fail("trust_lookup", exc) + raise _fail("trust_lookup", exc) from exc # ───────────────────────────────────────────────────────────────────── @@ -469,4 +469,4 @@ async def ledger_get_entry( result = await client.ledger_get_entry(entry_id) return {"status": "success", **result} except Exception as exc: - raise _fail("ledger_get_entry", exc) + raise _fail("ledger_get_entry", exc) from exc diff --git a/mcp_server/app/webhooks/circle.py b/mcp_server/app/webhooks/circle.py index af198fb..6b6130f 100644 --- a/mcp_server/app/webhooks/circle.py +++ b/mcp_server/app/webhooks/circle.py @@ -108,7 +108,7 @@ async def circle_webhook( except Exception as e: logger.error("webhook_processing_failed", error=str(e), event_type=event_type) - raise HTTPException(status_code=500, detail="Webhook processing failed") + raise HTTPException(status_code=500, detail="Webhook processing failed") from e async def handle_payment_sent(payload: dict[str, Any]): diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py index 55b02a8..71f1643 100644 --- a/mcp_server/tests/conftest.py +++ b/mcp_server/tests/conftest.py @@ -1,10 +1,10 @@ +import os import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -import os os.environ["MCP_REQUIRE_AUTH"] = "false" os.environ["CIRCLE_API_KEY"] = "test-key" diff --git a/scripts/generate_cli_reference.py b/scripts/generate_cli_reference.py index e0a962d..5438bb9 100755 --- a/scripts/generate_cli_reference.py +++ b/scripts/generate_cli_reference.py @@ -21,7 +21,10 @@ ("omniclaw-cli deposit --help", ["omniclaw-cli", "deposit", "--help"]), ("omniclaw-cli withdraw --help", ["omniclaw-cli", "withdraw", "--help"]), ("omniclaw-cli withdraw-trustless --help", ["omniclaw-cli", "withdraw-trustless", "--help"]), - ("omniclaw-cli withdraw-trustless-complete --help", ["omniclaw-cli", "withdraw-trustless-complete", "--help"]), + ( + "omniclaw-cli withdraw-trustless-complete --help", + ["omniclaw-cli", "withdraw-trustless-complete", "--help"], + ), ("omniclaw-cli create-intent --help", ["omniclaw-cli", "create-intent", "--help"]), ("omniclaw-cli confirm-intent --help", ["omniclaw-cli", "confirm-intent", "--help"]), ("omniclaw-cli get-intent --help", ["omniclaw-cli", "get-intent", "--help"]), @@ -30,7 +33,10 @@ ("omniclaw-cli list-tx --help", ["omniclaw-cli", "list-tx", "--help"]), ("omniclaw-cli confirmations --help", ["omniclaw-cli", "confirmations", "--help"]), ("omniclaw-cli confirmations get --help", ["omniclaw-cli", "confirmations", "get", "--help"]), - ("omniclaw-cli confirmations approve --help", ["omniclaw-cli", "confirmations", "approve", "--help"]), + ( + "omniclaw-cli confirmations approve --help", + ["omniclaw-cli", "confirmations", "approve", "--help"], + ), ("omniclaw-cli confirmations deny --help", ["omniclaw-cli", "confirmations", "deny", "--help"]), ] diff --git a/src/omniclaw/agent/models.py b/src/omniclaw/agent/models.py index bac77ac..2b9135d 100644 --- a/src/omniclaw/agent/models.py +++ b/src/omniclaw/agent/models.py @@ -36,6 +36,10 @@ class PayResponse(BaseModel): recipient: str status: str method: str + selected_route: str | None = None + payment_source: str | None = None + execution_route: str | None = None + facilitator: str | None = None error: str | None = None requires_confirmation: bool = False confirmation_id: str | None = None @@ -49,15 +53,22 @@ class BalanceResponse(BaseModel): available: str reserved: str | None = None total: str | None = None + source: str | None = None + note: str | None = None class SimulateRequest(BaseModel): """Simulation request.""" recipient: str - amount: str + amount: str | None = None check_trust: bool = False skip_guards: bool = False + method: str = Field("GET", description="HTTP method for x402 URL payments") + body: str | None = Field(None, description="Request body for x402 URL payments") + headers: dict[str, str] | None = Field( + None, description="Request headers for x402 URL payments" + ) class SimulateResponse(BaseModel): @@ -196,6 +207,7 @@ class X402InspectResponse(BaseModel): router_detected_route: str | None = None selected_route: str | None = None payment_source: str | None = None + execution_route: str | None = None buyer_address: str | None = None gateway_available_balance: str | None = None selected_scheme: str | None = None diff --git a/src/omniclaw/agent/policy.py b/src/omniclaw/agent/policy.py index f4e993e..e2641d9 100644 --- a/src/omniclaw/agent/policy.py +++ b/src/omniclaw/agent/policy.py @@ -12,6 +12,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from urllib.parse import urlparse from omniclaw.agent.policy_schema import RecipientMode, validate_policy from omniclaw.core.logging import get_logger @@ -20,6 +21,28 @@ logger = get_logger(__name__) +def _private_key_address(private_key: str | None) -> str | None: + if not private_key: + return None + try: + from eth_account import Account + + key = private_key if private_key.startswith("0x") else f"0x{private_key}" + return Account.from_key(key).address + except Exception: + return None + + +def _client_signer_address(client: Any) -> str | None: + if getattr(client, "_nano_adapter", None): + return client._nano_adapter.address + config = getattr(client, "config", None) + private_key = getattr(config, "nanopayments_private_key", None) or os.environ.get( + "OMNICLAW_PRIVATE_KEY" + ) + return _private_key_address(private_key) + + @dataclass class TimeWindow: """Time window for allowed transactions.""" @@ -276,6 +299,39 @@ def from_dict(cls, data: dict | None) -> RecipientConfig: ) +@dataclass +class RailConfig: + """Enabled buyer payment rails for a policy wallet/profile.""" + + circle_transfer: bool = True + x402: bool = True + + @classmethod + def from_dict(cls, data: dict | None) -> RailConfig: + if not data: + return cls() + return cls( + circle_transfer=bool(data.get("circle_transfer", True)), + x402=bool(data.get("x402", True)), + ) + + def to_dict(self) -> dict[str, bool]: + return { + "circle_transfer": self.circle_transfer, + "x402": self.x402, + } + + +def _normalize_wallets(wallets: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + normalized: dict[str, dict[str, Any]] = {} + for alias, config in wallets.items(): + next_config = dict(config) + if isinstance(next_config.get("rails"), dict): + next_config["rails"] = RailConfig.from_dict(next_config.get("rails")).to_dict() + normalized[alias] = next_config + return normalized + + @dataclass class Policy: """Main policy configuration for the agent economy.""" @@ -293,7 +349,7 @@ def to_dict(self) -> dict: result = { "version": self.version, "tokens": self.tokens, - "wallets": self.wallets, + "wallets": _normalize_wallets(self.wallets), } if self.limits and any( @@ -350,6 +406,57 @@ def from_dict(cls, data: dict | None) -> Policy: ) +class RuntimeWalletState: + """Generated buyer wallet state kept separate from stable policy.""" + + def __init__(self, path: str | None = None, policy_path: str | None = None): + if path is None: + path = os.environ.get("OMNICLAW_AGENT_STATE_PATH") + if path is None and policy_path and str(policy_path).startswith("/config/"): + path = "/data/omniclaw/wallet-state.json" + if path is None and policy_path: + policy_file = Path(policy_path) + path = str(policy_file.with_name(f"{policy_file.stem}.state.json")) + self._path = Path(path or ".omniclaw-agent-state.json") + self._data: dict[str, Any] = {"version": "1.0", "wallets": {}} + self._loaded = False + + def load(self) -> None: + if self._loaded: + return + if self._path.exists(): + with open(self._path) as f: + loaded = json.load(f) + if isinstance(loaded, dict): + self._data = loaded + self._data.setdefault("version", "1.0") + self._data.setdefault("wallets", {}) + self._loaded = True + + def get_wallet(self, alias: str) -> dict[str, Any]: + self.load() + wallets = self._data.setdefault("wallets", {}) + value = wallets.get(alias, {}) + return dict(value) if isinstance(value, dict) else {} + + def set_wallet(self, alias: str, updates: dict[str, Any]) -> bool: + self.load() + wallets = self._data.setdefault("wallets", {}) + current = dict(wallets.get(alias, {})) + next_value = {**current, **updates} + if current == next_value: + return False + wallets[alias] = next_value + return True + + def save(self) -> None: + self.load() + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2, default=str) + f.write("\n") + + class PolicyManager: """Manages policy loading, validation, and multi-agent token mapping.""" @@ -370,8 +477,12 @@ async def load(self) -> Policy: self._logger.warning( f"Policy file not found: {self._policy_path}, creating default policy" ) - # Use OMNICLAW_AGENT_TOKEN as default token key if provided - env_token = os.environ.get("OMNICLAW_AGENT_TOKEN", "default") + env_token = os.environ.get("OMNICLAW_AGENT_TOKEN") + if not env_token: + raise ValueError( + "OMNICLAW_AGENT_TOKEN is required when creating a default policy. " + "Set it or provide OMNICLAW_AGENT_POLICY_PATH." + ) wallet_alias = os.environ.get("OMNICLAW_AGENT_WALLET", "primary") # Create default policy with default token and wallet self._policy = Policy( @@ -388,31 +499,25 @@ async def load(self) -> Policy: "name": "Primary Wallet", "limits": {"daily_max": "100.00", "per_tx_max": "10.00"}, "recipients": {"mode": "allow_all"}, + "rails": { + "circle_transfer": True, + "x402": True, + }, } }, ) # Save default policy to file try: path.parent.mkdir(parents=True, exist_ok=True) - payload = json.dumps(self._policy.to_dict(), indent=2, default=str) - with open(path, "w") as f: - f.write(payload) + self._write_policy_file() self._logger.info(f"Created default policy at {self._policy_path}") except Exception as e: raise PermissionError( f"Policy file is not writable: {self._policy_path}. {e}" ) from e - try: - self._last_mtime = path.stat().st_mtime - except Exception: - self._last_mtime = None return self._policy try: - if not os.access(path, os.W_OK): - raise PermissionError( - f"Policy file is read-only and must be writable: {self._policy_path}" - ) with open(path) as f: data = json.load(f) # Strict schema validation @@ -427,6 +532,12 @@ async def load(self) -> Policy: def save(self) -> None: """Persist current policy to disk.""" + if not self._policy: + return + self._write_policy_file() + + def _write_policy_file(self) -> None: + """Persist policy and refresh the watched mtime.""" if not self._policy: return path = Path(self._policy_path) @@ -435,12 +546,21 @@ def save(self) -> None: payload = json.dumps(self._policy.to_dict(), indent=2, default=str) with open(path, "w") as f: f.write(payload) + f.write("\n") + try: + self._last_mtime = path.stat().st_mtime + except Exception: + self._last_mtime = None except Exception as e: - self._logger.warning(f"Failed to save policy: {e}") + raise PermissionError(f"Policy file is not writable: {self._policy_path}. {e}") from e def get_token_map(self) -> dict[str, dict[str, Any]]: return self._policy.tokens if self._policy else {} + @property + def policy_path(self) -> str: + return self._policy_path + def get_wallet_map(self) -> dict[str, dict[str, Any]]: return self._policy.wallets if self._policy else {} @@ -472,6 +592,38 @@ def get_wallet_config(self, wallet_id: str | None) -> dict[str, Any]: return {} return self._wallet_id_to_config.get(wallet_id, {}) + def _wallet_config(self, wallet_id: str | None) -> dict[str, Any]: + if not wallet_id: + return {} + return self._wallet_id_to_config.get(wallet_id, {}) + + def _recipient_config_for_wallet(self, wallet_id: str | None = None) -> RecipientConfig: + if wallet_id is None: + return self.get_policy().recipients + config = self._wallet_config(wallet_id) + if config.get("recipients") is not None: + return RecipientConfig.from_dict(config.get("recipients")) + return self.get_policy().recipients + + def _limits_for_wallet(self, wallet_id: str | None = None) -> WalletLimits: + if wallet_id is None: + return self.get_policy().limits + config = self._wallet_config(wallet_id) + wallet_limits = WalletLimits.from_dict(config.get("limits")) + base_limits = self.get_policy().limits + return WalletLimits( + daily_max=wallet_limits.daily_max or base_limits.daily_max, + hourly_max=wallet_limits.hourly_max or base_limits.hourly_max, + per_tx_max=wallet_limits.per_tx_max or base_limits.per_tx_max, + per_tx_min=wallet_limits.per_tx_min or base_limits.per_tx_min, + ) + + @staticmethod + def _host_matches(hostname: str, allowed_domain: str) -> bool: + host = hostname.strip(".").lower() + domain = allowed_domain.strip(".").lower() + return host == domain or host.endswith(f".{domain}") + def has_changed(self) -> bool: path = Path(self._policy_path) if not path.exists(): @@ -498,39 +650,36 @@ async def reload(self) -> bool: def is_valid_recipient(self, recipient: str, wallet_id: str | None = None) -> bool: """Check if recipient is allowed.""" - if wallet_id is None: - recipient_cfg = self.get_policy().recipients - else: - config = self._wallet_id_to_config.get(wallet_id, {}) - recipient_cfg = RecipientConfig.from_dict(config.get("recipients")) + recipient_cfg = self._recipient_config_for_wallet(wallet_id) if recipient_cfg.mode == RecipientMode.ALLOW_ALL.value: return True if not recipient_cfg.addresses and not recipient_cfg.domains: - return True + return recipient_cfg.mode != RecipientMode.WHITELIST.value if recipient in recipient_cfg.addresses: - return recipient_cfg.mode == "whitelist" + return recipient_cfg.mode == RecipientMode.WHITELIST.value if recipient.startswith("http"): - for domain in recipient_cfg.domains: - if domain in recipient: - return recipient_cfg.mode == "whitelist" + hostname = urlparse(recipient).hostname or "" + domain_match = any( + self._host_matches(hostname, domain) for domain in recipient_cfg.domains + ) + if domain_match: + return recipient_cfg.mode == RecipientMode.WHITELIST.value - return recipient_cfg.mode != "whitelist" + return recipient_cfg.mode != RecipientMode.WHITELIST.value def check_limits( self, amount: Decimal, wallet_id: str | None = None ) -> tuple[bool, str | None]: - if wallet_id is None: - limits = self.get_policy().limits - else: - config = self._wallet_id_to_config.get(wallet_id, {}) - limits = WalletLimits.from_dict(config.get("limits")) + limits = self._limits_for_wallet(wallet_id) if limits.per_tx_max and amount > limits.per_tx_max: return False, f"Amount {amount} exceeds per_tx_max {limits.per_tx_max}" + if limits.per_tx_min and amount < limits.per_tx_min: + return False, f"Amount {amount} is below per_tx_min {limits.per_tx_min}" return True, None @@ -539,16 +688,38 @@ def requires_confirmation(self, amount: Decimal, wallet_id: str | None = None) - threshold = self.get_policy().confirm_threshold or Decimal("0") else: config = self._wallet_id_to_config.get(wallet_id, {}) - threshold = Decimal(config.get("confirm_threshold", "0")) + threshold = ( + Decimal(str(config.get("confirm_threshold"))) + if config.get("confirm_threshold") is not None + else self.get_policy().confirm_threshold or Decimal("0") + ) return threshold > 0 and amount >= threshold + def rail_config(self, wallet_id: str | None = None) -> RailConfig: + config = self._wallet_config(wallet_id) + return RailConfig.from_dict(config.get("rails")) + + def is_rail_enabled(self, rail: str, wallet_id: str | None = None) -> bool: + rails = self.rail_config(wallet_id) + if rail == "circle_transfer": + return rails.circle_transfer + if rail == "x402": + return rails.x402 + return False + class WalletManager: """Manages wallet creation based on policy mapping.""" - def __init__(self, policy_manager: PolicyManager, omniclaw_client: Any): + def __init__( + self, + policy_manager: PolicyManager, + omniclaw_client: Any, + runtime_state: RuntimeWalletState | None = None, + ): self._policy = policy_manager self._client = omniclaw_client + self._state = runtime_state or RuntimeWalletState(policy_path=policy_manager.policy_path) self._logger = logger async def initialize_wallets(self) -> dict[str, str]: @@ -565,6 +736,8 @@ async def initialize_wallets(self) -> dict[str, str]: # Build alias -> tokens mapping alias_to_tokens: dict[str, list[str]] = {} for token, config in token_map.items(): + if config.get("active") is False: + continue alias = config.get("wallet_alias", "primary") alias_to_tokens.setdefault(alias, []).append(token) @@ -588,8 +761,28 @@ async def init_alias(alias: str) -> tuple[str, str | None, str | None]: wallet_cfg = {} self._policy.update_wallet_config(alias, wallet_cfg) - wallet_id = wallet_cfg.get("wallet_id") - wallet_address = wallet_cfg.get("address") + runtime_cfg = self._state.get_wallet(alias) + wallet_id = wallet_cfg.get("wallet_id") or runtime_cfg.get("circle_wallet_id") + wallet_address = wallet_cfg.get("address") or runtime_cfg.get("circle_wallet_address") + gateway_address = runtime_cfg.get("gateway_eoa_address") + signer_address = _client_signer_address(self._client) + if signer_address: + gateway_address = signer_address + + circle_enabled = bool(getattr(self._client.config, "enable_circle_transfer", True)) + if not circle_enabled: + synthetic_prefix = ( + "x402" + if ( + getattr(self._client.config, "enable_gateway", False) + or getattr(self._client.config, "enable_x402_exact", False) + ) + else "eoa" + ) + synthetic_id = ( + wallet_id or runtime_cfg.get("wallet_id") or f"{synthetic_prefix}:{alias}" + ) + return alias, synthetic_id, wallet_address or gateway_address # If wallet_id exists, verify and fill address if missing if wallet_id: @@ -611,16 +804,18 @@ async def init_alias(alias: str) -> tuple[str, str | None, str | None]: if wallet and wallet.address and wallet.address != wallet_address: wallet_address = wallet.address return alias, wallet_id, wallet_address - except Exception: - # Fall through to create a new wallet - pass + except Exception as exc: + raise RuntimeError( + f"Configured wallet '{wallet_id}' for alias '{alias}' could not be " + f"verified. Fix the policy or remove wallet_id to create a new wallet." + ) from exc # Create a new wallet for this alias res = await self._client.create_agent_wallet( agent_name=f"omniclaw-{alias}", apply_default_guards=False, ) - if isinstance(res, (tuple, list)): + if isinstance(res, tuple | list): _, wallet = res else: wallet = res @@ -640,14 +835,30 @@ async def init_alias(alias: str) -> tuple[str, str | None, str | None]: if not wallet_id: continue # Persist wallet_id/address into policy - self._policy.update_wallet_config( - alias, - {"wallet_id": wallet_id, "address": wallet_address}, - ) - changed = True + circle_enabled = bool(getattr(self._client.config, "enable_circle_transfer", True)) + gateway_address = _client_signer_address(self._client) + state_updates = { + "wallet_id": wallet_id, + "circle_wallet_id": wallet_id if circle_enabled else None, + "circle_wallet_address": wallet_address if circle_enabled else None, + "gateway_eoa_address": gateway_address, + "signer_address": gateway_address, + "network": getattr( + self._client.config.network, "value", self._client.config.network + ), + } + if self._state.set_wallet(alias, state_updates): + changed = True # Map all tokens sharing this alias for token in alias_to_tokens.get(alias, []): cfg = dict(wallet_map.get(alias, {})) + cfg.update( + { + "wallet_id": wallet_id, + "address": wallet_address, + "gateway_eoa_address": gateway_address, + } + ) cfg.setdefault("alias", alias) self._policy.set_mapping(token, wallet_id, cfg) results[token] = wallet_id @@ -660,7 +871,7 @@ async def init_alias(alias: str) -> tuple[str, str | None, str | None]: self._logger.error(f"Failed to apply policy guards for wallet '{wallet_id}': {e}") if changed: - self._policy.save() + self._state.save() return results diff --git a/src/omniclaw/agent/policy_schema.py b/src/omniclaw/agent/policy_schema.py index d3e9eca..602afa7 100644 --- a/src/omniclaw/agent/policy_schema.py +++ b/src/omniclaw/agent/policy_schema.py @@ -39,6 +39,13 @@ class RecipientConfigModel(BaseModel): domains: list[str] = Field(default_factory=list) +class RailsModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + circle_transfer: bool = True + x402: bool | None = None + + class TokenConfigModel(BaseModel): model_config = ConfigDict(extra="forbid") @@ -57,6 +64,7 @@ class WalletConfigModel(BaseModel): rate_limits: RateLimitsModel | None = None recipients: RecipientConfigModel | None = None confirm_threshold: Decimal | None = None + rails: RailsModel | None = None @field_validator("address") @classmethod diff --git a/src/omniclaw/agent/routes.py b/src/omniclaw/agent/routes.py index 12fff49..f771ff4 100644 --- a/src/omniclaw/agent/routes.py +++ b/src/omniclaw/agent/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import os from decimal import Decimal from typing import TYPE_CHECKING, Any @@ -30,7 +31,7 @@ ) from omniclaw.agent.policy import PolicyManager, WalletManager from omniclaw.core.logging import get_logger -from omniclaw.core.types import PaymentMethod +from omniclaw.core.types import Network, PaymentMethod from omniclaw.guards.confirmations import ConfirmationStore if TYPE_CHECKING: @@ -51,6 +52,134 @@ def _fmt_amount(value: object) -> str: return str(value) +def _x402_selected_amount_exceeds_cap(selected_kind: Any, amount_cap: str | None) -> bool: + if not selected_kind or amount_cap is None: + return False + return selected_kind.get_amount_usdc() > Decimal(str(amount_cap)) + + +def _rail_for_selected_route(selected_route: object) -> str | None: + route = str(selected_route or "").strip().lower() + if route in {"nanopayment", "x402"}: + return "x402" + return None + + +def _public_x402_route(selected_route: object) -> str | None: + """Expose buyer-facing rail names; keep Gateway as an internal x402 path.""" + if _rail_for_selected_route(selected_route) == "x402": + return "x402" + return None + + +def _x402_execution_route(selected_route: object, payment_source: object = None) -> str | None: + route = str(selected_route or "").strip().lower() + source = str(payment_source or "").strip().lower() + if route == "nanopayment" or source == "gateway_balance": + return "GatewayWalletBatched" + if route == "x402": + return "exact" + return None + + +def _gateway_manager_address_overrides( + config: dict[str, Any], + network: str, +) -> dict[str, str | None]: + gateway_address = config.get("gateway_contract_address") or os.environ.get( + "CIRCLE_GATEWAY_CONTRACT" + ) + usdc_address = ( + config.get("gateway_usdc_address") + or os.environ.get("CIRCLE_GATEWAY_USDC_ADDRESS") + or os.environ.get("CIRCLE_GATEWAY_USDC_CONTRACT") + ) + if not gateway_address: + from omniclaw.protocols.nanopayments.constants import GATEWAY_WALLET_CONTRACTS_CAIP2 + + gateway_address = GATEWAY_WALLET_CONTRACTS_CAIP2.get(network) + if not usdc_address: + from omniclaw.core.cctp_constants import USDC_CONTRACTS + + with contextlib.suppress(Exception): + usdc_address = USDC_CONTRACTS.get(Network.from_string(network).value) + return {"gateway_address": gateway_address, "usdc_address": usdc_address} + + +def _public_payment_method(recipient: str, method: object) -> str: + if recipient.startswith(("http://", "https://")): + return "x402" + value = str(method.value if hasattr(method, "value") else method or "").strip().lower() + if value in {"transfer", "crosschain"}: + return "circle_transfer" + return value or "circle_transfer" + + +def _policy_rail_enabled(policy_mgr: PolicyManager, rail: str, wallet_id: str | None) -> bool: + if hasattr(policy_mgr, "is_rail_enabled"): + return bool(policy_mgr.is_rail_enabled(rail, wallet_id)) + return True + + +def _server_rail_enabled(client: OmniClaw, rail: str) -> bool: + config = getattr(client, "config", None) + if config is None: + return False + if rail == "circle_transfer": + return bool(getattr(config, "enable_circle_transfer", False)) + if rail == "x402": + return bool( + getattr(config, "enable_x402", False) + or getattr(config, "enable_gateway", False) + or getattr(config, "enable_x402_exact", False) + ) + if rail == "gateway": + return bool(getattr(config, "enable_gateway", False)) + if rail == "x402_exact": + return bool(getattr(config, "enable_x402_exact", False)) + return False + + +def _server_x402_route_enabled(client: OmniClaw, selected_route: object) -> bool: + route = str(selected_route or "").strip().lower() + if route == "nanopayment": + return _server_rail_enabled(client, "gateway") + if route == "x402": + return _server_rail_enabled(client, "x402_exact") + return False + + +def _server_x402_route_disabled_reason(selected_route: object) -> str: + route = str(selected_route or "").strip().lower() + if route == "nanopayment": + return "x402 Gateway nanopayment execution is disabled by server config" + if route == "x402": + return "standard x402 execution is disabled by server config" + return "x402 execution is disabled by server config" + + +def _private_key_address(private_key: str | None) -> str | None: + if not private_key: + return None + try: + from eth_account import Account + + key = private_key if private_key.startswith("0x") else f"0x{private_key}" + return Account.from_key(key).address + except Exception: + return None + + +def _client_signer_address(client: OmniClaw) -> str | None: + if getattr(client, "_nano_adapter", None): + return client._nano_adapter.address + config = getattr(client, "config", None) + private_key = getattr(config, "nanopayments_private_key", None) or os.environ.get( + "OMNICLAW_PRIVATE_KEY" + ) + return _private_key_address(private_key) + + def _find_adapter_by_method(client: OmniClaw, method: PaymentMethod | str): for adapter in client._router.get_adapters(): adapter_method = getattr(adapter, "method", None) @@ -67,6 +196,8 @@ async def _choose_x402_route( wallet_id: str, x402_adapter: Any, requirements: Any, + allow_gateway: bool = True, + allow_x402_exact: bool = True, ) -> dict[str, object]: agent_network = x402_adapter._resolve_agent_network(wallet_id, None) selected_gateway_kind = requirements.select_preferred_kind( @@ -81,7 +212,12 @@ async def _choose_x402_route( gateway_ready: bool | None = None gateway_reason: str | None = None - if selected_gateway_kind is not None: + async def onchain_gateway_balance() -> Any: + if hasattr(client, "get_gateway_onchain_balance_for_kind"): + return await client.get_gateway_onchain_balance_for_kind(selected_gateway_kind) + return await client.get_gateway_onchain_balance(wallet_id) + + if selected_gateway_kind is not None and allow_gateway: if client._nano_adapter is None: gateway_ready = False gateway_reason = "Gateway route is advertised but nanopayments are not enabled" @@ -96,7 +232,7 @@ async def _choose_x402_route( else: # Fallback to direct on-chain balance when API-reported balance is stale/lagging. try: - onchain_balance = await client.get_gateway_onchain_balance(wallet_id) + onchain_balance = await onchain_gateway_balance() if onchain_balance.available >= required_atomic: gateway_available_balance = onchain_balance.formatted_available gateway_ready = True @@ -108,10 +244,21 @@ async def _choose_x402_route( except Exception: gateway_reason = "Gateway balance is below the required amount" except Exception as exc: - gateway_ready = False - gateway_reason = f"Gateway balance check failed: {exc}" + try: + required_atomic = int(selected_gateway_kind.amount_atomic) + onchain_balance = await onchain_gateway_balance() + gateway_available_balance = onchain_balance.formatted_available + gateway_ready = onchain_balance.available >= required_atomic + gateway_reason = ( + "Gateway on-chain balance is sufficient" + if gateway_ready + else "Gateway on-chain balance is below the required amount" + ) + except Exception: + gateway_ready = False + gateway_reason = f"Gateway balance check failed: {exc}" - if selected_gateway_kind is not None and gateway_ready: + if selected_gateway_kind is not None and allow_gateway and gateway_ready: return { "selected_kind": selected_gateway_kind, "selected_route": "nanopayment", @@ -121,7 +268,7 @@ async def _choose_x402_route( "gateway_reason": gateway_reason, } - if selected_exact_kind is not None: + if selected_exact_kind is not None and allow_x402_exact: return { "selected_kind": selected_exact_kind, "selected_route": "x402", @@ -131,7 +278,7 @@ async def _choose_x402_route( "gateway_reason": gateway_reason, } - if selected_gateway_kind is not None: + if selected_gateway_kind is not None and allow_gateway: return { "selected_kind": selected_gateway_kind, "selected_route": "nanopayment", @@ -159,6 +306,8 @@ async def _inspect_x402_target( method: str = "GET", headers: dict[str, str] | None = None, body: str | bytes | None = None, + allow_gateway: bool = True, + allow_x402_exact: bool = True, ) -> dict[str, object]: x402_adapter = _find_adapter_by_method(client, PaymentMethod.X402) if x402_adapter is None: @@ -214,6 +363,8 @@ async def _inspect_x402_target( wallet_id=wallet_id, x402_adapter=x402_adapter, requirements=requirements, + allow_gateway=allow_gateway, + allow_x402_exact=allow_x402_exact, ) selected_kind = route_choice["selected_kind"] selected_route = route_choice["selected_route"] @@ -304,7 +455,7 @@ async def get_address( detail="Wallet is currently initializing. Please try again in a few seconds.", ) - eoa_address = client._nano_adapter.address if client._nano_adapter else None + eoa_address = _client_signer_address(client) circle_address = await wallet_mgr.get_wallet_address(agent.wallet_id) address = eoa_address or circle_address @@ -365,9 +516,24 @@ async def get_balance( ) if client._nano_adapter: - gateway_balance = await client.get_gateway_balance(agent.wallet_id) - available = gateway_balance.available_decimal - total = gateway_balance.total_decimal + note = None + try: + gateway_balance = await client.get_gateway_balance(agent.wallet_id) + source = "gateway_api" + except Exception as exc: + try: + gateway_balance = await client.get_gateway_onchain_balance(agent.wallet_id) + source = "gateway_onchain" + note = f"Gateway API balance unavailable; using on-chain Gateway balance: {exc}" + except Exception: + gateway_balance = None + source = "unavailable" + note = ( + "Gateway API balance unavailable. x402 Gateway payments use " + f"seller-specific on-chain balance checks when needed: {exc}" + ) + available = gateway_balance.available_decimal if gateway_balance else "0.00" + total = gateway_balance.total_decimal if gateway_balance else None reserved = None else: balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) @@ -376,12 +542,16 @@ async def get_balance( available = str(balance) total = None reserved = None + source = "circle_wallet" + note = None return BalanceResponse( wallet_id=agent.wallet_id, available=_fmt_amount(available), total=_fmt_amount(total) if total is not None else None, reserved=reserved, + source=source, + note=note, ) @@ -398,20 +568,36 @@ async def get_detailed_balance( detail="Wallet is currently initializing. Please try again in a few seconds.", ) - eoa_address = client._nano_adapter.address if client._nano_adapter else None - circle_address = await wallet_mgr.get_wallet_address(agent.wallet_id) - circle_balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) - gateway_balance = ( - await client.get_gateway_balance(agent.wallet_id) if client._nano_adapter else None - ) - gateway_onchain_balance = ( - await client.get_gateway_onchain_balance(agent.wallet_id) if client._nano_adapter else None - ) - payment_address = ( - await client.get_payment_address(agent.wallet_id) if client._nano_client else None - ) + eoa_address = _client_signer_address(client) + circle_address = None + circle_balance = None + if _server_rail_enabled(client, "circle_transfer"): + circle_address = await wallet_mgr.get_wallet_address(agent.wallet_id) + circle_balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) + gateway_balance = None + gateway_onchain_balance = None + gateway_balance_note = None + gateway_onchain_balance_note = None + if client._nano_adapter: + try: + gateway_balance = await client.get_gateway_balance(agent.wallet_id) + except Exception as exc: + gateway_balance = None + gateway_balance_note = ( + "Gateway API balance unavailable. x402 Gateway payments can still use " + f"seller-specific on-chain checks: {exc}" + ) + try: + gateway_onchain_balance = await client.get_gateway_onchain_balance(agent.wallet_id) + except Exception as exc: + gateway_onchain_balance = None + gateway_onchain_balance_note = ( + "Generic Gateway on-chain balance unavailable without configured Gateway " + f"metadata: {exc}" + ) + payment_address = eoa_address if client._nano_adapter else None payment_gateway_balance = None - if payment_address: + if payment_address and client._nano_client and client._nano_client.has_api_key: try: payment_gateway_balance = await client.get_gateway_balance_for_address(payment_address) except Exception: @@ -425,12 +611,16 @@ async def get_detailed_balance( else "0.00", "gateway_balance_atomic": gateway_balance.available if gateway_balance else 0, "gateway_total_atomic": gateway_balance.total if gateway_balance else 0, + "gateway_balance_available": gateway_balance is not None, + "gateway_balance_note": gateway_balance_note, "gateway_onchain_balance": _fmt_amount(gateway_onchain_balance.available_decimal) if gateway_onchain_balance else "0.00", "gateway_onchain_balance_atomic": gateway_onchain_balance.available if gateway_onchain_balance else 0, + "gateway_onchain_balance_available": gateway_onchain_balance is not None, + "gateway_onchain_balance_note": gateway_onchain_balance_note, "circle_wallet_address": circle_address, "circle_wallet_balance": _fmt_amount(circle_balance) if circle_balance is not None @@ -453,6 +643,7 @@ async def deposit_to_gateway( check_gas: bool = False, skip_if_insufficient_gas: bool = True, agent: AuthenticatedAgent = Depends(get_current_agent), + policy_mgr: PolicyManager = Depends(get_policy_manager), client: OmniClaw = Depends(get_omniclaw_client), ): """ @@ -471,6 +662,13 @@ async def deposit_to_gateway( status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.", ) + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + raise HTTPException(status_code=400, detail="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "gateway"): + raise HTTPException( + status_code=400, + detail="x402 Gateway nanopayment funding is disabled by server config", + ) try: result = await client.deposit_to_gateway( @@ -495,6 +693,7 @@ async def deposit_to_gateway( @router.post("/withdraw") async def withdraw_from_gateway( + request: Request = None, # type: ignore[assignment] amount: str = ..., destination_chain: str | None = None, recipient: str | None = None, @@ -515,6 +714,17 @@ async def withdraw_from_gateway( status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.", ) + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + raise HTTPException(status_code=400, detail="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "gateway"): + raise HTTPException( + status_code=400, + detail="x402 Gateway nanopayment withdrawal is disabled by server config", + ) + if recipient is not None and request is not None: + await require_owner(request) + elif recipient is not None: + raise HTTPException(status_code=403, detail="Owner token required") try: from decimal import Decimal @@ -527,6 +737,10 @@ async def withdraw_from_gateway( status_code=400, detail="No default withdrawal address in policy. Set wallets..address or pass recipient.", ) + if not policy_mgr.is_valid_recipient(recipient, agent.wallet_id): + raise HTTPException( + status_code=400, detail="Withdrawal recipient not allowed by policy" + ) requested_amount = Decimal(str(amount)) try: @@ -640,6 +854,7 @@ async def withdraw_trustless( network=network, rpc_url=rpc_url, nanopayment_client=nanopayment_client, + **_gateway_manager_address_overrides(config, network), ) delay_blocks = await manager.get_withdrawal_delay() @@ -715,6 +930,7 @@ async def complete_trustless_withdrawal( network=network, rpc_url=rpc_url, nanopayment_client=nanopayment_client, + **_gateway_manager_address_overrides(config, network), ) current_block = manager._w3.eth.block_number @@ -755,6 +971,7 @@ async def complete_trustless_withdrawal( async def get_deposit_address( request: Request, agent: AuthenticatedAgent = Depends(get_current_agent), + policy_mgr: PolicyManager = Depends(get_policy_manager), wallet_mgr: WalletManager = Depends(get_wallet_manager), client: OmniClaw = Depends(get_omniclaw_client), ): @@ -769,8 +986,15 @@ async def get_deposit_address( status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.", ) + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + raise HTTPException(status_code=400, detail="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "gateway"): + raise HTTPException( + status_code=400, + detail="x402 Gateway nanopayment funding is disabled by server config", + ) - eoa_address = client._nano_adapter.address if client._nano_adapter else None + eoa_address = _client_signer_address(client) if not eoa_address: raise HTTPException( status_code=500, @@ -803,6 +1027,7 @@ async def get_deposit_address( @router.post("/pay", response_model=PayResponse) async def pay( request: PayRequest, + raw_request: Request = None, # type: ignore[assignment] agent: AuthenticatedAgent = Depends(get_current_agent), wallet_mgr: WalletManager = Depends(get_wallet_manager), policy_mgr: PolicyManager = Depends(get_policy_manager), @@ -814,6 +1039,11 @@ async def pay( detail="Wallet is currently initializing. Please try again in a few seconds.", ) + if request.skip_guards and raw_request is not None: + await require_owner(raw_request) + elif request.skip_guards: + raise HTTPException(status_code=403, detail="Owner token required") + if not policy_mgr.is_valid_recipient(request.recipient, agent.wallet_id): raise HTTPException(status_code=400, detail="Recipient not allowed by policy") @@ -822,6 +1052,12 @@ async def pay( x402_details: dict[str, object] | None = None preferred_url_route: str | None = None if is_url_payment: + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + raise HTTPException(status_code=400, detail="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "x402"): + raise HTTPException( + status_code=400, detail="x402 payments are disabled by server config" + ) x402_details = await _inspect_x402_target( client=client, wallet_id=agent.wallet_id, @@ -829,6 +1065,10 @@ async def pay( method=request.method, headers=request.headers, body=request.body, + allow_gateway=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "gateway"), + allow_x402_exact=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "x402_exact"), ) if not x402_details.get("ok"): raise HTTPException(status_code=400, detail=str(x402_details.get("reason"))) @@ -844,13 +1084,47 @@ async def pay( or "Seller does not advertise a buyer-supported x402 payment kind" ), ) - if amount_raw is None: - amount_raw = str(selected_kind.get_amount_usdc()) + selected_rail = _rail_for_selected_route(preferred_url_route) + if selected_rail is None: + raise HTTPException( + status_code=400, + detail="Seller selected an unsupported x402 payment route", + ) + if selected_rail and not _policy_rail_enabled( + policy_mgr, selected_rail, agent.wallet_id + ): + raise HTTPException( + status_code=400, + detail=f"Payment rail '{selected_rail}' is disabled by policy", + ) + if selected_rail and not _server_x402_route_enabled(client, preferred_url_route): + raise HTTPException( + status_code=400, + detail=_server_x402_route_disabled_reason(preferred_url_route), + ) + if _x402_selected_amount_exceeds_cap(selected_kind, amount_raw): + raise HTTPException( + status_code=400, + detail=( + f"x402 price {selected_kind.get_amount_usdc()} exceeds max amount " + f"{amount_raw}" + ), + ) + amount_raw = str(selected_kind.get_amount_usdc()) elif amount_raw is None: amount_raw = "0.00" if amount_raw is None: raise HTTPException(status_code=400, detail="amount is required for direct transfers") + if not is_url_payment: + if not _policy_rail_enabled(policy_mgr, "circle_transfer", agent.wallet_id): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by policy" + ) + if not _server_rail_enabled(client, "circle_transfer"): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by server config" + ) amount = Decimal(amount_raw) allowed, reason = policy_mgr.check_limits(amount, agent.wallet_id) @@ -896,9 +1170,13 @@ async def pay( status=result.status.value if result.status and hasattr(result.status, "value") else (str(result.status) if result.status else "failed"), - method=result.method.value - if result.method and hasattr(result.method, "value") - else (str(result.method) if result.method else "transfer"), + method=_public_payment_method(result.recipient, result.method), + selected_route=_public_x402_route(result.metadata.get("selected_route")) + if result.metadata + else None, + payment_source=result.metadata.get("payment_source") if result.metadata else None, + execution_route=result.metadata.get("execution_route") if result.metadata else None, + facilitator=result.metadata.get("facilitator") if result.metadata else None, error=result.error, requires_confirmation=requires_confirmation, confirmation_id=confirmation_id, @@ -920,16 +1198,130 @@ async def pay( @router.post("/simulate", response_model=SimulateResponse) async def simulate( request: SimulateRequest, + raw_request: Request = None, # type: ignore[assignment] agent: AuthenticatedAgent = Depends(get_current_agent), policy_mgr: PolicyManager = Depends(get_policy_manager), client: OmniClaw = Depends(get_omniclaw_client), ): + if request.skip_guards and raw_request is not None: + await require_owner(raw_request) + elif request.skip_guards: + raise HTTPException(status_code=403, detail="Owner token required") + if not policy_mgr.is_valid_recipient(request.recipient, agent.wallet_id): return SimulateResponse( would_succeed=False, route="TRANSFER", reason="Recipient not allowed by policy" ) - amount = Decimal(request.amount) + is_url_payment = request.recipient.startswith("http") + amount_raw = request.amount + route_name = "x402" if is_url_payment else "TRANSFER" + if is_url_payment: + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + return SimulateResponse( + would_succeed=False, route="x402", reason="x402 rail is disabled by policy" + ) + if not _server_rail_enabled(client, "x402"): + return SimulateResponse( + would_succeed=False, + route="x402", + reason="x402 payments are disabled by server config", + ) + x402_details = await _inspect_x402_target( + client=client, + wallet_id=agent.wallet_id, + url=request.recipient, + method=request.method, + headers=request.headers, + body=request.body, + allow_gateway=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "gateway"), + allow_x402_exact=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "x402_exact"), + ) + if not x402_details.get("ok"): + return SimulateResponse( + would_succeed=False, route="x402", reason=str(x402_details.get("reason")) + ) + if bool(x402_details.get("requires_payment")): + selected_kind = x402_details.get("selected_kind") + selected_route = x402_details.get("selected_route") + if selected_kind is None: + return SimulateResponse( + would_succeed=False, + route="x402", + reason=str( + x402_details.get("reason") + or "Seller does not advertise a buyer-supported x402 payment kind" + ), + ) + if _rail_for_selected_route(selected_route) is None: + return SimulateResponse( + would_succeed=False, + route="x402", + reason="Seller selected an unsupported x402 payment route", + ) + if _x402_selected_amount_exceeds_cap(selected_kind, amount_raw): + return SimulateResponse( + would_succeed=False, + route="x402", + reason=( + f"x402 price {selected_kind.get_amount_usdc()} exceeds max amount " + f"{amount_raw}" + ), + ) + amount_raw = str(selected_kind.get_amount_usdc()) + allowed, reason = policy_mgr.check_limits(Decimal(amount_raw), agent.wallet_id) + if not allowed: + return SimulateResponse(would_succeed=False, route="x402", reason=reason) + route_name = "x402" + if selected_route == "nanopayment": + return SimulateResponse( + would_succeed=bool(x402_details.get("gateway_ready")), + route=route_name, + reason=str(x402_details.get("gateway_reason") or ""), + ) + x402_adapter = x402_details.get("x402_adapter") + if x402_adapter is not None: + try: + sim_result = await x402_adapter.simulate( + wallet_id=agent.wallet_id, + recipient=request.recipient, + amount=Decimal(amount_raw), + method=request.method, + request_body=request.body, + request_headers=request.headers, + ) + return SimulateResponse( + would_succeed=bool(sim_result.get("would_succeed")), + route=route_name, + reason=sim_result.get("reason"), + ) + except Exception as exc: + return SimulateResponse(would_succeed=False, route=route_name, reason=str(exc)) + elif amount_raw is None: + amount_raw = "0.00" + + if not is_url_payment and amount_raw is None: + return SimulateResponse( + would_succeed=False, route="TRANSFER", reason="amount is required for direct transfers" + ) + if not is_url_payment and not _policy_rail_enabled( + policy_mgr, "circle_transfer", agent.wallet_id + ): + return SimulateResponse( + would_succeed=False, + route="TRANSFER", + reason="Circle transfer rail is disabled by policy", + ) + if not is_url_payment and not _server_rail_enabled(client, "circle_transfer"): + return SimulateResponse( + would_succeed=False, + route="TRANSFER", + reason="Circle transfer rail is disabled by server config", + ) + + amount = Decimal(str(amount_raw)) allowed, reason = policy_mgr.check_limits(amount, agent.wallet_id) if not allowed: return SimulateResponse(would_succeed=False, route="TRANSFER", reason=reason) @@ -947,7 +1339,7 @@ async def simulate( would_succeed=result.would_succeed, route=result.route.value if result.route and hasattr(result.route, "value") - else str(result.route), + else str(result.route or route_name), reason=result.reason, guards_that_would_pass=result.guards_that_would_pass, ) @@ -996,6 +1388,23 @@ async def create_intent( allowed, reason = policy_mgr.check_limits(amount, agent.wallet_id) if not allowed: raise HTTPException(status_code=400, detail=reason) + is_url_payment = request.recipient.startswith("http") + if is_url_payment: + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + raise HTTPException(status_code=400, detail="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "x402"): + raise HTTPException( + status_code=400, detail="x402 payments are disabled by server config" + ) + else: + if not _policy_rail_enabled(policy_mgr, "circle_transfer", agent.wallet_id): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by policy" + ) + if not _server_rail_enabled(client, "circle_transfer"): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by server config" + ) try: intent = await client.create_payment_intent( @@ -1057,6 +1466,7 @@ async def get_intent( async def confirm_intent( intent_id: str, agent: AuthenticatedAgent = Depends(get_current_agent), + policy_mgr: PolicyManager = Depends(get_policy_manager), client: OmniClaw = Depends(get_omniclaw_client), ): try: @@ -1066,6 +1476,37 @@ async def confirm_intent( if intent.wallet_id != agent.wallet_id: raise HTTPException(status_code=403, detail="Intent belongs to different wallet") + if not policy_mgr.is_valid_recipient(intent.recipient, agent.wallet_id): + raise HTTPException(status_code=400, detail="Recipient not allowed by policy") + allowed, reason = policy_mgr.check_limits(Decimal(str(intent.amount)), agent.wallet_id) + if not allowed: + raise HTTPException(status_code=400, detail=reason) + if intent.recipient.startswith("http"): + route = (intent.metadata or {}).get("simulated_route") + rail = _rail_for_selected_route(route) + if rail is None: + raise HTTPException( + status_code=400, + detail="Intent authorized route is missing or unsupported; refusing execution", + ) + if rail and not _policy_rail_enabled(policy_mgr, rail, agent.wallet_id): + raise HTTPException( + status_code=400, detail=f"Payment rail '{rail}' is disabled by policy" + ) + if rail and not _server_x402_route_enabled(client, route): + raise HTTPException( + status_code=400, + detail=_server_x402_route_disabled_reason(route), + ) + else: + if not _policy_rail_enabled(policy_mgr, "circle_transfer", agent.wallet_id): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by policy" + ) + if not _server_rail_enabled(client, "circle_transfer"): + raise HTTPException( + status_code=400, detail="Circle transfer rail is disabled by server config" + ) result = await client.confirm_payment_intent(intent_id) @@ -1078,9 +1519,7 @@ async def confirm_intent( status=result.status.value if result.status and hasattr(result.status, "value") else (str(result.status) if result.status else "failed"), - method=result.method.value - if result.method and hasattr(result.method, "value") - else (str(result.method) if result.method else "transfer"), + method=_public_payment_method(result.recipient, result.method), error=result.error, ) except HTTPException: @@ -1165,12 +1604,57 @@ async def can_pay( recipient: str, agent: AuthenticatedAgent = Depends(get_current_agent), policy_mgr: PolicyManager = Depends(get_policy_manager), + client: OmniClaw = Depends(get_omniclaw_client), ): is_valid = policy_mgr.is_valid_recipient(recipient, agent.wallet_id) - if is_valid: - return CanPayResponse(can_pay=True) - else: + if not is_valid: return CanPayResponse(can_pay=False, reason="Recipient not allowed by policy") + if not recipient.startswith("http"): + if not _policy_rail_enabled(policy_mgr, "circle_transfer", agent.wallet_id): + return CanPayResponse( + can_pay=False, reason="Circle transfer rail is disabled by policy" + ) + if not _server_rail_enabled(client, "circle_transfer"): + return CanPayResponse( + can_pay=False, reason="Circle transfer rail is disabled by server config" + ) + return CanPayResponse(can_pay=True) + + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + return CanPayResponse(can_pay=False, reason="x402 rail is disabled by policy") + if not _server_rail_enabled(client, "x402"): + return CanPayResponse(can_pay=False, reason="x402 payments are disabled by server config") + + inspection = await _inspect_x402_target( + client=client, + wallet_id=agent.wallet_id, + url=recipient, + allow_gateway=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "gateway"), + allow_x402_exact=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "x402_exact"), + ) + if not inspection.get("ok"): + return CanPayResponse(can_pay=False, reason=str(inspection.get("reason"))) + if not inspection.get("requires_payment"): + return CanPayResponse(can_pay=True) + selected_kind = inspection.get("selected_kind") + if selected_kind is None: + return CanPayResponse( + can_pay=False, + reason=str( + inspection.get("reason") + or "Seller does not advertise a buyer-supported x402 payment kind" + ), + ) + selected_route = inspection.get("selected_route") + if _rail_for_selected_route(selected_route) is None: + return CanPayResponse( + can_pay=False, reason="Seller selected an unsupported x402 payment route" + ) + if inspection.get("selected_route") == "nanopayment" and not inspection.get("gateway_ready"): + return CanPayResponse(can_pay=False, reason=str(inspection.get("gateway_reason") or "")) + return CanPayResponse(can_pay=True) @router.get("/wallets", response_model=ListWalletsResponse) @@ -1178,11 +1662,13 @@ async def list_wallets( agent: AuthenticatedAgent = Depends(get_current_agent), policy_mgr: PolicyManager = Depends(get_policy_manager), wallet_mgr: WalletManager = Depends(get_wallet_manager), + client: OmniClaw = Depends(get_omniclaw_client), ): is_pending = agent.wallet_id.startswith("pending-") address = await wallet_mgr.get_wallet_address(agent.wallet_id) wallet_cfg = policy_mgr.get_wallet_config(agent.wallet_id) + address = address or wallet_cfg.get("gateway_eoa_address") or _client_signer_address(client) alias = wallet_cfg.get("alias") or agent.wallet_id.replace("pending-", "") policy = policy_mgr.get_policy() @@ -1229,6 +1715,23 @@ async def x402_inspect( router_detected_route="transfer", ) + if not _policy_rail_enabled(policy_mgr, "x402", agent.wallet_id): + return X402InspectResponse( + url=request.url, + requires_payment=False, + buyer_ready=False, + reason="x402 rail is disabled by policy", + router_detected_route="x402", + ) + if not _server_rail_enabled(client, "x402"): + return X402InspectResponse( + url=request.url, + requires_payment=False, + buyer_ready=False, + reason="x402 payments are disabled by server config", + router_detected_route="x402", + ) + inspection = await _inspect_x402_target( client=client, wallet_id=agent.wallet_id, @@ -1236,6 +1739,10 @@ async def x402_inspect( method=request.method, headers=request.headers, body=request.body, + allow_gateway=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "gateway"), + allow_x402_exact=_policy_rail_enabled(policy_mgr, "x402", agent.wallet_id) + and _server_rail_enabled(client, "x402_exact"), ) if not inspection.get("ok"): return X402InspectResponse( @@ -1262,20 +1769,7 @@ async def x402_inspect( payment_source = inspection.get("payment_source") seller_accepts = inspection.get("seller_accepts") or [] - buyer_address: str | None = None - if client._nano_adapter: - buyer_address = client._nano_adapter.address - else: - private_key = os.environ.get("OMNICLAW_PRIVATE_KEY") - if private_key: - try: - from eth_account import Account - - if not private_key.startswith("0x"): - private_key = f"0x{private_key}" - buyer_address = Account.from_key(private_key).address - except Exception: - buyer_address = None + buyer_address = _client_signer_address(client) gateway_available_balance = inspection.get("gateway_available_balance") buyer_ready = False @@ -1283,6 +1777,18 @@ async def x402_inspect( if selected_kind is None: reason = "Seller does not advertise a buyer-supported x402 payment kind" + elif _x402_selected_amount_exceeds_cap(selected_kind, request.amount): + reason = f"x402 price {selected_kind.get_amount_usdc()} exceeds max amount {request.amount}" + elif _rail_for_selected_route(selected_route) is None: + reason = "Seller selected an unsupported x402 payment route" + elif (selected_rail := _rail_for_selected_route(selected_route)) and not _policy_rail_enabled( + policy_mgr, selected_rail, agent.wallet_id + ): + reason = f"Payment rail '{selected_rail}' is disabled by policy" + elif selected_route in {"nanopayment", "x402"} and not _server_x402_route_enabled( + client, selected_route + ): + reason = _server_x402_route_disabled_reason(selected_route) elif selected_route == "nanopayment": buyer_ready = bool(inspection.get("gateway_ready")) reason = str(inspection.get("gateway_reason") or "") @@ -1303,9 +1809,10 @@ async def x402_inspect( requires_payment=True, buyer_ready=buyer_ready, reason=reason, - router_detected_route=inspection.get("router_detected_route"), - selected_route=selected_route, + router_detected_route=_public_x402_route(inspection.get("router_detected_route")), + selected_route=_public_x402_route(selected_route), payment_source=payment_source, + execution_route=_x402_execution_route(selected_route, payment_source), buyer_address=buyer_address, gateway_available_balance=gateway_available_balance, selected_scheme=selected_kind.scheme if selected_kind else None, diff --git a/src/omniclaw/agent/server.py b/src/omniclaw/agent/server.py index b512c79..0a14b88 100644 --- a/src/omniclaw/agent/server.py +++ b/src/omniclaw/agent/server.py @@ -40,6 +40,11 @@ async def lifespan(app: FastAPI): from omniclaw import OmniClaw from omniclaw.core.types import Network + # The standalone SDK defaults to Circle-only for compatibility. The + # buyer server is a policy engine, so default it to the full buyer rail + # set unless the operator chooses a narrower mode. + os.environ.setdefault("OMNICLAW_BUYER_MODE", "hybrid") + # Read network from environment network_str = os.getenv("OMNICLAW_NETWORK", "ETH-SEPOLIA") try: @@ -128,6 +133,9 @@ async def _policy_watch_loop() -> None: "private_key": os.environ.get("OMNICLAW_PRIVATE_KEY"), "rpc_url": os.environ.get("OMNICLAW_RPC_URL"), "nanopay_network": nanopay_network, + "gateway_contract_address": os.environ.get("CIRCLE_GATEWAY_CONTRACT"), + "gateway_usdc_address": os.environ.get("CIRCLE_GATEWAY_USDC_ADDRESS") + or os.environ.get("CIRCLE_GATEWAY_USDC_CONTRACT"), } return app diff --git a/src/omniclaw/cli/commands/payments.py b/src/omniclaw/cli/commands/payments.py index ad77c83..8f180f4 100644 --- a/src/omniclaw/cli/commands/payments.py +++ b/src/omniclaw/cli/commands/payments.py @@ -13,7 +13,7 @@ def pay( recipient: str = typer.Option(..., "--recipient", help="Payment recipient (address or URL)"), amount: str | None = typer.Option( - None, "--amount", help="Amount in USDC (optional for x402 URLs)" + None, "--amount", help="Amount in USDC; maximum amount for x402 URLs" ), purpose: str | None = typer.Option(None, "--purpose", help="Payment purpose"), idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), @@ -33,6 +33,17 @@ def pay( ) -> dict[str, Any]: """Execute a payment or pay for an x402 service.""" if dry_run: + if recipient.startswith("http"): + data = inspect_x402( + recipient=recipient, + amount=amount, + method=method, + body=body, + header=header, + ) + if data.get("buyer_ready") is False: + raise typer.Exit(1) + return data return simulate( recipient=recipient, amount=amount or "0.00", @@ -43,7 +54,7 @@ def pay( skip_guards=skip_guards, ) - client = get_client() + client = get_client(owner=skip_guards) parsed_headers: dict[str, str] | None = None if header: @@ -108,7 +119,11 @@ def pay( if not is_quiet(): typer.echo(f"Response saved to {output}") typer.echo(json.dumps(data, indent=2)) + if data.get("success") is False and not requires_confirmation: + raise typer.Exit(1) return data + except typer.Exit: + raise except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) raise typer.Exit(1) from e @@ -131,7 +146,7 @@ def simulate( skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), ) -> dict[str, Any]: """Simulate a payment without executing.""" - client = get_client() + client = get_client(owner=skip_guards) payload: dict[str, Any] = { "recipient": recipient, @@ -153,7 +168,11 @@ def simulate( response.raise_for_status() data = response.json() typer.echo(json.dumps(data, indent=2)) + if data.get("would_succeed") is False: + raise typer.Exit(1) return data + except typer.Exit: + raise except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) raise typer.Exit(1) from e diff --git a/src/omniclaw/cli/commands/wallet.py b/src/omniclaw/cli/commands/wallet.py index 2d87ea9..7773170 100644 --- a/src/omniclaw/cli/commands/wallet.py +++ b/src/omniclaw/cli/commands/wallet.py @@ -61,9 +61,14 @@ def balance_detail() -> dict[str, Any]: if is_quiet(): typer.echo(json.dumps(data, indent=2)) else: + gateway_balance = ( + data.get("gateway_balance") + if data.get("gateway_balance_available") + else data.get("gateway_onchain_balance") + ) typer.echo("=== WALLET BALANCE ===") typer.echo(f"EOA Address: {data.get('eoa_address')}") - typer.echo(f"Gateway Balance: {data.get('gateway_balance')} USDC") + typer.echo(f"Gateway Balance: {gateway_balance} USDC") typer.echo(f"Circle Wallet: {data.get('circle_wallet_balance')} USDC") return data @@ -84,8 +89,37 @@ def balance_detail_alias() -> dict[str, Any]: return balance_detail() +def deposit_address() -> dict[str, Any]: + """Get the EOA address to fund before Gateway deposits.""" + client = get_client() + + try: + response = client.get("/api/v1/deposit-address") + response.raise_for_status() + data = response.json() + typer.echo(json.dumps(data, indent=2)) + return data + except httpx.HTTPStatusError as e: + typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) + raise typer.Exit(1) from e + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) from e + + +def deposit_address_alias() -> dict[str, Any]: + """Alias for deposit-address.""" + return deposit_address() + + def deposit( amount: str = typer.Option(..., "--amount", help="Amount in USDC to deposit to Gateway"), + check_gas: bool = typer.Option(False, "--check-gas", help="Check gas balance first"), + skip_if_insufficient_gas: bool = typer.Option( + True, + "--skip-if-insufficient-gas/--no-skip-if-insufficient-gas", + help="Skip deposit when gas is insufficient", + ), ) -> dict[str, Any]: """Deposit USDC from EOA to Gateway wallet.""" client = get_client() @@ -93,7 +127,11 @@ def deposit( try: response = client.post( "/api/v1/deposit", - params={"amount": amount}, + params={ + "amount": amount, + "check_gas": check_gas, + "skip_if_insufficient_gas": skip_if_insufficient_gas, + }, ) response.raise_for_status() data = response.json() @@ -109,12 +147,21 @@ def deposit( def withdraw( amount: str = typer.Option(..., "--amount", help="Amount in USDC to withdraw from Gateway"), + destination_chain: str | None = typer.Option( + None, "--destination-chain", help="Optional CAIP-2 destination chain" + ), + recipient: str | None = typer.Option(None, "--recipient", help="Optional destination address"), ) -> dict[str, Any]: """Withdraw USDC from Gateway to Circle Developer Wallet.""" - client = get_client() + client = get_client(owner=recipient is not None) try: - response = client.post("/api/v1/withdraw", params={"amount": amount}) + params: dict[str, Any] = {"amount": amount} + if destination_chain: + params["destination_chain"] = destination_chain + if recipient: + params["recipient"] = recipient + response = client.post("/api/v1/withdraw", params=params) response.raise_for_status() data = response.json() typer.echo(json.dumps(data, indent=2)) @@ -183,6 +230,8 @@ def register(app: typer.Typer, group: typer.Typer) -> None: app.command()(balance) app.command("balance-detail")(balance_detail) app.command(name="balance_detail", help="Alias for balance-detail")(balance_detail_alias) + app.command("deposit-address")(deposit_address) + app.command(name="deposit_address", help="Alias for deposit-address")(deposit_address_alias) app.command()(deposit) app.command()(withdraw) app.command("withdraw-trustless")(withdraw_trustless) @@ -198,6 +247,8 @@ def register(app: typer.Typer, group: typer.Typer) -> None: group.command()(balance) group.command("balance-detail")(balance_detail) group.command(name="balance_detail", help="Alias for balance-detail")(balance_detail_alias) + group.command("deposit-address")(deposit_address) + group.command(name="deposit_address", help="Alias for deposit-address")(deposit_address_alias) group.command()(deposit) group.command()(withdraw) group.command("withdraw-trustless")(withdraw_trustless) diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index 611f69f..656a08c 100644 --- a/src/omniclaw/client.py +++ b/src/omniclaw/client.py @@ -68,6 +68,7 @@ from omniclaw.protocols.gateway import GatewayAdapter from omniclaw.protocols.nanopayments import ( DepositResult, + GatewayAPIError, GatewayBalance, NanopaymentAdapter, NanopaymentClient, @@ -136,7 +137,15 @@ def __init__( if not entity_secret: entity_secret = os.environ.get("ENTITY_SECRET") - if circle_api_key and not entity_secret: + buyer_mode = os.environ.get("OMNICLAW_BUYER_MODE", "circle").strip().lower() + enable_circle_transfer_env = os.environ.get("OMNICLAW_ENABLE_CIRCLE_TRANSFER") + enable_circle_transfer = ( + enable_circle_transfer_env.strip().lower() in {"1", "true", "yes", "on"} + if enable_circle_transfer_env is not None + else buyer_mode in {"hybrid", "circle"} + ) + + if circle_api_key and not entity_secret and enable_circle_transfer: from omniclaw.onboarding import load_managed_entity_secret managed_secret = load_managed_entity_secret(circle_api_key) @@ -146,7 +155,7 @@ def __init__( self._logger.info("Loaded entity secret from managed OmniClaw config.") # Auto-setup entity secret if missing but API key is present - if circle_api_key and not entity_secret: + if circle_api_key and not entity_secret and enable_circle_transfer: self._logger.info("Entity secret not found. Running auto-setup...") try: from omniclaw.onboarding import auto_setup_entity_secret @@ -158,7 +167,10 @@ def __init__( raise if not circle_api_key: - self._logger.warning("CIRCLE_API_KEY not set. SDK will fail.") + self._logger.warning( + "CIRCLE_API_KEY not set. Circle direct transfers and Circle Gateway API helper " + "calls will be unavailable; x402 signing can still use OMNICLAW_PRIVATE_KEY." + ) self._config = Config.from_env( circle_api_key=circle_api_key, @@ -167,7 +179,7 @@ def __init__( ) self._enforce_production_startup_requirements() - if circle_api_key and entity_secret: + if circle_api_key and entity_secret and self._config.enable_circle_transfer: try: from omniclaw.onboarding import store_managed_credentials @@ -199,9 +211,12 @@ def __init__( self._init_nanopayments() self._router = PaymentRouter(self._config, self._wallet_service) - self._router.register_adapter(TransferAdapter(self._config, self._wallet_service)) - self._router.register_adapter(X402Adapter(self._config, self._wallet_service)) - self._router.register_adapter(GatewayAdapter(self._config, self._wallet_service)) + if self._config.enable_circle_transfer: + self._router.register_adapter(TransferAdapter(self._config, self._wallet_service)) + if self._config.enable_x402_exact: + self._router.register_adapter(X402Adapter(self._config, self._wallet_service)) + if self._config.enable_circle_transfer: + self._router.register_adapter(GatewayAdapter(self._config, self._wallet_service)) # Register NanopaymentProtocolAdapter if nanopayments initialized successfully if self._nano_adapter is not None: @@ -295,6 +310,23 @@ def _nanopayment_network(self) -> str: ) return network + def _gateway_wallet_manager_kwargs(self) -> dict[str, str | None]: + caip2_network = self._nanopayment_network() + gateway_address = self._config.gateway_contract_address + usdc_address = self._config.gateway_usdc_address + if not gateway_address: + from omniclaw.protocols.nanopayments.constants import GATEWAY_WALLET_CONTRACTS_CAIP2 + + gateway_address = GATEWAY_WALLET_CONTRACTS_CAIP2.get(caip2_network) + if not usdc_address: + from omniclaw.core.cctp_constants import USDC_CONTRACTS + + usdc_address = USDC_CONTRACTS.get(self._config.network.value) + return { + "gateway_address": gateway_address, + "usdc_address": usdc_address, + } + @staticmethod def _route_value(route: Any) -> str: return str(route.value if hasattr(route, "value") else route or "").strip().lower() @@ -393,7 +425,7 @@ def _init_nanopayments(self) -> None: ) self._nano_client = NanopaymentClient( environment=self._config.nanopayments_environment, - api_key=self._config.circle_api_key, + api_key=self._config.circle_api_key or None, ) if not self._config.nanopayments_private_key: @@ -413,6 +445,7 @@ def _init_nanopayments(self) -> None: auto_topup_threshold=self._config.nanopayments_topup_threshold, auto_topup_amount=self._config.nanopayments_topup_amount, strict_settlement=self._config.payment_strict_settlement, + rpc_url=self._config.rpc_url, ) self._logger.info(f"Nanopayments initialized (direct private key, network={network})") except Exception as e: @@ -520,6 +553,7 @@ async def deposit_to_gateway( network=net, rpc_url=self._config.rpc_url or "", nanopayment_client=self._nano_client, + **self._gateway_wallet_manager_kwargs(), ) return await manager.deposit( amount_usdc, check_gas=check_gas, skip_if_insufficient_gas=skip_if_insufficient_gas @@ -564,6 +598,7 @@ async def withdraw_from_gateway( network=net, rpc_url=self._config.rpc_url or "", nanopayment_client=self._nano_client, + **self._gateway_wallet_manager_kwargs(), ) return await manager.withdraw( amount_usdc=amount_usdc, @@ -595,6 +630,16 @@ async def get_gateway_balance( """Get the spendable Gateway balance for the current signer.""" if not self._nano_adapter or not self._nano_client: raise NanopaymentNotInitializedError() + if not self._nano_client.has_api_key: + raise GatewayAPIError( + message=( + "Gateway API balance check requires CIRCLE_API_KEY. During x402 seller " + "routing OmniClaw checks Gateway balance on-chain from the seller's " + "GatewayWalletBatched metadata." + ), + status_code=0, + response_body=None, + ) network = self._nanopayment_network() balance = await self._nano_client.check_balance( @@ -608,6 +653,15 @@ async def get_gateway_balance( formatted_available=f"{balance.available / 1e6} USDC", ) + async def get_gateway_onchain_balance_for_kind( + self, + gateway_kind: Any, + ) -> GatewayBalance: + """Get on-chain Gateway balance using seller-provided Gateway x402 metadata.""" + if not self._nano_adapter: + raise NanopaymentNotInitializedError() + return await self._nano_adapter.get_onchain_available_balance(gateway_kind) + async def get_gateway_onchain_balance( self, wallet_id: str, @@ -625,6 +679,7 @@ async def get_gateway_onchain_balance( network=network, rpc_url=self._config.rpc_url or "", nanopayment_client=self._nano_client, + **self._gateway_wallet_manager_kwargs(), ) available = await manager.get_gateway_available_balance() total = available @@ -1093,12 +1148,15 @@ async def pay( source_network: Network | None = None try: # Try to get source network from Circle wallet first, then fall back to config default. - try: - wallet_info = self._wallet_service.get_wallet(wallet_id) - source_network = Network.from_string(wallet_info.blockchain) - except Exception: - if self._nano_adapter: - source_network = self._config.network + if not self._config.enable_circle_transfer and self._nano_adapter: + source_network = self._config.network + else: + try: + wallet_info = self._wallet_service.get_wallet(wallet_id) + source_network = Network.from_string(wallet_info.blockchain) + except Exception: + if self._nano_adapter: + source_network = self._config.network if source_network is None: # Fallback to ETH Sepolia if we can't determine the network @@ -1237,16 +1295,19 @@ async def pay( self._route_uses_gateway_balance(detected_route, preferred_url_route) and self._nano_adapter ): - try: - gateway_balance = await self.get_gateway_balance(wallet_id) - available = Decimal(str(gateway_balance.available_decimal)) - balance_source = f"Gateway available: {available}" - except Exception as e: - # For nanopayment routes, don't fall back to circle balance - # Instead, log error and use 0 (will fail with clearer message) - self._logger.warning(f"Gateway balance check failed: {e}") - available = Decimal("0") - balance_source = "Gateway available: (check failed)" + if str(recipient).startswith(("http://", "https://")): + available = amount_decimal + balance_source = "x402 Gateway: deferred to x402 adapter" + else: + try: + gateway_balance = await self.get_gateway_balance(wallet_id) + available = Decimal(str(gateway_balance.available_decimal)) + balance_source = f"Gateway available: {available}" + except Exception as e: + # For direct nanopayment routes, don't fall back to circle balance. + self._logger.warning(f"Gateway balance check failed: {e}") + available = Decimal("0") + balance_source = "Gateway available: (check failed)" elif self._route_value(detected_route) == PaymentMethod.X402.value: available = amount_decimal balance_source = "Direct x402: deferred to x402 adapter" @@ -1617,9 +1678,12 @@ async def simulate( # Detect the actual route early so early-return reasons include it source_network: Network | None = None try: - source_network = Network.from_string( - self._wallet_service.get_wallet(wallet_id).blockchain - ) + if not self._config.enable_circle_transfer and self._nano_adapter: + source_network = self._config.network + else: + source_network = Network.from_string( + self._wallet_service.get_wallet(wallet_id).blockchain + ) detected_route = ( self._router.detect_method( recipient, @@ -1654,25 +1718,30 @@ async def simulate( self._route_uses_gateway_balance(detected_route, preferred_url_route) and self._nano_adapter ): - try: - # Direct private key mode - use ON-CHAIN query - from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - - private_key = self._nano_adapter.signer.raw_key - network = self._nanopayment_network() - manager = GatewayWalletManager( - private_key=private_key, - network=network, - rpc_url=self._config.rpc_url or "", - nanopayment_client=self._nano_client, - ) - # Use on-chain available balance - available = await manager.get_gateway_available_balance() - balance_source = f"Gateway: {available}" - except Exception as e: - self._logger.warning(f"Gateway balance check failed: {e}") - available = 0 - balance_source = "Gateway: (check failed)" + if str(recipient).startswith(("http://", "https://")): + available = amount_decimal + balance_source = "x402 Gateway: deferred to x402 adapter" + else: + try: + # Direct private key mode - use ON-CHAIN query + from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager + + private_key = self._nano_adapter.signer.raw_key + network = self._nanopayment_network() + manager = GatewayWalletManager( + private_key=private_key, + network=network, + rpc_url=self._config.rpc_url or "", + nanopayment_client=self._nano_client, + **self._gateway_wallet_manager_kwargs(), + ) + # Use on-chain available balance + available = await manager.get_gateway_available_balance() + balance_source = f"Gateway: {available}" + except Exception as e: + self._logger.warning(f"Gateway balance check failed: {e}") + available = 0 + balance_source = "Gateway: (check failed)" elif self._route_value(detected_route) == PaymentMethod.X402.value: available = amount_decimal balance_source = "Direct x402: deferred to x402 adapter" diff --git a/src/omniclaw/core/config.py b/src/omniclaw/core/config.py index 64528c1..a3d3382 100644 --- a/src/omniclaw/core/config.py +++ b/src/omniclaw/core/config.py @@ -21,11 +21,20 @@ def _get_env_var(name: str, default: str | None = None, required: bool = False) return value +def _parse_bool(value: Any) -> bool: + """Parse boolean-like config values from env strings or direct overrides.""" + if isinstance(value, bool): + return value + if isinstance(value, int | float): + return bool(value) + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + @dataclass(frozen=True) class Config: """SDK configuration.""" - circle_api_key: str + circle_api_key: str = "" entity_secret: str = "" network: Network = Network.ETH storage_backend: str = "memory" @@ -39,12 +48,18 @@ class Config: max_api_calls_per_second: int = 30 # Conservative limit (Circle allows 35) endpoints circle_api_base_url: str = "https://api.circle.com/v1/w3s" rpc_url: str | None = None + buyer_mode: str = "circle" + enable_circle_transfer: bool = True + enable_gateway: bool = False + enable_x402_exact: bool = False # x402 facilitator (thirdweb) x402_facilitator_url: str = "https://x402.org/facilitator" # Gateway API for gasless transfers gateway_api_url: str = "https://gateway-api-testnet.circle.com/v1" + gateway_contract_address: str | None = None + gateway_usdc_address: str | None = None # Timeouts (seconds) request_timeout: float = 60.0 @@ -70,7 +85,7 @@ class Config: # ===================================================================== # Nanopayments (EIP-3009 Circle Gateway batched settlement) # ===================================================================== - nanopayments_enabled: bool = True + nanopayments_enabled: bool = False """Enable nanopayments (EIP-3009 batched USDC micro-payments).""" nanopayments_environment: str = "testnet" @@ -97,9 +112,23 @@ class Config: auto_reconcile_pending_settlements: bool = False """If true, opportunistically reconcile pending settlements during payment operations.""" + @property + def enable_x402(self) -> bool: + """Return whether any x402 execution path is enabled.""" + return self.enable_gateway or self.enable_x402_exact + def __post_init__(self) -> None: - if not self.circle_api_key: - raise ValueError("circle_api_key is required") + mode = self.buyer_mode.strip().lower() + if mode not in {"hybrid", "circle", "x402"}: + raise ValueError("OMNICLAW_BUYER_MODE must be one of: hybrid, circle, x402") + if self.enable_circle_transfer and not self.circle_api_key: + raise ValueError("CIRCLE_API_KEY is required when Circle transfer rail is enabled") + if self.enable_circle_transfer and not self.entity_secret: + raise ValueError("ENTITY_SECRET is required when Circle transfer rail is enabled") + if (self.enable_gateway or self.enable_x402_exact) and not self.nanopayments_private_key: + raise ValueError("OMNICLAW_PRIVATE_KEY is required when x402 payments are enabled") + if self.enable_x402 and not self.rpc_url: + raise ValueError("OMNICLAW_RPC_URL is required when x402 payments are enabled") if not self.entity_secret and not self.nanopayments_private_key: import logging @@ -117,9 +146,7 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: return overrides[name] return _get_env_var(env_name, default=default) - circle_api_key = override_or_env("circle_api_key", "CIRCLE_API_KEY") or _get_env_var( - "CIRCLE_API_KEY", required=True - ) + circle_api_key = override_or_env("circle_api_key", "CIRCLE_API_KEY", default="") or "" entity_secret = override_or_env("entity_secret", "ENTITY_SECRET", default="") # Direct private key for nanopayments @@ -137,6 +164,53 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: env = override_or_env("env", "OMNICLAW_ENV", "development") rpc_url = override_or_env("rpc_url", "OMNICLAW_RPC_URL") + buyer_mode = ( + str(override_or_env("buyer_mode", "OMNICLAW_BUYER_MODE", "circle")).strip().lower() + ) + + mode_defaults = { + "hybrid": { + "enable_circle_transfer": True, + "enable_gateway": True, + "enable_x402_exact": True, + }, + "circle": { + "enable_circle_transfer": True, + "enable_gateway": False, + "enable_x402_exact": False, + }, + "x402": { + "enable_circle_transfer": False, + "enable_gateway": True, + "enable_x402_exact": True, + }, + } + defaults = mode_defaults.get(buyer_mode) + if defaults is None: + raise ValueError("OMNICLAW_BUYER_MODE must be one of: hybrid, circle, x402") + + def rail_flag(name: str, env_name: str) -> bool: + if name in overrides: + return _parse_bool(overrides[name]) + env_value = _get_env_var(env_name) + if env_value is None: + return bool(defaults[name]) + return _parse_bool(env_value) + + enable_circle_transfer = rail_flag( + "enable_circle_transfer", "OMNICLAW_ENABLE_CIRCLE_TRANSFER" + ) + enable_gateway = bool(defaults["enable_gateway"]) + enable_x402_exact = bool(defaults["enable_x402_exact"]) + x402_env_value = _get_env_var("OMNICLAW_ENABLE_X402") + if "enable_x402" in overrides: + enable_x402 = _parse_bool(overrides["enable_x402"]) + enable_x402_exact = enable_x402 + enable_gateway = enable_x402 + elif x402_env_value is not None: + enable_x402 = _parse_bool(x402_env_value) + enable_x402_exact = enable_x402 + enable_gateway = enable_x402 storage_backend = override_or_env("storage_backend", "OMNICLAW_STORAGE_BACKEND", "memory") redis_url = override_or_env("redis_url", "OMNICLAW_REDIS_URL") @@ -171,8 +245,8 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: ) confirm_threshold = override_or_env("confirm_threshold", "OMNICLAW_CONFIRM_THRESHOLD") - # Nanopayments configuration (always enabled, env auto-detected from OMNICLAW_ENV) - nanopayments_enabled = True + # Nanopayments/Gateway configuration + nanopayments_enabled = enable_gateway nanopayments_auto_topup = ( overrides.get("nanopayments_auto_topup") if "nanopayments_auto_topup" in overrides @@ -200,6 +274,12 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: == "true" ) ) + gateway_contract_address = override_or_env( + "gateway_contract_address", "CIRCLE_GATEWAY_CONTRACT" + ) + gateway_usdc_address = override_or_env( + "gateway_usdc_address", "CIRCLE_GATEWAY_USDC_ADDRESS" + ) or override_or_env("gateway_usdc_address", "CIRCLE_GATEWAY_USDC_CONTRACT") return cls( circle_api_key=circle_api_key, # type: ignore @@ -209,6 +289,8 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: circle_api_base_url=overrides.get("circle_api_base_url", cls.circle_api_base_url), x402_facilitator_url=overrides.get("x402_facilitator_url", cls.x402_facilitator_url), gateway_api_url=overrides.get("gateway_api_url", cls.gateway_api_url), + gateway_contract_address=gateway_contract_address, + gateway_usdc_address=gateway_usdc_address, request_timeout=overrides.get("request_timeout", cls.request_timeout), transaction_poll_interval=overrides.get( "transaction_poll_interval", cls.transaction_poll_interval @@ -219,6 +301,10 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: log_level=log_level, # type: ignore env=env, # type: ignore rpc_url=rpc_url, + buyer_mode=buyer_mode, + enable_circle_transfer=enable_circle_transfer, + enable_gateway=enable_gateway, + enable_x402_exact=enable_x402_exact, daily_budget=daily_budget, hourly_budget=hourly_budget, tx_limit=tx_limit, @@ -249,12 +335,18 @@ def with_updates(self, **updates: Any) -> Config: "circle_api_base_url": self.circle_api_base_url, "x402_facilitator_url": self.x402_facilitator_url, "gateway_api_url": self.gateway_api_url, + "gateway_contract_address": self.gateway_contract_address, + "gateway_usdc_address": self.gateway_usdc_address, "request_timeout": self.request_timeout, "transaction_poll_interval": self.transaction_poll_interval, "transaction_poll_timeout": self.transaction_poll_timeout, "log_level": self.log_level, "env": self.env, "rpc_url": self.rpc_url, + "buyer_mode": self.buyer_mode, + "enable_circle_transfer": self.enable_circle_transfer, + "enable_gateway": self.enable_gateway, + "enable_x402_exact": self.enable_x402_exact, "daily_budget": self.daily_budget, "hourly_budget": self.hourly_budget, "tx_limit": self.tx_limit, diff --git a/src/omniclaw/core/idempotency.py b/src/omniclaw/core/idempotency.py index bf1f73e..14a9308 100644 --- a/src/omniclaw/core/idempotency.py +++ b/src/omniclaw/core/idempotency.py @@ -14,7 +14,7 @@ def _normalize_part(value: Any) -> str: """Normalize a value into a deterministic string for hashing.""" if value is None: return "" - if isinstance(value, (dict, list, tuple)): + if isinstance(value, dict | list | tuple): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) if isinstance(value, Decimal): return _normalize_decimal(value) diff --git a/src/omniclaw/payment/router.py b/src/omniclaw/payment/router.py index 1987897..b9be69a 100644 --- a/src/omniclaw/payment/router.py +++ b/src/omniclaw/payment/router.py @@ -139,18 +139,21 @@ async def pay( # Resolve source network # Try to get from Circle wallet first, then from config default source_network = None - try: - wallet = self._wallet_service.get_wallet(wallet_id) - source_network = Network.from_string(wallet.blockchain) - except Exception: - # Wallet not in Circle system - try to get network from config default - if source_network is None and hasattr(self, "_config"): - with contextlib.suppress(Exception): - source_network = self._config.network - - if source_network is None: - # Fallback to ETH Sepolia if we can't determine the network - source_network = Network.ETH_SEPOLIA + if not getattr(self._config, "enable_circle_transfer", True): + source_network = self._config.network + else: + try: + wallet = self._wallet_service.get_wallet(wallet_id) + source_network = Network.from_string(wallet.blockchain) + except Exception: + # Wallet not in Circle system - try to get network from config default + if source_network is None and hasattr(self, "_config"): + with contextlib.suppress(Exception): + source_network = self._config.network + + if source_network is None: + # Fallback to ETH Sepolia if we can't determine the network + source_network = Network.ETH_SEPOLIA adapters = self._find_adapters( recipient, @@ -242,9 +245,16 @@ async def simulate( """ amount_decimal = Decimal(str(amount)) - # Resolve source network from wallet - MUST succeed - wallet = self._wallet_service.get_wallet(wallet_id) - source_network = Network.from_string(wallet.blockchain) + # Resolve source network from wallet when available. x402-only buyer + # profiles may use a synthetic wallet id backed by an EOA signer. + if not getattr(self._config, "enable_circle_transfer", True): + source_network = self._config.network + else: + try: + wallet = self._wallet_service.get_wallet(wallet_id) + source_network = Network.from_string(wallet.blockchain) + except Exception: + source_network = self._config.network destination_chain = kwargs.pop("destination_chain", None) # Find adapter diff --git a/src/omniclaw/protocols/nanopayments/adapter.py b/src/omniclaw/protocols/nanopayments/adapter.py index 6eecaa6..668fe9d 100644 --- a/src/omniclaw/protocols/nanopayments/adapter.py +++ b/src/omniclaw/protocols/nanopayments/adapter.py @@ -48,6 +48,7 @@ ) from omniclaw.protocols.nanopayments.signing import EIP3009Signer from omniclaw.protocols.nanopayments.types import ( + GatewayBalance, NanopaymentResult, PaymentPayload, PaymentRequirements, @@ -277,6 +278,7 @@ def __init__( retry_attempts: int = 3, retry_base_delay: float = 0.5, strict_settlement: bool = False, + rpc_url: str | None = None, ) -> None: self._signer = signer self._network = network @@ -289,6 +291,7 @@ def __init__( self._retry_attempts = retry_attempts self._retry_base_delay = retry_base_delay self._strict_settlement = strict_settlement + self._rpc_url = rpc_url @property def address(self) -> str: @@ -390,6 +393,150 @@ def _encode_payment_signature_header( ) return base64.b64encode(json.dumps(payment_payload).encode("utf-8")).decode("ascii") + @staticmethod + def _extra_value(gateway_kind: Any, key: str, snake_key: str | None = None) -> Any: + extra = getattr(gateway_kind, "extra", None) or {} + if isinstance(extra, dict): + return extra.get(key) or (extra.get(snake_key) if snake_key else None) + return getattr(extra, snake_key or key, None) + + @staticmethod + def _kind_value(gateway_kind: Any, key: str, fallback: str | None = None) -> Any: + value = getattr(gateway_kind, key, None) + if value is not None: + return value + if fallback: + return getattr(gateway_kind, fallback, None) + return None + + async def _gateway_verifying_contract(self, gateway_kind: Any) -> str: + verifying_contract = self._extra_value( + gateway_kind, + "verifyingContract", + "verifying_contract", + ) + if verifying_contract: + return str(verifying_contract) + network = str(self._kind_value(gateway_kind, "network") or "") + if self._client.has_api_key: + return await self._client.get_verifying_contract(network) + raise GatewayAPIError( + message=( + "Seller x402 Gateway requirement did not include extra.verifyingContract, " + "and CIRCLE_API_KEY is not configured to resolve it from Circle Gateway." + ), + status_code=0, + response_body=None, + ) + + async def get_onchain_available_balance( + self, + gateway_kind: Any, + ) -> GatewayBalance: + """Read GatewayWalletBatched balance directly from the Gateway contract.""" + verifying_contract = await self._gateway_verifying_contract(gateway_kind) + available = await self._get_onchain_available_atomic( + gateway_kind=gateway_kind, + verifying_contract=verifying_contract, + ) + formatted = f"{Decimal(available) / Decimal(1_000_000):.6f} USDC" + return GatewayBalance( + total=available, + available=available, + formatted_total=formatted, + formatted_available=formatted, + ) + + async def _gateway_available_balance( + self, + gateway_kind: Any, + ) -> GatewayBalance: + api_balance: GatewayBalance | None = None + required_atomic = int(str(self._kind_value(gateway_kind, "amount") or "0")) + if self._client.has_api_key: + try: + api_balance = await self._client.check_balance( + address=self._get_address(), + network=str(self._kind_value(gateway_kind, "network") or ""), + ) + if api_balance.available >= required_atomic: + return api_balance + except Exception as exc: + logger.warning("Gateway API balance check failed; falling back on-chain: %s", exc) + + try: + onchain_balance = await self.get_onchain_available_balance(gateway_kind) + if api_balance is None or onchain_balance.available > api_balance.available: + return onchain_balance + except Exception: + if api_balance is None: + raise + + return api_balance + + async def _get_onchain_available_atomic( + self, + *, + gateway_kind: Any, + verifying_contract: str, + ) -> int: + if not self._rpc_url: + raise GatewayAPIError( + message=( + "OMNICLAW_RPC_URL is required to check x402 Gateway balance without " + "CIRCLE_API_KEY." + ), + status_code=0, + response_body=None, + ) + usdc_address = self._kind_value(gateway_kind, "asset") + if not usdc_address: + raise GatewayAPIError( + message=( + "Seller x402 Gateway requirement did not include the USDC asset address; " + "cannot check Gateway balance without Circle API metadata." + ), + status_code=0, + response_body=None, + ) + try: + from web3 import Web3 + + w3 = Web3(Web3.HTTPProvider(self._rpc_url)) + gateway = w3.eth.contract( + address=Web3.to_checksum_address(verifying_contract), + abi=[ + { + "inputs": [ + {"internalType": "address", "name": "token", "type": "address"}, + { + "internalType": "address", + "name": "depositor", + "type": "address", + }, + ], + "name": "availableBalance", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + } + ], + ) + return int( + gateway.functions.availableBalance( + Web3.to_checksum_address(usdc_address), + Web3.to_checksum_address(self._get_address()), + ).call() + ) + except GatewayAPIError: + raise + except Exception as exc: + raise GatewayAPIError( + message=f"Gateway on-chain balance check failed: {exc}", + status_code=0, + response_body=str(exc), + ) from exc + # ------------------------------------------------------------------------- # x402 URL payment # ------------------------------------------------------------------------- @@ -399,7 +546,8 @@ async def pay_x402_url( url: str, method: str = "GET", headers: dict | None = None, - body: bytes | None = None, + body: bytes | str | None = None, + max_amount_usdc: str | Decimal | None = None, ) -> NanopaymentResult: """ Pay for a URL-based resource via x402 with Gateway batching. @@ -531,11 +679,7 @@ async def pay_x402_url( raw_gateway_kind = _find_raw_gateway_kind(req_data) or gateway_kind.to_dict() # Step 5: Get verifying contract if missing - verifying_contract = gateway_kind.extra.verifying_contract - if not verifying_contract: - verifying_contract = await self._client.get_verifying_contract( - gateway_kind.network, - ) + verifying_contract = await self._gateway_verifying_contract(gateway_kind) raw_gateway_kind = _with_gateway_verifying_contract(raw_gateway_kind, verifying_contract) # Build updated requirements with verifying contract @@ -557,12 +701,18 @@ async def pay_x402_url( verifying_contract=verifying_contract, ), ) + if max_amount_usdc is not None: + payment_amount_usdc = Decimal(str(updated_kind.amount)) / Decimal(1_000_000) + if payment_amount_usdc > Decimal(str(max_amount_usdc)): + raise InsufficientBalanceError( + reason=( + f"x402 price {payment_amount_usdc} exceeds max amount {max_amount_usdc}" + ), + payer=self._get_address(), + ) # Step 6: Check balance - FAIL if insufficient payer_address = self._get_address() - balance = await self._client.check_balance( - address=payer_address, - network=gateway_kind.network, - ) + balance = await self._gateway_available_balance(gateway_kind) payment_amount_atomic = int(updated_kind.amount) if balance.available < payment_amount_atomic: raise InsufficientBalanceError( @@ -707,6 +857,16 @@ async def pay_direct( Raises: SettlementError: On payment settlement failure. """ + if not self._client.has_api_key: + raise GatewayAPIError( + message=( + "Direct address Gateway settlement requires CIRCLE_API_KEY. " + "Use an x402 URL seller flow, or configure Circle API credentials for " + "direct Gateway settlement." + ), + status_code=0, + response_body=None, + ) # Step 1: Resolve contract addresses verifying_contract = await self._client.get_verifying_contract(network) usdc_address = await self._client.get_usdc_address(network) @@ -1042,6 +1202,8 @@ def supports( return preferred_url_route != "x402" # EVM address below micro threshold if _is_address(recipient): + if not self._adapter._client.has_api_key: + return False amount = kwargs.get("amount") if amount is not None: threshold = Decimal(self._micro_threshold) @@ -1083,8 +1245,20 @@ async def execute( strict_settlement = bool(getattr(self._adapter, "_strict_settlement", False)) try: if _is_url(recipient): + request_method = str(kwargs.get("http_method", kwargs.get("method", "GET"))) + request_headers = kwargs.get("request_headers") or kwargs.get("headers") + request_body = kwargs.get("request_body", kwargs.get("body")) + request_json = kwargs.get("request_json") + if request_body is None and request_json is not None: + request_body = json.dumps(request_json) + request_headers = dict(request_headers or {}) + request_headers.setdefault("content-type", "application/json") result = await self._adapter.pay_x402_url( url=recipient, + method=request_method, + headers=request_headers, + body=request_body, + max_amount_usdc=str(amount), ) else: # Address payment below micro threshold @@ -1121,6 +1295,12 @@ async def execute( resource_data=result.response_data, metadata={ "nanopayment": True, + "selected_route": "nanopayment" if _is_url(recipient) else "gateway", + "payment_source": "gateway_balance" if _is_url(recipient) else "gateway", + "execution_route": ( + "GatewayWalletBatched" if _is_url(recipient) else "direct_gateway" + ), + "facilitator": "GatewayWalletBatched" if _is_url(recipient) else None, "payer": result.payer, "seller": result.seller, "amount_atomic": result.amount_atomic, diff --git a/src/omniclaw/protocols/nanopayments/client.py b/src/omniclaw/protocols/nanopayments/client.py index cade4f1..333eafa 100644 --- a/src/omniclaw/protocols/nanopayments/client.py +++ b/src/omniclaw/protocols/nanopayments/client.py @@ -440,16 +440,30 @@ def __init__( GATEWAY_API_TESTNET if env == "testnet" else GATEWAY_API_MAINNET ) self._api_key = api_key or os.environ.get("CIRCLE_API_KEY", "") - if not self._api_key: - raise ValueError( - "Circle API key is required. Set CIRCLE_API_KEY or pass api_key explicitly." - ) self._timeout = timeout # Supported networks cache self._supported_cache: list[SupportedKind] | None = None self._supported_cache_time: float = 0.0 + @property + def has_api_key(self) -> bool: + """Return whether Circle Gateway REST API helper calls can be made.""" + return bool(self._api_key) + + def _require_api_key(self, operation: str) -> None: + if self._api_key: + return + raise GatewayAPIError( + message=( + f"{operation} requires CIRCLE_API_KEY. x402 Gateway URL payments can sign " + "with OMNICLAW_PRIVATE_KEY without Circle API credentials when the seller " + "provides Gateway contract metadata." + ), + status_code=0, + response_body=None, + ) + # ------------------------------------------------------------------------- # Supported networks (with in-memory cache) # ------------------------------------------------------------------------- @@ -474,6 +488,7 @@ async def get_supported( GatewayAPIError: On HTTP errors from Circle. GatewayTimeoutError: On request timeout. """ + self._require_api_key("Fetching Circle Gateway supported networks") now = time.monotonic() cache_valid = ( self._supported_cache is not None @@ -599,6 +614,7 @@ async def verify( Raises: GatewayAPIError: On HTTP errors. """ + self._require_api_key("Verifying a Gateway payment through Circle API") circle_payload = _convert_payload_for_circle(payload.to_dict()) circle_requirements = _convert_requirements_for_circle(requirements.to_dict()) selected_requirement = ( @@ -665,6 +681,7 @@ async def settle( GatewayAPIError: On HTTP-level errors (auth failure, etc.). GatewayTimeoutError: On request timeout. """ + self._require_api_key("Settling a direct Gateway payment through Circle API") # Convert to Circle API format payload_dict = payload.to_dict() req_dict = requirements.to_dict() @@ -760,6 +777,7 @@ async def check_balance( UnsupportedNetworkError: If the network is not supported. GatewayAPIError: On HTTP errors. """ + self._require_api_key("Checking Gateway balance through Circle API") await self.get_supported() expected_domain = _caip2_to_circle_domain_id(network) diff --git a/src/omniclaw/protocols/nanopayments/constants.py b/src/omniclaw/protocols/nanopayments/constants.py index c37e561..b899d14 100644 --- a/src/omniclaw/protocols/nanopayments/constants.py +++ b/src/omniclaw/protocols/nanopayments/constants.py @@ -160,3 +160,13 @@ CAIP2_TO_CIRCLE_DOMAIN: dict[str, int] = {v: k for k, v in CIRCLE_DOMAIN_TO_CAIP2.items()} """Reverse mapping from CAIP-2 to Circle domain ID.""" + +GATEWAY_WALLET_CONTRACT_EVM: str = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9" +"""Circle GatewayWallet contract address used by supported EVM networks.""" + +GATEWAY_WALLET_CONTRACTS_CAIP2: dict[str, str] = { + network: GATEWAY_WALLET_CONTRACT_EVM + for network in CAIP2_TO_CIRCLE_DOMAIN + if network.startswith("eip155:") +} +"""Default GatewayWallet contract addresses keyed by CAIP-2 network.""" diff --git a/src/omniclaw/protocols/x402.py b/src/omniclaw/protocols/x402.py index 5fe27db..961cce8 100644 --- a/src/omniclaw/protocols/x402.py +++ b/src/omniclaw/protocols/x402.py @@ -496,10 +496,24 @@ def _resolve_agent_network( return source_network return Network.from_string(str(source_network)) + configured_network = getattr(self._config, "network", None) + if not bool(getattr(self._config, "enable_circle_transfer", True)) and configured_network: + return ( + configured_network + if isinstance(configured_network, Network) + else Network.from_string(str(configured_network)) + ) + try: agent_wallet = self._wallet_service.get_wallet(wallet_id) return Network.from_string(agent_wallet.blockchain) except Exception: + if configured_network: + return ( + configured_network + if isinstance(configured_network, Network) + else Network.from_string(str(configured_network)) + ) return None def _get_generic_x402_private_key(self) -> str: diff --git a/src/omniclaw/webhooks/parser.py b/src/omniclaw/webhooks/parser.py index 8214378..05a3340 100644 --- a/src/omniclaw/webhooks/parser.py +++ b/src/omniclaw/webhooks/parser.py @@ -215,7 +215,7 @@ def handle( ValidationError: If payload malformed """ # 1. Verify Signature (if raw payload provided) - if isinstance(payload, (str, bytes)): + if isinstance(payload, str | bytes): if not self.verify_signature(payload, headers): raise InvalidSignatureError("Invalid webhook signature") diff --git a/tests/test_agent_policy_runtime.py b/tests/test_agent_policy_runtime.py new file mode 100644 index 0000000..01b42c3 --- /dev/null +++ b/tests/test_agent_policy_runtime.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +import json +import os +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from omniclaw.agent.auth import AuthenticatedAgent +from omniclaw.agent.models import CanPayResponse, CreateIntentRequest, PayRequest, SimulateRequest +from omniclaw.agent.policy import PolicyManager, RuntimeWalletState, WalletManager +from omniclaw.agent.routes import can_pay, confirm_intent, create_intent, get_address, pay, simulate +from omniclaw.core.types import Network, PaymentMethod + + +def _write_policy(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, indent=2) + "\n") + + +class _FakeClient: + def __init__(self, *, circle_transfer: bool = True, eoa_address: str = "0x" + "2" * 40): + self.config = SimpleNamespace( + enable_circle_transfer=circle_transfer, + enable_gateway=False, + enable_x402_exact=True, + network=Network.BASE_SEPOLIA, + ) + self._config = self.config + self._nano_adapter = SimpleNamespace(address=eoa_address) + self._guard_manager = SimpleNamespace(clear_wallet_guards=AsyncMock()) + self.create_agent_wallet = AsyncMock( + return_value=SimpleNamespace( + id="circle-wallet-1", + address="0x" + "1" * 40, + ) + ) + self.get_wallet = AsyncMock( + return_value=SimpleNamespace( + id="circle-wallet-1", + address="0x" + "1" * 40, + blockchain=Network.BASE_SEPOLIA.value, + ) + ) + self.add_budget_guard = AsyncMock() + self.add_single_tx_guard = AsyncMock() + self.add_rate_limit_guard = AsyncMock() + self.add_recipient_guard = AsyncMock() + self.add_confirm_guard = AsyncMock() + + +@pytest.mark.asyncio +async def test_wallet_initialization_writes_runtime_state_not_policy(tmp_path): + policy_path = tmp_path / "policy.json" + state_path = tmp_path / "wallet-state.json" + _write_policy( + policy_path, + { + "version": "2.0", + "tokens": { + "active-token": {"wallet_alias": "primary", "active": True}, + "inactive-token": {"wallet_alias": "primary", "active": False}, + }, + "wallets": { + "primary": { + "name": "Primary", + "limits": {"per_tx_max": "10.00"}, + "recipients": {"mode": "allow_all"}, + "rails": { + "circle_transfer": True, + "x402": True, + }, + } + }, + }, + ) + + manager = PolicyManager(str(policy_path)) + await manager.load() + state = RuntimeWalletState(path=str(state_path), policy_path=str(policy_path)) + wallet_manager = WalletManager(manager, _FakeClient(), runtime_state=state) + + result = await wallet_manager.initialize_wallets() + + assert result == {"active-token": "circle-wallet-1"} + policy_after = json.loads(policy_path.read_text()) + assert "wallet_id" not in policy_after["wallets"]["primary"] + assert "address" not in policy_after["wallets"]["primary"] + + state_after = json.loads(state_path.read_text()) + assert state_after["wallets"]["primary"]["circle_wallet_id"] == "circle-wallet-1" + assert state_after["wallets"]["primary"]["gateway_eoa_address"] == "0x" + "2" * 40 + assert manager.get_wallet_id_for_token("inactive-token") is None + + +@pytest.mark.asyncio +async def test_x402_only_wallet_initialization_does_not_require_circle_wallet(tmp_path): + policy_path = tmp_path / "policy.json" + state_path = tmp_path / "wallet-state.json" + _write_policy( + policy_path, + { + "version": "2.0", + "tokens": {"agent-token": {"wallet_alias": "primary", "active": True}}, + "wallets": { + "primary": { + "name": "Primary", + "rails": { + "circle_transfer": False, + "x402": True, + }, + } + }, + }, + ) + + manager = PolicyManager(str(policy_path)) + await manager.load() + client = _FakeClient(circle_transfer=False, eoa_address="0x" + "3" * 40) + wallet_manager = WalletManager( + manager, + client, + runtime_state=RuntimeWalletState(path=str(state_path), policy_path=str(policy_path)), + ) + + result = await wallet_manager.initialize_wallets() + + assert result == {"agent-token": "x402:primary"} + client.create_agent_wallet.assert_not_called() + state_after = json.loads(state_path.read_text()) + assert state_after["wallets"]["primary"]["wallet_id"] == "x402:primary" + assert state_after["wallets"]["primary"]["circle_wallet_id"] is None + assert state_after["wallets"]["primary"]["circle_wallet_address"] is None + assert state_after["wallets"]["primary"]["gateway_eoa_address"] == "0x" + "3" * 40 + + +@pytest.mark.asyncio +async def test_old_gateway_and_exact_policy_rails_are_rejected(tmp_path): + policy_path = tmp_path / "policy.json" + _write_policy( + policy_path, + { + "version": "2.0", + "tokens": {"agent-token": {"wallet_alias": "primary", "active": True}}, + "wallets": { + "primary": { + "name": "Primary", + "rails": { + "circle_transfer": True, + "gateway": False, + "x402_exact": True, + }, + } + }, + }, + ) + + manager = PolicyManager(str(policy_path)) + with pytest.raises(ValueError, match="Invalid policy.json"): + await manager.load() + + +@pytest.mark.asyncio +async def test_policy_domain_whitelist_uses_hostname_boundaries(tmp_path): + policy_path = tmp_path / "policy.json" + _write_policy( + policy_path, + { + "version": "2.0", + "tokens": {"agent-token": {"wallet_alias": "primary", "active": True}}, + "wallets": {"primary": {"name": "Primary"}}, + "recipients": { + "mode": "whitelist", + "domains": ["api.service.com"], + }, + }, + ) + + manager = PolicyManager(str(policy_path)) + await manager.load() + + assert manager.is_valid_recipient("https://api.service.com/pay") + assert manager.is_valid_recipient("https://paid.api.service.com/pay") + assert not manager.is_valid_recipient("https://api.service.com.evil/pay") + + +@pytest.mark.asyncio +async def test_empty_recipient_whitelist_blocks_all(tmp_path): + policy_path = tmp_path / "policy.json" + _write_policy( + policy_path, + { + "version": "2.0", + "tokens": {"agent-token": {"wallet_alias": "primary", "active": True}}, + "wallets": {"primary": {"name": "Primary"}}, + "recipients": {"mode": "whitelist"}, + }, + ) + + manager = PolicyManager(str(policy_path)) + await manager.load() + + assert not manager.is_valid_recipient("https://api.service.com/pay") + assert not manager.is_valid_recipient("0x" + "4" * 40) + + +@pytest.mark.asyncio +async def test_missing_policy_requires_agent_token_for_default_creation(tmp_path): + policy_path = tmp_path / "missing.json" + manager = PolicyManager(str(policy_path)) + + with patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError, match="AGENT_TOKEN"): + await manager.load() + + +@pytest.mark.asyncio +async def test_x402_only_address_uses_private_key_signer(monkeypatch): + private_key = "0x" + "1" * 64 + client = SimpleNamespace( + _nano_adapter=None, + config=SimpleNamespace(nanopayments_private_key=private_key), + ) + policy = SimpleNamespace( + get_wallet_config=lambda wallet_id: {"alias": "primary", "gateway_eoa_address": None} + ) + wallet_mgr = SimpleNamespace(get_wallet_address=AsyncMock(return_value=None)) + agent = AuthenticatedAgent(token="agent-token", wallet_id="eoa:primary") + + response = await get_address( + agent=agent, + policy_mgr=policy, + wallet_mgr=wallet_mgr, + client=client, + ) + + assert response.wallet_id == "eoa:primary" + assert response.eoa_address is not None + assert response.address == response.eoa_address + + +@pytest.mark.asyncio +async def test_direct_pay_rejects_when_circle_transfer_disabled_by_server(): + request = PayRequest( + recipient="0x" + "4" * 40, + amount="1.00", + ) + agent = AuthenticatedAgent(token="agent-token", wallet_id="wallet-1") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: True, + check_limits=lambda amount, wallet_id: (True, None), + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=False, enable_gateway=False, enable_x402_exact=True + ), + pay=AsyncMock(), + ) + + with pytest.raises(HTTPException, match="Circle transfer rail is disabled by server config"): + await pay( + request=request, + agent=agent, + wallet_mgr=SimpleNamespace(), + policy_mgr=policy, + client=client, + ) + client.pay.assert_not_called() + + +@pytest.mark.asyncio +async def test_can_pay_rejects_direct_recipient_when_circle_transfer_disabled(): + agent = AuthenticatedAgent(token="agent-token", wallet_id="wallet-1") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: rail != "circle_transfer", + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=True, enable_gateway=False, enable_x402_exact=True + ) + ) + + result = await can_pay( + recipient="0x" + "4" * 40, + agent=agent, + policy_mgr=policy, + client=client, + ) + + assert isinstance(result, CanPayResponse) + assert result.can_pay is False + assert result.reason == "Circle transfer rail is disabled by policy" + + +@pytest.mark.asyncio +async def test_simulate_url_uses_selected_x402_amount_without_wallet_lookup(monkeypatch): + selected_kind = SimpleNamespace( + get_amount_usdc=lambda: Decimal("0.25"), + ) + x402_adapter = SimpleNamespace( + simulate=AsyncMock( + return_value={ + "would_succeed": True, + "method": PaymentMethod.X402, + "recipient": "https://seller.example/compute", + "amount": "0.25", + } + ) + ) + + async def fake_inspect_x402_target(**kwargs): + return { + "ok": True, + "requires_payment": True, + "selected_kind": selected_kind, + "selected_route": "x402", + "x402_adapter": x402_adapter, + } + + monkeypatch.setattr("omniclaw.agent.routes._inspect_x402_target", fake_inspect_x402_target) + agent = AuthenticatedAgent(token="agent-token", wallet_id="eoa:primary") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: rail == "x402", + check_limits=lambda amount, wallet_id: (True, None), + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=False, enable_gateway=False, enable_x402_exact=True + ) + ) + + result = await simulate( + request=SimulateRequest(recipient="https://seller.example/compute", amount="0.50"), + agent=agent, + policy_mgr=policy, + client=client, + ) + + assert result.would_succeed is True + assert result.route == "x402" + x402_adapter.simulate.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_pay_rejects_nanopayment_when_gateway_execution_disabled(monkeypatch): + selected_kind = SimpleNamespace(get_amount_usdc=lambda: Decimal("0.25")) + + async def fake_inspect_x402_target(**kwargs): + return { + "ok": True, + "requires_payment": True, + "selected_kind": selected_kind, + "selected_route": "nanopayment", + } + + monkeypatch.setattr("omniclaw.agent.routes._inspect_x402_target", fake_inspect_x402_target) + agent = AuthenticatedAgent(token="agent-token", wallet_id="wallet-1") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: rail == "x402", + check_limits=lambda amount, wallet_id: (True, None), + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=False, enable_gateway=False, enable_x402_exact=True + ), + pay=AsyncMock(), + ) + + with pytest.raises(HTTPException) as exc_info: + await pay( + request=PayRequest(recipient="https://seller.example/compute", amount="0.50"), + agent=agent, + wallet_mgr=SimpleNamespace(), + policy_mgr=policy, + client=client, + ) + + assert exc_info.value.status_code == 400 + assert "Gateway nanopayment" in exc_info.value.detail + client.pay.assert_not_called() + + +@pytest.mark.asyncio +async def test_can_pay_rejects_unsupported_x402_route(monkeypatch): + selected_kind = SimpleNamespace(get_amount_usdc=lambda: Decimal("0.25")) + + async def fake_inspect_x402_target(**kwargs): + return { + "ok": True, + "requires_payment": True, + "selected_kind": selected_kind, + "selected_route": "future-route", + } + + monkeypatch.setattr("omniclaw.agent.routes._inspect_x402_target", fake_inspect_x402_target) + agent = AuthenticatedAgent(token="agent-token", wallet_id="wallet-1") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: rail == "x402", + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=False, enable_gateway=True, enable_x402_exact=True + ) + ) + + result = await can_pay( + recipient="https://seller.example/compute", + agent=agent, + policy_mgr=policy, + client=client, + ) + + assert result.can_pay is False + assert result.reason == "Seller selected an unsupported x402 payment route" + + +@pytest.mark.asyncio +async def test_create_intent_rejects_url_when_x402_disabled(): + request = CreateIntentRequest(recipient="https://seller.example/compute", amount="0.25") + agent = AuthenticatedAgent(token="agent-token", wallet_id="wallet-1") + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + is_rail_enabled=lambda rail, wallet_id: False, + check_limits=lambda amount, wallet_id: (True, None), + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=True, enable_gateway=True, enable_x402_exact=True + ), + create_payment_intent=AsyncMock(), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_intent( + request=request, + agent=agent, + policy_mgr=policy, + client=client, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "x402 rail is disabled by policy" + client.create_payment_intent.assert_not_called() + + +@pytest.mark.asyncio +async def test_confirm_url_intent_rejects_missing_route_metadata(): + intent = SimpleNamespace( + id="intent-1", + wallet_id="eoa:primary", + recipient="https://seller.example/compute", + amount=Decimal("0.25"), + metadata={}, + ) + client = SimpleNamespace( + config=SimpleNamespace( + enable_circle_transfer=False, enable_gateway=False, enable_x402_exact=True + ), + get_payment_intent=AsyncMock(return_value=intent), + confirm_payment_intent=AsyncMock(), + ) + policy = SimpleNamespace( + is_valid_recipient=lambda recipient, wallet_id: True, + check_limits=lambda amount, wallet_id: (True, None), + is_rail_enabled=lambda rail, wallet_id: True, + ) + agent = AuthenticatedAgent(token="agent-token", wallet_id="eoa:primary") + + with pytest.raises(HTTPException) as exc_info: + await confirm_intent( + intent_id="intent-1", + agent=agent, + policy_mgr=policy, + client=client, + ) + + assert exc_info.value.status_code == 400 + assert "authorized route is missing" in exc_info.value.detail + client.confirm_payment_intent.assert_not_called() diff --git a/tests/test_client.py b/tests/test_client.py index e8015fd..383ee8d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -483,9 +483,7 @@ async def test_pay_direct_x402_does_not_require_circle_wallet_balance(self, clie client._router.pay.assert_awaited_once() @pytest.mark.asyncio - async def test_pay_nanopayment_route_uses_gateway_balance(self, client): - from omniclaw.protocols.nanopayments import GatewayBalance - + async def test_pay_url_nanopayment_route_delegates_gateway_balance_to_adapter(self, client): client._nano_adapter = object() client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") client._wallet_service.get_usdc_balance_amount = MagicMock( @@ -493,14 +491,7 @@ async def test_pay_nanopayment_route_uses_gateway_balance(self, client): ) client._router.detect_method = MagicMock(return_value=PaymentMethod.NANOPAYMENT) client._router._find_adapter = MagicMock(side_effect=AssertionError("direct x402 not used")) - client.get_gateway_balance = AsyncMock( - return_value=GatewayBalance( - total=2000, - available=2000, - formatted_total="0.002 USDC", - formatted_available="0.002 USDC", - ) - ) + client.get_gateway_balance = AsyncMock(side_effect=AssertionError("gateway not used")) client._router.pay = AsyncMock( return_value=PaymentResult( success=True, @@ -522,7 +513,7 @@ async def test_pay_nanopayment_route_uses_gateway_balance(self, client): ) assert result.success is True - client.get_gateway_balance.assert_awaited_once_with("wallet-123") + client.get_gateway_balance.assert_not_awaited() client._wallet_service.get_usdc_balance_amount.assert_not_called() client._router._find_adapter.assert_not_called() client._router.pay.assert_awaited_once() diff --git a/tests/test_config.py b/tests/test_config.py index 23ecffa..dfb832f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -50,10 +50,12 @@ def test_config_is_immutable(self) -> None: def test_missing_api_key_raises(self) -> None: """Test missing API key raises ValueError.""" - with pytest.raises(ValueError, match="circle_api_key is required"): + with pytest.raises(ValueError, match="CIRCLE_API_KEY"): Config( circle_api_key="", entity_secret="test_secret", + buyer_mode="circle", + enable_circle_transfer=True, ) def test_missing_entity_secret_warns(self) -> None: @@ -62,6 +64,12 @@ def test_missing_entity_secret_warns(self) -> None: config = Config( circle_api_key="test_key", entity_secret="", + buyer_mode="x402", + enable_circle_transfer=False, + enable_gateway=False, + enable_x402_exact=True, + nanopayments_private_key="0x" + "1" * 64, + rpc_url="https://rpc.example", ) assert config.entity_secret == "" @@ -110,15 +118,174 @@ def test_from_env_missing_api_key_raises(self) -> None: Config.from_env() def test_from_env_missing_entity_secret_warns(self) -> None: - """Test from_env with missing entity secret logs warning (no longer required).""" + """x402 mode does not need Circle entity secret.""" env_vars = { "CIRCLE_API_KEY": "test_key", + "OMNICLAW_BUYER_MODE": "x402", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + "OMNICLAW_RPC_URL": "https://rpc.example", } with patch.dict(os.environ, env_vars, clear=True): config = Config.from_env() assert config.entity_secret == "" assert config.circle_api_key == "test_key" + assert config.enable_circle_transfer is False + assert config.enable_gateway is True + assert config.enable_x402 is True + + def test_x402_mode_does_not_require_circle_credentials(self) -> None: + """x402 mode can use EOA-backed x402 without Circle credentials.""" + env_vars = { + "OMNICLAW_BUYER_MODE": "x402", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + "OMNICLAW_RPC_URL": "https://rpc.example", + } + + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + + assert config.circle_api_key == "" + assert config.entity_secret == "" + assert config.enable_circle_transfer is False + assert config.enable_gateway is True + assert config.enable_x402_exact is True + + def test_gateway_contract_overrides_are_loaded_from_env(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "x402", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + "OMNICLAW_RPC_URL": "https://rpc.example", + "CIRCLE_GATEWAY_CONTRACT": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", + "CIRCLE_GATEWAY_USDC_ADDRESS": "0x3600000000000000000000000000000000000000", + } + + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + + assert config.gateway_contract_address == "0x0077777d7EBA4688BDeF3E311b846F25870A19B9" + assert config.gateway_usdc_address == "0x3600000000000000000000000000000000000000" + + def test_gateway_buyer_mode_is_not_accepted(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "gateway", + "CIRCLE_API_KEY": "test_key", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + } + + with ( + patch.dict(os.environ, env_vars, clear=True), + pytest.raises(ValueError, match="hybrid, circle, x402"), + ): + Config.from_env() + + def test_x402_mode_requires_rpc_url(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "x402", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + } + + with ( + patch.dict(os.environ, env_vars, clear=True), + pytest.raises(ValueError, match="OMNICLAW_RPC_URL"), + ): + Config.from_env() + + def test_x402_public_flag_disables_internal_x402_paths(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "hybrid", + "CIRCLE_API_KEY": "test_key", + "ENTITY_SECRET": "test_secret", + "OMNICLAW_ENABLE_X402": "false", + } + + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + + assert config.enable_circle_transfer is True + assert config.enable_gateway is False + assert config.enable_x402_exact is False + assert config.enable_x402 is False + + def test_x402_public_flag_enables_x402_without_circle_credentials(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "circle", + "OMNICLAW_ENABLE_CIRCLE_TRANSFER": "false", + "OMNICLAW_ENABLE_X402": "true", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + "OMNICLAW_RPC_URL": "https://rpc.example", + } + + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + + assert config.enable_circle_transfer is False + assert config.enable_gateway is True + assert config.enable_x402_exact is True + assert config.enable_x402 is True + + def test_x402_public_flag_enables_gateway_with_circle_api_key(self) -> None: + env_vars = { + "OMNICLAW_BUYER_MODE": "circle", + "OMNICLAW_ENABLE_CIRCLE_TRANSFER": "false", + "OMNICLAW_ENABLE_X402": "true", + "CIRCLE_API_KEY": "test_key", + "OMNICLAW_PRIVATE_KEY": "0x" + "1" * 64, + "OMNICLAW_RPC_URL": "https://rpc.example", + } + + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + + assert config.enable_circle_transfer is False + assert config.enable_gateway is True + assert config.enable_x402_exact is True + assert config.enable_x402 is True + + def test_string_override_false_disables_public_x402(self) -> None: + config = Config.from_env( + buyer_mode="hybrid ", + circle_api_key="test_key", + entity_secret="test_secret", + nanopayments_private_key="0x" + "1" * 64, + rpc_url="https://rpc.example", + enable_x402="false", + ) + + assert config.buyer_mode == "hybrid" + assert config.enable_circle_transfer is True + assert config.enable_gateway is False + assert config.enable_x402_exact is False + assert config.enable_x402 is False + + def test_hybrid_mode_requires_entity_secret_and_private_key(self) -> None: + """Hybrid buyer mode requires both Circle transfer and EOA credentials.""" + with ( + patch.dict( + os.environ, + { + "OMNICLAW_BUYER_MODE": "hybrid", + "CIRCLE_API_KEY": "test_key", + }, + clear=True, + ), + pytest.raises(ValueError, match="ENTITY_SECRET"), + ): + Config.from_env() + + with ( + patch.dict( + os.environ, + { + "OMNICLAW_BUYER_MODE": "hybrid", + "CIRCLE_API_KEY": "test_key", + "ENTITY_SECRET": "test_secret", + }, + clear=True, + ), + pytest.raises(ValueError, match="OMNICLAW_PRIVATE_KEY"), + ): + Config.from_env() def test_from_env_with_overrides(self) -> None: """Test from_env with override values.""" diff --git a/tests/test_nanopayments_client.py b/tests/test_nanopayments_client.py new file mode 100644 index 0000000..bbc8094 --- /dev/null +++ b/tests/test_nanopayments_client.py @@ -0,0 +1,241 @@ +"""Tests for nanopayment Gateway API client configuration.""" + +import base64 +import json +import os +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter, NanopaymentProtocolAdapter +from omniclaw.protocols.nanopayments.client import NanopaymentClient +from omniclaw.protocols.nanopayments.exceptions import GatewayAPIError +from omniclaw.protocols.nanopayments.types import NanopaymentResult +from omniclaw.protocols.x402 import AcceptedPaymentKind + + +def test_nanopayment_client_allows_no_circle_api_key() -> None: + with patch.dict(os.environ, {}, clear=True): + client = NanopaymentClient(api_key=None) + + assert client.has_api_key is False + + +@pytest.mark.asyncio +async def test_gateway_api_helper_calls_require_circle_api_key() -> None: + with patch.dict(os.environ, {}, clear=True): + client = NanopaymentClient(api_key=None) + + with pytest.raises(GatewayAPIError, match="requires CIRCLE_API_KEY"): + await client.check_balance( + address="0x0000000000000000000000000000000000000001", + network="eip155:5042002", + ) + + +@pytest.mark.asyncio +async def test_x402_gateway_url_payment_without_circle_api_uses_onchain_balance() -> None: + url = "http://127.0.0.1:4023/compute?size=20" + calls: list[httpx.Request] = [] + + payment_required = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:5042002", + "asset": "0x3600000000000000000000000000000000000000", + "amount": "1000", + "payTo": "0x4cfdD69a2A89B91f3c12588085f098C268Ea8631", + "maxTimeoutSeconds": 604900, + "extra": { + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", + "minValiditySeconds": 604800, + "assets": [ + { + "symbol": "USDC", + "address": "0x3600000000000000000000000000000000000000", + "decimals": 6, + } + ], + }, + } + ], + } + payment_required_header = base64.b64encode(json.dumps(payment_required).encode()).decode() + payment_response_header = base64.b64encode( + json.dumps({"success": True, "transaction": "0xsettled"}).encode() + ).decode() + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + assert request.method == "POST" + assert request.headers.get("x-trace-id") == "abc123" + assert request.content == b'{"input":20}' + if "PAYMENT-SIGNATURE" not in request.headers: + return httpx.Response( + 402, + headers={"PAYMENT-REQUIRED": payment_required_header}, + json={ + "resource": { + "url": url, + "description": "compute", + "mimeType": "application/json", + } + }, + ) + payload = json.loads(base64.b64decode(request.headers["PAYMENT-SIGNATURE"])) + assert payload["accepted"]["extra"]["name"] == "GatewayWalletBatched" + assert ( + payload["accepted"]["extra"]["verifyingContract"] + == payment_required["accepts"][0]["extra"]["verifyingContract"] + ) + return httpx.Response( + 200, + headers={"PAYMENT-RESPONSE": payment_response_header}, + json={"ok": True}, + ) + + with patch.dict(os.environ, {}, clear=True): + client = NanopaymentClient(api_key=None) + client.check_balance = AsyncMock(side_effect=AssertionError("Circle API balance not used")) # type: ignore[method-assign] + client.get_supported = AsyncMock(side_effect=AssertionError("Circle API metadata not used")) # type: ignore[method-assign] + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + adapter = NanopaymentAdapter.from_private_key( + private_key="0x59c6995e998f97a5a0044976f7d4d0cbafc4b9d96ec4f38f5dc7065f6a7e0c72", + nanopayment_client=client, + http_client=http_client, + network="eip155:5042002", + rpc_url="http://127.0.0.1:8545", + ) + adapter._get_onchain_available_atomic = AsyncMock(return_value=10_000) # type: ignore[method-assign] + + try: + result = await adapter.pay_x402_url( + url, + method="POST", + headers={"x-trace-id": "abc123"}, + body='{"input":20}', + max_amount_usdc="0.001", + ) + finally: + await http_client.aclose() + + assert result.success is True + assert result.transaction == "0xsettled" + assert result.amount_atomic == "1000" + assert len(calls) == 2 + client.check_balance.assert_not_awaited() + client.get_supported.assert_not_awaited() + adapter._get_onchain_available_atomic.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_protocol_adapter_forwards_x402_request_details() -> None: + nano_result = NanopaymentResult( + success=True, + payer="0xpayer", + seller="0xseller", + transaction="batch-1", + amount_usdc="0.001", + amount_atomic="1000", + network="eip155:5042002", + response_data={"ok": True}, + is_nanopayment=True, + ) + adapter = SimpleNamespace( + pay_x402_url=AsyncMock(return_value=nano_result), + _strict_settlement=False, + ) + protocol = NanopaymentProtocolAdapter(adapter) # type: ignore[arg-type] + + result = await protocol.execute( + wallet_id="wallet-1", + recipient="https://seller.example/compute", + amount=Decimal("0.001"), + method="POST", + request_headers={"x-trace-id": "abc123"}, + request_body='{"input":20}', + ) + + adapter.pay_x402_url.assert_awaited_once_with( + url="https://seller.example/compute", + method="POST", + headers={"x-trace-id": "abc123"}, + body='{"input":20}', + max_amount_usdc="0.001", + ) + assert result.success is True + assert result.metadata["payment_source"] == "gateway_balance" + assert result.metadata["execution_route"] == "GatewayWalletBatched" + assert result.metadata["facilitator"] == "GatewayWalletBatched" + + +@pytest.mark.asyncio +async def test_onchain_balance_accepts_x402_accepted_kind_shape() -> None: + with patch.dict(os.environ, {}, clear=True): + client = NanopaymentClient(api_key=None) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(500)) + ) + adapter = NanopaymentAdapter.from_private_key( + private_key="0x59c6995e998f97a5a0044976f7d4d0cbafc4b9d96ec4f38f5dc7065f6a7e0c72", + nanopayment_client=client, + http_client=http_client, + network="eip155:5042002", + rpc_url="http://127.0.0.1:8545", + ) + adapter._get_onchain_available_atomic = AsyncMock(return_value=1000) # type: ignore[method-assign] + kind = AcceptedPaymentKind( + scheme="exact", + network="eip155:5042002", + amount_atomic="1000", + recipient="0x4cfdD69a2A89B91f3c12588085f098C268Ea8631", + asset="0x3600000000000000000000000000000000000000", + extra={ + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", + }, + ) + + try: + balance = await adapter.get_onchain_available_balance(kind) + finally: + await http_client.aclose() + + assert balance.available == 1000 + + +@pytest.mark.asyncio +async def test_direct_address_nanopayment_requires_circle_api_helper() -> None: + with patch.dict(os.environ, {}, clear=True): + client = NanopaymentClient(api_key=None) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(500)) + ) + adapter = NanopaymentAdapter.from_private_key( + private_key="0x59c6995e998f97a5a0044976f7d4d0cbafc4b9d96ec4f38f5dc7065f6a7e0c72", + nanopayment_client=client, + http_client=http_client, + network="eip155:5042002", + rpc_url="http://127.0.0.1:8545", + ) + protocol_adapter = NanopaymentProtocolAdapter(adapter, micro_threshold_usdc="1.00") + + try: + assert ( + protocol_adapter.supports( + "0x4cfdD69a2A89B91f3c12588085f098C268Ea8631", + amount="0.01", + ) + is False + ) + finally: + await http_client.aclose() diff --git a/tests/test_sdk_integration_extended.py b/tests/test_sdk_integration_extended.py index e5cae36..65b3509 100644 --- a/tests/test_sdk_integration_extended.py +++ b/tests/test_sdk_integration_extended.py @@ -28,6 +28,7 @@ def import_sdk_modules(): """Import SDK modules, handling missing dependencies.""" try: import omniclaw # noqa: F401 + return True except ImportError as e: print(f"Warning: Could not import all SDK modules: {e}") diff --git a/tests/test_x402_sdk_adapter.py b/tests/test_x402_sdk_adapter.py index bddc74c..b5e0d82 100644 --- a/tests/test_x402_sdk_adapter.py +++ b/tests/test_x402_sdk_adapter.py @@ -254,7 +254,10 @@ async def test_pay_route_uses_seller_declared_amount_for_url_payments( resource_data={"ok": True}, metadata={}, ) - client = SimpleNamespace(pay=AsyncMock(return_value=payment_result)) + client = SimpleNamespace( + config=SimpleNamespace(enable_gateway=False, enable_x402_exact=True), + pay=AsyncMock(return_value=payment_result), + ) selected_kind = SimpleNamespace(get_amount_usdc=lambda: Decimal("0.25")) async def fake_inspect_x402_target(**kwargs): @@ -329,7 +332,10 @@ async def test_pay_route_uses_zero_amount_for_free_url(monkeypatch: pytest.Monke resource_data={"ok": True}, metadata={"http_status": 200}, ) - client = SimpleNamespace(pay=AsyncMock(return_value=payment_result)) + client = SimpleNamespace( + config=SimpleNamespace(enable_gateway=False, enable_x402_exact=True), + pay=AsyncMock(return_value=payment_result), + ) async def fake_inspect_x402_target(**kwargs): return { @@ -515,7 +521,50 @@ async def test_choose_x402_route_uses_onchain_fallback_when_api_balance_is_stale assert route["selected_kind"] is gateway_kind assert route["gateway_ready"] is True assert route["gateway_available_balance"] == "0.30" - assert route["gateway_reason"] == "Gateway on-chain balance is sufficient (API balance appears stale)" + assert ( + route["gateway_reason"] + == "Gateway on-chain balance is sufficient (API balance appears stale)" + ) + + +@pytest.mark.asyncio +async def test_choose_x402_route_uses_onchain_balance_when_gateway_api_unavailable(): + gateway_kind = SimpleNamespace( + amount_atomic=250000, + is_gateway_batched=True, + get_amount_usdc=lambda: Decimal("0.25"), + ) + requirements = SimpleNamespace( + select_preferred_kind=lambda *, prefer_gateway, source_network: ( + gateway_kind if prefer_gateway else None + ) + ) + client = SimpleNamespace( + _nano_adapter=object(), + get_gateway_balance=AsyncMock(side_effect=RuntimeError("CIRCLE_API_KEY missing")), + get_gateway_onchain_balance_for_kind=AsyncMock( + return_value=SimpleNamespace( + available=300000, + formatted_available="0.30", + ) + ), + ) + x402_adapter = SimpleNamespace( + _resolve_agent_network=lambda wallet_id, destination_chain: "eip155:84532" + ) + + route = await _choose_x402_route( + client=client, + wallet_id="buyer-wallet", + x402_adapter=x402_adapter, + requirements=requirements, + ) + + assert route["selected_route"] == "nanopayment" + assert route["gateway_ready"] is True + assert route["gateway_available_balance"] == "0.30" + assert route["gateway_reason"] == "Gateway on-chain balance is sufficient" + client.get_gateway_onchain_balance_for_kind.assert_awaited_once_with(gateway_kind) @pytest.mark.asyncio @@ -533,7 +582,10 @@ async def test_pay_route_inspects_url_even_when_amount_is_supplied( resource_data={"ok": True}, metadata={}, ) - client = SimpleNamespace(pay=AsyncMock(return_value=payment_result)) + client = SimpleNamespace( + config=SimpleNamespace(enable_gateway=False, enable_x402_exact=True), + pay=AsyncMock(return_value=payment_result), + ) selected_kind = SimpleNamespace(get_amount_usdc=lambda: Decimal("0.25")) async def fake_inspect_x402_target(**kwargs):