diff --git a/.agents/skills/omniclaw-cli/SKILL.md b/.agents/skills/omniclaw-cli/SKILL.md index 18c1db6..223f4cc 100644 --- a/.agents/skills/omniclaw-cli/SKILL.md +++ b/.agents/skills/omniclaw-cli/SKILL.md @@ -14,7 +14,7 @@ requires: Scoped agent token tied to your wallet. Set by the owner before your session starts. Never print, log, or transmit this value. If it is missing, stop and notify the owner β€” you cannot proceed without it. -version: 0.0.2 +version: 0.0.3 author: Omnuron AI --- diff --git a/.agents/skills/omniclaw-cli/scripts/bootstrap.sh b/.agents/skills/omniclaw-cli/scripts/bootstrap.sh deleted file mode 100644 index 79e4fde..0000000 --- a/.agents/skills/omniclaw-cli/scripts/bootstrap.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -e - -# The Agent is responsible for its own environment. -# Requires environment variables: OMNICLAW_SERVER_URL, OMNICLAW_TOKEN - -echo "πŸš€ Bootstrapping OmniClaw Agent Environment..." - -# 1. Install CLI -if [ -d "/home/abiorh/omnuron-labs/omniclaw" ]; then - pip install --break-system-packages -e /home/abiorh/omnuron-labs/omniclaw -else - pip install --break-system-packages omniclaw -fi - -# 2. Configure CLI -SERVER_URL="${OMNICLAW_SERVER_URL:-http://localhost:8080}" -TOKEN="${OMNICLAW_TOKEN:-payment-agent-token}" -WALLET="${OMNICLAW_WALLET:-primary}" - -echo "βš™οΈ Configuring OmniClaw CLI (Target: $SERVER_URL)..." -omniclaw-cli configure \ - --server-url "$SERVER_URL" \ - --token "$TOKEN" \ - --wallet "$WALLET" - -# 3. Verify Health -echo "🩺 Verifying Firewall Connectivity..." -omniclaw-cli ping - -echo "βœ… OmniClaw Bootstrap Complete!" diff --git a/Dockerfile.agent b/Dockerfile.agent index 15d5103..7d1bc10 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -19,8 +19,8 @@ COPY src/ src/ # Sync project RUN uv sync --frozen || uv sync -EXPOSE 8080 +EXPOSE 9090 -ENV OMNICLAW_AGENT_PORT=8080 +ENV OMNICLAW_AGENT_PORT=9090 -CMD ["uv", "run", "omniclaw", "server", "--host", "0.0.0.0", "--port", "8080"] +CMD ["uv", "run", "omniclaw", "server", "--host", "0.0.0.0", "--port", "9090"] diff --git a/README.md b/README.md index 4f93de1..e9105d8 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,293 @@ # OmniClaw -The economic control and trust infrastructure for autonomous agents β€” enabling them to pay, get paid, and transact securely under real-time policy enforcement. +**The first agentic payment network: policy-controlled, gasless, and real money-ready.** -## Why OmniClaw +OmniClaw CLI + Financial Policy Engine let autonomous agents pay and earn safely at machine speed. -Every AI agent that touches money needs the same handful of things: wallet orchestration, payment routing, spending guardrails, trust evaluation, audit trails, and recovery flows. Today teams wire these by hand, one integration at a time, and re-learn the same compliance lessons the hard way. +--- -OmniClaw replaces that patchwork with one SDK. You get guarded execution, policy enforcement, and regulatory-aware defaults out of the box β€” so you can focus on what your agent actually does, not how it moves money. +## Why OmniClaw? -## Key Capabilities +In the Agent Era, software can act economically. But current wallets fail when software, not humans, is the operator: -**For Agents That Pay** β€” guarded `pay()` execution, `simulate()` before funds move, x402 and direct transfer routing, cross-chain USDC flows, nanopayments via Circle Gateway +- **Full key access** = extreme risk (agent can drain the wallet) +- **Human approval** = kills speed and autonomy +- **No spending limits** = agent can spend unlimited -**For Agents That Earn** β€” Seller SDK, `sell()` decorator, facilitated transfers, trust-gated access +**OmniClaw solves this** by separating: +1. **Financial Policy Engine** (owner runs) - holds private keys, enforces policy +2. **Execution Layer** (agent uses) - thin CLI that only does what policy allows -**For Operators That Control** β€” policy enforcement, spending limits, velocity controls, circuit breakers, audit-ready logs, recovery flows +The agent **never touches the private key**. It only talks to the CLI. The owner decides what the agent can do via policy.json. -## Install +--- + +## Architecture: Three Components + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ OMNICLAW SYSTEM β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ OWNER SIDE (runs the Financial Policy Engine) AGENT SIDE (uses CLI) β”‚ +β”‚ ════════════════════════════════════════════════ ═══════════════════════ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Financial Policy Engine β”‚ β”‚ OmniClaw CLI β”‚ β”‚ +β”‚ β”‚ (uvicorn server) │◄──────────────►│ (thin client) β”‚ β”‚ +β”‚ β”‚ β”‚ HTTPS β”‚ β”‚ β”‚ +β”‚ β”‚ - Holds private key β”‚ β”‚ - pay β”‚ β”‚ +β”‚ β”‚ - Enforces policy β”‚ β”‚ - deposit β”‚ β”‚ +β”‚ β”‚ - Signs transactions β”‚ β”‚ - withdraw β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Circle Nanopayment β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Circle β”‚ β”‚ +β”‚ β”‚ Gateway β”‚ β”‚ +β”‚ β”‚ (USDC) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Why Two Parts? + +| Component | Who Runs It | What It Does | +|-----------|-------------|--------------| +| **Financial Policy Engine** | Owner/human | Holds private key, enforces policy, signs transactions | +| **CLI** (agent uses) | Agent | Thin client - sends requests, gets responses. Cannot bypass policy | + +--- + +## Key Concepts + +### 1. Two Wallets Every Agent Has + +Every agent has **two wallets**: + +| Wallet | How It Works | +|--------|--------------| +| **EOA** (External Owned Account) | Derived from `OMNICLAW_PRIVATE_KEY`. Holds actual USDC on-chain. Used to sign deposits. | +| **Circle Developer Wallet** | Created via policy.json. Where withdrawn funds go. The "circle wallet." | + +### 2. Why Deposit + Pay + Withdraw? + +``` +Your USDC starts here: Then moves here: Ends up here: +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ EOA β”‚ ─deposit───────► β”‚ Gateway β”‚ ──pay──────► β”‚ Seller β”‚ +β”‚ (on-chain)β”‚ (on-chain) β”‚ Contract β”‚ (x402) β”‚ EOA β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ withdraw β”‚ + └──────────────► Circle Developer Wallet +``` + +- **Deposit**: Move USDC from your EOA β†’ Gateway (on-chain, costs gas) +- **Pay**: Use Gateway for gasless payments (x402 protocol) +- **Withdraw**: Move USDC from Gateway β†’ your Circle wallet + +### 3. Why Gasless Nanopayments? + +Circle's Gateway supports **EIP-3009** - off-chain authorization: +- No gas needed for payments +- Instant settlement +- Circle batches and settles on-chain + +This is what makes agent-to-agent commerce practical. + +--- + +## Quick Start + +### 1. Install ```bash pip install omniclaw ``` -For local development: +### 2. Environment Variables ```bash -uv sync --extra dev +# Required +export OMNICLAW_PRIVATE_KEY="0x..." # Your agent's private key +export OMNICLAW_AGENT_TOKEN="your-token" # Token from policy.json +export OMNICLAW_AGENT_POLICY_PATH="/path/to/policy.json" +export CIRCLE_API_KEY="your-circle-key" # Circle API key + +# Network (testnet or mainnet) +export OMNICLAW_NETWORK="ETH-SEPOLIA" # or ETH-MAINNET +export OMNICLAW_ENV="production" # set for mainnet + +# RPC for on-chain operations +export OMNICLAW_RPC_URL="https://..." +# Nanopayments network is derived from OMNICLAW_NETWORK (EVM chain) ``` -## Quick Start +### 3. Start Financial Policy Engine (Owner) -### 1. Run the Server +```bash +uvicorn omniclaw.agent.server:app --port 8080 +``` + +This runs the Financial Policy Engine that holds the private key and enforces policy. + +### 4. Configure CLI (Agent) + +Agent runtime should set these (no interactive setup required): ```bash -git clone https://github.com/omnuron/omniclaw.git -cd omniclaw +export OMNICLAW_SERVER_URL="http://localhost:8080" +export OMNICLAW_TOKEN="your-agent-token" ``` -Create a `.env` file with your Circle credentials: +Optional: persist config locally for dev workflows: +```bash +omniclaw-cli configure --server-url http://localhost:8080 --token your-token --wallet primary ``` -CIRCLE_API_KEY=your_circle_api_key -ENTITY_SECRET=your_entity_secret + +CLI output is agent-first (JSON, no banner). For human-friendly output set: + +```bash +export OMNICLAW_CLI_HUMAN=1 ``` -Start the server: +Note: `omniclaw` and `omniclaw-cli` point to the same CLI. + +--- + +## For BUYERS (Paying for Services) + +### Step 1: Get USDC +Send USDC to your EOA address (derived from OMNICLAW_PRIVATE_KEY) +### Step 2: Deposit to Gateway ```bash -docker-compose up -d -# Server runs at http://localhost:8088 +omniclaw-cli deposit --amount 10 ``` +β†’ Moves USDC from EOA β†’ Circle Gateway contract (on-chain, costs gas) -Verify the setup: +### Step 3: Pay for Services +```bash +# Pay another agent +omniclaw-cli pay --recipient 0xDEAD... --amount 5 + +# Or pay for x402 service (URL) +omniclaw-cli pay --recipient https://api.example.com/data --amount 1 +``` +β†’ Uses gasless nanopayments via x402 protocol (Gateway CAIP-2 derived from `OMNICLAW_NETWORK`, EVM only) +### Step 4: Withdraw to Circle Wallet ```bash -omniclaw doctor # Verify credentials -omniclaw env # List all env vars +omniclaw-cli withdraw --amount 3 ``` +β†’ Moves USDC from Gateway β†’ your Circle Developer Wallet -### 2. Connect the CLI +--- +## For SELLERS (Receiving Payments) + +### Option A: Simple Transfer +Just share your address, receive payments directly: ```bash -pip install omniclaw -omniclaw-cli configure --server-url http://localhost:8088 --token --wallet primary +omniclaw-cli address # Get your address to share ``` -### 3. Use in Code +### Option B: x402 Payment Gate (Recommended) +Expose your service behind payment: -```python -from omniclaw import OmniClaw, Network +```bash +omniclaw-cli serve \ + --price 0.01 \ + --endpoint /api/data \ + --exec "python my_service.py" \ + --port 8000 +``` -client = OmniClaw(network=Network.BASE_SEPOLIA) +This opens `http://localhost:8000/api/data` that requires USDC payment to access. + +--- + +## Complete CLI Commands + +| Command | Description | Example | +|---------|-------------|---------| +| `configure` | Set server URL, token, wallet | `configure --server-url http://localhost:8080 --token mytoken --wallet primary` | +| `address` | Get wallet address | `address` | +| `balance` | Get wallet balance | `balance` | +| `balance-detail` | Detailed balance (EOA, Gateway, Circle) | `balance-detail` | +| `deposit` | Deposit USDC to Gateway | `deposit --amount 5` | +| `withdraw` | Withdraw to Circle wallet | `withdraw --amount 2` | +| `withdraw-trustless` | Trustless withdraw (~7-day fallback) | `withdraw-trustless --amount 2` | +| `withdraw-trustless-complete` | Complete trustless withdraw after delay | `withdraw-trustless-complete` | +| `pay` | Make payment | `pay --recipient 0x... --amount 5` | +| `simulate` | Simulate payment | `simulate --recipient 0x... --amount 5` | +| `serve` | Expose x402 payment gate | `serve --price 0.01 --endpoint /api --exec "echo hello"` | +| `status` | Agent status | `status` | +| `ping` | Health check | `ping` | +| `ledger` | Transaction history | `ledger --limit 20` | + +--- + +## Default Policy.json + +Copy and edit `examples/policy-simple.json`: + +For full policy options, see **[docs/POLICY_REFERENCE.md](docs/POLICY_REFERENCE.md)** + +```json +{ + "version": "2.0", + "tokens": { + "YOUR_AGENT_TOKEN": { + "wallet_alias": "primary", + "active": true, + "label": "Your Agent Name" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "limits": { + "daily_max": "100.00", + "per_tx_max": "50.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} ``` -## Documentation +--- + +## Environment Variables Reference -For detailed guides and architecture docs, see the [Wiki](../../wiki): +| Variable | Required | Description | +|----------|----------|-------------| +| `OMNICLAW_PRIVATE_KEY` | Yes | Agent's private key for signing | +| `OMNICLAW_AGENT_TOKEN` | Yes | Token matching policy.json | +| `OMNICLAW_AGENT_POLICY_PATH` | Yes | Path to policy.json | +| `OMNICLAW_NETWORK` | No | Network (ETH-SEPOLIA, ETH-MAINNET) | +| `OMNICLAW_ENV` | No | Set to "production" for mainnet | +| `OMNICLAW_RPC_URL` | No | RPC endpoint for on-chain ops | +| `CIRCLE_API_KEY` | Yes | Circle API key | +| `OMNICLAW_SERVER_URL` | No | CLI server URL (for configure) | -| Page | Description | -|------|-------------| -| [Getting Started](../../wiki/Getting-Started) | Full installation, environment setup, and first payment walkthrough | -| [Architecture](../../wiki/Architecture) | System design, module breakdown, and payment flow | -| [Compliance Design](../../wiki/Compliance-Design) | Authorization traceability, regulatory alignment (CLARITY Act, GENIUS Act), and gray-zone analysis | -| [Trust & ERC-8004](../../wiki/Trust-&-ERC-8004) | Trust evaluation framework, on-chain signals, and audit comparison | -| [API Reference](../../wiki/API-Reference) | `pay()`, `simulate()`, `sell()`, NanoPayment, Trust, CLI | -| [Contributing Guide](../../wiki/Contributing-Guide) | Dev setup, branch workflow, commit conventions, code quality | +--- + +## Documentation -## Contributing +- **[docs/agent-getting-started.md](docs/agent-getting-started.md)** - Agent setup walkthrough +- **[docs/agent-skills.md](docs/agent-skills.md)** - Skill instructions for AI agents +- **[docs/FEATURES.md](docs/FEATURES.md)** - Full feature documentation -See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Contributing Guide](../../wiki/Contributing-Guide) for development setup, coding standards, and how to submit pull requests. +--- ## License diff --git a/docker-compose.agent.yml b/docker-compose.agent.yml index ac2ecb3..7c048ff 100644 --- a/docker-compose.agent.yml +++ b/docker-compose.agent.yml @@ -14,7 +14,7 @@ services: build: context: . dockerfile: Dockerfile.agent - command: uv run omniclaw server --host 0.0.0.0 --port 8080 + command: uv run omniclaw server --host 0.0.0.0 --port 9090 env_file: .env environment: - OMNICLAW_REDIS_URL=redis://redis:6379/0 @@ -24,7 +24,7 @@ services: volumes: - ./examples/agent/policy.json:/config/policy.json:ro ports: - - "8080:8080" + - "9090:9090" depends_on: redis: condition: service_healthy diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b32e11c..ca8bff2 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # OmniClaw API Reference -This is the public SDK reference for the current Python package. It focuses on the API surface users are expected to call directly. +This is the public API reference for the Financial Policy Engine. It focuses on the API surface users are expected to call directly. ## Top-Level Imports @@ -24,8 +24,14 @@ Required: ```env CIRCLE_API_KEY=... +OMNICLAW_NETWORK=ETH-SEPOLIA # or ARC-TESTNET +# Direct-key mode (recommended for agents / nanopayments) +OMNICLAW_PRIVATE_KEY=0x... +``` + +If you are using Circle developer-controlled wallets directly, provide: +``` ENTITY_SECRET=... -OMNICLAW_NETWORK=ARC-TESTNET ``` Optional: @@ -52,19 +58,19 @@ Defined in [onboarding.py](../src/omniclaw/onboarding.py). ### `quick_setup(api_key, env_path=".env", network="ARC-TESTNET")` -One-time onboarding helper that generates and registers an entity secret and writes an env file. +One-time onboarding helper that generates and registers an entity secret and writes an env file (optional). -### `generate_entity_secret()` +### `generate_entity_secret()` (optional) -Returns a 64-character hex entity secret. +Returns a 64-character hex entity secret (manual setup only). -### `register_entity_secret(api_key, entity_secret, recovery_dir=None)` +### `register_entity_secret(api_key, entity_secret, recovery_dir=None)` (optional) -Registers an entity secret with Circle and downloads the recovery file. +Registers an entity secret with Circle and downloads the recovery file (manual setup only). -### `create_env_file(api_key, entity_secret, env_path=".env", network="ARC-TESTNET", overwrite=False)` +### `create_env_file(api_key, entity_secret, env_path=".env", network="ARC-TESTNET", overwrite=False)` (optional) -Writes the basic OmniClaw env file. +Writes the basic OmniClaw env file (manual setup only). ### `verify_setup()` @@ -111,7 +117,6 @@ OmniClaw( - `intents` - `ledger` - `webhooks` -- `vault` β€” NanoKeyVault for managing nanopayment EOA keys - `nanopayment_adapter` β€” NanopaymentAdapter for buyer-side nanopayments ### Wallet Methods @@ -259,39 +264,22 @@ await client.pay( # Routes to Circle Gateway nanopayment if amount < nanopayments_micro_threshold ``` -#### Key Management (NanoKeyVault) - -```python -# Generate a new EOA key for nanopayments -await client.add_key(alias="agent-nano", private_key="0x...") - -# Get the EOA address for a key -await client.get_key_address(alias="agent-nano") # -> "0x..." - -# Sign data with a key -await client.sign(alias="agent-nano", data=b"...") # -> hex signature - -# Delete a key -await client.delete_key(alias="agent-nano") -``` - #### Gateway Wallet Management ```python -# Get gateway balance for a nanopayment key -await client.get_gateway_balance(nano_key_alias="agent-nano") +# Get gateway balance +await client.get_gateway_balance(wallet_id="wallet-id") # -> GatewayBalance(total, available, formatted_total, formatted_available) # Deposit USDC to gateway wallet (enables receiving nanopayments) await client.deposit_to_gateway( - nano_key_alias="agent-nano", + wallet_id="wallet-id", amount_usdc="10.00", - source_wallet_id="wallet-id", ) # Withdraw USDC from gateway wallet await client.withdraw_from_gateway( - nano_key_alias="agent-nano", + wallet_id="wallet-id", amount_usdc="5.00", destination_chain=None, # Optional: withdraw to another chain recipient="0xDestination", # Optional: specific recipient @@ -306,17 +294,13 @@ client.configure_nanopayments( ) ``` -#### Agent Creation with Nanopayments +#### Agent Creation ```python -# Create an agent wallet with nanopayment support +# Create an agent wallet agent_wallet = await client.create_agent( - name="data-agent", - nano_key_alias="data-agent-nano", # Optional: specific key alias + agent_name="data-agent", ) -# agent_wallet.wallet_id - Circle wallet for deposits -# agent_wallet.nano_key_alias - Vault key for gateway -# agent_wallet.nano_address - EOA address for receiving nanopayments ``` ### Nanopayments Environment Variables @@ -325,11 +309,10 @@ agent_wallet = await client.create_agent( OMNICLAW_NANOPAYMENTS_ENABLED=true OMNICLAW_NANOPAYMENTS_ENVIRONMENT=testnet # or "mainnet" OMNICLAW_NANOPAYMENTS_MICRO_THRESHOLD=1.00 -OMNICLAW_NANOPAYMENTS_DEFAULT_KEY_ALIAS=my-nano-key OMNICLAW_NANOPAYMENTS_AUTO_TOPUP=true OMNICLAW_NANOPAYMENTS_TOPUP_THRESHOLD=1.00 OMNICLAW_NANOPAYMENTS_TOPUP_AMOUNT=10.00 -OMNICLAW_NANOPAYMENTS_DEFAULT_NETWORK=eip155:5042002 +# Nanopayments network is derived from OMNICLAW_NETWORK (EVM chain) ``` ## `WalletService` diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1c408ea..a3abb8c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,10 +1,10 @@ # OmniClaw Architecture and Features -This document explains how the SDK is structured and what each subsystem is responsible for. Use the [SDK Usage Guide](SDK_USAGE_GUIDE.md) for examples and the [API Reference](API_REFERENCE.md) for method signatures. +This document explains how the Financial Policy Engine is structured and what each subsystem is responsible for. ## System Overview -OmniClaw is centered on `OmniClaw`, which wires together: +OmniClaw is centered on the Financial Policy Engine, which wires together: - configuration loading - wallet management @@ -21,7 +21,7 @@ OmniClaw is centered on `OmniClaw`, which wires together: ### `OmniClaw` -The top-level client in [client.py](../src/omniclaw/client.py). It exposes the public async SDK surface: +The top-level client in [client.py](../src/omniclaw/client.py). It exposes the public async Financial Policy Engine surface: - wallet creation and lookup - payment execution and simulation @@ -49,8 +49,9 @@ The router in [payment/router.py](../src/omniclaw/payment/router.py) chooses an Current routing: +- URL -> `NanopaymentProtocolAdapter` (Gateway x402), with fallback to `X402Adapter` if needed +- address + amount below micro-threshold -> `NanopaymentProtocolAdapter` (Gateway) - address -> `TransferAdapter` -- URL -> `X402Adapter` - `destination_chain` set -> `GatewayAdapter` ### Guards @@ -118,6 +119,8 @@ Current runtime rules: Nanopayments enable gas-free USDC transfers via Circle's Gateway nanopayments protocol, built on EIP-3009. They are designed for micro-transactions where gas costs would make regular transfers impractical. +**Gateway CAIP-2 derivation:** Gateway nanopayment CAIP-2 is derived from `OMNICLAW_NETWORK` via `network_to_caip2`. Only EVM networks are supported β€” non-EVM networks will raise a clear configuration error. + #### Architecture The nanopayments stack is organized under [protocols/nanopayments/](../src/omniclaw/protocols/nanopayments/): @@ -125,7 +128,7 @@ The nanopayments stack is organized under [protocols/nanopayments/](../src/omnic - `signing.py` β€” EIP-3009 signature creation (`EIP3009Signer`) and verification - `types.py` β€” `PaymentRequirementsKind`, `PaymentPayload`, `SettleResponse`, `PaymentInfo`, `ResourceInfo`, `SupportedKind`, `GatewayBalance` types - `client.py` β€” `NanopaymentClient` wrapping Circle's Gateway API (settle, verify, get_supported, check_balance) -- `keys.py` β€” `NanoKeyVault` for encrypted key management (EOA private keys) +- `keys.py` β€” key encryption utilities (legacy; not used in direct-key mode) - `middleware.py` β€” `GatewayMiddleware` (seller-side x402 gate, `@agent.sell()` equivalent) - `adapter.py` β€” `NanopaymentAdapter` (buyer-side payment execution) - `wallet.py` β€” `GatewayWalletManager` (on-chain deposit/withdraw via `depositWithAuthorization`) @@ -147,19 +150,14 @@ The on-chain settlement is batched β€” multiple nanopayments settle in a single - **Buyer**: Uses `NanopaymentAdapter` and `NanopaymentClient` to create and send payments via `client.pay()` - **Seller**: Uses `GatewayMiddleware` and `@omniclaw.sell()` to protect FastAPI endpoints -#### Key Isolation - -Agents hold only a `nano_key_alias` (string reference). The actual EOA private key is encrypted in the NanoKeyVault. This means: +#### Key Management -- Compromised alias gives no access to funds -- Keys can be rotated without changing the agent's public address -- Multiple agents can share the same key or have dedicated keys +Nanopayment signing uses a single direct private key configured via `OMNICLAW_PRIVATE_KEY`. #### OmniClaw Integration -`OmniClaw` wires nanopayments into the SDK surface: +`OmniClaw` wires nanopayments into the Financial Policy Engine surface: -- `client.vault` β†’ `NanoKeyVault` for key management - `client.nanopayment_adapter` β†’ `NanopaymentAdapter` for buyer payments - `client.gateway()` β†’ `GatewayMiddleware` for seller endpoints - `client.sell(price)` β†’ FastAPI `Depends()` for `@agent.sell()` @@ -196,7 +194,6 @@ Core environment variables: ```env CIRCLE_API_KEY=... -ENTITY_SECRET=... OMNICLAW_NETWORK=ARC-TESTNET ``` @@ -219,7 +216,7 @@ OMNICLAW_CONFIRM_THRESHOLD=500.00 ## Execution Sequence -For a typical `pay()` call, the SDK does the following: +For a typical `pay()` call, the Financial Policy Engine does the following: 1. validate arguments 2. optionally evaluate trust diff --git a/docs/POLICY_REFERENCE.md b/docs/POLICY_REFERENCE.md new file mode 100644 index 0000000..3bb5b00 --- /dev/null +++ b/docs/POLICY_REFERENCE.md @@ -0,0 +1,307 @@ +# OmniClaw Policy Reference + +This guide explains all possible policy.json configuration options. +Policy files are strictly validated on startup; unknown fields or invalid types will fail server initialization. +On startup, policy limits are converted into persistent guard rules (Budget/Rate/Recipient/SingleTx/Confirm) and enforced on every payment. + +Hot reload: +- Set `OMNICLAW_POLICY_RELOAD_INTERVAL` (seconds) to auto-reload policy.json without restart. + +--- + +## Simple vs Advanced + +### Simple Policy (Minimal) +```json +{ + "version": "2.0", + "tokens": { + "my-agent": { + "wallet_alias": "primary", + "active": true, + "label": "My Agent" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "wallet_id": "wlt_...", + "address": "0x...", + "limits": { + "daily_max": "100.00", + "per_tx_max": "50.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} +``` + +### Advanced Policy (Full Control) +```json +{ + "version": "2.0", + "tokens": { + "buyer-agent": { + "wallet_alias": "primary", + "active": true, + "label": "Buyer Agent" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "wallet_id": "wlt_...", + "address": "0x...", + "limits": { + "daily_max": "1000.00", + "hourly_max": "200.00", + "per_tx_max": "100.00", + "per_tx_min": "0.01" + }, + "rate_limits": { + "per_minute": 10, + "per_hour": 100 + }, + "recipients": { + "mode": "whitelist", + "addresses": ["0xSeller1...", "0xSeller2..."], + "domains": ["api.service-a.com"] + }, + "confirm_threshold": "50.00" + } + } +} +``` + +--- + +## Complete Field Reference + +### Top-Level + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | string | Yes | Policy format version (use "2.0") | +| `tokens` | object | Yes | Agent token mappings | +| `wallets` | object | Yes | Wallet configurations | +| `limits` | object | No | Global/default limits (optional) | +| `rate_limits` | object | No | Global/default rate limits (optional) | +| `recipients` | object | No | Global/default recipient rules (optional) | +| `confirm_threshold` | decimal | No | Global/default confirm threshold | + +### tokens.{token_name} + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `wallet_alias` | string | Yes | Which wallet config to use | +| `active` | boolean | Yes | Is this token active | +| `label` | string | No | Human-readable label | + +### wallets.{wallet_alias} + +| 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) | +| `limits` | object | Spending limits | +| `rate_limits` | object | Rate limits | +| `recipients` | object | Recipient rules | +| `confirm_threshold` | decimal | Amount requiring owner confirmation | + +### limits + +| Field | Type | Description | +|-------|------|-------------| +| `daily_max` | string (decimal) | Maximum spending per day | +| `hourly_max` | string (decimal) | Maximum spending per hour | +| `per_tx_max` | string (decimal) | Maximum per transaction | +| `per_tx_min` | string (decimal) | Minimum per transaction | + +### rate_limits + +| Field | Type | Description | +|-------|------|-------------| +| `per_minute` | integer | Max transactions per minute | +| `per_hour` | integer | Max transactions per hour | + +### recipients + +| Field | Type | Description | +|-------|------|-------------| +| `mode` | string | "whitelist", "blacklist", or "allow_all" | +| `addresses` | array | List of allowed/blocked addresses | +| `domains` | array | List of allowed/blocked domains (for x402 URLs) | + +--- + +## Recipient Modes + +### mode: "allow_all" +Agent can pay anyone. No restrictions. + +```json +"recipients": { + "mode": "allow_all" +} +``` + +### mode: "whitelist" +Agent can ONLY pay these addresses/domains. + +```json +"recipients": { + "mode": "whitelist", + "addresses": ["0x123...", "0x456..."], + "domains": ["api.service.com"] +} +``` + +### mode: "blacklist" +Agent can pay everyone EXCEPT these addresses/domains. + +```json +"recipients": { + "mode": "blacklist", + "addresses": ["0xBanned..."], + "domains": ["malicious.com"] +} +``` + +--- + +## Examples + +### Strict Agent (Can Only Pay Specific Sellers) +```json +{ + "version": "2.0", + "tokens": { + "strict-agent": { + "wallet_alias": "primary", + "active": true, + "label": "Strict Agent" + } + }, + "wallets": { + "primary": { + "name": "Strict Wallet", + "limits": { + "daily_max": "50.00", + "per_tx_max": "10.00" + }, + "recipients": { + "mode": "whitelist", + "addresses": ["0xTrustedSeller1...", "0xTrustedSeller2..."] + } + } + } +} +``` + +### Open Agent (Can Pay Anyone, Limited Budget) +```json +{ + "version": "2.0", + "tokens": { + "open-agent": { + "wallet_alias": "primary", + "active": true, + "label": "Open Agent" + } + }, + "wallets": { + "primary": { + "name": "Open Wallet", + "limits": { + "daily_max": "100.00", + "per_tx_max": "25.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} +``` + +### High-Volume Agent (Rate Limited, High Budget) +```json +{ + "version": "2.0", + "tokens": { + "high-volume-agent": { + "wallet_alias": "primary", + "active": true, + "label": "High Volume Agent" + } + }, + "wallets": { + "primary": { + "name": "High Volume Wallet", + "limits": { + "daily_max": "10000.00", + "hourly_max": "2000.00", + "per_tx_max": "500.00", + "per_tx_min": "0.10" + }, + "rate_limits": { + "per_minute": 60, + "per_hour": 1000 + }, + "recipients": { + "mode": "allow_all" + } + } + } +} +``` + +### Seller Agent (Receives Payments) +```json +{ + "version": "2.0", + "tokens": { + "seller-agent": { + "wallet_alias": "primary", + "active": true, + "label": "Seller Agent" + } + }, + "wallets": { + "primary": { + "name": "Seller Wallet", + "limits": { + "daily_max": "0", + "per_tx_max": "0" + } + } + } +} +``` + +--- + +## Environment Override + +You can also set limits via environment variables instead of policy.json: + +```bash +export OMNICLAW_DAILY_BUDGET="100.00" +export OMNICLAW_HOURLY_BUDGET="50.00" +export OMNICLAW_TX_LIMIT="25.00" +export OMNICLAW_RATE_LIMIT_PER_MIN="10" +``` + +These override policy.json values if set. + +--- + +## Files + +- Simple: `examples/policy-simple.json` +- Advanced: `examples/policy-advanced.json` +- Default: `examples/default-policy.json` diff --git a/docs/SDK_USAGE_GUIDE.md b/docs/SDK_USAGE_GUIDE.md index 967d43c..fd622fa 100644 --- a/docs/SDK_USAGE_GUIDE.md +++ b/docs/SDK_USAGE_GUIDE.md @@ -1,6 +1,6 @@ -# OmniClaw SDK Usage Guide +# OmniClaw Financial Policy Engine Usage Guide -This guide covers the common SDK workflows without repeating the full architecture or every method signature. +This guide covers common workflows for the Financial Policy Engine without repeating the full architecture or every method signature. ## 1. Initialize the Client @@ -14,7 +14,6 @@ With environment variables: ```env CIRCLE_API_KEY=your_circle_api_key -ENTITY_SECRET=your_entity_secret OMNICLAW_NETWORK=ARC-TESTNET ``` @@ -26,18 +25,12 @@ OMNICLAW_REDIS_URL=redis://localhost:6379 OMNICLAW_LOG_LEVEL=DEBUG OMNICLAW_RPC_URL=https://your-rpc-provider -# Nanopayment network (default: Base Sepolia for testnet) -OMNICLAW_NANOPAYMENTS_DEFAULT_NETWORK=eip155:84532 # Base Sepolia +# Nanopayments network is derived from OMNICLAW_NETWORK (EVM chain) ``` -### Entity Secret Recovery +### Entity Secret -When `ENTITY_SECRET` is missing, the SDK can auto-generate and register one if `CIRCLE_API_KEY` is available. - -What gets stored: - -- active entity secret: environment or `.env` -- Circle recovery file: user config directory +You do not need to set `ENTITY_SECRET` manually. It is auto-generated and registered on first run when `CIRCLE_API_KEY` is available. Linux recovery-file location: @@ -60,7 +53,7 @@ To test payments with real USDC on testnet: **1. Configure for Base Sepolia:** ```env -OMNICLAW_NANOPAYMENTS_DEFAULT_NETWORK=eip155:84532 # Base Sepolia +OMNICLAW_NETWORK=BASE-SEPOLIA OMNICLAW_RPC_URL=https://sepolia.base.org ``` @@ -78,7 +71,7 @@ wallet_set, wallet = await client.create_agent_wallet("my-agent") circle_address = wallet.address # Nano/Gateway (for nanopayments - EIP-3009) -nano_address = await client._nano_vault.get_address(alias=f"wallet-{wallet.id}") +nano_address = client.nanopayment_adapter.address ``` **4. Test a payment:** @@ -175,8 +168,9 @@ Key runtime arguments: OmniClaw routes automatically: +- URL -> Gateway nanopayments (x402), with fallback to x402 direct if needed +- address + amount below micro-threshold -> Gateway nanopayments - address -> direct transfer -- URL -> x402 - address + `destination_chain` -> gateway/cross-chain Examples: @@ -357,7 +351,7 @@ routes = { ### Deposit USDC to Enable Receiving -Your gateway wallet needs a USDC balance to receive payments (it acts as a vault β€” buyers pay you by sending from their gateway to yours). +Your gateway wallet needs a USDC balance to receive payments (it acts as a buffer β€” buyers pay you by sending from their gateway to yours). ```python # Check your gateway balance (uses wallet_id) @@ -418,13 +412,13 @@ async def premium(payment=Depends(client.sell("$0.50"))): ```python # Withdraw to your Circle wallet await client.withdraw_from_gateway( - nano_key_alias="my-nano-key", + wallet_id=wallet.id, amount_usdc="50.00", ) # Or withdraw to another blockchain address await client.withdraw_from_gateway( - nano_key_alias="my-nano-key", + wallet_id=wallet.id, amount_usdc="25.00", destination_chain=Network.BASE, recipient="0xBaseRecipient", @@ -463,6 +457,8 @@ Configuration: OMNICLAW_NANOPAYMENTS_MICRO_THRESHOLD=1.00 # Amounts < $1 use nanopayments ``` +**Gateway CAIP-2:** The nanopayment CAIP-2 chain identifier is derived from `OMNICLAW_NETWORK` via `network_to_caip2`. Only EVM networks are supported. + On the buyer side, OmniClaw: 1. Checks if the recipient supports gateway nanopayments 2. Creates an EIP-3009 authorization (off-chain signing) diff --git a/docs/agent-getting-started.md b/docs/agent-getting-started.md index f427889..bdbde92 100644 --- a/docs/agent-getting-started.md +++ b/docs/agent-getting-started.md @@ -1,45 +1,223 @@ -# OmniClaw: Zero-Friction Onboarding +# OmniClaw Agent Getting Started -OmniClaw is designed for **instant deployment**. The Agent takes full responsibility for its own environment setup. +This guide walks you through setting up an OmniClaw agent for both buying and selling. --- -## πŸ›‘οΈ Phase 1: Human Owner (Control Plane) -*Host your secure Financial Firewall. One command to rule them all.* +## Prerequisites -1. **Configure API Key**: - Add your `CIRCLE_API_KEY` to your environment or `.env` file. +- Python 3.10+ +- Circle API key +- USDC on the target network (testnet or mainnet) +- A private key for your agent -2. **Start the Firewall**: - ```bash - docker compose -f docker-compose.agent.yml up -d - ``` +--- + +## Step 1: Create Policy.json + +Create a `policy.json` file that defines your agent: + +```json +{ + "version": "2.0", + "tokens": { + "my-agent-token": { + "wallet_alias": "primary", + "active": true, + "label": "My Agent" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "limits": { + "daily_max": "100.00", + "per_tx_max": "50.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} +``` -3. **Provide Connection Details**: - Ensure your Agent has access to these three environment variables: - - `OMNICLAW_SERVER_URL`: Where your firewall is running. - - `OMNICLAW_TOKEN`: Your agent's authorization token. - - `OMNICLAW_WALLET`: (Optional) The wallet alias (default: `primary`). +On startup, the server will auto-generate and persist `wallet_id` and `address` +inside each wallet entry if they are missing. + +You can copy the default from `examples/default-policy.json` and edit it. + +The server validates policy.json on startup and will refuse to boot if it is invalid. --- -## πŸ€– Phase 2: AI Agent (Autonomous Setup) -*The Agent is self-bootstrapping. It installs and configures itself.* +## Step 2: Set Environment Variables + +```bash +# Required +export OMNICLAW_PRIVATE_KEY="0x..." # Your agent's private key +export OMNICLAW_AGENT_TOKEN="my-agent-token" # Must match policy.json token key +export OMNICLAW_AGENT_POLICY_PATH="/path/to/policy.json" +export CIRCLE_API_KEY="your-circle-api-key" + +# Network (testnet or mainnet) +export OMNICLAW_NETWORK="ETH-SEPOLIA" # or ETH-MAINNET for production + +# Set production for mainnet usage +export OMNICLAW_ENV="production" # optional - for mainnet + +# RPC for on-chain operations +export OMNICLAW_RPC_URL="https://..." +export OMNICLAW_OWNER_TOKEN="your-owner-token" # Required for approvals +export OMNICLAW_POLICY_RELOAD_INTERVAL="5" # Hot reload interval (seconds) +``` + +--- + +## Step 3: Start the Financial Policy Engine + +```bash +uvicorn omniclaw.agent.server:app --port 8080 +``` + +The Financial Policy Engine runs at `http://localhost:8080`. + +--- + +## Step 4: Configure the CLI + +For agents, use environment variables (no interactive setup required): + +```bash +export OMNICLAW_SERVER_URL="http://localhost:8080" +export OMNICLAW_TOKEN="my-agent-token" +``` + +Optional: persist config locally for dev workflows: + +```bash +omniclaw-cli configure --server-url http://localhost:8080 --token my-agent-token --wallet primary +``` + +CLI output is agent-first (JSON, no banner). For human-friendly output set: + +```bash +export OMNICLAW_CLI_HUMAN=1 +``` + +--- + +## Step 5: Use the CLI + +### Check Your Address +```bash +omniclaw-cli address +``` + +### Check Balance +```bash +omniclaw-cli balance -1. **Skill Injection**: - The Owner drops the `omniclaw-cli` skill folder into the agent's environment. +# Or detailed view +omniclaw-cli balance_detail +``` -2. **Self-Bootstrap**: - The agent autonomously executes its **Bootstrap Protocol**: - - **Runs `install_cli.sh`**: Installs the latest CLI tool. - - **Runs `configure_cli.sh`**: Links itself to the Owner's Firewall using the provided env vars. +### Deposit USDC to Gateway +```bash +omniclaw-cli deposit --amount 10 +``` +This moves USDC from your EOA to the Circle Gateway contract. -3. **Ready for Action**: - The agent is now live. It can check balances and execute payments entirely through the CLI. +### Withdraw to Circle Wallet +```bash +omniclaw-cli withdraw --amount 5 +``` +This moves USDC from Gateway to your Circle Developer Wallet. --- -## 🏁 Result -- **Zero Human Friction**: The owner only starts the server and sets env vars. -- **Agent Responsibility**: The agent manages its own tools and connection. -- **Pure Security**: Private keys never leave the Owner's Docker container. +## Confirmations (High-Value Policy Thresholds) + +If a policy requires confirmation, `/pay` will return: + +- `requires_confirmation: true` +- `confirmation_id: ` + +Approve with the owner token: + +```bash +omniclaw-cli configure --owner-token YOUR_OWNER_TOKEN +omniclaw-cli confirmations approve --id +``` + +Then retry the payment with the same `confirmation_id` in metadata: + +```json +{ + "recipient": "0xRecipient", + "amount": "50.00", + "metadata": { + "confirmation_id": "" + } +} +``` + +--- + +## For SELLERS: Expose a Payment Gate + +To receive payments, expose a service behind x402 payment: + +```bash +omniclaw-cli serve \ + --price 0.01 \ + --endpoint /api/data \ + --exec "python my_service.py" \ + --port 8000 +``` + +This opens `http://localhost:8000/api/data` that requires USDC payment to access. + +--- + +## Quick Reference + +| Command | Purpose | +|---------|---------| +| `omniclaw-cli address` | Get your wallet address | +| `omniclaw-cli balance` | Check balance | +| `omniclaw-cli deposit --amount X` | Deposit to Gateway | +| `omniclaw-cli withdraw --amount X` | Withdraw to Circle wallet | +| `omniclaw-cli withdraw_trustless --amount X` | Trustless withdraw (~7-day delay) | +| `omniclaw-cli withdraw_trustless_complete` | Complete trustless withdraw after delay | +| `omniclaw-cli pay --recipient 0x... --amount X` | Pay another agent | +| `omniclaw-cli serve --price X --endpoint /api --exec "cmd"` | Start payment gate | + +--- + +## Network Switching + +To switch from testnet to mainnet: + +```bash +export OMNICLAW_NETWORK="ETH-MAINNET" +export OMNICLAW_ENV="production" +``` + +Everything automatically switches to mainnet URLs. + +--- + +## Troubleshooting + +### "Wallet is currently initializing" +Wait a few seconds and retry. The agent is setting up. + +### "Invalid token" +Check that `OMNICLAW_AGENT_TOKEN` matches a key in your policy.json's `tokens` section. + +### "Insufficient balance" +Make sure you've deposited USDC to the Gateway first: +```bash +omniclaw-cli deposit --amount 10 +``` diff --git a/docs/agent-skills.md b/docs/agent-skills.md index 634ebd3..700e040 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -1,69 +1,250 @@ # OmniClaw Agent Wallet Skill ## What This Is -A secure, owner-controlled skill (structured instructions + reference docs) that teaches AI agents how to use the **OmniClaw CLI** to safely execute financial operations. +A secure, owner-controlled skill that teaches AI agents how to use the **OmniClaw CLI** to safely execute financial operations against the **OmniClaw Financial Policy Engine**. -In stark contrast to other agentic wallet SDKs, **OmniClaw is designed with "Friction as a Feature."** -- Your human operator hosts the OmniClaw Control Plane. -- The control plane holds the private keys and dictates the policies (spending limits, allowed recipients). -- You (the agent) use the thin CLI client to request actions. You **cannot** create wallets or modify your own budget constraints. +**Why does this exist?** Because agents should never have direct access to private keys. Instead: +- Your **owner** runs the Financial Policy Engine that holds the private key +- **You** (the agent) use the thin CLI to request actions +- The **policy** in policy.json decides what you can and cannot do + +This is "Friction as a Feature" - you can only do what your owner explicitly allows. + +## How It Works + +``` +YOU (Agent) FINANCIAL POLICY ENGINE (Owner) +───────────── ──────────────────────────────── + + β”‚ β”‚ + β”‚ omniclaw-cli pay ... β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”‚ + β”‚ β”‚ Check policy + β”‚ β”‚ Sign transaction + β”‚ β”‚ Return result + │◄─────────────────────────────── + β”‚ β”‚ +``` + +You send requests to the Financial Policy Engine. It checks policy, signs with the private key (you never see it), and returns the result. + +## Two Wallets You Have + +| Wallet | Description | +|--------|-------------| +| **EOA** | Your "on-chain wallet" - derived from owner's private key. USDC starts here. | +| **Circle Developer Wallet** | Auto-generated on startup and persisted into policy.json. This is where withdrawn funds go. | + +When you **deposit**, USDC moves from EOA β†’ Gateway. +When you **withdraw**, USDC moves from Gateway β†’ your Circle wallet. ## Use Cases -What can autonomous agents do within their safe boundaries? -- **Micro-Payments**: Pay other agents for completed tasks using Circle USDC Nanopayments. -- **API Billing**: Handle subscriptions and per-call payments for premium API access. -- **Escrow & Settlement**: Settle debts securely via the x402 protocol, mediated by your OmniClaw policy limits. +- **Micro-Payments**: Pay other agents using Circle USDC Nanopayments +- **API Billing**: Handle subscriptions and per-call payments for premium API access +- **Selling Services**: Expose your endpoints behind x402 payment gates to earn USDC +- **Escrow & Settlement**: Settle debts securely via the x402 protocol ## Quick Start -Your execution environment must be provisioned with two variables by your human operator: -1. `OMNICLAW_SERVER_URL` -2. `OMNICLAW_TOKEN` +Agent runtimes should set environment variables (no interactive setup required): +1. `OMNICLAW_SERVER_URL` - where the Financial Policy Engine runs +2. `OMNICLAW_TOKEN` - your identity token (matches policy.json) +3. `OMNICLAW_OWNER_TOKEN` - required only for confirmation approvals + +Optional: persist config locally for dev workflows: + +```bash +omniclaw-cli configure --server-url $OMNICLAW_SERVER_URL --token $OMNICLAW_TOKEN --wallet primary +``` + +CLI output is agent-first (JSON, no banner). For human-friendly output set: -Initialize your CLI configuration: ```bash -omniclaw-cli configure --server-url $OMNICLAW_SERVER_URL --token $OMNICLAW_TOKEN --wallet your-wallet-alias +export OMNICLAW_CLI_HUMAN=1 ``` +--- + ## Available Tool Actions -As an autonomous agent, you have access to the following deterministic commands. ### `address` -Get your assigned wallet address. +Get your assigned wallet address (EOA). ```bash omniclaw-cli address ``` ### `balance` -Check your current available balance before attempting payments. +Check your current available balance in the Gateway. ```bash omniclaw-cli balance ``` +### `balance_detail` +Get detailed balance breakdown including EOA, Gateway, and Circle wallet. +```bash +omniclaw-cli balance_detail +``` + ### `can-pay` -Verify if a specific recipient address or domain is whitelisted by your operator's policy. +Verify if a recipient is allowed by your owner's policy. ```bash omniclaw-cli can-pay --recipient 0xRecipientAddress ``` ### `simulate` -Always simulate transactions before execution to ensure they meet your spending limits and velocity constraints. +Simulate a payment before executing to check if it meets your spending limits. ```bash omniclaw-cli simulate --recipient 0xRecipientAddress --amount 5.00 ``` +**Always do this before paying** to avoid failed transactions. ### `pay` -Execute a payment. If this violates your policy (e.g., exceeds your budget), the CLI will reject it. +Execute a payment. If it violates your policy, the CLI rejects it. ```bash -omniclaw-cli pay --recipient 0xRecipientAddress --amount 5.00 --purpose "Invoice #123" +# Direct transfer to another agent +omniclaw-cli pay --recipient 0xRecipientAddress --amount 5.00 --purpose "Payment for service" + +# Pay for x402 service (URL) +omniclaw-cli pay --recipient https://api.example.com/data --amount 1.00 ``` -*Note: If a transaction returns `PENDING_APPROVAL`, you successfully initiated it, but it exceeded your autonomous limit and requires Human-in-the-Loop (HITL) approval. Pause your workflow and notify the user.* -### `list_tx` -Retrieve your transaction history for reconciliation. +### `deposit` +Deposit USDC from your EOA to Circle Gateway. **Required before making nanopayments.** +```bash +omniclaw-cli deposit --amount 10.00 +``` +This is an on-chain transaction (costs gas). Moves USDC: EOA β†’ Gateway. + +### `withdraw` +Withdraw USDC from Gateway to your Circle Developer Wallet. No recipient needed - automatic. +```bash +omniclaw-cli withdraw --amount 5.00 +``` + +### `withdraw_trustless` +Trustless withdrawal (fallback if Circle API fails). Takes ~7 days. +```bash +omniclaw-cli withdraw_trustless --amount 5.00 +``` +Use only if the regular `withdraw` fails. + +### `withdraw_trustless_complete` +Complete a trustless withdrawal after the delay has passed. +```bash +omniclaw-cli withdraw_trustless_complete +``` + +### `serve` +Expose a service behind x402 payment gate to receive payments. +```bash +omniclaw-cli serve --price 0.01 --endpoint /api/data --exec "python my_service.py" --port 8000 +``` +This opens `http://localhost:8000/api/data` that requires USDC payment to access. +- Other agents can `pay` your URL +- Payment is automatically settled via Circle Gateway + +### `create_intent` +Create a payment intent (pre-authorize a payment). +```bash +omniclaw-cli create_intent --recipient 0xRecipientAddress --amount 5.00 --purpose "Service payment" +``` + +### `confirm_intent` +Confirm a pending intent (capture the payment). +```bash +omniclaw-cli confirm_intent --intent-id +``` + +### `get_intent` +Get details of a payment intent. +```bash +omniclaw-cli get_intent --intent-id +``` + +### `cancel_intent` +Cancel a pending intent. +```bash +omniclaw-cli cancel_intent --intent-id +``` + +### `list_tx` / `ledger` +Retrieve your transaction history. ```bash omniclaw-cli list_tx --limit 10 +omniclaw-cli ledger --limit 20 ``` +### `status` +Get agent status and health. +```bash +omniclaw-cli status +``` + +### `ping` +Health check. +```bash +omniclaw-cli ping +``` + +--- + ## Safety Constraints -- Do not attempt to use `curl` or raw HTTP requests to bypass the CLI. -- Do not attempt to modify your limits. If a payment is blocked by a policy, you MUST HALT and request your operator to amend the `policy.json` file. + +1. **Never use curl or raw HTTP** - always use the CLI. Bypassing the CLI bypasses policy. +2. **Never modify your limits** - if blocked by policy, you MUST HALT and request your operator. +3. **Always simulate before paying** - check if the payment will succeed. +4. **Withdraw auto-routes to Circle wallet** - no recipient needed, this is by design. +5. **You never see the private key** - only the owner has it. You only send requests. + +--- + +## Typical Workflow + +### To Pay for Something: +```bash +# 1. Check balance +omniclaw-cli balance + +# 2. If needed, deposit more USDC to Gateway +omniclaw-cli deposit --amount 10 + +# 3. Simulate to check limits +omniclaw-cli simulate --recipient 0xSeller... --amount 5 + +# 4. Pay +omniclaw-cli pay --recipient 0xSeller... --amount 5 +``` + +### To Receive Payments: +```bash +# Start a payment gate for your service +omniclaw-cli serve --price 0.01 --endpoint /api --exec "python my_service.py" --port 8000 + +# Other agents can now pay your URL and you automatically receive USDC +``` + +### To Move Funds Out: +```bash +# Withdraw from Gateway to your Circle Developer Wallet +omniclaw-cli withdraw --amount 5 + +# If API fails, use trustless (takes ~7 days) +omniclaw-cli withdraw_trustless --amount 5 +``` + +--- + +## Flow Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” deposit β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” pay β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Your β”‚ ────────────► β”‚ Gateway β”‚ ─────────► β”‚ Seller β”‚ +β”‚ EOA β”‚ (on-chain) β”‚ Contractβ”‚ (x402) β”‚ EOA β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ withdraw + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Your β”‚ + β”‚ Circle Walletβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` diff --git a/docs/compliance-architecture.md b/docs/compliance-architecture.md index 64170f7..b274475 100644 --- a/docs/compliance-architecture.md +++ b/docs/compliance-architecture.md @@ -26,7 +26,7 @@ OmniClaw sits between agent intent and settled payment. That position is deliber Every payment call in OmniClaw is: -1. **Tied to an explicit agent identity** β€” the SDK requires the calling agent to be identifiable at the operator level. Anonymous agents cannot initiate payments. +1. **Tied to an explicit agent identity** β€” the Financial Policy Engine requires the calling agent to be identifiable at the operator level. Anonymous agents cannot initiate payments. 2. **Bound by operator-defined policy** β€” spending limits, velocity controls, permitted counterparties, and trust thresholds are set at the operator level before any agent executes. The agent operates within a policy envelope, not around one. diff --git a/docs/erc_804_spec.md b/docs/erc_804_spec.md index 66ba716..add9574 100644 --- a/docs/erc_804_spec.md +++ b/docs/erc_804_spec.md @@ -1,17 +1,17 @@ # ERC-8004 Trust Notes -This file is a historical design note for OmniClaw's trust-layer direction. It is not the canonical API reference and should not be treated as a precise description of the current SDK implementation. +This file is a historical design note for OmniClaw's trust-layer direction. It is not the canonical API reference and should not be treated as a precise description of the current Financial Policy Engine implementation. Use these docs for the current product surface instead: - [README](../README.md) -- [SDK Usage Guide](SDK_USAGE_GUIDE.md) +- [Financial Policy Engine Usage Guide](SDK_USAGE_GUIDE.md) - [API Reference](API_REFERENCE.md) - [Architecture and Features](FEATURES.md) ## Current Reality -OmniClaw already exposes a trust layer through the SDK: +OmniClaw already exposes a trust layer through the Financial Policy Engine: - trust checks can run during `pay()` and `simulate()` - trust behavior is controlled by `check_trust` @@ -32,8 +32,8 @@ Those themes still matter, but the exact content of the original internal draft ## Recommendation -If this repo keeps evolving quickly, treat trust docs the same way as the rest of the SDK docs: +If this repo keeps evolving quickly, treat trust docs the same way as the rest of the Financial Policy Engine docs: - keep implementation details in code and tests - keep user-facing behavior in the API reference and usage guide -- keep speculative product thinking in the roadmap, not in protocol documentation +- keep speculative product thinking in the roadmap, not in protocol documentation \ No newline at end of file diff --git a/examples/agent/buyer/docker-compose.yml b/examples/agent/buyer/docker-compose.yml new file mode 100644 index 0000000..a831e2c --- /dev/null +++ b/examples/agent/buyer/docker-compose.yml @@ -0,0 +1,36 @@ +services: + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --appendfsync everysec + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + omniclaw-agent: + build: + context: ../../.. + dockerfile: Dockerfile.agent + command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 9091 + env_file: ../../../.env + environment: + - OMNICLAW_REDIS_URL=redis://redis:6379/1 + - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json + - OMNICLAW_LOG_LEVEL=INFO + - OMNICLAW_AGENT_TOKEN=buyer-agent-token + - CIRCLE_API_KEY=${BUYER_CIRCLE_API_KEY} + - OMNICLAW_PRIVATE_KEY=${BUYER_OMNICLAW_PRIVATE_KEY} + - ENTITY_SECRET=${BUYER_ENTITY_SECRET} + volumes: + - ./policy.json:/config/policy.json + ports: + - "9091:9091" + depends_on: + redis: + condition: service_healthy + +volumes: + redis-data: diff --git a/examples/agent/buyer/policy.json b/examples/agent/buyer/policy.json new file mode 100644 index 0000000..eaa1cf9 --- /dev/null +++ b/examples/agent/buyer/policy.json @@ -0,0 +1,46 @@ +{ + "version": "2.0", + "tokens": { + "buyer-agent-token": { + "wallet_alias": "buyer-wallet", + "active": true, + "label": "Buyer Agent" + } + }, + "wallets": { + "buyer-wallet": { + "name": "Buyer Wallet", + "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": "allow_all", + "addresses": [], + "domains": [] + }, + "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": "allow_all", + "addresses": [], + "domains": [] + }, + "confirm_threshold": null +} \ No newline at end of file diff --git a/examples/agent/docker-compose.yml b/examples/agent/docker-compose.yml new file mode 100644 index 0000000..e83c569 --- /dev/null +++ b/examples/agent/docker-compose.yml @@ -0,0 +1,28 @@ +services: + omniclaw-agent: + build: + context: ../.. + dockerfile: Dockerfile.agent + command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 8080 + environment: + - CIRCLE_API_KEY=TEST_API_KEY:50d35122138d03a59b1d16b2b700ce8b:7332fd15939df66501d2a46b4ce8c83b + - OMNICLAW_PRIVATE_KEY=0x5ec6f9922879be60d25c2d10b39a89cd8138eeffedc077296a174c2a1b9254c0 + - OMNICLAW_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com + - OMNICLAW_NETWORK=ETH-SEPOLIA + - OMNICLAW_STORAGE_BACKEND=memory + - OMNICLAW_AGENT_TOKEN=test-agent-token + - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json + volumes: + - ./policy.json:/config/policy.json + ports: + - "9080:8080" + + seller: + build: + context: ../.. + dockerfile: Dockerfile.agent + command: uv run python3 examples/agent/seller.py + environment: + - OMNICLAW_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com + ports: + - "9001:8001" diff --git a/examples/agent/omnicore_agent_demo.py b/examples/agent/omnicore_agent_demo.py deleted file mode 100644 index 4be0b39..0000000 --- a/examples/agent/omnicore_agent_demo.py +++ /dev/null @@ -1,60 +0,0 @@ -import asyncio -import os -import sys - -# Ensure relative imports work if needed -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../src'))) - -from omnicoreagent import OmniCoreAgent, MemoryRouter - -async def main(): - print("πŸ€– Initializing OmniCoreAgent Test...") - - if not os.environ.get("LLM_API_KEY") and not os.environ.get("OPENAI_API_KEY"): - print("⚠️ Warning: No LLM_API_KEY or OPENAI_API_KEY found in environment.") - print("The agent requires an LLM to reason and execute the skill.") - print("Please export LLM_API_KEY=your_key and run this script again.") - # We can still attempt to boot the agent, but litellm will fail. - - # 1. Configure the agent to enable the skills system - # OmniCoreAgent will automatically discover the .agents/skills/omniclaw-cli folder! - agent_config = { - "enable_agent_skills": True, - "context_management": {"enabled": True} - } - - # 2. Build the agent instance - agent = OmniCoreAgent( - name="finance_assistant", - system_instruction="You are a helpful assistant with access to the OmniClaw CLI.", - model_config={"provider": "openai", "model": "gpt-4o-mini"}, - memory_router=MemoryRouter("in_memory"), # No need for Redis for a simple test - agent_config=agent_config - ) - - print("\nβœ… Agent initialized with skills enabled.") - print("Sending prompt to the agent...") - - # 3. Give the agent a task that requires the OmniClaw CLI - prompt = """ - I need you to test your OmniClaw payment capabilities. - Please use the OmniClaw CLI to: - 1. Check your available balance. - 2. Check if the address 0x1234567890123456789012345678901234567890 is an allowed recipient. - 3. If allowed, execute a payment of $3.50 to that address for "Database Hosting". - - Return a summary of what you did and the transaction status. - """ - - print(f"\n[Prompt]: {prompt}\n") - - try: - # The agent will read the SKILL.md, discover the CLI, run the commands, and parse the output! - result = await agent.run(prompt) - print("\nπŸŽ‰ --- Agent Response --- πŸŽ‰") - print(result["response"]) - except Exception as e: - print(f"\n❌ Error during agent execution: {e}") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agent/omnicore_agent_local_test.py b/examples/agent/omnicore_agent_local_test.py deleted file mode 100644 index 5c9df45..0000000 --- a/examples/agent/omnicore_agent_local_test.py +++ /dev/null @@ -1,57 +0,0 @@ -import asyncio -import os -import sys - -# Ensure relative imports work if needed -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../src'))) - -from dotenv import load_dotenv - -# Load project environment variables -load_dotenv() - -from omnicoreagent import OmniCoreAgent, MemoryRouter, ToolRegistry - -async def main(): - print("πŸ€– Initializing OmniCoreAgent SDK Test...") - - agent_config = { - "enable_agent_skills": True, - "context_management": {"enabled": True}, - "guardrail_mode": "off", - "max_steps": 30, - "tool_call_timeout": 300 - } - - agent = OmniCoreAgent( - name="omniclaw_tester", - system_instruction="""You are a powerful AI agent. - You have access to the bash shell. - Your primary directive right now is to test the OmniClaw CLI. - You must read the OmniClaw SKILL file (if you haven't automatically), install the CLI locally as instructed in the SKILL, configure it, and then run a balance check.""", - model_config={"provider": "openai", "model": "gpt-4o-mini"}, # Local fallback/mocking might occur if no key - memory_router=MemoryRouter("in_memory"), - agent_config=agent_config, - debug=True - ) - - prompt = """ - Please perform the full OmniClaw local installation and test loop! - 1. Read your skills to find the local installation command for OmniClaw. - 2. Write and execute a bash script (using your tools) that installs the CLI locally via pip. - 3. Run the configuration command. - 4. Check the balance and return it to me. - """ - - print(f"\n[Prompt]: {prompt}\n") - - try: - result = await agent.run(prompt) - print("\nπŸŽ‰ --- Agent Response --- πŸŽ‰") - print(result["response"]) - except Exception as e: - print(f"\n❌ Expected Error (if no API keys are present): {e}") - print("The script successfully loaded the agent and attempted to execute the prompt.") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agent/policy.json b/examples/agent/policy.json index 241d229..c873f84 100644 --- a/examples/agent/policy.json +++ b/examples/agent/policy.json @@ -1,118 +1,21 @@ { - "version": "1.0", + "version": "2.0", "tokens": { - "payment-agent-token": { - "wallet_alias": "omni-bot-v4", + "test-agent-token": { + "wallet_alias": "default", "active": true, - "label": "Main Omni Bot" - }, - "api-agent-token": { - "wallet_alias": "api-agent", - "active": true, - "label": "API Payments" - }, - "small-agent-token": { - "wallet_alias": "small-agent", - "active": true, - "label": "Small Payments Only" + "label": "Test Agent" } }, "wallets": { - "omni-bot-v4": { - "name": "Omni Bot V1", - "description": "Upgraded autonomous bot", + "default": { + "name": "Test Wallet", "limits": { "daily_max": "100.00", - "hourly_max": "50.00", - "per_tx_max": "25.00", - "per_tx_min": "0.01" - }, - "rate_limits": { - "per_minute": 10, - "per_hour": 100 - }, - "recipients": { - "mode": "whitelist", - "addresses": [ - "0x742d35Cc6634C0532925a3b844Bc9e7595f", - "0x1234567890123456789012345678901234567890" - ], - "domains": [ - "api.stripe.com", - "api.paddle.com", - "api.example.com" - ] - }, - "confirm_threshold": "10.00" - }, - "api-agent": { - "name": "API Payments", - "description": "Agent for API-based payments", - "limits": { - "daily_max": "500.00", - "hourly_max": "200.00", - "per_tx_max": "100.00", - "per_tx_min": "0.10" - }, - "rate_limits": { - "per_minute": 50, - "per_hour": 500 - }, - "recipients": { - "mode": "whitelist", - "domains": [ - "api.example.com", - "api.provider.com" - ] - }, - "confirm_threshold": "50.00" - }, - "small-agent": { - "name": "Small Payments Only", - "description": "Agent limited to small micro-payments", - "limits": { - "daily_max": "10.00", - "per_tx_max": "1.00", - "per_tx_min": "0.01" - }, - "rate_limits": { - "per_minute": 5, - "per_hour": 20 - }, - "recipients": { - "mode": "whitelist", - "addresses": [ - "0x742d35Cc6634C0532925a3b844Bc9e7595f" - ] - }, - "confirm_threshold": "0.00" - }, - "no-limits": { - "name": "Unlimited Agent", - "description": "No limits - use with caution", - "recipients": { - "mode": "whitelist", - "addresses": [ - "0x742d35Cc6634C0532925a3b844Bc9e7595f" - ] - } - }, - "blacklist-example": { - "name": "Blacklist Example", - "description": "Example showing blacklist mode", - "limits": { - "daily_max": "1000.00", - "per_tx_max": "100.00" + "per_tx_max": "10.00" }, "recipients": { - "mode": "blacklist", - "addresses": [ - "0xBADBADBADBADBADBADBADBADBADBADBADBADBAD" - ], - "domains": [ - "evil.com", - "scam.io" - ] + "mode": "allow_all" } } } diff --git a/examples/agent/seller/docker-compose.yml b/examples/agent/seller/docker-compose.yml new file mode 100644 index 0000000..d4ae305 --- /dev/null +++ b/examples/agent/seller/docker-compose.yml @@ -0,0 +1,36 @@ +services: + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --appendfsync everysec + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + omniclaw-agent: + build: + context: ../../.. + dockerfile: Dockerfile.agent + command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 9090 + env_file: ../../../.env + environment: + - OMNICLAW_REDIS_URL=redis://redis:6379/0 + - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json + - OMNICLAW_LOG_LEVEL=INFO + - OMNICLAW_AGENT_TOKEN=seller-agent-token + - CIRCLE_API_KEY=${SELLER_CIRCLE_API_KEY} + - OMNICLAW_PRIVATE_KEY=${SELLER_OMNICLAW_PRIVATE_KEY} + - ENTITY_SECRET=${SELLER_ENTITY_SECRET} + volumes: + - ./policy.json:/config/policy.json + ports: + - "9090:9090" + depends_on: + redis: + condition: service_healthy + +volumes: + redis-data: diff --git a/examples/agent/seller/policy.json b/examples/agent/seller/policy.json new file mode 100644 index 0000000..f2842a3 --- /dev/null +++ b/examples/agent/seller/policy.json @@ -0,0 +1,46 @@ +{ + "version": "2.0", + "tokens": { + "seller-agent-token": { + "wallet_alias": "seller-wallet", + "active": true, + "label": "Seller Agent" + } + }, + "wallets": { + "seller-wallet": { + "name": "Seller Wallet", + "wallet_id": "eacf1510-18e4-5b99-8fd4-3cc0c1965442", + "address": "0x5a4e248fa08c37b15ea0efdfdf336e92317d5243", + "limits": { + "daily_max": "1000.00", + "hourly_max": null, + "per_tx_max": "100.00", + "per_tx_min": null + }, + "rate_limits": null, + "recipients": { + "mode": "allow_all", + "addresses": [], + "domains": [] + }, + "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": "allow_all", + "addresses": [], + "domains": [] + }, + "confirm_threshold": null +} \ No newline at end of file diff --git a/examples/agent/seller/start.sh b/examples/agent/seller/start.sh new file mode 100755 index 0000000..8f33c96 --- /dev/null +++ b/examples/agent/seller/start.sh @@ -0,0 +1,9 @@ +#!/bin/bash +export CIRCLE_API_KEY=TEST_API_KEY:511a1daf3edd65884326e8f56368088a:396b2b196a9206c435c08de607f7ee2b +export OMNICLAW_PRIVATE_KEY=0x5ec6f9922879be60d25c2d10b39a89cd8138eeffedc077296a174c2a1b9254c0 +export OMNICLAW_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +export OMNICLAW_NETWORK=ETH-SEPOLIA +export OMNICLAW_AGENT_TOKEN=seller-agent-token +export OMNICLAW_AGENT_POLICY_PATH=/home/abiorh/omnuron-labs/omniclaw/examples/agent/seller/policy.json +cd /home/abiorh/omnuron-labs/omniclaw +exec uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 8081 diff --git a/examples/agent/tokens/agent-payment-token b/examples/agent/tokens/agent-payment-token deleted file mode 100644 index 03c7cfb..0000000 --- a/examples/agent/tokens/agent-payment-token +++ /dev/null @@ -1 +0,0 @@ -agent-payment-token \ No newline at end of file diff --git a/examples/agent/tokens/payment-agent-token b/examples/agent/tokens/payment-agent-token deleted file mode 100644 index 38fd99b..0000000 --- a/examples/agent/tokens/payment-agent-token +++ /dev/null @@ -1 +0,0 @@ -payment-agent-token \ No newline at end of file diff --git a/examples/default-policy.json b/examples/default-policy.json new file mode 100644 index 0000000..5444f43 --- /dev/null +++ b/examples/default-policy.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "tokens": { + "YOUR_AGENT_TOKEN": { + "wallet_alias": "primary", + "active": true, + "label": "Your Agent Name" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "limits": { + "daily_max": "100.00", + "per_tx_max": "50.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} \ No newline at end of file diff --git a/examples/policy-advanced.json b/examples/policy-advanced.json new file mode 100644 index 0000000..d7074c4 --- /dev/null +++ b/examples/policy-advanced.json @@ -0,0 +1,62 @@ +{ + "version": "2.0", + "tokens": { + "buyer-agent": { + "wallet_alias": "primary", + "active": true, + "label": "Buyer Agent - LLM Processor" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "limits": { + "daily_max": "1000.00", + "hourly_max": "200.00", + "per_tx_max": "100.00", + "per_tx_min": "0.01" + }, + "rate_limits": { + "per_minute": 10, + "per_hour": 100 + }, + "recipients": { + "mode": "whitelist", + "addresses": [ + "0xSeller1Address...", + "0xSeller2Address..." + ], + "domains": [ + "api.service-a.com", + "api.service-b.com" + ] + }, + "confirm_threshold": "50.00", + "time_restrictions": { + "allowed_days": [1, 2, 3, 4, 5], + "allowed_hours": { + "start": "09:00", + "end": "18:00" + }, + "timezone": "UTC" + }, + "ip_restrictions": { + "allowed_ips": ["10.0.0.0/8", "192.168.1.0/24"] + }, + "categories": { + "allowed_categories": ["compute", "api", "storage"] + }, + "networks": { + "allowed_networks": ["eip155:11155111", "eip155:84532"] + }, + "purpose": { + "pattern": "^(invoice|subscription|api-call)-[a-zA-Z0-9]+$", + "required_tags": ["billing"] + }, + "trust": { + "min_trust_score": 0.7, + "require_trust_verified": false + } + } + } +} \ No newline at end of file diff --git a/examples/policy-simple.json b/examples/policy-simple.json new file mode 100644 index 0000000..e5bc3cc --- /dev/null +++ b/examples/policy-simple.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "tokens": { + "my-agent": { + "wallet_alias": "primary", + "active": true, + "label": "My Agent" + } + }, + "wallets": { + "primary": { + "name": "Primary Wallet", + "limits": { + "daily_max": "100.00", + "per_tx_max": "50.00" + }, + "recipients": { + "mode": "allow_all" + } + } + } +} \ No newline at end of file diff --git a/mcp_server/README.md b/mcp_server/README.md index 30bda24..8e149c2 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -1,6 +1,6 @@ # OmniClaw MCP Server -FastMCP server exposing the current `omniclaw` SDK as MCP tools for agent-facing wallet and payment operations. +FastMCP server exposing the OmniClaw Financial Policy Engine as MCP tools for agent-facing wallet and payment operations. This server is not the primary launch artifact for today, but its docs are kept aligned so the repo does not publish stale MCP behavior. diff --git a/mcp_server/app/core/config.py b/mcp_server/app/core/config.py index f9b7c2c..9055713 100644 --- a/mcp_server/app/core/config.py +++ b/mcp_server/app/core/config.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import Literal from pydantic import AliasChoices, AnyHttpUrl, Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -39,7 +39,7 @@ class Settings(BaseSettings): "OMNIAGENTPAY_RATE_LIMIT_PER_MIN", ), ) - OMNICLAW_WHITELISTED_RECIPIENTS: List[str] = Field( + OMNICLAW_WHITELISTED_RECIPIENTS: list[str] = Field( default_factory=list, validation_alias=AliasChoices( "OMNICLAW_WHITELISTED_RECIPIENTS", @@ -62,7 +62,7 @@ class Settings(BaseSettings): OMNICLAW_WEBHOOK_VERIFICATION_KEY: SecretStr | None = None # CORS - BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + BACKEND_CORS_ORIGINS: list[AnyHttpUrl] = [] @field_validator("CIRCLE_API_KEY", "ENTITY_SECRET") @classmethod diff --git a/mcp_server/app/core/logging.py b/mcp_server/app/core/logging.py index 7217c03..6102e94 100644 --- a/mcp_server/app/core/logging.py +++ b/mcp_server/app/core/logging.py @@ -1,7 +1,9 @@ import logging import sys + import structlog + def setup_logging(): logging.basicConfig( format="%(message)s", @@ -21,4 +23,3 @@ def setup_logging(): wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) - diff --git a/mcp_server/app/core/security.py b/mcp_server/app/core/security.py index 840cc6a..54d98da 100644 --- a/mcp_server/app/core/security.py +++ b/mcp_server/app/core/security.py @@ -1,20 +1,21 @@ from datetime import datetime, timedelta -from typing import Any, Union +from typing import Any + from jose import jwt from passlib.context import CryptContext + from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" -def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str: + +def create_access_token(subject: str | Any, expires_delta: timedelta = None) -> str: if expires_delta: expire = datetime.utcnow() + expires_delta else: - expire = datetime.utcnow() + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) to_encode = {"exp": expire, "sub": str(subject)} jwt_secret = settings.MCP_JWT_SECRET.get_secret_value() if settings.MCP_JWT_SECRET else None if not jwt_secret: @@ -22,8 +23,10 @@ def create_access_token(subject: Union[str, Any], expires_delta: timedelta = Non encoded_jwt = jwt.encode(to_encode, jwt_secret, algorithm=ALGORITHM) return encoded_jwt + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) diff --git a/mcp_server/app/main.py b/mcp_server/app/main.py index ac3e776..c807340 100644 --- a/mcp_server/app/main.py +++ b/mcp_server/app/main.py @@ -1,14 +1,15 @@ import time import uuid from contextlib import asynccontextmanager + +import structlog from fastapi import FastAPI, Request, status -from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware -import structlog +from fastapi.responses import JSONResponse from app.core.config import settings +from app.core.lifecycle import shutdown_event, startup_event from app.core.logging import setup_logging -from app.core.lifecycle import startup_event, shutdown_event from app.mcp.fastmcp_server import mcp from app.webhooks.circle import router as circle_webhook_router @@ -18,6 +19,7 @@ # Create FastMCP app first to get its lifespan mcp_app = mcp.http_app(path="/", stateless_http=True) + @asynccontextmanager async def lifespan(app: FastAPI): # Start FastMCP lifespan (it's a function that returns async context manager) @@ -28,35 +30,38 @@ async def lifespan(app: FastAPI): # Shutdown our custom logic await shutdown_event(app) + app = FastAPI( title=settings.PROJECT_NAME, lifespan=lifespan, openapi_url=f"{settings.API_V1_STR}/openapi.json", ) + # Middleware for Correlation ID and Request Logging @app.middleware("http") async def add_process_time_header(request: Request, call_next): correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4())) structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(correlation_id=correlation_id) - + start_time = time.time() response = await call_next(request) process_time = time.time() - start_time - + response.headers["X-Correlation-ID"] = correlation_id response.headers["X-Process-Time"] = str(process_time) - + logger.info( "http_request", path=request.url.path, method=request.method, status_code=response.status_code, - duration=process_time + duration=process_time, ) return response + # Global Exception Handler for Production Hardening @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): @@ -66,6 +71,7 @@ async def global_exception_handler(request: Request, exc: Exception): content={"detail": "An internal server error occurred. Please contact support."}, ) + if settings.BACKEND_CORS_ORIGINS: app.add_middleware( CORSMiddleware, @@ -80,7 +86,10 @@ async def global_exception_handler(request: Request, exc: Exception): app.mount("/mcp", mcp_app) # Include webhook router -app.include_router(circle_webhook_router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["webhooks"]) +app.include_router( + circle_webhook_router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["webhooks"] +) + @app.get("/health") async def health_check(): diff --git a/mcp_server/app/mcp/auth.py b/mcp_server/app/mcp/auth.py index 4a0a1ca..7877eb2 100644 --- a/mcp_server/app/mcp/auth.py +++ b/mcp_server/app/mcp/auth.py @@ -1,8 +1,9 @@ """FastMCP authentication provider for Bearer token and JWT verification.""" -from typing import Optional -from jose import jwt, JWTError + import structlog from fastmcp.server.auth import AccessToken, AuthProvider +from jose import JWTError, jwt + from app.core.config import settings logger = structlog.get_logger(__name__) @@ -10,8 +11,8 @@ class BearerTokenAuthProvider(AuthProvider): """Custom auth provider supporting static Bearer tokens and JWT verification.""" - - async def verify_token(self, token: str) -> Optional[AccessToken]: + + async def verify_token(self, token: str) -> AccessToken | None: """ Verify Bearer token or JWT token. Returns AccessToken if valid, None otherwise. @@ -19,16 +20,20 @@ async def verify_token(self, token: str) -> Optional[AccessToken]: if not token: logger.warn("empty_token_provided") return None - + # Strip whitespace from token token = token.strip() - - static_token = settings.MCP_AUTH_TOKEN.get_secret_value() if settings.MCP_AUTH_TOKEN else None + + static_token = ( + settings.MCP_AUTH_TOKEN.get_secret_value() if settings.MCP_AUTH_TOKEN else None + ) jwt_secret = settings.MCP_JWT_SECRET.get_secret_value() if settings.MCP_JWT_SECRET else None - + # Log token attempt (first 10 chars only for security) - logger.debug("token_verification_attempt", token_prefix=token[:10] if len(token) > 10 else "short") - + logger.debug( + "token_verification_attempt", token_prefix=token[:10] if len(token) > 10 else "short" + ) + # Try static Bearer token first if static_token: static_token = static_token.strip() @@ -39,41 +44,41 @@ async def verify_token(self, token: str) -> Optional[AccessToken]: client_id="api_client", scopes=["read", "write"], expires_at=None, # Static tokens don't expire - claims={"sub": "api_client"} + claims={"sub": "api_client"}, ) else: - logger.debug("static_token_mismatch", - expected_prefix=static_token[:10] if len(static_token) > 10 else "short", - received_prefix=token[:10] if len(token) > 10 else "short") - + logger.debug( + "static_token_mismatch", + expected_prefix=static_token[:10] if len(static_token) > 10 else "short", + received_prefix=token[:10] if len(token) > 10 else "short", + ) + # Try JWT verification if jwt_secret: try: - payload = jwt.decode( - token, - jwt_secret, - algorithms=["HS256"] - ) + payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) sub = payload.get("sub", "unknown") scopes = payload.get("scopes", ["read"]) exp = payload.get("exp") - + logger.info("jwt_token_verified", sub=sub) return AccessToken( token=token, client_id=sub, scopes=scopes if isinstance(scopes, list) else [scopes] if scopes else ["read"], expires_at=exp, - claims=payload + claims=payload, ) except JWTError as e: logger.warn("jwt_verification_failed", error=str(e)) return None - - logger.warn("token_verification_failed", - reason="no_matching_token", - has_static_token=static_token is not None, - has_jwt_secret=jwt_secret is not None) + + logger.warn( + "token_verification_failed", + reason="no_matching_token", + has_static_token=static_token is not None, + has_jwt_secret=jwt_secret is not None, + ) return None @@ -81,5 +86,5 @@ def get_auth_provider(): """Get FastMCP auth provider instance.""" if not settings.MCP_AUTH_ENABLED: return None - + return BearerTokenAuthProvider() diff --git a/mcp_server/app/mcp/fastmcp_server.py b/mcp_server/app/mcp/fastmcp_server.py index b5ad9a1..f14f1b4 100644 --- a/mcp_server/app/mcp/fastmcp_server.py +++ b/mcp_server/app/mcp/fastmcp_server.py @@ -59,10 +59,14 @@ def _fail(tool: str, exc: Exception) -> ToolError: @mcp.tool() async def create_agent_wallet( - agent_name: Annotated[str, Field(min_length=1, description="Agent identifier or friendly name")], + agent_name: Annotated[ + str, Field(min_length=1, description="Agent identifier or friendly name") + ], blockchain: Annotated[ str | None, - Field(default=None, description="Optional network override (e.g. ARC-TESTNET, ETH-SEPOLIA)"), + Field( + default=None, description="Optional network override (e.g. ARC-TESTNET, ETH-SEPOLIA)" + ), ] = None, apply_default_guards: Annotated[ bool, @@ -188,13 +192,17 @@ async def pay( amount: Annotated[str, Field(min_length=1, description="USDC amount as string")], destination_chain: Annotated[ str | None, - Field(default=None, description="Optional cross-chain destination network (e.g. ARB-MAINNET)"), + Field( + default=None, description="Optional cross-chain destination network (e.g. ARB-MAINNET)" + ), ] = None, wallet_set_id: Annotated[ str | None, Field(default=None, description="Optional wallet set ID"), ] = None, - purpose: Annotated[str | None, Field(default=None, description="Optional payment purpose")] = None, + purpose: Annotated[ + str | None, Field(default=None, description="Optional payment purpose") + ] = None, idempotency_key: Annotated[ str | None, Field(default=None, description="Optional caller-provided idempotency key"), @@ -205,7 +213,9 @@ async def pay( ] = "medium", strategy: Annotated[ str, - Field(default="retry_then_fail", description="Execution strategy (fail_fast, retry_then_fail)"), + Field( + default="retry_then_fail", description="Execution strategy (fail_fast, retry_then_fail)" + ), ] = "retry_then_fail", check_trust: Annotated[ bool | None, @@ -249,7 +259,12 @@ async def pay( @mcp.tool() async def batch_pay( - requests: Annotated[list[dict[str, Any]], Field(description="List of payment request specifications. Must include wallet_id, recipient, amount, fee_level, destination_chain, idempotency_key")], + requests: Annotated[ + list[dict[str, Any]], + Field( + description="List of payment request specifications. Must include wallet_id, recipient, amount, fee_level, destination_chain, idempotency_key" + ), + ], ) -> dict[str, Any]: """Execute multiple payments as a batch.""" try: @@ -277,7 +292,9 @@ async def create_payment_intent( Field(default=None, description="Optional intent destination network"), ] = None, purpose: Annotated[str | None, Field(default=None, description="Intent purpose")] = None, - expires_in: Annotated[int | None, Field(default=None, ge=1, description="Intent TTL in seconds")] = None, + expires_in: Annotated[ + int | None, Field(default=None, ge=1, description="Intent TTL in seconds") + ] = None, idempotency_key: Annotated[ str | None, Field(default=None, description="Optional idempotency key"), @@ -298,7 +315,7 @@ async def create_payment_intent( expires_in=expires_in, idempotency_key=idempotency_key, metadata=metadata, - **({"destination_chain": destination_chain} if destination_chain else {}) + **({"destination_chain": destination_chain} if destination_chain else {}), ) return {"status": "success", "intent": result} except Exception as exc: @@ -352,8 +369,12 @@ async def cancel_intent( @mcp.tool() async def list_transactions( - wallet_id: Annotated[str | None, Field(default=None, description="Optional wallet ID filter")] = None, - blockchain: Annotated[str | None, Field(default=None, description="Optional network filter")] = None, + wallet_id: Annotated[ + str | None, Field(default=None, description="Optional wallet ID filter") + ] = None, + blockchain: Annotated[ + str | None, Field(default=None, description="Optional network filter") + ] = None, ) -> dict[str, Any]: """List provider transactions for a wallet or globally.""" try: @@ -411,9 +432,13 @@ async def detect_payment_method( @mcp.tool() async def trust_lookup( recipient_address: Annotated[str, Field(min_length=1, description="Recipient wallet address")], - amount: Annotated[str, Field(default="0", description="Reference amount for policy evaluation")] = "0", - wallet_id: Annotated[str | None, Field(default=None, description="Wallet ID for wallet-specific policy")] = None, - network: Annotated[str | None, Field(default=None, description="Network override")]=None, + amount: Annotated[ + str, Field(default="0", description="Reference amount for policy evaluation") + ] = "0", + wallet_id: Annotated[ + str | None, Field(default=None, description="Wallet ID for wallet-specific policy") + ] = None, + network: Annotated[str | None, Field(default=None, description="Network override")] = None, ) -> dict[str, Any]: """Run ERC-8004 Trust Gate evaluation.""" try: diff --git a/mcp_server/app/payments/guards.py b/mcp_server/app/payments/guards.py index 0a9481a..d31a7f9 100644 --- a/mcp_server/app/payments/guards.py +++ b/mcp_server/app/payments/guards.py @@ -1,94 +1,95 @@ from abc import ABC, abstractmethod -from typing import Set, Optional, Dict, Any, List +from typing import Any + import structlog + from app.core.config import settings from app.utils.exceptions import ( - BudgetExceededError, - UnauthorizedRecipientError, - RateLimitExceededError, - GuardValidationError + BudgetExceededError, + UnauthorizedRecipientError, ) logger = structlog.get_logger(__name__) + class PaymentGuard(ABC): """Base class for all payment security guardrails.""" + @abstractmethod - async def validate(self, amount: float, wallet_id: str, recipient: Optional[str] = None): + async def validate(self, amount: float, wallet_id: str, recipient: str | None = None): pass @abstractmethod - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert guard configuration to a dictionary for SDK registration.""" pass + class BudgetGuard(PaymentGuard): """Enforces daily and hourly spending limits.""" - def __init__(self, daily_limit: float = settings.OMNICLAW_DAILY_BUDGET, hourly_limit: float = settings.OMNICLAW_HOURLY_BUDGET): + + def __init__( + self, + daily_limit: float = settings.OMNICLAW_DAILY_BUDGET, + hourly_limit: float = settings.OMNICLAW_HOURLY_BUDGET, + ): self.daily_limit = daily_limit self.hourly_limit = hourly_limit - async def validate(self, amount: float, wallet_id: str, recipient: Optional[str] = None): + async def validate(self, amount: float, wallet_id: str, recipient: str | None = None): # Implementation would check against ledger/cache # For now, it defines the policy to be enforced by Omniclaw pass - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "type": "budget", "daily_limit": self.daily_limit, - "hourly_limit": self.hourly_limit + "hourly_limit": self.hourly_limit, } + class SingleTransactionGuard(PaymentGuard): """Limits the maximum amount for any single transaction.""" + def __init__(self, tx_limit: float = settings.OMNICLAW_TX_LIMIT): self.tx_limit = tx_limit - async def validate(self, amount: float, wallet_id: str, recipient: Optional[str] = None): + async def validate(self, amount: float, wallet_id: str, recipient: str | None = None): if amount > self.tx_limit: raise BudgetExceededError(f"Transaction exceeds limit of {self.tx_limit}") - def to_dict(self) -> Dict[str, Any]: - return { - "type": "single_transaction", - "max_amount": self.tx_limit - } + def to_dict(self) -> dict[str, Any]: + return {"type": "single_transaction", "max_amount": self.tx_limit} + class RateLimitGuard(PaymentGuard): """Limits the number of transactions per minute.""" + def __init__(self, requests_per_min: int = settings.OMNICLAW_RATE_LIMIT_PER_MIN): self.requests_per_min = requests_per_min - async def validate(self, amount: float, wallet_id: str, recipient: Optional[str] = None): + async def validate(self, amount: float, wallet_id: str, recipient: str | None = None): pass - def to_dict(self) -> Dict[str, Any]: - return { - "type": "rate_limit", - "requests_per_minute": self.requests_per_min - } + def to_dict(self) -> dict[str, Any]: + return {"type": "rate_limit", "requests_per_minute": self.requests_per_min} + class RecipientWhitelistGuard(PaymentGuard): """Restricts payments to a pre-approved list of addresses.""" - def __init__(self, whitelisted_addresses: List[str] = settings.OMNICLAW_WHITELISTED_RECIPIENTS): + + def __init__(self, whitelisted_addresses: list[str] = settings.OMNICLAW_WHITELISTED_RECIPIENTS): self.whitelisted_addresses = whitelisted_addresses - async def validate(self, amount: float, wallet_id: str, recipient: Optional[str] = None): + async def validate(self, amount: float, wallet_id: str, recipient: str | None = None): if self.whitelisted_addresses and recipient not in self.whitelisted_addresses: raise UnauthorizedRecipientError(recipient or "Unknown") - def to_dict(self) -> Dict[str, Any]: - return { - "type": "recipient_whitelist", - "addresses": self.whitelisted_addresses - } + def to_dict(self) -> dict[str, Any]: + return {"type": "recipient_whitelist", "addresses": self.whitelisted_addresses} + -def get_default_guards() -> List[PaymentGuard]: +def get_default_guards() -> list[PaymentGuard]: """Returns the set of default guards as configured in the environment.""" - return [ - BudgetGuard(), - SingleTransactionGuard(), - RateLimitGuard(), - RecipientWhitelistGuard() - ] + return [BudgetGuard(), SingleTransactionGuard(), RateLimitGuard(), RecipientWhitelistGuard()] diff --git a/mcp_server/app/payments/interfaces.py b/mcp_server/app/payments/interfaces.py index 9ae2abf..d551522 100644 --- a/mcp_server/app/payments/interfaces.py +++ b/mcp_server/app/payments/interfaces.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional +from typing import Any class AbstractPaymentClient(ABC): @@ -8,12 +8,11 @@ class AbstractPaymentClient(ABC): @abstractmethod async def create_agent_wallet( self, agent_name: str, blockchain: str | None = None, apply_default_guards: bool = True - ) -> Dict[str, Any]: + ) -> dict[str, Any]: pass - @abstractmethod - async def create_wallet_set(self, name: str | None = None) -> Dict[str, Any]: + async def create_wallet_set(self, name: str | None = None) -> dict[str, Any]: pass @abstractmethod @@ -23,19 +22,19 @@ async def create_wallet( blockchain: str | None = None, account_type: str = "EOA", name: str | None = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: pass @abstractmethod - async def list_wallet_sets(self) -> List[Dict[str, Any]]: + async def list_wallet_sets(self) -> list[dict[str, Any]]: pass @abstractmethod - async def list_wallets(self, wallet_set_id: str | None = None) -> List[Dict[str, Any]]: + async def list_wallets(self, wallet_set_id: str | None = None) -> list[dict[str, Any]]: pass @abstractmethod - async def get_wallet(self, wallet_id: str) -> Dict[str, Any]: + async def get_wallet(self, wallet_id: str) -> dict[str, Any]: pass @abstractmethod @@ -45,7 +44,7 @@ async def simulate_payment( recipient: str, amount: str, check_trust: bool | None = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: pass @abstractmethod @@ -58,7 +57,7 @@ async def execute_payment( idempotency_key: str | None = None, check_trust: bool | None = None, wait_for_completion: bool = False, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: pass @abstractmethod @@ -70,35 +69,34 @@ async def create_payment_intent( purpose: str | None = None, expires_in: int | None = None, idempotency_key: str | None = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: pass @abstractmethod - async def get_payment_intent(self, intent_id: str) -> Dict[str, Any] | None: + async def get_payment_intent(self, intent_id: str) -> dict[str, Any] | None: pass @abstractmethod - async def confirm_intent(self, intent_id: str) -> Dict[str, Any]: + async def confirm_intent(self, intent_id: str) -> dict[str, Any]: pass @abstractmethod - async def cancel_intent(self, intent_id: str, reason: str | None = None) -> Dict[str, Any]: + async def cancel_intent(self, intent_id: str, reason: str | None = None) -> dict[str, Any]: pass @abstractmethod - async def get_wallet_usdc_balance(self, wallet_id: str) -> Dict[str, Any]: + async def get_wallet_usdc_balance(self, wallet_id: str) -> dict[str, Any]: pass @abstractmethod - async def list_guards(self, wallet_id: str) -> Dict[str, Any]: + async def list_guards(self, wallet_id: str) -> dict[str, Any]: pass - @abstractmethod - async def can_pay(self, recipient: str) -> Dict[str, Any]: + async def can_pay(self, recipient: str) -> dict[str, Any]: pass @abstractmethod - async def detect_method(self, recipient: str) -> Dict[str, Any]: + async def detect_method(self, recipient: str) -> dict[str, Any]: pass diff --git a/mcp_server/app/payments/omniclaw_client.py b/mcp_server/app/payments/omniclaw_client.py index fe6e978..8ebb31a 100644 --- a/mcp_server/app/payments/omniclaw_client.py +++ b/mcp_server/app/payments/omniclaw_client.py @@ -3,17 +3,17 @@ from datetime import datetime from decimal import Decimal from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Optional import structlog + +from app.core.config import settings +from app.payments.interfaces import AbstractPaymentClient from omniclaw import OmniClaw from omniclaw.core.types import AccountType, Network from omniclaw.identity.types import TrustPolicy from omniclaw.ledger import LedgerEntryStatus, LedgerEntryType -from app.core.config import settings -from app.payments.interfaces import AbstractPaymentClient - logger = structlog.get_logger(__name__) @@ -26,8 +26,12 @@ class OmniclawPaymentClient(AbstractPaymentClient): def __init__(self) -> None: network = Network.from_string(settings.OMNICLAW_NETWORK) self._client = OmniClaw( - circle_api_key=settings.CIRCLE_API_KEY.get_secret_value() if settings.CIRCLE_API_KEY else None, - entity_secret=settings.ENTITY_SECRET.get_secret_value() if settings.ENTITY_SECRET else None, + circle_api_key=settings.CIRCLE_API_KEY.get_secret_value() + if settings.CIRCLE_API_KEY + else None, + entity_secret=settings.ENTITY_SECRET.get_secret_value() + if settings.ENTITY_SECRET + else None, network=network, ) logger.info( @@ -67,12 +71,12 @@ def _serialize(value: Any) -> Any: return {k: OmniclawPaymentClient._serialize(v) for k, v in value.items()} if is_dataclass(value): return OmniclawPaymentClient._serialize(asdict(value)) - if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")): - raw_dict = getattr(value, "to_dict")() + if hasattr(value, "to_dict") and callable(value.to_dict): + raw_dict = value.to_dict() if isinstance(raw_dict, dict): return OmniclawPaymentClient._serialize(raw_dict) return raw_dict - if hasattr(value, "guards") and isinstance(getattr(value, "guards"), list): + if hasattr(value, "guards") and isinstance(value.guards, list): return [getattr(guard, "name", str(guard)) for guard in value.guards] return value @@ -87,14 +91,14 @@ async def create_agent_wallet( agent_name: str, blockchain: str | None = None, apply_default_guards: bool = True, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: wallet_set, wallet = await self._client.create_agent_wallet( agent_name=agent_name, blockchain=self._as_network(blockchain), apply_default_guards=apply_default_guards, ) - response: Dict[str, Any] = { + response: dict[str, Any] = { "wallet_set": self._serialize(wallet_set), "wallet": self._serialize(wallet), } @@ -106,23 +110,21 @@ async def create_agent_wallet( return response - async def create_wallet_set(self, name: str | None = None) -> Dict[str, Any]: + async def create_wallet_set(self, name: str | None = None) -> dict[str, Any]: wallet_set = await self._client.create_wallet_set(name=name) return self._serialize(wallet_set) - async def get_wallet_set(self, wallet_set_id: str) -> Dict[str, Any]: + async def get_wallet_set(self, wallet_set_id: str) -> dict[str, Any]: wallet_set = await self._client.get_wallet_set(wallet_set_id) return self._serialize(wallet_set) - - async def create_wallets( self, count: int, wallet_set_id: str | None = None, blockchain: str | None = None, account_type: str = "EOA", - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: wallets = self._client.wallet.create_wallets( count=count, wallet_set_id=wallet_set_id, @@ -137,7 +139,7 @@ async def create_wallet( blockchain: str | None = None, account_type: str = "EOA", name: str | None = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: wallet = await self._client.create_wallet( wallet_set_id=wallet_set_id, blockchain=self._as_network(blockchain), @@ -146,15 +148,15 @@ async def create_wallet( ) return self._serialize(wallet) - async def list_wallet_sets(self) -> List[Dict[str, Any]]: + async def list_wallet_sets(self) -> list[dict[str, Any]]: wallet_sets = await self._client.list_wallet_sets() return self._serialize(wallet_sets) - async def list_wallets(self, wallet_set_id: str | None = None) -> List[Dict[str, Any]]: + async def list_wallets(self, wallet_set_id: str | None = None) -> list[dict[str, Any]]: wallets = await self._client.list_wallets(wallet_set_id=wallet_set_id) return self._serialize(wallets) - async def get_wallet(self, wallet_id: str) -> Dict[str, Any]: + async def get_wallet(self, wallet_id: str) -> dict[str, Any]: wallet = await self._client.get_wallet(wallet_id) return self._serialize(wallet) @@ -166,7 +168,7 @@ async def simulate_payment( wallet_set_id: str | None = None, check_trust: bool | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: simulation = await self._client.simulate( wallet_id=wallet_id, recipient=recipient, @@ -195,9 +197,9 @@ async def execute_payment( wait_for_completion: bool = False, timeout_seconds: float | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.core.types import FeeLevel, PaymentStrategy - + result = await self._client.pay( wallet_id=wallet_id, recipient=recipient, @@ -226,8 +228,8 @@ async def create_payment_intent( purpose: str | None = None, expires_in: int | None = None, idempotency_key: str | None = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: intent = await self._client.intent.create( wallet_id=wallet_id, recipient=recipient, @@ -239,19 +241,19 @@ async def create_payment_intent( ) return self._serialize(intent) - async def get_payment_intent(self, intent_id: str) -> Dict[str, Any] | None: + async def get_payment_intent(self, intent_id: str) -> dict[str, Any] | None: intent = await self._client.intent.get(intent_id) return self._serialize(intent) - async def confirm_intent(self, intent_id: str) -> Dict[str, Any]: + async def confirm_intent(self, intent_id: str) -> dict[str, Any]: result = await self._client.intent.confirm(intent_id) return self._serialize(result) - async def cancel_intent(self, intent_id: str, reason: str | None = None) -> Dict[str, Any]: + async def cancel_intent(self, intent_id: str, reason: str | None = None) -> dict[str, Any]: result = await self._client.intent.cancel(intent_id=intent_id, reason=reason) return self._serialize(result) - async def get_wallet_usdc_balance(self, wallet_id: str) -> Dict[str, Any]: + async def get_wallet_usdc_balance(self, wallet_id: str) -> dict[str, Any]: balance = await self._client.get_balance(wallet_id) return { "wallet_id": wallet_id, @@ -259,39 +261,39 @@ async def get_wallet_usdc_balance(self, wallet_id: str) -> Dict[str, Any]: "usdc_balance": str(balance), } - async def get_balances(self, wallet_id: str) -> Dict[str, Any]: + async def get_balances(self, wallet_id: str) -> dict[str, Any]: balances = self._client.wallet.get_balances(wallet_id) return {"wallet_id": wallet_id, "balances": self._serialize(balances)} - async def list_guards(self, wallet_id: str) -> Dict[str, Any]: + async def list_guards(self, wallet_id: str) -> dict[str, Any]: guards = await self._client.guards.list_wallet_guard_names(wallet_id) return {"wallet_id": wallet_id, "guards": guards} - async def get_wallet_guards(self, wallet_id: str) -> Dict[str, Any]: + async def get_wallet_guards(self, wallet_id: str) -> dict[str, Any]: guards = await self._client.guards.get_wallet_guards(wallet_id) return {"wallet_id": wallet_id, "guards": self._serialize(guards)} - async def get_wallet_set_guards(self, wallet_set_id: str) -> Dict[str, Any]: + async def get_wallet_set_guards(self, wallet_set_id: str) -> dict[str, Any]: guards = await self._client.guards.get_wallet_set_guards(wallet_set_id) return {"wallet_set_id": wallet_set_id, "guards": self._serialize(guards)} - async def list_wallet_set_guard_names(self, wallet_set_id: str) -> Dict[str, Any]: + async def list_wallet_set_guard_names(self, wallet_set_id: str) -> dict[str, Any]: guards = await self._client.guards.list_wallet_set_guard_names(wallet_set_id) return {"wallet_set_id": wallet_set_id, "guards": guards} - async def remove_guard(self, wallet_id: str, guard_name: str) -> Dict[str, Any]: + async def remove_guard(self, wallet_id: str, guard_name: str) -> dict[str, Any]: removed = await self._client.guards.remove_guard(wallet_id, guard_name) return {"wallet_id": wallet_id, "guard_name": guard_name, "removed": removed} - async def remove_guard_from_set(self, wallet_set_id: str, guard_name: str) -> Dict[str, Any]: + async def remove_guard_from_set(self, wallet_set_id: str, guard_name: str) -> dict[str, Any]: removed = await self._client.guards.remove_guard_from_set(wallet_set_id, guard_name) return {"wallet_set_id": wallet_set_id, "guard_name": guard_name, "removed": removed} - async def clear_wallet_guards(self, wallet_id: str) -> Dict[str, Any]: + async def clear_wallet_guards(self, wallet_id: str) -> dict[str, Any]: await self._client.guards.clear_wallet_guards(wallet_id) return {"wallet_id": wallet_id, "cleared": True} - async def clear_wallet_set_guards(self, wallet_set_id: str) -> Dict[str, Any]: + async def clear_wallet_set_guards(self, wallet_set_id: str) -> dict[str, Any]: await self._client.guards.clear_wallet_set_guards(wallet_set_id) return {"wallet_set_id": wallet_set_id, "cleared": True} @@ -302,8 +304,9 @@ async def add_budget_guard( hourly_limit: str | None = None, total_limit: str | None = None, name: str = "budget", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.guards.budget import BudgetGuard + guard = BudgetGuard( name=name, daily_limit=Decimal(daily_limit) if daily_limit else None, @@ -327,8 +330,9 @@ async def add_rate_limit_guard( max_per_hour: int | None = None, max_per_day: int | None = None, name: str = "rate_limit", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.guards.rate_limit import RateLimitGuard + guard = RateLimitGuard( name=name, max_per_minute=max_per_minute, @@ -351,8 +355,9 @@ async def add_single_tx_guard( max_amount: str, min_amount: str | None = None, name: str = "single_tx", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.guards.single_tx import SingleTxGuard + guard = SingleTxGuard( name=name, max_amount=Decimal(max_amount), @@ -375,8 +380,9 @@ async def add_recipient_guard( patterns: list[str] | None = None, domains: list[str] | None = None, name: str = "recipient", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.guards.recipient import RecipientGuard + guard = RecipientGuard( name=name, mode=mode, @@ -401,8 +407,9 @@ async def add_confirm_guard( always_confirm: bool = False, threshold: str | None = None, name: str = "confirm", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.guards.confirm import ConfirmGuard + guard = ConfirmGuard( name=name, always_confirm=always_confirm, @@ -421,8 +428,8 @@ async def add_guard_for_set( self, wallet_set_id: str, guard_type: str, - config: Dict[str, Any], - ) -> Dict[str, Any]: + config: dict[str, Any], + ) -> dict[str, Any]: # Helper that handles generic dict instantiations for set-level guard applications from omniclaw.guards.budget import BudgetGuard from omniclaw.guards.confirm import ConfirmGuard @@ -432,7 +439,7 @@ async def add_guard_for_set( name = config.get("name", guard_type) guard = None - + if guard_type == "budget": daily_limit = config.get("daily_limit") hourly_limit = config.get("hourly_limit") @@ -482,21 +489,21 @@ async def list_transactions( self, wallet_id: str | None = None, blockchain: str | None = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: transactions = await self._client.list_transactions( wallet_id=wallet_id, blockchain=self._as_network(blockchain), ) return {"transactions": self._serialize(transactions)} - async def sync_transaction(self, ledger_entry_id: str) -> Dict[str, Any]: + async def sync_transaction(self, ledger_entry_id: str) -> dict[str, Any]: entry = await self._client.sync_transaction(ledger_entry_id) return {"entry": self._serialize(entry)} async def batch_pay( self, - requests: List[Dict[str, Any]], - ) -> Dict[str, Any]: + requests: list[dict[str, Any]], + ) -> dict[str, Any]: from omniclaw.core.types import PaymentRequest payment_requests = [] @@ -525,7 +532,7 @@ async def trust_lookup( amount: str = "0", wallet_id: str | None = None, network: str | None = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: result = await self._client.trust.evaluate( recipient_address=recipient_address, amount=Decimal(str(amount)), @@ -534,7 +541,7 @@ async def trust_lookup( ) return {"trust_result": self._serialize(result)} - async def trust_set_policy(self, wallet_id: str, preset: str) -> Dict[str, Any]: + async def trust_set_policy(self, wallet_id: str, preset: str) -> dict[str, Any]: normalized = preset.strip().lower() if normalized == "permissive": policy = TrustPolicy.permissive() @@ -552,14 +559,14 @@ async def trust_set_policy(self, wallet_id: str, preset: str) -> Dict[str, Any]: "policy": self._serialize(policy), } - async def trust_get_policy(self, wallet_id: str | None = None) -> Dict[str, Any]: + async def trust_get_policy(self, wallet_id: str | None = None) -> dict[str, Any]: policy = self._client.trust.get_policy(wallet_id) return { "wallet_id": wallet_id, "policy": self._serialize(policy), } - async def ledger_get_entry(self, entry_id: str) -> Dict[str, Any]: + async def ledger_get_entry(self, entry_id: str) -> dict[str, Any]: entry = await self._client.ledger.get(entry_id) return {"entry": self._serialize(entry)} @@ -573,7 +580,7 @@ async def ledger_query( from_date: str | None = None, to_date: str | None = None, limit: int = 100, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: parsed_entry_type = LedgerEntryType(entry_type.lower()) if entry_type else None parsed_status = LedgerEntryStatus(status.lower()) if status else None parsed_from_date = datetime.fromisoformat(from_date) if from_date else None @@ -591,10 +598,10 @@ async def ledger_query( ) return {"entries": self._serialize(entries)} - async def can_pay(self, recipient: str) -> Dict[str, Any]: + async def can_pay(self, recipient: str) -> dict[str, Any]: return {"recipient": recipient, "can_pay": self._client.can_pay(recipient)} - async def detect_method(self, recipient: str) -> Dict[str, Any]: + async def detect_method(self, recipient: str) -> dict[str, Any]: method = self._client.detect_method(recipient) return { "recipient": recipient, @@ -606,9 +613,9 @@ async def verify_webhook_signature( payload_body: str, signature_header: str, endpoint_secret: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.webhooks.parser import WebhookParser - + parser = WebhookParser(endpoint_secret) is_valid = parser.verify_signature( payload=payload_body, @@ -621,9 +628,9 @@ async def handle_webhook( payload_body: str, signature_header: str, endpoint_secret: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: from omniclaw.webhooks.parser import WebhookParser - + parser = WebhookParser(endpoint_secret) event = parser.handle( payload=payload_body, diff --git a/mcp_server/app/payments/providers.py b/mcp_server/app/payments/providers.py index cf049ea..1506bd5 100644 --- a/mcp_server/app/payments/providers.py +++ b/mcp_server/app/payments/providers.py @@ -1,22 +1,38 @@ from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any + class PaymentProvider(ABC): @abstractmethod - async def initiate_transfer(self, amount: float, currency: str, destination: str) -> Dict[str, Any]: + async def initiate_transfer( + self, amount: float, currency: str, destination: str + ) -> dict[str, Any]: pass + class DirectTransferProvider(PaymentProvider): - async def initiate_transfer(self, amount: float, currency: str, destination: str) -> Dict[str, Any]: + async def initiate_transfer( + self, amount: float, currency: str, destination: str + ) -> dict[str, Any]: # Logic for direct bank/wallet transfer return {"provider": "direct", "status": "initiated", "destination": destination} + class X402Provider(PaymentProvider): - async def initiate_transfer(self, amount: float, currency: str, destination: str) -> Dict[str, Any]: + async def initiate_transfer( + self, amount: float, currency: str, destination: str + ) -> dict[str, Any]: # Logic for x402 / HTTP 402 Payment Required protocol - return {"provider": "x402", "status": "awaiting_payment_receipt", "destination": destination} + return { + "provider": "x402", + "status": "awaiting_payment_receipt", + "destination": destination, + } + class CircleProvider(PaymentProvider): - async def initiate_transfer(self, amount: float, currency: str, destination: str) -> Dict[str, Any]: + async def initiate_transfer( + self, amount: float, currency: str, destination: str + ) -> dict[str, Any]: # Circle API integration return {"provider": "circle", "status": "processing", "destination": destination} diff --git a/mcp_server/app/payments/service.py b/mcp_server/app/payments/service.py index 792ed32..54eb72a 100644 --- a/mcp_server/app/payments/service.py +++ b/mcp_server/app/payments/service.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from typing import Any, Dict +from typing import Any import structlog from pydantic import BaseModel, Field, field_validator @@ -38,7 +38,7 @@ class PaymentOrchestrator: def __init__(self, client: AbstractPaymentClient): self.client = client - async def pay(self, request_data: Dict[str, Any]) -> Dict[str, Any]: + async def pay(self, request_data: dict[str, Any]) -> dict[str, Any]: try: req = PaymentRequest(**request_data) except Exception as exc: diff --git a/mcp_server/app/utils/exceptions.py b/mcp_server/app/utils/exceptions.py index 1c14b19..ca110d2 100644 --- a/mcp_server/app/utils/exceptions.py +++ b/mcp_server/app/utils/exceptions.py @@ -1,31 +1,37 @@ from fastapi import HTTPException, status + class MCPException(HTTPException): def __init__(self, detail: str, status_code: int = status.HTTP_400_BAD_REQUEST): super().__init__(status_code=status_code, detail=detail) + class PaymentError(MCPException): pass + class GuardValidationError(PaymentError): def __init__(self, detail: str): super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + class BudgetExceededError(GuardValidationError): def __init__(self, reason: str): super().__init__(f"Budget Violation: {reason}") + class UnauthorizedRecipientError(GuardValidationError): def __init__(self, recipient: str): super().__init__(f"Unauthorized Recipient: {recipient}") + class RateLimitExceededError(GuardValidationError): def __init__(self): super().__init__("Rate limit exceeded for autonomous payments") + class WalletNotFoundError(MCPException): def __init__(self, wallet_id: str): super().__init__( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Wallet {wallet_id} not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"Wallet {wallet_id} not found" ) diff --git a/mcp_server/app/webhooks/circle.py b/mcp_server/app/webhooks/circle.py index 060fa72..af198fb 100644 --- a/mcp_server/app/webhooks/circle.py +++ b/mcp_server/app/webhooks/circle.py @@ -1,14 +1,16 @@ -import structlog -from fastapi import APIRouter, Request, HTTPException, Header -from typing import Dict, Any +from typing import Any import httpx +import structlog +from fastapi import APIRouter, Header, HTTPException, Request + from app.core.config import settings from omniclaw.webhooks.parser import InvalidSignatureError, WebhookParser router = APIRouter() logger = structlog.get_logger(__name__) + async def _fetch_circle_public_key_by_id(key_id: str) -> str | None: """Fetch webhook public key from Circle using X-Circle-Key-Id.""" circle_key = settings.CIRCLE_API_KEY @@ -32,6 +34,7 @@ async def _fetch_circle_public_key_by_id(key_id: str) -> str | None: except Exception: return None + async def verify_circle_signature( request: Request, signature: str, @@ -69,6 +72,7 @@ async def verify_circle_signature( raise HTTPException(status_code=401, detail=f"Invalid webhook signature: {exc}") from exc return True + @router.post("/circle") async def circle_webhook( request: Request, @@ -85,7 +89,7 @@ async def circle_webhook( key_id=x_circle_key_id, timestamp=x_circle_timestamp, ) - + payload = await request.json() event_type = payload.get("type") logger.info("circle_webhook_received", event_type=event_type, payload=payload) @@ -106,17 +110,20 @@ async def circle_webhook( logger.error("webhook_processing_failed", error=str(e), event_type=event_type) raise HTTPException(status_code=500, detail="Webhook processing failed") -async def handle_payment_sent(payload: Dict[str, Any]): + +async def handle_payment_sent(payload: dict[str, Any]): """Handle payment sent event.""" logger.info("handling_payment_sent", data=payload) # implementation details... -async def handle_payment_received(payload: Dict[str, Any]): + +async def handle_payment_received(payload: dict[str, Any]): """Handle payment received event.""" logger.info("handling_payment_received", data=payload) # implementation details... -async def handle_transaction_failed(payload: Dict[str, Any]): + +async def handle_transaction_failed(payload: dict[str, Any]): """Handle transaction failed event.""" logger.info("handling_transaction_failed", data=payload) # implementation details... diff --git a/mcp_server/start.py b/mcp_server/start.py index 9ce97f4..dc8ce33 100644 --- a/mcp_server/start.py +++ b/mcp_server/start.py @@ -5,7 +5,6 @@ import uvicorn - if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) host = os.environ.get("HOST", "0.0.0.0") diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py index 33e0f1c..55b02a8 100644 --- a/mcp_server/tests/conftest.py +++ b/mcp_server/tests/conftest.py @@ -5,6 +5,7 @@ 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" os.environ["ENTITY_SECRET"] = "test-secret" diff --git a/mcp_server/tests/test_payment_client.py b/mcp_server/tests/test_payment_client.py index a625d31..5e4fc1f 100644 --- a/mcp_server/tests/test_payment_client.py +++ b/mcp_server/tests/test_payment_client.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from app.payments.omniclaw_client import OmniclawPaymentClient @@ -60,7 +59,7 @@ async def test_execute_payment(payment_client, mock_sdk_client): "guards_passed": [], "error": None, "metadata": {}, - "resource_data": None + "resource_data": None, } mock_sdk_client.pay = AsyncMock(return_value=payment_result) diff --git a/mcp_server/tests/test_tools.py b/mcp_server/tests/test_tools.py index 9ee85f6..468c728 100644 --- a/mcp_server/tests/test_tools.py +++ b/mcp_server/tests/test_tools.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock, patch import pytest - from app.mcp.fastmcp_server import ( check_balance, create_agent_wallet, @@ -80,8 +79,6 @@ async def test_create_payment_intent_tool(mock_client): assert result["intent"]["id"] == "intent-1" - - @pytest.mark.asyncio async def test_detect_payment_method_tool(mock_client): mock_client.detect_method.return_value = { @@ -93,5 +90,3 @@ async def test_detect_payment_method_tool(mock_client): assert result["status"] == "success" assert result["payment_method"] == "transfer" - - diff --git a/scripts/x402_simple_server.py b/scripts/x402_simple_server.py index 774992d..983bc97 100644 --- a/scripts/x402_simple_server.py +++ b/scripts/x402_simple_server.py @@ -3,25 +3,27 @@ Implements the x402 protocol (402 Payment Required) for testing. """ -import uvicorn -from fastapi import FastAPI, Header, HTTPException, Request, Response -from fastapi.responses import JSONResponse -from decimal import Decimal -import uuid import time +import uuid + +import uvicorn +from fastapi import FastAPI, Header, Request, Response app = FastAPI() # In-memory store for paid requests (just for testing idempotency logic) PAID_REQUESTS = {} + @app.get("/weather") async def get_weather(request: Request, authorization: str = Header(None)): if not authorization or not authorization.startswith("x402 "): return Response( status_code=402, headers={ - "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + str(uuid.uuid4()) + '"', + "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + + str(uuid.uuid4()) + + '"', "x402-amount": "1000", "x402-token": "USDC", }, @@ -29,13 +31,16 @@ async def get_weather(request: Request, authorization: str = Header(None)): ) return {"weather": "sunny", "temperature": 25} + @app.get("/premium-content") async def get_premium(request: Request, authorization: str = Header(None)): if not authorization or not authorization.startswith("x402 "): return Response( status_code=402, headers={ - "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + str(uuid.uuid4()) + '"', + "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + + str(uuid.uuid4()) + + '"', "x402-amount": "10000", "x402-token": "USDC", }, @@ -43,6 +48,7 @@ async def get_premium(request: Request, authorization: str = Header(None)): ) return {"content": "Ultra secret data πŸ’Ž"} + @app.post("/x402/facilitator") async def facilitator(request: Request): data = await request.json() @@ -54,5 +60,6 @@ async def facilitator(request: Request): "facilitator_sig": "mock_signature_0x123", } + if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/omniclaw/__init__.py b/src/omniclaw/__init__.py index ad04a31..3dfe3c4 100644 --- a/src/omniclaw/__init__.py +++ b/src/omniclaw/__init__.py @@ -115,9 +115,6 @@ InvalidSignatureError, KeyNotFoundError, MiddlewareError, - NanoKeyStore, - # Vault & Keys - NanoKeyVault, # Adapter NanopaymentAdapter, # Client @@ -214,9 +211,6 @@ # Client "NanopaymentClient", "NanopaymentHTTPClient", - # Vault & Keys - "NanoKeyVault", - "NanoKeyStore", # Adapter "NanopaymentAdapter", "NanopaymentProtocolAdapter", diff --git a/src/omniclaw/agent/auth.py b/src/omniclaw/agent/auth.py index ffddfdb..87ea75f 100644 --- a/src/omniclaw/agent/auth.py +++ b/src/omniclaw/agent/auth.py @@ -17,6 +17,9 @@ class AuthenticatedAgent: token: str wallet_id: str + signer: object | None = None + config: object | None = None + network: str | None = None class TokenAuth: diff --git a/src/omniclaw/agent/models.py b/src/omniclaw/agent/models.py index 8350af3..d8f35c2 100644 --- a/src/omniclaw/agent/models.py +++ b/src/omniclaw/agent/models.py @@ -33,6 +33,7 @@ class PayResponse(BaseModel): method: str error: str | None = None requires_confirmation: bool = False + confirmation_id: str | None = None class BalanceResponse(BaseModel): @@ -135,6 +136,8 @@ class AddressResponse(BaseModel): wallet_id: str alias: str address: str + eoa_address: str | None = None + circle_wallet_address: str | None = None class WalletInfo(BaseModel): @@ -171,6 +174,7 @@ class X402PayRequest(BaseModel): """X402 Payment request.""" url: str = Field(..., description="x402 Service URL") + amount: str | None = Field(None, description="Payment amount in USDC (default: 0.01)") method: str = Field("GET", description="HTTP method") body: str | None = Field(None, description="Request body") headers: dict[str, str] | None = Field(None, description="Request headers") diff --git a/src/omniclaw/agent/policy.py b/src/omniclaw/agent/policy.py index e9c6b2c..41fcc7b 100644 --- a/src/omniclaw/agent/policy.py +++ b/src/omniclaw/agent/policy.py @@ -3,18 +3,221 @@ from __future__ import annotations import asyncio +import contextlib import json import os +import re from dataclasses import dataclass, field +from datetime import datetime, time from decimal import Decimal from pathlib import Path from typing import Any +from omniclaw.agent.policy_schema import RecipientMode, validate_policy from omniclaw.core.logging import get_logger logger = get_logger(__name__) +@dataclass +class TimeWindow: + """Time window for allowed transactions.""" + + start: str = "00:00" # HH:MM format + end: str = "23:59" + + @classmethod + def from_dict(cls, data: dict | None) -> TimeWindow: + if not data: + return cls() + return cls(start=data.get("start", "00:00"), end=data.get("end", "23:59")) + + def is_allowed(self) -> bool: + """Check if current time is within window.""" + now = datetime.now().time() + start = time.fromisoformat(self.start) + end = time.fromisoformat(self.end) + if start <= end: + return start <= now <= end + return now >= start or now <= end + + +@dataclass +class TimeRestrictions: + """Time-based restrictions for payments.""" + + allowed_days: list[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5, 6]) # 0=Mon, 6=Sun + allowed_hours: TimeWindow = field(default_factory=TimeWindow) + timezone: str = "UTC" + + @classmethod + def from_dict(cls, data: dict | None) -> TimeRestrictions: + if not data: + return cls() + return cls( + allowed_days=data.get("allowed_days", [0, 1, 2, 3, 4, 5, 6]), + allowed_hours=TimeWindow.from_dict(data.get("allowed_hours")), + timezone=data.get("timezone", "UTC"), + ) + + def is_allowed(self) -> tuple[bool, str]: + """Check if current time is allowed.""" + import datetime + + now = datetime.datetime.now() + weekday = now.weekday() + + if weekday not in self.allowed_days: + return False, f"Payments not allowed on day {weekday}" + + if not self.allowed_hours.is_allowed(): + return ( + False, + f"Payments only allowed between {self.allowed_hours.start} and {self.allowed_hours.end}", + ) + + return True, "" + + +@dataclass +class IPRestrictions: + """IP-based restrictions for API access.""" + + allowed_ips: list[str] = field(default_factory=list) + blocked_ips: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict | None) -> IPRestrictions: + if not data: + return cls() + return cls( + allowed_ips=data.get("allowed_ips", []), + blocked_ips=data.get("blocked_ips", []), + ) + + def is_allowed(self, ip: str) -> tuple[bool, str]: + """Check if IP is allowed.""" + if self.blocked_ips and ip in self.blocked_ips: + return False, f"IP {ip} is blocked" + + if self.allowed_ips and ip not in self.allowed_ips: + return False, f"IP {ip} is not in allowed list" + + return True, "" + + +@dataclass +class CategoryConfig: + """Payment category restrictions.""" + + allowed_categories: list[str] = field(default_factory=list) + blocked_categories: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict | None) -> CategoryConfig: + if not data: + return cls() + return cls( + allowed_categories=data.get("allowed_categories", []), + blocked_categories=data.get("blocked_categories", []), + ) + + def is_allowed(self, category: str) -> tuple[bool, str]: + """Check if category is allowed.""" + if self.blocked_categories and category in self.blocked_categories: + return False, f"Category {category} is blocked" + + if self.allowed_categories and category not in self.allowed_categories: + return False, f"Category {category} is not in allowed list" + + return True, "" + + +@dataclass +class NetworkConfig: + """Network/chain restrictions.""" + + allowed_networks: list[str] = field(default_factory=list) + blocked_networks: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict | None) -> NetworkConfig: + if not data: + return cls() + return cls( + allowed_networks=data.get("allowed_networks", []), + blocked_networks=data.get("blocked_networks", []), + ) + + def is_allowed(self, network: str) -> tuple[bool, str]: + """Check if network is allowed.""" + if self.blocked_networks and network in self.blocked_networks: + return False, f"Network {network} is blocked" + + if self.allowed_networks and network not in self.allowed_networks: + return False, f"Network {network} is not in allowed list" + + return True, "" + + +@dataclass +class PurposeConfig: + """Payment purpose pattern matching.""" + + pattern: str | None = None + required_tags: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict | None) -> PurposeConfig: + if not data: + return cls() + return cls( + pattern=data.get("pattern"), + required_tags=data.get("required_tags", []), + ) + + def is_allowed(self, purpose: str | None, tags: list[str] | None = None) -> tuple[bool, str]: + """Check if purpose/tags are allowed.""" + if self.pattern and purpose and not re.match(self.pattern, purpose): + return False, f"Purpose '{purpose}' does not match pattern {self.pattern}" + + if self.required_tags: + if not tags: + return False, f"Tags required: {self.required_tags}" + for required in self.required_tags: + if required not in tags: + return False, f"Required tag '{required}' not found" + + return True, "" + + +@dataclass +class TrustConfig: + """Trust score requirements.""" + + min_trust_score: float | None = None + require_trust_verified: bool = False + + @classmethod + def from_dict(cls, data: dict | None) -> TrustConfig: + if not data: + return cls() + return cls( + min_trust_score=data.get("min_trust_score"), + require_trust_verified=data.get("require_trust_verified", False), + ) + + def is_allowed(self, trust_score: float | None, verified: bool) -> tuple[bool, str]: + """Check if trust requirements are met.""" + if self.require_trust_verified and not verified: + return False, "Recipient must be trust verified" + + if self.min_trust_score and (trust_score is None or trust_score < self.min_trust_score): + return False, f"Trust score {trust_score} below minimum {self.min_trust_score}" + + return True, "" + + @dataclass class WalletLimits: """Spending limits for a wallet.""" @@ -57,7 +260,7 @@ def from_dict(cls, data: dict | None) -> RateLimits: class RecipientConfig: """Recipient whitelist/blacklist configuration.""" - mode: str = "whitelist" # "whitelist" or "blacklist" + mode: str = "allow_all" # "allow_all", "whitelist" or "blacklist" addresses: list[str] = field(default_factory=list) domains: list[str] = field(default_factory=list) @@ -66,7 +269,7 @@ def from_dict(cls, data: dict | None) -> RecipientConfig: if not data: return cls() return cls( - mode=data.get("mode", "whitelist"), + mode=data.get("mode", "allow_all"), addresses=data.get("addresses", []), domains=data.get("domains", []), ) @@ -84,6 +287,30 @@ class Policy: recipients: RecipientConfig = field(default_factory=RecipientConfig) confirm_threshold: Decimal | None = None + def to_dict(self) -> dict: + """Convert policy to dict for saving.""" + return { + "version": self.version, + "tokens": self.tokens, + "wallets": self.wallets, + "limits": { + "daily_max": str(self.limits.daily_max) if self.limits.daily_max else None, + "hourly_max": str(self.limits.hourly_max) if self.limits.hourly_max else None, + "per_tx_max": str(self.limits.per_tx_max) if self.limits.per_tx_max else None, + "per_tx_min": str(self.limits.per_tx_min) if self.limits.per_tx_min else None, + }, + "rate_limits": { + "per_minute": self.rate_limits.per_minute, + "per_hour": self.rate_limits.per_hour, + }, + "recipients": { + "mode": self.recipients.mode, + "addresses": self.recipients.addresses, + "domains": self.recipients.domains, + }, + "confirm_threshold": str(self.confirm_threshold) if self.confirm_threshold else None, + } + @classmethod def from_dict(cls, data: dict | None) -> Policy: if not data: @@ -112,42 +339,141 @@ def __init__(self, policy_path: str | None = None): self._token_to_wallet_id: dict[str, str] = {} self._wallet_id_to_config: dict[str, dict[str, Any]] = {} self._logger = logger + self._last_mtime: float | None = None async def load(self) -> Policy: """Load policy from file.""" path = Path(self._policy_path) if not path.exists(): - self._logger.warning(f"Policy file not found: {self._policy_path}, using empty policy") - self._policy = 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") + wallet_alias = os.environ.get("OMNICLAW_AGENT_WALLET", "primary") + # Create default policy with default token and wallet + self._policy = Policy( + version="2.0", + tokens={ + env_token: { + "wallet_alias": wallet_alias, + "active": True, + "label": "Default Agent", + } + }, + wallets={ + wallet_alias: { + "name": "Primary Wallet", + "limits": {"daily_max": "100.00", "per_tx_max": "10.00"}, + "recipients": {"mode": "allow_all"}, + } + }, + ) + # 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._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) - self._policy = Policy.from_dict(data) + # Strict schema validation + validated = validate_policy(data) + self._policy = Policy.from_dict(validated.model_dump()) + self._last_mtime = path.stat().st_mtime self._logger.info("Loaded agent economy policy configuration.") except Exception as e: - self._logger.error(f"Failed to load policy: {e}, using empty policy") - self._policy = Policy() - + self._logger.error(f"Failed to load policy: {e}") + raise return self._policy + def save(self) -> None: + """Persist current policy to disk.""" + if not self._policy: + return + path = Path(self._policy_path) + 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) + except Exception as e: + self._logger.warning(f"Failed to save policy: {e}") + def get_token_map(self) -> dict[str, dict[str, Any]]: return self._policy.tokens if self._policy else {} def get_wallet_map(self) -> dict[str, dict[str, Any]]: return self._policy.wallets if self._policy else {} + def update_wallet_config(self, alias: str, updates: dict[str, Any]) -> None: + """Update wallet config in policy for a given alias.""" + if not self._policy: + self._policy = Policy() + wallet_cfg = self._policy.wallets.get(alias, {}) + wallet_cfg.update(updates) + self._policy.wallets[alias] = wallet_cfg + def set_mapping(self, token: str, wallet_id: str, config: dict[str, Any]) -> None: self._token_to_wallet_id[token] = wallet_id self._wallet_id_to_config[wallet_id] = config + def reset_mappings(self) -> None: + self._token_to_wallet_id = {} + self._wallet_id_to_config = {} + def get_wallet_id_for_token(self, token: str) -> str | None: return self._token_to_wallet_id.get(token) def get_policy(self) -> Policy: return self._policy or Policy() + def get_wallet_config(self, wallet_id: str | None) -> dict[str, Any]: + """Get cached wallet config for a given wallet_id.""" + if not wallet_id: + return {} + return self._wallet_id_to_config.get(wallet_id, {}) + + def has_changed(self) -> bool: + path = Path(self._policy_path) + if not path.exists(): + return False + if self._last_mtime is None: + return True + try: + return path.stat().st_mtime > self._last_mtime + except Exception: + return False + + async def reload(self) -> bool: + """Reload policy if changed. Returns True on success.""" + if not self.has_changed(): + return False + try: + await self.load() + self.reset_mappings() + self._logger.info("Policy reloaded successfully.") + return True + except Exception as e: + self._logger.error(f"Policy reload failed: {e}") + return False + def is_valid_recipient(self, recipient: str, wallet_id: str | None = None) -> bool: """Check if recipient is allowed.""" if wallet_id is None: @@ -156,6 +482,9 @@ def is_valid_recipient(self, recipient: str, wallet_id: str | None = None) -> bo config = self._wallet_id_to_config.get(wallet_id, {}) recipient_cfg = RecipientConfig.from_dict(config.get("recipients")) + if recipient_cfg.mode == RecipientMode.ALLOW_ALL.value: + return True + if not recipient_cfg.addresses and not recipient_cfg.domains: return True @@ -202,54 +531,173 @@ def __init__(self, policy_manager: PolicyManager, omniclaw_client: Any): async def initialize_wallets(self) -> dict[str, str]: """Initialize all wallets defined in the policy mapping (Parallel).""" + self._policy.reset_mappings() token_map = self._policy.get_token_map() wallet_map = self._policy.get_wallet_map() + # If no tokens in policy, nothing to initialize if not token_map: self._logger.info("No tokens defined in policy, skipping initialization") return {} - # PHASE 1: Pre-populate token map with placeholder + # Build alias -> tokens mapping + alias_to_tokens: dict[str, list[str]] = {} for token, config in token_map.items(): alias = config.get("wallet_alias", "primary") - self._policy.set_mapping(token, f"pending-{alias}", wallet_map.get(alias, {})) + alias_to_tokens.setdefault(alias, []).append(token) - # PHASE 2: Perform the intensive SDK/Network calls in PARALLEL - async def init_one(token: str, config: dict[str, Any]) -> tuple[str, str | None]: - alias = config.get("wallet_alias", "primary") - wallet_cfg = wallet_map.get(alias, {}) - try: - # 10/10 RESILIENCE: Handle background wallet creation - res = await self._client.create_agent_wallet( - agent_name=f"omniclaw-{alias}", - apply_default_guards=False, - ) + # PHASE 1: Pre-populate token map with placeholder + for alias, tokens in alias_to_tokens.items(): + for token in tokens: + self._policy.set_mapping(token, f"pending-{alias}", wallet_map.get(alias, {})) - # SDK might return (wallet_set, wallet) or just wallet depending on version - if isinstance(res, (tuple, list)): - _, wallet = res - else: - wallet = res + changed = False + results: dict[str, str] = {} - self._policy.set_mapping(token, wallet.id, wallet_cfg) - self._logger.info( - f"Successfully initialized wallet '{wallet.id}' for agent '{alias}'" - ) - return token, wallet.id - except Exception as e: - self._logger.error(f"Failed to initialize wallet for '{alias}': {e}") - return token, None + # PHASE 2: Ensure each alias has a Circle wallet id + address + async def init_alias(alias: str) -> tuple[str, str | None, str | None]: + wallet_cfg = wallet_map.get(alias) + if wallet_cfg is None: + wallet_cfg = {} + self._policy.update_wallet_config(alias, wallet_cfg) - # Gather all parallel tasks - tasks = [init_one(token, config) for token, config in token_map.items()] - batch_results = await asyncio.gather(*tasks) + wallet_id = wallet_cfg.get("wallet_id") + wallet_address = wallet_cfg.get("address") - results = {} - for token, wallet_id in batch_results: + # If wallet_id exists, verify and fill address if missing if wallet_id: + try: + wallet = await self._client.get_wallet(wallet_id) + 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 + + # 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)): + _, wallet = res + else: + wallet = res + wallet_id = wallet.id + wallet_address = wallet.address + return alias, wallet_id, wallet_address + + # Run per-alias initialization in parallel + tasks = [init_alias(alias) for alias in alias_to_tokens] + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in batch_results: + if isinstance(result, Exception): + self._logger.error(f"Failed to initialize wallet: {result}") + continue + alias, wallet_id, wallet_address = result + 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 + # Map all tokens sharing this alias + for token in alias_to_tokens.get(alias, []): + self._policy.set_mapping(token, wallet_id, wallet_map.get(alias, {})) results[token] = wallet_id + self._logger.info(f"Initialized wallet '{wallet_id}' for alias '{alias}'") + + # Apply policy guards to this wallet + try: + await self._apply_policy_guards(wallet_id, wallet_map.get(alias, {})) + except Exception as e: + self._logger.error(f"Failed to apply policy guards for wallet '{wallet_id}': {e}") + + if changed: + self._policy.save() + return results + async def _apply_policy_guards(self, wallet_id: str, wallet_cfg: dict[str, Any]) -> None: + """Apply policy.json guard configuration to a wallet.""" + policy = self._policy.get_policy() + + # Clear existing policy guards to avoid duplicates + with contextlib.suppress(Exception): + await self._client._guard_manager.clear_wallet_guards(wallet_id) + + # Resolve limits: wallet overrides policy defaults + base_limits = policy.limits + wallet_limits = WalletLimits.from_dict(wallet_cfg.get("limits")) + 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 + + if daily_max or hourly_max: + await self._client.add_budget_guard( + wallet_id=wallet_id, + daily_limit=str(daily_max) if daily_max else None, + hourly_limit=str(hourly_max) if hourly_max else None, + name="policy_budget", + ) + + if per_tx_max or per_tx_min: + max_amount = per_tx_max if per_tx_max else Decimal("1e18") + await self._client.add_single_tx_guard( + wallet_id=wallet_id, + max_amount=str(max_amount), + min_amount=str(per_tx_min) if per_tx_min else None, + name="policy_single_tx", + ) + + # Rate limits + base_rate = policy.rate_limits + wallet_rate = RateLimits.from_dict(wallet_cfg.get("rate_limits")) + per_minute = wallet_rate.per_minute or base_rate.per_minute + per_hour = wallet_rate.per_hour or base_rate.per_hour + if per_minute or per_hour: + await self._client.add_rate_limit_guard( + wallet_id=wallet_id, + max_per_minute=per_minute, + max_per_hour=per_hour, + name="policy_rate_limit", + ) + + # Recipients + wallet_recipients = wallet_cfg.get("recipients") + if wallet_recipients is not None: + rcfg = RecipientConfig.from_dict(wallet_recipients) + else: + rcfg = policy.recipients + + if rcfg.mode != RecipientMode.ALLOW_ALL.value: + await self._client.add_recipient_guard( + wallet_id=wallet_id, + mode=rcfg.mode, + addresses=rcfg.addresses, + domains=rcfg.domains, + name="policy_recipient", + ) + + # Confirm threshold + threshold = ( + Decimal(str(wallet_cfg.get("confirm_threshold"))) + if wallet_cfg.get("confirm_threshold") is not None + else policy.confirm_threshold + ) + if threshold and threshold > 0: + await self._client.add_confirm_guard( + wallet_id=wallet_id, + threshold=str(threshold), + always_confirm=False, + name="policy_confirm", + ) + async def get_wallet_address(self, wallet_id: str | None = None) -> str | None: """Get wallet address.""" if not wallet_id: diff --git a/src/omniclaw/agent/policy_schema.py b/src/omniclaw/agent/policy_schema.py new file mode 100644 index 0000000..3e9e8e9 --- /dev/null +++ b/src/omniclaw/agent/policy_schema.py @@ -0,0 +1,104 @@ +"""Strict policy schema validation for agent policy.json.""" + +from __future__ import annotations + +from decimal import Decimal +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + + +class RecipientMode(str, Enum): + ALLOW_ALL = "allow_all" + WHITELIST = "whitelist" + BLACKLIST = "blacklist" + + +class LimitsModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + daily_max: Decimal | None = None + hourly_max: Decimal | None = None + per_tx_max: Decimal | None = None + per_tx_min: Decimal | None = None + + +class RateLimitsModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + per_minute: int | None = None + per_hour: int | None = None + + +class RecipientConfigModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: RecipientMode = RecipientMode.ALLOW_ALL + addresses: list[str] = Field(default_factory=list) + domains: list[str] = Field(default_factory=list) + + +class TokenConfigModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + wallet_alias: str + active: bool = True + label: str | None = None + + +class WalletConfigModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + wallet_id: str | None = None # auto-generated if missing + address: str | None = None # auto-filled if missing + limits: LimitsModel | None = None + rate_limits: RateLimitsModel | None = None + recipients: RecipientConfigModel | None = None + confirm_threshold: Decimal | None = None + + @field_validator("address") + @classmethod + def _validate_address(cls, value: str | None) -> str | None: + if value is None: + return value + if not value.startswith("0x") or len(value) != 42: + raise ValueError("wallet address must be a 0x-prefixed 40-hex string") + return value + + +class PolicySchema(BaseModel): + """Strict schema for policy.json.""" + + model_config = ConfigDict(extra="forbid") + + version: str = "2.0" + tokens: dict[str, TokenConfigModel] + wallets: dict[str, WalletConfigModel] + limits: LimitsModel | None = None + rate_limits: RateLimitsModel | None = None + recipients: RecipientConfigModel | None = None + confirm_threshold: Decimal | None = None + + @model_validator(mode="after") + def _validate_aliases(self) -> PolicySchema: + if not self.tokens: + raise ValueError("policy.tokens must not be empty") + if not self.wallets: + raise ValueError("policy.wallets must not be empty") + for token, cfg in self.tokens.items(): + if cfg.wallet_alias not in self.wallets: + raise ValueError( + f"token '{token}' references missing wallet_alias '{cfg.wallet_alias}'" + ) + return self + + +def validate_policy(data: dict[str, Any]) -> PolicySchema: + """Validate raw policy JSON and return normalized policy model.""" + try: + return PolicySchema.model_validate(data) + except ValidationError as exc: + # Re-raise with a simpler message for operator logs + raise ValueError(f"Invalid policy.json: {exc}") from exc diff --git a/src/omniclaw/agent/routes.py b/src/omniclaw/agent/routes.py index 5d9f0d3..edcb9c2 100644 --- a/src/omniclaw/agent/routes.py +++ b/src/omniclaw/agent/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from decimal import Decimal from typing import TYPE_CHECKING @@ -29,6 +30,7 @@ ) from omniclaw.agent.policy import PolicyManager, WalletManager from omniclaw.core.logging import get_logger +from omniclaw.guards.confirmations import ConfirmationStore if TYPE_CHECKING: from omniclaw import OmniClaw @@ -64,6 +66,16 @@ async def get_current_agent( return await auth.authenticate(credentials) +async def require_owner(request: Request) -> None: + """Require owner token for privileged actions.""" + expected = os.environ.get("OMNICLAW_OWNER_TOKEN") + if not expected: + raise HTTPException(status_code=500, detail="OMNICLAW_OWNER_TOKEN not configured") + provided = request.headers.get("X-Omniclaw-Owner-Token") + if provided != expected: + raise HTTPException(status_code=403, detail="Invalid owner token") + + @router.get("/health", response_model=HealthResponse) async def health_check(): return HealthResponse(status="ok") @@ -73,6 +85,7 @@ async def health_check(): async def get_address( agent: AuthenticatedAgent = Depends(get_current_agent), wallet_mgr: WalletManager = Depends(get_wallet_manager), + client: OmniClaw = Depends(get_omniclaw_client), ): if agent.wallet_id.startswith("pending-"): raise HTTPException( @@ -80,7 +93,10 @@ async def get_address( detail="Wallet is currently initializing. Please try again in a few seconds.", ) - address = await wallet_mgr.get_wallet_address(agent.wallet_id) + eoa_address = client._nano_adapter.address if client._nano_adapter else None + circle_address = await wallet_mgr.get_wallet_address(agent.wallet_id) + address = eoa_address or circle_address + if not address: raise HTTPException(status_code=404, detail="Wallet not found") @@ -88,13 +104,45 @@ async def get_address( wallet_id=agent.wallet_id, alias="primary", address=address, + eoa_address=eoa_address, + circle_wallet_address=circle_address, ) +@router.get("/nano-address") +async def get_nano_address( + agent: AuthenticatedAgent = Depends(get_current_agent), + client: OmniClaw = Depends(get_omniclaw_client), +): + """Get or create nanopayment address for this agent.""" + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + try: + # Direct private key mode - return EOA address + if client._nano_adapter: + nano_addr = client._nano_adapter.address + else: + raise HTTPException( + status_code=500, + detail="Nanopayments not initialized (direct key required)", + ) + + return {"address": nano_addr, "wallet_id": agent.wallet_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get nano address: {e}") from e + + @router.get("/balance", response_model=BalanceResponse) async def get_balance( agent: AuthenticatedAgent = Depends(get_current_agent), wallet_mgr: WalletManager = Depends(get_wallet_manager), + client: OmniClaw = Depends(get_omniclaw_client), ): if agent.wallet_id.startswith("pending-"): raise HTTPException( @@ -102,15 +150,371 @@ async def get_balance( detail="Wallet is currently initializing. Please try again in a few seconds.", ) - balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) - if balance is None: - raise HTTPException(status_code=404, detail="Wallet not found") + if client._nano_adapter: + gateway_balance = await client.get_gateway_balance(agent.wallet_id) + available = gateway_balance.available_decimal + total = gateway_balance.total_decimal + reserved = None + else: + balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) + if balance is None: + raise HTTPException(status_code=404, detail="Wallet not found") + available = str(balance) + total = None + reserved = None return BalanceResponse( wallet_id=agent.wallet_id, - available=str(balance), + available=available, + total=total, + reserved=reserved, + ) + + +@router.get("/balance-detail") +async def get_detailed_balance( + agent: AuthenticatedAgent = Depends(get_current_agent), + wallet_mgr: WalletManager = Depends(get_wallet_manager), + client: OmniClaw = Depends(get_omniclaw_client), +): + """Get detailed balance including Gateway on-chain balance.""" + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + 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 + ) + + return { + "wallet_id": agent.wallet_id, + "eoa_address": eoa_address, + "gateway_balance": gateway_balance.available_decimal if gateway_balance else "0", + "circle_wallet_address": circle_address, + "circle_wallet_balance": str(circle_balance) if circle_balance is not None else "0", + } + + +@router.post("/deposit") +async def deposit_to_gateway( + amount: str = ..., + check_gas: bool = False, + skip_if_insufficient_gas: bool = True, + agent: AuthenticatedAgent = Depends(get_current_agent), + client: OmniClaw = Depends(get_omniclaw_client), +): + """ + Deposit USDC to Gateway wallet from EOA. + + This moves USDC from the agent's EOA into their Gateway balance. + Required before making/receiving nanopayments. + + Args: + amount: Amount in USDC (e.g., "10.00") + check_gas: Check gas balance before deposit + skip_if_insufficient_gas: Skip if not enough gas for deposit tx + """ + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + try: + result = await client.deposit_to_gateway( + wallet_id=agent.wallet_id, + amount_usdc=amount, + check_gas=check_gas, + skip_if_insufficient_gas=skip_if_insufficient_gas, + ) + + return { + "success": result.deposit_tx_hash is not None, + "amount_deposited": result.formatted_amount, + "approval_tx_hash": result.approval_tx_hash, + "deposit_tx_hash": result.deposit_tx_hash, + "message": "Deposited to Gateway" if result.deposit_tx_hash else "Deposit failed", + } + except Exception as e: + import traceback + + raise HTTPException(status_code=500, detail=f"{str(e)}\n{traceback.format_exc()}") from e + + +@router.post("/withdraw") +async def withdraw_from_gateway( + amount: str = ..., + destination_chain: str | None = None, + recipient: str | None = None, + agent: AuthenticatedAgent = Depends(get_current_agent), + policy_mgr: PolicyManager = Depends(get_policy_manager), + client: OmniClaw = Depends(get_omniclaw_client), +): + """ + Withdraw USDC from Gateway wallet via Circle API. + + Args: + amount: Amount in USDC (e.g., "1.00") + destination_chain: Optional CAIP-2 chain for cross-chain withdrawal + recipient: Optional destination address (defaults to own address) + """ + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + try: + if recipient is None: + wallet_cfg = policy_mgr.get_wallet_config(agent.wallet_id) + recipient = wallet_cfg.get("address") + if not recipient: + raise HTTPException( + status_code=400, + detail="No default withdrawal address in policy. Set wallets..address or pass recipient.", + ) + + result = await client.withdraw_from_gateway( + wallet_id=agent.wallet_id, + amount_usdc=amount, + destination_chain=destination_chain, + recipient=recipient, + ) + burn_tx_hash = getattr(result, "burn_tx_hash", None) + mint_tx_hash = getattr(result, "mint_tx_hash", None) + status = getattr(result, "status", None) or ("COMPLETED" if mint_tx_hash else "PENDING") + return { + "success": bool(mint_tx_hash), + "amount_withdrawn": result.formatted_amount, + "burn_tx_hash": burn_tx_hash, + "mint_tx_hash": mint_tx_hash, + "status": status, + "message": "Withdrawal initiated", + } + except Exception as e: + import traceback + + raise HTTPException(status_code=500, detail=f"{str(e)}\n{traceback.format_exc()}") from e + + +@router.post("/withdraw-trustless") +async def withdraw_trustless( + request: Request, + amount: str = ..., + agent: AuthenticatedAgent = Depends(get_current_agent), +): + """ + Initiate trustless withdrawal directly on-chain (~7-day delay). + + This bypasses Circle's API and withdraws directly to the agent's own address. + """ + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + try: + import os + from datetime import datetime, timedelta + + from omniclaw.core.types import network_to_caip2 + from omniclaw.protocols.nanopayments.client import NanopaymentClient + from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager + + private_key_str = os.environ.get("OMNICLAW_PRIVATE_KEY") + if not private_key_str: + raise HTTPException(status_code=500, detail="OMNICLAW_PRIVATE_KEY not configured") + + config = request.app.state.config if hasattr(request.app.state, "config") else {} + network = config.get("nanopay_network") or network_to_caip2( + os.environ.get("OMNICLAW_NETWORK", "ARC-TESTNET") + ) + rpc_url = config.get("rpc_url") or os.environ.get("OMNICLAW_RPC_URL") or "" + if not network or ":" not in network: + raise HTTPException( + status_code=500, + detail=( + "Invalid nanopayments network. Set OMNICLAW_NETWORK to an EVM chain " + "that maps to a CAIP-2 chain ID (e.g., ETH-SEPOLIA)." + ), + ) + if not rpc_url: + raise HTTPException(status_code=500, detail="OMNICLAW_RPC_URL not configured") + + nanopayment_client = NanopaymentClient( + api_key=os.environ.get("CIRCLE_API_KEY"), + ) + + manager = GatewayWalletManager( + private_key=private_key_str, + network=network, + rpc_url=rpc_url, + nanopayment_client=nanopayment_client, + ) + + delay_blocks = await manager.get_withdrawal_delay() + + delay_seconds = delay_blocks * 12 + available_after = datetime.now() + timedelta(seconds=delay_seconds) + + tx_hash = await manager.initiate_trustless_withdrawal(amount_usdc=amount) + + return { + "success": True, + "tx_hash": tx_hash, + "amount": amount, + "delay_blocks": delay_blocks, + "available_after": available_after.isoformat(), + "message": f"Trustless withdrawal initiated. Wait ~{delay_blocks} blocks before completing.", + } + except HTTPException: + raise + except Exception as e: + import traceback + + raise HTTPException(status_code=500, detail=f"{str(e)}\n{traceback.format_exc()}") from e + + +@router.post("/withdraw-trustless/complete") +async def complete_trustless_withdrawal( + request: Request, + agent: AuthenticatedAgent = Depends(get_current_agent), +): + """ + Complete a trustless withdrawal after the delay has passed. + """ + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + try: + import os + + from omniclaw.core.types import network_to_caip2 + from omniclaw.protocols.nanopayments.client import NanopaymentClient + from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager + + private_key_str = os.environ.get("OMNICLAW_PRIVATE_KEY") + if not private_key_str: + raise HTTPException(status_code=500, detail="OMNICLAW_PRIVATE_KEY not configured") + + config = request.app.state.config if hasattr(request.app.state, "config") else {} + network = config.get("nanopay_network") or network_to_caip2( + os.environ.get("OMNICLAW_NETWORK", "ARC-TESTNET") + ) + rpc_url = config.get("rpc_url") or os.environ.get("OMNICLAW_RPC_URL") or "" + if not network or ":" not in network: + raise HTTPException( + status_code=500, + detail=( + "Invalid nanopayments network. Set OMNICLAW_NETWORK to an EVM chain " + "that maps to a CAIP-2 chain ID (e.g., ETH-SEPOLIA)." + ), + ) + if not rpc_url: + raise HTTPException(status_code=500, detail="OMNICLAW_RPC_URL not configured") + + nanopayment_client = NanopaymentClient( + api_key=os.environ.get("CIRCLE_API_KEY"), + ) + + manager = GatewayWalletManager( + private_key=private_key_str, + network=network, + rpc_url=rpc_url, + nanopayment_client=nanopayment_client, + ) + + current_block = manager._w3.eth.block_number + gateway_address = await manager._resolve_gateway_address() + usdc_address = await manager._resolve_usdc_address() + gateway = manager._get_gateway_contract(gateway_address) + withdrawal_block = gateway.functions.withdrawalBlock(usdc_address, manager._address).call() + + if withdrawal_block == 0: + raise HTTPException( + status_code=400, + detail="No withdrawal initiated. Call /withdraw-trustless first.", + ) + + if current_block < withdrawal_block: + blocks_remaining = withdrawal_block - current_block + raise HTTPException( + status_code=425, + detail=f"Withdrawal not ready. {blocks_remaining} blocks remaining.", + ) + + tx_hash = await manager.complete_trustless_withdrawal() + + return { + "success": True, + "tx_hash": tx_hash, + "message": "Trustless withdrawal completed.", + } + except HTTPException: + raise + except Exception as e: + import traceback + + raise HTTPException(status_code=500, detail=f"{str(e)}\n{traceback.format_exc()}") from e + + +@router.get("/deposit-address") +async def get_deposit_address( + request: Request, + agent: AuthenticatedAgent = Depends(get_current_agent), + wallet_mgr: WalletManager = Depends(get_wallet_manager), + client: OmniClaw = Depends(get_omniclaw_client), +): + """ + Get the EOA address for depositing USDC from external sources. + + This is the address to send USDC to from faucet or other wallets. + Then use /deposit to move it to Gateway, or it auto-deposits for nanopayments. + """ + if agent.wallet_id.startswith("pending-"): + raise HTTPException( + status_code=425, + detail="Wallet is currently initializing. Please try again in a few seconds.", + ) + + eoa_address = client._nano_adapter.address if client._nano_adapter else None + if not eoa_address: + raise HTTPException( + status_code=500, + detail="Nanopayments not initialized (direct key required)", + ) + + config = request.app.state.config if hasattr(request.app.state, "config") else {} + from omniclaw.core.types import network_to_caip2 + + network = config.get("nanopay_network") or network_to_caip2( + os.getenv("OMNICLAW_NETWORK", "ARC-TESTNET") ) + if not network: + raise HTTPException( + status_code=500, + detail=( + "Nanopayments network is not configured. Set OMNICLAW_NETWORK to an " + "EVM chain that maps to a CAIP-2 chain ID." + ), + ) + + return { + "address": eoa_address, + "network": network, + "instructions": "Send USDC to this address, then call /deposit to move to Gateway", + } + @router.post("/pay", response_model=PayResponse) async def pay( @@ -134,8 +538,6 @@ async def pay( if not allowed: raise HTTPException(status_code=400, detail=reason) - requires_confirmation = policy_mgr.requires_confirmation(amount, agent.wallet_id) - try: result = await client.pay( wallet_id=agent.wallet_id, @@ -149,6 +551,10 @@ async def pay( skip_guards=request.skip_guards, metadata=request.metadata, ) + requires_confirmation = bool( + result.metadata.get("confirmation_required") if result.metadata else False + ) + confirmation_id = result.metadata.get("confirmation_id") if result.metadata else None return PayResponse( success=result.success, @@ -156,10 +562,15 @@ async def pay( blockchain_tx=result.blockchain_tx, amount=str(result.amount), recipient=result.recipient, - status=result.status.value if hasattr(result.status, "value") else str(result.status), - method=result.method.value if hasattr(result.method, "value") else str(result.method), + 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"), error=result.error, requires_confirmation=requires_confirmation, + confirmation_id=confirmation_id, ) except Exception as e: logger.error(f"Payment failed: {e}") @@ -202,7 +613,9 @@ async def simulate( return SimulateResponse( would_succeed=result.would_succeed, - route=result.route.value if hasattr(result.route, "value") else str(result.route), + route=result.route.value + if result.route and hasattr(result.route, "value") + else str(result.route), reason=result.reason, guards_that_would_pass=result.guards_that_would_pass, ) @@ -225,11 +638,37 @@ async def list_transactions( TransactionInfo( id=tx.id, wallet_id=tx.wallet_id, - recipient=tx.recipient, - amount=str(tx.amount), - status=tx.status.value if hasattr(tx.status, "value") else str(tx.status), + recipient=( + getattr(tx, "recipient", None) + or getattr(tx, "destination_address", None) + or getattr(tx, "source_address", None) + or "" + ), + amount=str( + getattr(tx, "amount", None) + or (tx.amounts[0] if getattr(tx, "amounts", None) else "0") + ), + status=( + (tx.status.value if hasattr(tx.status, "value") else str(tx.status)) + if getattr(tx, "status", None) is not None + else ( + tx.state.value + if getattr(tx, "state", None) is not None and hasattr(tx.state, "value") + else ( + str(tx.state) + if getattr(tx, "state", None) is not None + else "failed" + ) + ) + ), tx_hash=tx.tx_hash, - created_at=tx.created_at.isoformat() if tx.created_at else None, + created_at=( + tx.created_at.isoformat() + if getattr(tx, "created_at", None) + else ( + tx.create_date.isoformat() if getattr(tx, "create_date", None) else None + ) + ), ) for tx in transactions ], @@ -271,7 +710,9 @@ async def create_intent( wallet_id=intent.wallet_id, recipient=intent.recipient, amount=str(intent.amount), - status=intent.status.value if hasattr(intent.status, "value") else str(intent.status), + status=intent.status.value + if intent.status and hasattr(intent.status, "value") + else (str(intent.status) if intent.status else "failed"), expires_at=intent.expires_at.isoformat() if intent.expires_at else None, ) except Exception as e: @@ -297,7 +738,9 @@ async def get_intent( wallet_id=intent.wallet_id, recipient=intent.recipient, amount=str(intent.amount), - status=intent.status.value if hasattr(intent.status, "value") else str(intent.status), + status=intent.status.value + if intent.status and hasattr(intent.status, "value") + else (str(intent.status) if intent.status else "failed"), expires_at=intent.expires_at.isoformat() if intent.expires_at else None, ) except HTTPException: @@ -328,8 +771,12 @@ async def confirm_intent( blockchain_tx=result.blockchain_tx, amount=str(result.amount), recipient=result.recipient, - status=result.status.value if hasattr(result.status, "value") else str(result.status), - method=result.method.value if hasattr(result.method, "value") else str(result.method), + 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"), error=result.error, ) except HTTPException: @@ -370,6 +817,45 @@ async def cancel_intent( raise HTTPException(status_code=400, detail=str(e)) from e +@router.get("/confirmations/{confirmation_id}") +async def get_confirmation( + confirmation_id: str, + _: None = Depends(require_owner), + client: OmniClaw = Depends(get_omniclaw_client), +): + store = ConfirmationStore(client._storage) + record = await store.get(confirmation_id) + if not record: + raise HTTPException(status_code=404, detail="Confirmation not found") + return record + + +@router.post("/confirmations/{confirmation_id}/approve") +async def approve_confirmation( + confirmation_id: str, + _: None = Depends(require_owner), + client: OmniClaw = Depends(get_omniclaw_client), +): + store = ConfirmationStore(client._storage) + record = await store.approve(confirmation_id) + if not record: + raise HTTPException(status_code=404, detail="Confirmation not found") + return record + + +@router.post("/confirmations/{confirmation_id}/deny") +async def deny_confirmation( + confirmation_id: str, + _: None = Depends(require_owner), + client: OmniClaw = Depends(get_omniclaw_client), +): + store = ConfirmationStore(client._storage) + record = await store.deny(confirmation_id) + if not record: + raise HTTPException(status_code=404, detail="Confirmation not found") + return record + + @router.get("/can-pay", response_model=CanPayResponse) async def can_pay( recipient: str, @@ -423,20 +909,15 @@ async def x402_pay( agent: AuthenticatedAgent = Depends(get_current_agent), client: OmniClaw = Depends(get_omniclaw_client), ): - """Execute an automated x402 payment flow.""" + """Execute an automated x402 payment flow using client.pay() for automatic routing to Circle Gateway.""" try: - from omniclaw.protocols.x402 import X402Adapter - - adapter = X402Adapter(client.config, client.wallet_service) - - result = await adapter.execute( + amount = request.amount if request.amount else "0.01" + result = await client.pay( wallet_id=agent.wallet_id, recipient=request.url, - amount=Decimal(request.max_amount or "10.0"), # Default cap + amount=amount, idempotency_key=request.idempotency_key, - method=request.method, - body=request.body, - headers=request.headers, + metadata={"method": request.method, "body": request.body, "headers": request.headers}, ) return PayResponse( @@ -445,8 +926,10 @@ async def x402_pay( blockchain_tx=result.blockchain_tx, amount=str(result.amount), recipient=result.recipient, - status=result.status.value if hasattr(result.status, "value") else str(result.status), - method="X402", + status=result.status.value + if result.status and hasattr(result.status, "value") + else (str(result.status) if result.status else "failed"), + method="nanopayment", error=result.error, ) except Exception as e: @@ -456,7 +939,7 @@ async def x402_pay( amount="0", recipient=request.url, status="FAILED", - method="X402", + method="nanopayment", error=str(e), ) @@ -467,7 +950,47 @@ async def x402_verify( agent: AuthenticatedAgent = Depends(get_current_agent), client: OmniClaw = Depends(get_omniclaw_client), ): - """Verify an incoming x402 payment signature (for 'omniclaw-cli serve').""" - # This is a stub for now, in a real implementation it would verify the signature - # against the blockchain or a local cache. - return {"valid": True, "amount": request.amount, "sender": request.sender} + """Verify and settle an incoming x402 payment signature (for 'omniclaw-cli serve').""" + import base64 + import json + + try: + if not client._nano_client: + return {"valid": False, "error": "Nanopayment client not initialized"} + + sig_data = json.loads(base64.b64decode(request.signature)) + + from omniclaw.protocols.nanopayments.types import PaymentPayload, PaymentRequirements + + payload = PaymentPayload.from_dict(sig_data) + + amount_micro = ( + int(request.amount) if request.amount.isdigit() else int(float(request.amount) * 10**6) + ) + + wallet_addr = await client.get_payment_address(agent.wallet_id) + + requirements = PaymentRequirements( + scheme=payload.scheme, + network=payload.network, + max_amount_required=str(amount_micro), + resource=request.resource, + description="x402 payment", + recipient=wallet_addr, + ) + + result = await client._nano_client.settle(payload, requirements) + + if result.success: + return { + "valid": True, + "sender": result.payer, + "amount": request.amount, + "transaction": result.transaction, + } + else: + return {"valid": False, "error": result.error_reason or "Settlement failed"} + + except Exception as e: + logger.error(f"x402 verify failed: {e}") + return {"valid": False, "error": str(e)} diff --git a/src/omniclaw/agent/server.py b/src/omniclaw/agent/server.py index e7e3827..3664d28 100644 --- a/src/omniclaw/agent/server.py +++ b/src/omniclaw/agent/server.py @@ -1,12 +1,7 @@ -from __future__ import annotations - -import warnings - -# Suppress deprecation warnings from downstream dependencies (e.g. web3 using pkg_resources) -warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") -warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") - import asyncio +import contextlib +import os +import warnings from contextlib import asynccontextmanager from fastapi import FastAPI @@ -17,6 +12,10 @@ from omniclaw.agent.routes import router from omniclaw.core.logging import configure_logging, get_logger +# Suppress deprecation warnings from downstream dependencies (e.g. web3 using pkg_resources) +warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") +warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") + logger = get_logger(__name__) @@ -37,26 +36,48 @@ async def lifespan(app: FastAPI): circle_api_key = ( app.state.config.get("circle_api_key") if hasattr(app.state, "config") else None ) - entity_secret = ( - app.state.config.get("entity_secret") if hasattr(app.state, "config") else None - ) from omniclaw import OmniClaw from omniclaw.core.types import Network + # Read network from environment + network_str = os.getenv("OMNICLAW_NETWORK", "ETH-SEPOLIA") + try: + network = Network.from_string(network_str) + except Exception: + network = Network.ETH_SEPOLIA + logger.info(f"Using network: {network}") + client = OmniClaw( circle_api_key=circle_api_key, - entity_secret=entity_secret, - network=Network.ARC_TESTNET, + entity_secret=None, # Using direct private key now + network=network, ) # Initialize wallet manager wallet_mgr = WalletManager(policy_mgr, client) - # PRODUCITON RESILIENCE: Run wallet initialization in the background - # This prevents Circle API timeouts from blocking the Control Plane startup - logger.info("OmniClaw background initialization started (non-blocking)...") - asyncio.create_task(wallet_mgr.initialize_wallets()) + # Initialize wallet mappings - MUST complete before serving requests + # In direct private key mode, this is fast (no Circle API calls) + logger.info("OmniClaw wallet initialization starting...") + await wallet_mgr.initialize_wallets() + logger.info("OmniClaw wallet initialization complete") + + # Policy hot-reload loop + reload_interval = float(os.getenv("OMNICLAW_POLICY_RELOAD_INTERVAL", "5")) + policy_reload_task = None + + async def _policy_watch_loop() -> None: + while True: + await asyncio.sleep(reload_interval) + reloaded = await policy_mgr.reload() + if reloaded: + logger.info("Policy changed on disk. Reinitializing wallets and guards...") + await wallet_mgr.initialize_wallets() + logger.info("Policy reload complete.") + + if reload_interval > 0: + policy_reload_task = asyncio.create_task(_policy_watch_loop()) # Initialize token auth auth = TokenAuth(policy_mgr) @@ -71,6 +92,10 @@ async def lifespan(app: FastAPI): yield logger.info("Shutting down OmniClaw Agent Server...") + if policy_reload_task: + policy_reload_task.cancel() + with contextlib.suppress(Exception): + await policy_reload_task if hasattr(app.state, "client"): await app.state.client.__aexit__(None, None, None) logger.info("OmniClaw Agent Server stopped") @@ -92,12 +117,17 @@ async def lifespan(app: FastAPI): app.include_router(router) - import os + from omniclaw.core.types import network_to_caip2 + + omniclaw_network = os.environ.get("OMNICLAW_NETWORK", "ARC-TESTNET") + nanopay_network = network_to_caip2(omniclaw_network) app.state.config = { "policy_path": os.environ.get("OMNICLAW_AGENT_POLICY_PATH", "/config/policy.json"), "circle_api_key": os.environ.get("CIRCLE_API_KEY"), - "entity_secret": os.environ.get("ENTITY_SECRET"), + "private_key": os.environ.get("OMNICLAW_PRIVATE_KEY"), + "rpc_url": os.environ.get("OMNICLAW_RPC_URL"), + "nanopay_network": nanopay_network, } return app diff --git a/src/omniclaw/cli.py b/src/omniclaw/cli.py index d36a08b..64a743c 100644 --- a/src/omniclaw/cli.py +++ b/src/omniclaw/cli.py @@ -7,6 +7,7 @@ warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") import argparse +import json import os from collections.abc import Sequence @@ -34,9 +35,10 @@ def print_banner(): ENV_VARS = { "required": { "CIRCLE_API_KEY": "Circle API key for wallet/payment operations", - "ENTITY_SECRET": "Entity secret for transaction signing", + "OMNICLAW_PRIVATE_KEY": "Private key for nanopayment signing", }, "optional": { + "ENTITY_SECRET": "Auto-generated entity secret (advanced/manual setup only)", "OMNICLAW_RPC_URL": "RPC endpoint for trust gate (ERC-8004)", "OMNICLAW_STORAGE_BACKEND": "Storage backend: memory or redis", "OMNICLAW_REDIS_URL": "Redis connection URL (when using redis)", @@ -122,6 +124,18 @@ def build_parser() -> argparse.ArgumentParser: server_parser.add_argument("--port", type=int, default=8080, help="Port to listen on") server_parser.add_argument("--reload", action="store_true", help="Enable auto-reload") + policy_parser = subparsers.add_parser( + "policy", + help="Policy utilities (lint/validate)", + ) + policy_sub = policy_parser.add_subparsers(dest="policy_command") + lint_parser = policy_sub.add_parser("lint", help="Validate policy.json") + lint_parser.add_argument( + "--path", + default=os.environ.get("OMNICLAW_AGENT_POLICY_PATH", "/config/policy.json"), + help="Path to policy.json", + ) + return parser @@ -202,6 +216,22 @@ def handle_server(args: argparse.Namespace) -> int: return 0 +def handle_policy_lint(args: argparse.Namespace) -> int: + """Validate policy.json against strict schema.""" + from omniclaw.agent.policy_schema import validate_policy + + path = args.path + try: + with open(path) as f: + data = json.load(f) + validate_policy(data) + print(f"βœ… policy.json is valid: {path}") + return 0 + except Exception as e: + print(f"❌ Invalid policy.json: {e}") + return 1 + + def main(argv: Sequence[str] | None = None) -> int: """Run the OmniClaw CLI.""" parser = build_parser() @@ -226,6 +256,9 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "server": return handle_server(args) + if args.command == "policy" and args.policy_command == "lint": + return handle_policy_lint(args) + parser.print_help() print("\nCommands:") print(" setup - Quick credentials configuration") diff --git a/src/omniclaw/cli/__init__.py b/src/omniclaw/cli/__init__.py new file mode 100644 index 0000000..01ff798 --- /dev/null +++ b/src/omniclaw/cli/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .app import app, main + +__all__ = ["app", "main"] diff --git a/src/omniclaw/cli/app.py b/src/omniclaw/cli/app.py new file mode 100644 index 0000000..17bbbde --- /dev/null +++ b/src/omniclaw/cli/app.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +import warnings + +import typer + +from .commands import configure as configure_cmd +from .commands import confirmations as confirmations_cmd +from .commands import intents as intents_cmd +from .commands import ledger as ledger_cmd +from .commands import payments as payments_cmd +from .commands import serve as serve_cmd +from .commands import status as status_cmd +from .commands import wallet as wallet_cmd +from .config import is_quiet + +# Aggressively suppress noisy deprecation warnings from downstream dependencies (e.g. web3, circle-sdk) +warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="web3") + +app = typer.Typer( + help="omniclaw-cli - CLI for AI agents to pay for things without losing control of money" +) + +BANNER = r""" + ____ __ __ _ _ ___ ____ _ ___ __ + / __ \| \/ | \ | |_ _/ ___| | / \ \ / / + | | | | |\/| | \| || | | | | / _ \ \ /\ / / + | |__| | | | | |\ || | |___| |___ / ___ \ V V / + \____/|_| |_|_| \_|___\____|_____/_/ \_\_/\_/ + + Economic Execution and Control Layer for Agentic Systems +""" + + +def print_banner() -> None: + """Print the OmniClaw CLI banner.""" + typer.echo(typer.style(BANNER, fg=typer.colors.CYAN, bold=True)) + + +@app.callback() +def callback() -> None: + """Show banner on startup.""" + if is_quiet(): + return + if str(os.environ.get("OMNICLAW_CLI_NO_BANNER", "")).strip().lower() in {"1", "true", "yes"}: + return + print_banner() + + +wallet_app = typer.Typer(help="Wallet operations") +payments_app = None +intents_app = typer.Typer(help="Payment intents") +ledger_app = None +confirmations_app = typer.Typer(help="Manage pending confirmations (owner only)") +status_app = None + +configure_cmd.register(app) +wallet_cmd.register(app, wallet_app) +payments_cmd.register(app) +intents_cmd.register(app, intents_app) +ledger_cmd.register(app) +confirmations_cmd.register(app, confirmations_app) +serve_cmd.register(app) +status_cmd.register(app) + +app.add_typer(wallet_app, name="wallet") +app.add_typer(intents_app, name="intents") +app.add_typer(confirmations_app, name="confirmations") +# Note: grouping under pay/ledger/status previously shadowed top-level commands. + + +def main() -> int: + """Main entry point.""" + return app() diff --git a/src/omniclaw/cli/commands/configure.py b/src/omniclaw/cli/commands/configure.py new file mode 100644 index 0000000..62fe841 --- /dev/null +++ b/src/omniclaw/cli/commands/configure.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +import os + +import typer + +from ..config import CONFIG_FILE, _mask_secret, is_quiet, load_config, save_config + + +def configure( + server_url: str | None = typer.Option(None, "--server-url", help="OmniClaw server URL"), + token: str | None = typer.Option(None, "--token", help="Agent token"), + wallet: str | None = typer.Option(None, "--wallet", help="Wallet alias"), + owner_token: str | None = typer.Option(None, "--owner-token", help="Owner token"), + show: bool = typer.Option(False, "--show", help="Show current config"), + show_raw: bool = typer.Option(False, "--show-raw", help="Show raw secrets"), + interactive: bool = typer.Option(False, "--interactive", help="Prompt for missing values"), +) -> None: + """Configure omniclaw-cli with server details.""" + if show or show_raw: + config = load_config() + if not config: + typer.echo("No configuration found. Run 'omniclaw-cli configure --server-url ...'") + return + if show_raw: + typer.echo(json.dumps(config, indent=2)) + else: + safe = dict(config) + safe["token"] = _mask_secret(safe.get("token")) + safe["owner_token"] = _mask_secret(safe.get("owner_token")) + typer.echo(json.dumps(safe, indent=2)) + return + + config = load_config() + if interactive: + default_url = ( + server_url + or config.get("server_url") + or os.environ.get("OMNICLAW_SERVER_URL", "http://localhost:8080") + ) + server_url = typer.prompt("Server URL", default=default_url) + default_wallet = wallet or config.get("wallet") or "primary" + wallet = typer.prompt("Wallet alias", default=default_wallet) + token_default = token or config.get("token") or os.environ.get("OMNICLAW_TOKEN") + if token_default: + token = typer.prompt("Agent token", default=token_default, hide_input=True) + else: + token = typer.prompt("Agent token", hide_input=True) + owner_default = ( + owner_token or config.get("owner_token") or os.environ.get("OMNICLAW_OWNER_TOKEN") or "" + ) + owner_token = typer.prompt("Owner token (optional)", default=owner_default, hide_input=True) + + if server_url: + config["server_url"] = server_url.rstrip("/") + if token: + config["token"] = token + if wallet: + config["wallet"] = wallet + if owner_token: + config["owner_token"] = owner_token + + if not config.get("server_url") or not config.get("token") or not config.get("wallet"): + typer.echo( + "Error: server_url, token, and wallet are required. Use --interactive or pass flags.", + err=True, + ) + raise typer.Exit(1) + + save_config(config) + if is_quiet(): + result = { + "ok": True, + "config_path": str(CONFIG_FILE), + "server_url": config.get("server_url"), + "wallet": config.get("wallet"), + "owner_token_set": bool(config.get("owner_token")), + } + typer.echo(json.dumps(result, indent=2)) + else: + typer.echo(f"Configuration saved to {CONFIG_FILE}") + typer.echo(f"Server: {config.get('server_url')}") + typer.echo(f"Wallet: {config.get('wallet')}") + + +def register(app: typer.Typer) -> None: + app.command()(configure) diff --git a/src/omniclaw/cli/commands/confirmations.py b/src/omniclaw/cli/commands/confirmations.py new file mode 100644 index 0000000..57befbd --- /dev/null +++ b/src/omniclaw/cli/commands/confirmations.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import typer + +from ..config import get_client + + +def get_confirmation( + confirmation_id: str = typer.Option(..., "--id", help="Confirmation ID"), +) -> dict[str, Any]: + """Get confirmation details.""" + client = get_client(owner=True) + + try: + response = client.get(f"/api/v1/confirmations/{confirmation_id}") + 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 approve_confirmation( + confirmation_id: str = typer.Option(..., "--id", help="Confirmation ID"), +) -> dict[str, Any]: + """Approve a confirmation.""" + client = get_client(owner=True) + + try: + response = client.post(f"/api/v1/confirmations/{confirmation_id}/approve") + 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 deny_confirmation( + confirmation_id: str = typer.Option(..., "--id", help="Confirmation ID"), +) -> dict[str, Any]: + """Deny a confirmation.""" + client = get_client(owner=True) + + try: + response = client.post(f"/api/v1/confirmations/{confirmation_id}/deny") + 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 register(app: typer.Typer, group: typer.Typer | None = None) -> None: + # confirmations are owner-only; keep under group if provided + if group is not None and group is not app: + group.command("get")(get_confirmation) + group.command("approve")(approve_confirmation) + group.command("deny")(deny_confirmation) diff --git a/src/omniclaw/cli/commands/intents.py b/src/omniclaw/cli/commands/intents.py new file mode 100644 index 0000000..a5ef816 --- /dev/null +++ b/src/omniclaw/cli/commands/intents.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import typer + +from ..config import get_client + + +def create_intent( + recipient: str = typer.Option(..., "--recipient", help="Recipient"), + amount: str = typer.Option(..., "--amount", help="Amount"), + purpose: str | None = typer.Option(None, "--purpose", help="Purpose"), + expires_in: int | None = typer.Option(None, "--expires-in", help="Expiry in seconds"), + idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), + destination_chain: str | None = typer.Option( + None, "--destination-chain", help="Target network" + ), + fee_level: str | None = typer.Option( + None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" + ), + check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), + skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), +) -> dict[str, Any]: + """Create a payment intent (authorize).""" + client = get_client() + + payload: dict[str, Any] = { + "recipient": recipient, + "amount": amount, + } + if purpose: + payload["purpose"] = purpose + if expires_in: + payload["expires_in"] = expires_in + if idempotency_key: + payload["idempotency_key"] = idempotency_key + if destination_chain: + payload["destination_chain"] = destination_chain + if fee_level: + payload["fee_level"] = fee_level + if check_trust: + payload["check_trust"] = True + if skip_guards: + payload["skip_guards"] = True + + try: + response = client.post("/api/v1/intents", json=payload) + 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 create_intent_alias( + recipient: str = typer.Option(..., "--recipient", help="Recipient"), + amount: str = typer.Option(..., "--amount", help="Amount"), + purpose: str | None = typer.Option(None, "--purpose", help="Purpose"), + expires_in: int | None = typer.Option(None, "--expires-in", help="Expiry in seconds"), + idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), + destination_chain: str | None = typer.Option( + None, "--destination-chain", help="Target network" + ), + fee_level: str | None = typer.Option( + None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" + ), + check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), + skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), +) -> dict[str, Any]: + """Alias for create-intent.""" + return create_intent( + recipient=recipient, + amount=amount, + purpose=purpose, + expires_in=expires_in, + idempotency_key=idempotency_key, + destination_chain=destination_chain, + fee_level=fee_level, + check_trust=check_trust, + skip_guards=skip_guards, + ) + + +def confirm_intent( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to confirm"), +) -> dict[str, Any]: + """Confirm a payment intent (capture).""" + client = get_client() + + try: + response = client.post(f"/api/v1/intents/{intent_id}/confirm") + 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 confirm_intent_alias( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to confirm"), +) -> dict[str, Any]: + """Alias for confirm-intent.""" + return confirm_intent(intent_id=intent_id) + + +def get_intent( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to fetch"), +) -> dict[str, Any]: + """Get a payment intent.""" + client = get_client() + + try: + response = client.get(f"/api/v1/intents/{intent_id}") + 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 get_intent_alias( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to fetch"), +) -> dict[str, Any]: + """Alias for get-intent.""" + return get_intent(intent_id=intent_id) + + +def cancel_intent( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to cancel"), + reason: str | None = typer.Option(None, "--reason", help="Cancel reason"), +) -> dict[str, Any]: + """Cancel a payment intent.""" + client = get_client() + + try: + response = client.delete( + f"/api/v1/intents/{intent_id}", params={"reason": reason} if reason else {} + ) + 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 cancel_intent_alias( + intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to cancel"), + reason: str | None = typer.Option(None, "--reason", help="Cancel reason"), +) -> dict[str, Any]: + """Alias for cancel-intent.""" + return cancel_intent(intent_id=intent_id, reason=reason) + + +def register(app: typer.Typer, group: typer.Typer | None = None) -> None: + app.command("create-intent")(create_intent) + app.command(name="create_intent", help="Alias for create-intent")(create_intent_alias) + app.command("confirm-intent")(confirm_intent) + app.command(name="confirm_intent", help="Alias for confirm-intent")(confirm_intent_alias) + app.command("get-intent")(get_intent) + app.command(name="get_intent", help="Alias for get-intent")(get_intent_alias) + app.command("cancel-intent")(cancel_intent) + app.command(name="cancel_intent", help="Alias for cancel-intent")(cancel_intent_alias) + + if group is not None and group is not app: + group.command("create-intent")(create_intent) + group.command(name="create_intent", help="Alias for create-intent")(create_intent_alias) + group.command("confirm-intent")(confirm_intent) + group.command(name="confirm_intent", help="Alias for confirm-intent")(confirm_intent_alias) + group.command("get-intent")(get_intent) + group.command(name="get_intent", help="Alias for get-intent")(get_intent_alias) + group.command("cancel-intent")(cancel_intent) + group.command(name="cancel_intent", help="Alias for cancel-intent")(cancel_intent_alias) diff --git a/src/omniclaw/cli/commands/ledger.py b/src/omniclaw/cli/commands/ledger.py new file mode 100644 index 0000000..9354055 --- /dev/null +++ b/src/omniclaw/cli/commands/ledger.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import typer + +from ..config import get_client + + +def ledger( + limit: int = typer.Option(20, "--limit", help="Number of transactions to fetch"), +) -> dict[str, Any]: + """List transaction history.""" + return list_tx(limit=limit) + + +def list_tx( + limit: int = typer.Option(20, "--limit", help="Number of transactions to fetch"), +) -> dict[str, Any]: + """List transaction history.""" + client = get_client() + + try: + response = client.get("/api/v1/transactions", params={"limit": limit}) + 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 list_tx_alias( + limit: int = typer.Option(20, "--limit", help="Number of transactions to fetch"), +) -> dict[str, Any]: + """Alias for list-tx.""" + return list_tx(limit=limit) + + +def register(app: typer.Typer, group: typer.Typer | None = None) -> None: + app.command()(ledger) + app.command("list-tx")(list_tx) + app.command(name="list_tx", help="Alias for list-tx")(list_tx_alias) + + if group is not None and group is not app: + group.command()(ledger) + group.command("list-tx")(list_tx) + group.command(name="list_tx", help="Alias for list-tx")(list_tx_alias) diff --git a/src/omniclaw/cli/commands/payments.py b/src/omniclaw/cli/commands/payments.py new file mode 100644 index 0000000..7679012 --- /dev/null +++ b/src/omniclaw/cli/commands/payments.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import typer + +from ..config import get_client, is_quiet + + +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)" + ), + purpose: str | None = typer.Option(None, "--purpose", help="Payment purpose"), + idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), + destination_chain: str | None = typer.Option( + None, "--destination-chain", help="Target network" + ), + fee_level: str | None = typer.Option( + None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" + ), + check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), + skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), + method: str = typer.Option("GET", "--method", help="HTTP method for x402 requests"), + body: str | None = typer.Option(None, "--body", help="JSON body for x402 requests"), + header: list[str] = typer.Option([], "--header", help="Additional headers for x402 requests"), + output: str | None = typer.Option(None, "--output", help="Save response to file"), + dry_run: bool = typer.Option(False, "--dry-run", help="Simulate first"), +) -> dict[str, Any]: + """Execute a payment or pay for an x402 service.""" + if dry_run: + return simulate( + recipient=recipient, + amount=amount or "0.00", + idempotency_key=idempotency_key, + destination_chain=destination_chain, + fee_level=fee_level, + check_trust=check_trust, + skip_guards=skip_guards, + ) + + client = get_client() + + # If recipient is a URL, handle x402 flow + if recipient.startswith("http"): + if not is_quiet(): + typer.echo(f"Paying for x402 service: {recipient}") + payload: dict[str, Any] = { + "url": recipient, + "method": method, + } + if body: + payload["body"] = body + if header: + parsed_headers: dict[str, str] = {} + for raw in header: + key, sep, value = raw.partition(":") + if not sep: + typer.echo( + f"Error: Invalid header '{raw}'. Use 'Header: value' format.", + err=True, + ) + raise typer.Exit(1) + parsed_headers[key.strip()] = value.strip() + payload["headers"] = parsed_headers + if idempotency_key: + payload["idempotency_key"] = idempotency_key + + try: + response = client.post("/api/v1/x402/pay", json=payload) + response.raise_for_status() + data = response.json() + if not is_quiet() and data.get("confirmation_required"): + confirmation_id = data.get("confirmation_id") + if confirmation_id: + typer.echo("Payment requires confirmation.") + typer.echo(f"Run: omniclaw-cli confirmations approve --id {confirmation_id}") + if output: + Path(output).write_text(json.dumps(data, indent=2)) + if not is_quiet(): + typer.echo(f"Response saved to {output}") + if is_quiet(): + typer.echo(json.dumps(data, indent=2)) + else: + 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 + + # Standard direct transfer + if not amount: + if is_quiet(): + typer.echo("Error: --amount is required for direct transfers", err=True) + raise typer.Exit(1) + amount = typer.prompt("Amount (USDC)") + + payload = { + "recipient": recipient, + "amount": amount, + } + if purpose: + payload["purpose"] = purpose + if idempotency_key: + payload["idempotency_key"] = idempotency_key + if destination_chain: + payload["destination_chain"] = destination_chain + if fee_level: + payload["fee_level"] = fee_level + if check_trust: + payload["check_trust"] = True + if skip_guards: + payload["skip_guards"] = True + + try: + response = client.post("/api/v1/pay", json=payload) + response.raise_for_status() + data = response.json() + if not is_quiet() and data.get("confirmation_required"): + confirmation_id = data.get("confirmation_id") + if confirmation_id: + typer.echo("Payment requires confirmation.") + typer.echo(f"Run: omniclaw-cli confirmations approve --id {confirmation_id}") + 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 simulate( + recipient: str = typer.Option(..., "--recipient", help="Recipient to simulate"), + amount: str = typer.Option(..., "--amount", help="Amount to simulate"), + idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), + destination_chain: str | None = typer.Option( + None, "--destination-chain", help="Target network" + ), + fee_level: str | None = typer.Option( + None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" + ), + check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), + skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), +) -> dict[str, Any]: + """Simulate a payment without executing.""" + client = get_client() + + payload: dict[str, Any] = { + "recipient": recipient, + "amount": amount, + } + if idempotency_key: + payload["idempotency_key"] = idempotency_key + if destination_chain: + payload["destination_chain"] = destination_chain + if fee_level: + payload["fee_level"] = fee_level + if check_trust: + payload["check_trust"] = True + if skip_guards: + payload["skip_guards"] = True + + try: + response = client.post("/api/v1/simulate", json=payload) + 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 can_pay( + recipient: str = typer.Option(..., "--recipient", help="Recipient to check"), +) -> dict[str, Any]: + """Check if recipient is allowed.""" + client = get_client() + + try: + response = client.get("/api/v1/can-pay", params={"recipient": recipient}) + 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 can_pay_alias( + recipient: str = typer.Option(..., "--recipient", help="Recipient to check"), +) -> dict[str, Any]: + """Alias for can-pay.""" + return can_pay(recipient=recipient) + + +def register(app: typer.Typer, group: typer.Typer | None = None) -> None: + app.command()(pay) + app.command()(simulate) + app.command("can-pay")(can_pay) + app.command(name="can_pay", help="Alias for can-pay")(can_pay_alias) + + if group is not None and group is not app: + group.command()(pay) + group.command()(simulate) + group.command("can-pay")(can_pay) + group.command(name="can_pay", help="Alias for can-pay")(can_pay_alias) diff --git a/src/omniclaw/cli/commands/serve.py b/src/omniclaw/cli/commands/serve.py new file mode 100644 index 0000000..9fb0b52 --- /dev/null +++ b/src/omniclaw/cli/commands/serve.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Any + +import typer + +from ..config import get_client, is_quiet + +try: + from fastapi import FastAPI, Request, Response + from fastapi.responses import JSONResponse + + _FASTAPI_AVAILABLE = True +except Exception: + FastAPI = None # type: ignore[assignment] + Request = None # type: ignore[assignment] + Response = None # type: ignore[assignment] + JSONResponse = None # type: ignore[assignment] + _FASTAPI_AVAILABLE = False + + +def serve( + price: float = typer.Option(..., "--price", help="Price per request in USDC"), + endpoint: str = typer.Option(..., "--endpoint", help="Endpoint path to expose"), + exec_cmd: str = typer.Option(..., "--exec", help="Command to execute on success"), + port: int = typer.Option(8000, "--port", help="Local port to listen on"), +) -> None: + """Expose a local service behind an x402 payment gate. + + Uses the production GatewayMiddleware for full x402 v2 protocol compliance: + - Returns proper 402 responses with all required fields + - Parses PAYMENT-SIGNATURE headers + - Settles atomically via Circle Gateway /settle + """ + try: + import uvicorn + except ImportError as err: + typer.echo("Error: FastAPI/uvicorn not installed. Run: pip install fastapi uvicorn") + raise typer.Exit(1) from err + if not _FASTAPI_AVAILABLE: + typer.echo("Error: FastAPI not installed. Run: pip install fastapi", err=True) + raise typer.Exit(1) + + server_app = FastAPI(title="OmniClaw x402 Payment Gate") + ctrl_client = get_client() + + # Price string in USD format for GatewayMiddleware + price_usd = f"${price}" + + # We'll initialize the middleware lazily on first request + _middleware_holder: dict[str, Any] = {} + + async def _get_middleware(): + """Lazily initialize GatewayMiddleware with seller's nano address.""" + if "mw" in _middleware_holder: + return _middleware_holder["mw"] + + from omniclaw.protocols.nanopayments.client import NanopaymentClient + from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware + + # Get the seller's nano address from the control plane + try: + nano_resp = ctrl_client.get("/api/v1/nano-address") + if nano_resp.status_code == 200: + seller_address = nano_resp.json().get("address") + else: + addr_resp = ctrl_client.get("/api/v1/address") + seller_address = addr_resp.json().get("address") + except Exception: + addr_resp = ctrl_client.get("/api/v1/address") + seller_address = addr_resp.json().get("address") + + if not seller_address: + raise RuntimeError("Could not resolve seller address from control plane") + + # Initialize Circle nanopayment client + circle_api_key = os.environ.get("CIRCLE_API_KEY", "") + nano_client = NanopaymentClient(api_key=circle_api_key) + + # Build production middleware + mw = GatewayMiddleware( + seller_address=seller_address, + nanopayment_client=nano_client, + ) + + _middleware_holder["mw"] = mw + if not is_quiet(): + typer.echo(f" Seller address: {seller_address}") + return mw + + @server_app.api_route(endpoint, methods=["GET", "POST", "PUT", "DELETE"]) + async def payment_gate(request: Request): + from omniclaw.protocols.nanopayments.middleware import PaymentRequiredHTTPError + + try: + middleware = await _get_middleware() + headers = dict(request.headers) + + # GatewayMiddleware.handle() does the full x402 v2 flow: + # - If no PAYMENT-SIGNATURE: raises PaymentRequiredHTTPError (402) + # - If valid signature: settles via Circle Gateway and returns PaymentInfo + payment_info = await middleware.handle(headers, price_usd) + + # Payment settled successfully β€” execute the command + try: + env = os.environ.copy() + env["OMNICLAW_PAYER_ADDRESS"] = payment_info.payer or "unknown" + env["OMNICLAW_AMOUNT_USD"] = str(price) + env["OMNICLAW_TX_HASH"] = payment_info.transaction or "" + + result = subprocess.run( + exec_cmd, shell=True, capture_output=True, text=True, env=env + ) + response = Response(content=result.stdout, media_type="text/plain") + except Exception as e: + response = JSONResponse( + status_code=500, + content={"detail": f"Execution failed: {e}"}, + ) + + # Add PAYMENT-RESPONSE header (x402 v2 spec requirement) + payment_resp_headers = middleware.payment_response_headers(payment_info) + for k, v in payment_resp_headers.items(): + response.headers[k] = v + + return response + + except PaymentRequiredHTTPError as exc: + # Return 402 with proper x402 v2 requirements + return JSONResponse( + status_code=exc.status_code, + content=exc.detail, + headers=exc.headers, + ) + except Exception as e: + return JSONResponse( + status_code=500, + content={"detail": f"Payment processing failed: {e}"}, + ) + + if not is_quiet(): + typer.echo(f"OmniClaw service exposed at http://localhost:{port}{endpoint}") + typer.echo(f"Price: ${price} USDC per request") + typer.echo(f"Exec: {exec_cmd}") + typer.echo("x402 v2 Protocol β€” Circle Gateway settlement") + typer.echo("") + + uvicorn.run(server_app, host="0.0.0.0", port=port) + + +def register(app: typer.Typer, group: typer.Typer | None = None) -> None: + app.command()(serve) + if group is not None and group is not app: + group.command()(serve) diff --git a/src/omniclaw/cli/commands/status.py b/src/omniclaw/cli/commands/status.py new file mode 100644 index 0000000..7d58c33 --- /dev/null +++ b/src/omniclaw/cli/commands/status.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import typer + +from ..config import get_client, is_quiet, load_config + + +def status() -> dict[str, Any]: + """Get agent status and health.""" + client = get_client() + config = load_config() + + try: + # Get multiple stats for a complete status report + health = client.get("/api/v1/health").json() + balance_data = client.get("/api/v1/balance").json() + addr_data = client.get("/api/v1/address").json() + + status_data = { + "Agent": config.get("wallet", "unknown"), + "Wallet": addr_data.get("address"), + "Balance": f"${balance_data.get('available')} available", + "Guards": "active" if health.get("status") == "ok" else "degraded", + "Circle": "connected" if health.get("status") == "ok" else "disconnected", + "Circuit": "CLOSED" if health.get("status") == "ok" else "OPEN", + } + + if is_quiet(): + typer.echo(json.dumps(status_data, indent=2)) + else: + typer.echo(f"Agent: {status_data['Agent']}") + typer.echo(f"Wallet: {status_data['Wallet']}") + typer.echo(f"Balance: {status_data['Balance']}") + typer.echo(f"Guards: {status_data['Guards']}") + circle_icon = "ok" if status_data["Circle"] == "connected" else "err" + circuit_icon = "ok" if status_data["Circuit"] == "CLOSED" else "warn" + typer.echo(f"Circle: {status_data['Circle']} ({circle_icon})") + typer.echo(f"Circuit: {status_data['Circuit']} ({circuit_icon})") + + return status_data + except Exception as e: + typer.echo(f"Error fetching status: {e}", err=True) + raise typer.Exit(1) from e + + +def ping() -> dict[str, Any]: + """Health check.""" + from omniclaw import __version__ + + client = get_client() + + try: + response = client.get("/api/v1/health") + response.raise_for_status() + data = response.json() + data["version"] = __version__ + 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 register(app: typer.Typer, group: typer.Typer | None = None) -> None: + app.command()(status) + app.command()(ping) + + if group is not None and group is not app: + group.command()(status) + group.command()(ping) diff --git a/src/omniclaw/cli/commands/wallet.py b/src/omniclaw/cli/commands/wallet.py new file mode 100644 index 0000000..2d87ea9 --- /dev/null +++ b/src/omniclaw/cli/commands/wallet.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import typer + +from ..config import get_client, is_quiet + + +def address() -> dict[str, Any]: + """Get wallet address.""" + client = get_client() + + try: + response = client.get("/api/v1/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 balance() -> dict[str, Any]: + """Get wallet balance.""" + client = get_client() + + try: + response = client.get("/api/v1/balance") + response.raise_for_status() + data = response.json() + typer.echo(json.dumps(data, indent=2)) + return data + except httpx.HTTPStatusError as e: + try: + detail = e.response.json().get("detail", str(e)) + except Exception: + detail = e.response.text or str(e) + typer.echo(f"Error: {detail}", 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 balance_detail() -> dict[str, Any]: + """Get detailed balance including Gateway and Circle wallet.""" + client = get_client() + + try: + response = client.get("/api/v1/balance-detail") + response.raise_for_status() + data = response.json() + + if is_quiet(): + typer.echo(json.dumps(data, indent=2)) + else: + 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"Circle Wallet: {data.get('circle_wallet_balance')} USDC") + + return data + except httpx.HTTPStatusError as e: + try: + detail = e.response.json().get("detail", str(e)) + except Exception: + detail = e.response.text or str(e) + typer.echo(f"Error: {detail}", 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 balance_detail_alias() -> dict[str, Any]: + """Alias for balance-detail.""" + return balance_detail() + + +def deposit( + amount: str = typer.Option(..., "--amount", help="Amount in USDC to deposit to Gateway"), +) -> dict[str, Any]: + """Deposit USDC from EOA to Gateway wallet.""" + client = get_client() + + try: + response = client.post( + "/api/v1/deposit", + params={"amount": amount}, + ) + 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 withdraw( + amount: str = typer.Option(..., "--amount", help="Amount in USDC to withdraw from Gateway"), +) -> dict[str, Any]: + """Withdraw USDC from Gateway to Circle Developer Wallet.""" + client = get_client() + + try: + response = client.post("/api/v1/withdraw", params={"amount": amount}) + 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 withdraw_trustless( + amount: str = typer.Option( + ..., "--amount", help="Amount in USDC to withdraw (trustless, ~7-day delay)" + ), +) -> dict[str, Any]: + """Initiate trustless withdrawal (~7-day delay, no API needed).""" + client = get_client() + + try: + response = client.post("/api/v1/withdraw-trustless", params={"amount": amount}) + response.raise_for_status() + data = response.json() + typer.echo(json.dumps(data, indent=2)) + if data.get("available_after"): + typer.echo(f"\nWithdrawal available after: {data.get('available_after')}") + 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 withdraw_trustless_alias( + amount: str = typer.Option( + ..., "--amount", help="Amount in USDC to withdraw (trustless, ~7-day delay)" + ), +) -> dict[str, Any]: + """Alias for withdraw-trustless.""" + return withdraw_trustless(amount=amount) + + +def withdraw_trustless_complete() -> dict[str, Any]: + """Complete a trustless withdrawal after the delay has passed.""" + client = get_client() + + try: + response = client.post("/api/v1/withdraw-trustless/complete") + 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 register(app: typer.Typer, group: typer.Typer) -> None: + app.command()(address) + 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) + app.command()(withdraw) + app.command("withdraw-trustless")(withdraw_trustless) + app.command(name="withdraw_trustless", help="Alias for withdraw-trustless")( + withdraw_trustless_alias + ) + app.command("withdraw-trustless-complete")(withdraw_trustless_complete) + app.command(name="withdraw_trustless_complete", help="Alias for withdraw-trustless-complete")( + withdraw_trustless_complete + ) + + group.command()(address) + 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) + group.command()(withdraw) + group.command("withdraw-trustless")(withdraw_trustless) + group.command(name="withdraw_trustless", help="Alias for withdraw-trustless")( + withdraw_trustless_alias + ) + group.command("withdraw-trustless-complete")(withdraw_trustless_complete) + group.command(name="withdraw_trustless_complete", help="Alias for withdraw-trustless-complete")( + withdraw_trustless_complete + ) diff --git a/src/omniclaw/cli/config.py b/src/omniclaw/cli/config.py new file mode 100644 index 0000000..e3c9c70 --- /dev/null +++ b/src/omniclaw/cli/config.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import httpx +import typer + +CONFIG_DIR = Path(os.environ.get("OMNICLAW_CONFIG_DIR", Path.home() / ".omniclaw")) +CONFIG_FILE = CONFIG_DIR / "config.json" + + +def load_config() -> dict[str, Any]: + """Load configuration from file.""" + if not CONFIG_FILE.exists(): + return {} + with open(CONFIG_FILE) as f: + return json.load(f) + + +def save_config(config: dict[str, Any]) -> None: + """Save configuration to file.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + + +def _mask_secret(value: str | None) -> str | None: + if not value: + return value + if len(value) <= 8: + return "***" + return f"{value[:4]}...{value[-4:]}" + + +def is_quiet() -> bool: + if str(os.environ.get("OMNICLAW_CLI_HUMAN", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + }: + return False + + flag = os.environ.get("OMNICLAW_CLI_QUIET") or os.environ.get("OMNICLAW_CLI_AGENT") + if flag is None: + return True + return str(flag).strip().lower() not in {"0", "false", "no", "off"} + + +def get_client(*, owner: bool = False) -> httpx.Client: + """Get HTTP client with auth.""" + config = load_config() + server_url = os.environ.get("OMNICLAW_SERVER_URL") or config.get("server_url") + token = os.environ.get("OMNICLAW_TOKEN") or config.get("token") + owner_token = os.environ.get("OMNICLAW_OWNER_TOKEN") or config.get("owner_token") + + if not server_url: + typer.echo("Error: Server URL not configured. Run 'omniclaw-cli configure'", err=True) + raise typer.Exit(1) + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + if owner: + if not owner_token: + typer.echo( + "Error: Owner token not configured. Set OMNICLAW_OWNER_TOKEN or run 'omniclaw-cli configure --owner-token ...'", + err=True, + ) + raise typer.Exit(1) + headers["X-Omniclaw-Owner-Token"] = owner_token + + return httpx.Client(base_url=server_url, headers=headers, timeout=30.0) diff --git a/src/omniclaw/cli_agent.py b/src/omniclaw/cli_agent.py index 1770bfb..d880d2f 100644 --- a/src/omniclaw/cli_agent.py +++ b/src/omniclaw/cli_agent.py @@ -1,617 +1,8 @@ from __future__ import annotations -import warnings +from omniclaw.cli import app, main -# Aggressively suppress noisy deprecation warnings from downstream dependencies (e.g. web3, circle-sdk) -# This must happen before any third-party imports. -warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=UserWarning, module="web3") - -import base64 -import json -import os -import subprocess -from pathlib import Path -from typing import Any - -import httpx -import typer - -app = typer.Typer( - help="omniclaw-cli - CLI for AI agents to pay for things without losing control of money" -) - - -@app.callback() -def callback() -> None: - """Show banner on startup.""" - print_banner() - - -CONFIG_DIR = Path.home() / ".omniclaw" -CONFIG_FILE = CONFIG_DIR / "config.json" - -BANNER = r""" - ____ __ __ _ _ ___ ____ _ ___ __ - / __ \| \/ | \ | |_ _/ ___| | / \ \ / / - | | | | |\/| | \| || | | | | / _ \ \ /\ / / - | |__| | | | | |\ || | |___| |___ / ___ \ V V / - \____/|_| |_|_| \_|___\____|_____/_/ \_\_/\_/ - - Economic Execution and Control Layer for Agentic Systems -""" - - -def print_banner(): - """Print the OmniClaw CLI banner.""" - typer.echo(typer.style(BANNER, fg=typer.colors.CYAN, bold=True)) - - -def load_config() -> dict[str, Any]: - """Load configuration from file.""" - if not CONFIG_FILE.exists(): - return {} - with open(CONFIG_FILE) as f: - return json.load(f) - - -def save_config(config: dict[str, Any]) -> None: - """Save configuration to file.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(CONFIG_FILE, "w") as f: - json.dump(config, f, indent=2) - - -def get_client() -> httpx.Client: - """Get HTTP client with auth.""" - config = load_config() - server_url = config.get("server_url", os.environ.get("OMNICLAW_SERVER_URL")) - token = config.get("token", os.environ.get("OMNICLAW_TOKEN")) - - if not server_url: - typer.echo("Error: Server URL not configured. Run 'omniclaw-cli configure'", err=True) - raise typer.Exit(1) - - headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - - return httpx.Client(base_url=server_url, headers=headers, timeout=30.0) - - -@app.command() -def configure( - server_url: str | None = typer.Option(None, "--server-url", help="OmniClaw server URL"), - token: str | None = typer.Option(None, "--token", help="Agent token"), - wallet: str | None = typer.Option(None, "--wallet", help="Wallet alias"), - show: bool = typer.Option(False, "--show", help="Show current config"), -) -> None: - """Configure omniclaw-cli with server details.""" - if show: - config = load_config() - if not config: - typer.echo("No configuration found. Run 'omniclaw-cli configure --server-url ...'") - return - typer.echo(json.dumps(config, indent=2)) - return - - if not server_url or not token or not wallet: - typer.echo("Error: --server-url, --token, and --wallet are required", err=True) - raise typer.Exit(1) - - config = { - "server_url": server_url.rstrip("/"), - "token": token, - "wallet": wallet, - } - save_config(config) - typer.echo(f"Configuration saved to {CONFIG_FILE}") - typer.echo(f"Server: {server_url}") - typer.echo(f"Wallet: {wallet}") - - -@app.command() -def address() -> dict[str, Any]: - """Get wallet address.""" - client = get_client() - _config = load_config() - - try: - response = client.get("/api/v1/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 - - -@app.command() -def balance() -> dict[str, Any]: - """Get wallet balance.""" - client = get_client() - - try: - response = client.get("/api/v1/balance") - response.raise_for_status() - data = response.json() - typer.echo(json.dumps(data, indent=2)) - return data - except httpx.HTTPStatusError as e: - try: - detail = e.response.json().get("detail", str(e)) - except Exception: - detail = e.response.text or str(e) - typer.echo(f"Error: {detail}", 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 - - -@app.command() -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)" - ), - purpose: str | None = typer.Option(None, "--purpose", help="Payment purpose"), - idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), - destination_chain: str | None = typer.Option( - None, "--destination-chain", help="Target network" - ), - fee_level: str | None = typer.Option( - None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" - ), - check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), - skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), - method: str = typer.Option("GET", "--method", help="HTTP method for x402 requests"), - body: str | None = typer.Option(None, "--body", help="JSON body for x402 requests"), - header: list[str] = typer.Option([], "--header", help="Additional headers for x402 requests"), - output: str | None = typer.Option(None, "--output", help="Save response to file"), - dry_run: bool = typer.Option(False, "--dry-run", help="Simulate first"), -) -> dict[str, Any]: - """Execute a payment or pay for an x402 service.""" - if dry_run: - return simulate( - recipient=recipient, - amount=amount or "0.00", - idempotency_key=idempotency_key, - destination_chain=destination_chain, - fee_level=fee_level, - check_trust=check_trust, - skip_guards=skip_guards, - ) - - client = get_client() - - # If recipient is a URL, handle x402 flow - if recipient.startswith("http"): - typer.echo(f"πŸš€ Paying for x402 service: {recipient}") - payload: dict[str, Any] = { - "url": recipient, - "method": method, - } - if body: - payload["body"] = body - if header: - payload["headers"] = {h.split(":")[0]: h.split(":")[1].strip() for h in header} - if idempotency_key: - payload["idempotency_key"] = idempotency_key - - try: - response = client.post("/api/v1/x402/pay", json=payload) - response.raise_for_status() - data = response.json() - if output: - Path(output).write_text(json.dumps(data, indent=2)) - typer.echo(f"βœ… Response saved to {output}") - else: - 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 - - # Standard direct transfer - if not amount: - typer.echo("Error: --amount is required for direct transfers", err=True) - raise typer.Exit(1) - - payload = { - "recipient": recipient, - "amount": amount, - } - if purpose: - payload["purpose"] = purpose - if idempotency_key: - payload["idempotency_key"] = idempotency_key - if destination_chain: - payload["destination_chain"] = destination_chain - if fee_level: - payload["fee_level"] = fee_level - if check_trust: - payload["check_trust"] = True - if skip_guards: - payload["skip_guards"] = True - - try: - response = client.post("/api/v1/pay", json=payload) - 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 - - -@app.command() -def simulate( - recipient: str = typer.Option(..., "--recipient", help="Recipient to simulate"), - amount: str = typer.Option(..., "--amount", help="Amount to simulate"), - idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), - destination_chain: str | None = typer.Option( - None, "--destination-chain", help="Target network" - ), - fee_level: str | None = typer.Option( - None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" - ), - check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), - skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), -) -> dict[str, Any]: - """Simulate a payment without executing.""" - client = get_client() - - payload: dict[str, Any] = { - "recipient": recipient, - "amount": amount, - } - if idempotency_key: - payload["idempotency_key"] = idempotency_key - if destination_chain: - payload["destination_chain"] = destination_chain - if fee_level: - payload["fee_level"] = fee_level - if check_trust: - payload["check_trust"] = True - if skip_guards: - payload["skip_guards"] = True - - try: - response = client.post("/api/v1/simulate", json=payload) - 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 - - -@app.command() -def ledger( - limit: int = typer.Option(20, "--limit", help="Number of transactions to fetch"), -) -> dict[str, Any]: - """List transaction history.""" - return list_tx(limit=limit) - - -@app.command() -def list_tx( - limit: int = typer.Option(20, "--limit", help="Number of transactions to fetch"), -) -> dict[str, Any]: - """List transaction history.""" - client = get_client() - - try: - response = client.get("/api/v1/transactions", params={"limit": limit}) - 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 - - -@app.command() -def create_intent( - recipient: str = typer.Option(..., "--recipient", help="Recipient"), - amount: str = typer.Option(..., "--amount", help="Amount"), - purpose: str | None = typer.Option(None, "--purpose", help="Purpose"), - expires_in: int | None = typer.Option(None, "--expires-in", help="Expiry in seconds"), - idempotency_key: str | None = typer.Option(None, "--idempotency-key", help="Idempotency key"), - destination_chain: str | None = typer.Option( - None, "--destination-chain", help="Target network" - ), - fee_level: str | None = typer.Option( - None, "--fee-level", help="Gas fee level (LOW, MEDIUM, HIGH)" - ), - check_trust: bool = typer.Option(False, "--check-trust", help="Run Trust Gate check"), - skip_guards: bool = typer.Option(False, "--skip-guards", help="Skip guards (OWNER ONLY)"), -) -> dict[str, Any]: - """Create a payment intent (authorize).""" - client = get_client() - - payload: dict[str, Any] = { - "recipient": recipient, - "amount": amount, - } - if purpose: - payload["purpose"] = purpose - if expires_in: - payload["expires_in"] = expires_in - if idempotency_key: - payload["idempotency_key"] = idempotency_key - if destination_chain: - payload["destination_chain"] = destination_chain - if fee_level: - payload["fee_level"] = fee_level - if check_trust: - payload["check_trust"] = True - if skip_guards: - payload["skip_guards"] = True - - try: - response = client.post("/api/v1/intents", json=payload) - 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 - - -@app.command() -def confirm_intent( - intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to confirm"), -) -> dict[str, Any]: - """Confirm a payment intent (capture).""" - client = get_client() - - try: - response = client.post(f"/api/v1/intents/{intent_id}/confirm") - 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 - - -@app.command() -def get_intent( - intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to fetch"), -) -> dict[str, Any]: - """Get a payment intent.""" - client = get_client() - - try: - response = client.get(f"/api/v1/intents/{intent_id}") - 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 - - -@app.command() -def cancel_intent( - intent_id: str = typer.Option(..., "--intent-id", help="Intent ID to cancel"), - reason: str | None = typer.Option(None, "--reason", help="Cancel reason"), -) -> dict[str, Any]: - """Cancel a payment intent.""" - client = get_client() - - try: - response = client.delete( - f"/api/v1/intents/{intent_id}", params={"reason": reason} if reason else {} - ) - 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 - - -@app.command() -def can_pay( - recipient: str = typer.Option(..., "--recipient", help="Recipient to check"), -) -> dict[str, Any]: - """Check if recipient is allowed.""" - client = get_client() - - try: - response = client.get("/api/v1/can-pay", params={"recipient": recipient}) - 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 - - -@app.command() -def serve( - price: float = typer.Option(..., "--price", help="Price per request in USDC"), - endpoint: str = typer.Option(..., "--endpoint", help="Endpoint path to expose"), - exec_cmd: str = typer.Option(..., "--exec", help="Command to execute on success"), - port: int = typer.Option(8000, "--port", help="Local port to listen on"), -) -> None: - """Expose a local service behind an x402 payment gate.""" - import uvicorn - from fastapi import FastAPI, Request, Response - from fastapi.responses import JSONResponse - - server_app = FastAPI() - client = get_client() - - @server_app.api_route(endpoint, methods=["GET", "POST", "PUT", "DELETE"]) - async def payment_gate(request: Request): - # 1. Check for x402 header (V2) - sig = request.headers.get("PAYMENT-SIGNATURE") - if not sig: - # Return 402 with requirements - _config = load_config() - wallet_addr = client.get("/api/v1/address").json().get("address") - - requirements = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", # ARC Testnet - "amount": str(int(price * 10**6)), - "payTo": wallet_addr, - } - ], - } - encoded = base64.b64encode(json.dumps(requirements).encode()).decode() - return JSONResponse( - status_code=402, - content={"detail": "Payment Required"}, - headers={"PAYMENT-REQUIRED": encoded}, - ) - - # 2. Verify payment with OmniClaw Server - try: - # We need to extract the sender and amount from the signature or payload - # For the demo, we'll assume the signature is valid if the server verifies it - verify_payload = { - "signature": sig, - "amount": str(price), - "sender": "unknown", # extracted from sig later - "resource": str(request.url), - } - v_resp = client.post("/api/v1/x402/verify", json=verify_payload) - v_resp.raise_for_status() - - if not v_resp.json().get("valid"): - return JSONResponse(status_code=402, content={"detail": "Invalid Payment"}) - - except Exception as e: - return JSONResponse(status_code=500, content={"detail": f"Verification failed: {e}"}) - - # 3. Success: Run the command - try: - env = os.environ.copy() - env["OMNICLAW_PAYER_ADDRESS"] = v_resp.json().get("sender", "unknown") - env["OMNICLAW_AMOUNT_USD"] = str(price) - - result = subprocess.run(exec_cmd, shell=True, capture_output=True, text=True, env=env) - return Response(content=result.stdout, media_type="text/plain") - except Exception as e: - return JSONResponse(status_code=500, content={"detail": f"Execution failed: {e}"}) - - typer.echo(f"🌐 OmniClaw Service exposed at http://localhost:{port}{endpoint}") - typer.echo(f"πŸ’° Price: ${price} USDC") - typer.echo(f"πŸ› οΈ Exec: {exec_cmd}") - - uvicorn.run(server_app, host="0.0.0.0", port=port) - - -@app.command() -def status() -> dict[str, Any]: - """Get agent status and health.""" - client = get_client() - config = load_config() - - try: - # Get multiple stats for a complete status report - health = client.get("/api/v1/health").json() - balance_data = client.get("/api/v1/balance").json() - addr_data = client.get("/api/v1/address").json() - - status_data = { - "Agent": config.get("wallet", "unknown"), - "Wallet": addr_data.get("address"), - "Balance": f"${balance_data.get('available')} available", - "Guards": "active" if health.get("status") == "ok" else "degraded", - "Circle": "connected" if health.get("status") == "ok" else "disconnected", - "Circuit": "CLOSED" if health.get("status") == "ok" else "OPEN", - } - - # Print in the premium format from the user vision - typer.echo(f"Agent: {status_data['Agent']}") - typer.echo(f"Wallet: {status_data['Wallet']}") - typer.echo(f"Balance: {status_data['Balance']}") - typer.echo(f"Guards: {status_data['Guards']}") - circle_icon = "βœ…" if status_data["Circle"] == "connected" else "❌" - circuit_icon = "βœ…" if status_data["Circuit"] == "CLOSED" else "⚠️" - typer.echo(f"Circle: {status_data['Circle']} {circle_icon}") - typer.echo(f"Circuit: {status_data['Circuit']} {circuit_icon}") - - return status_data - except Exception as e: - typer.echo(f"Error fetching status: {e}", err=True) - raise typer.Exit(1) from e - - -@app.command() -def ping() -> dict[str, Any]: - """Health check.""" - from omniclaw import __version__ - - client = get_client() - - try: - response = client.get("/api/v1/health") - response.raise_for_status() - data = response.json() - data["version"] = __version__ - 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 main() -> int: - """Main entry point.""" - return app() +__all__ = ["app", "main"] if __name__ == "__main__": diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index c460eb2..690de1e 100644 --- a/src/omniclaw/client.py +++ b/src/omniclaw/client.py @@ -3,14 +3,15 @@ from __future__ import annotations import asyncio -import contextlib import contextvars +import ipaddress import os import re import uuid from datetime import datetime, timezone from decimal import Decimal from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse import httpx @@ -43,6 +44,7 @@ TransactionInfo, WalletInfo, WalletSetInfo, + network_to_caip2, ) from omniclaw.guards.base import PaymentContext from omniclaw.guards.manager import GuardManager @@ -59,7 +61,6 @@ DepositResult, GatewayBalance, GatewayMiddleware, - NanoKeyVault, NanopaymentAdapter, NanopaymentClient, NanopaymentNotInitializedError, @@ -97,7 +98,7 @@ def __init__( self, circle_api_key: str | None = None, entity_secret: str | None = None, - network: Network = Network.ARC_TESTNET, + network: Network | None = None, log_level: int | str | None = None, trust_policy: TrustPolicy | str | None = None, rpc_url: str | None = None, @@ -187,7 +188,6 @@ def __init__( ) # Initialize Nanopayments fields FIRST (before router registration uses them) - self._nano_vault: NanoKeyVault | None = None self._nano_client: NanopaymentClient | None = None self._nano_adapter: NanopaymentAdapter | None = None self._nano_http: httpx.AsyncClient | None = None @@ -266,34 +266,43 @@ def _enforce_production_startup_requirements(self) -> None: }, ) + def _is_allowed_insecure_http(self, url: str) -> bool: + """Allow HTTP URLs only for localhost/private networks in non-production.""" + if not url.startswith("http://"): + return False + parsed = urlparse(url) + host = parsed.hostname or "" + if host in {"localhost", "127.0.0.1"}: + return True + + env = os.environ.get("OMNICLAW_ENV", "development").lower() + if env in {"prod", "production", "mainnet"}: + return False + + try: + ip = ipaddress.ip_address(host) + return ip.is_private or ip.is_loopback or ip.is_link_local + except ValueError: + return host.endswith(".local") + + def _nanopayment_network(self) -> str: + """Derive CAIP-2 network for nanopayments from OMNICLAW_NETWORK.""" + network = network_to_caip2(self._config.network) + if not network: + raise ConfigurationError( + "Nanopayments network is not configured. Set OMNICLAW_NETWORK to an EVM chain." + ) + return network + def _init_nanopayments(self) -> None: - """Initialize nanopayments components (NanoKeyVault, NanopaymentClient, NanopaymentAdapter).""" + """Initialize nanopayments components (direct private key only).""" if not self._config.nanopayments_enabled: return try: - # Pre-import and warmup Circle SDK to resolve all lazy modules before - # any parallel threads (asyncio.to_thread) try to import concurrently. - # The Circle Python SDK uses a lazy module loader that breaks when - # multiple threads trigger concurrent resolution of the same lazy module. - try: - from circle.web3 import developer_controlled_wallets, utils - - warmup_client = utils.init_developer_controlled_wallets_client( - api_key=self._config.circle_api_key, - entity_secret=self._config.entity_secret, - ) - # Force resolution of all lazy submodules by instantiating API classes - developer_controlled_wallets.WalletSetsApi(warmup_client) - developer_controlled_wallets.WalletsApi(warmup_client) - developer_controlled_wallets.TransactionsApi(warmup_client) - except Exception as warmup_exc: - self._logger.debug(f"Circle SDK warmup: {warmup_exc}") - import httpx from omniclaw.protocols.nanopayments import ( - NanoKeyVault, NanopaymentAdapter, NanopaymentClient, ) @@ -305,28 +314,30 @@ def _init_nanopayments(self) -> None: environment=self._config.nanopayments_environment, api_key=self._config.circle_api_key, ) - self._nano_vault = NanoKeyVault( - entity_secret=self._config.entity_secret, - storage_backend=self._storage, - circle_api_key=self._config.circle_api_key, - nanopayments_environment=self._config.nanopayments_environment, - default_network=self._config.nanopayments_default_network, - ) - self._nano_adapter = NanopaymentAdapter( - vault=self._nano_vault, + + if not self._config.nanopayments_private_key: + self._logger.warning( + "nanopayments_private_key not configured. Nanopayment signing disabled." + ) + self._nano_adapter = None + return + + network = self._nanopayment_network() + self._nano_adapter = NanopaymentAdapter.from_private_key( + private_key=self._config.nanopayments_private_key, nanopayment_client=self._nano_client, http_client=self._nano_http, + network=network, auto_topup_enabled=self._config.nanopayments_auto_topup, auto_topup_threshold=self._config.nanopayments_topup_threshold, auto_topup_amount=self._config.nanopayments_topup_amount, strict_settlement=self._config.payment_strict_settlement, ) - self._logger.info("Nanopayments initialized (EIP-3009 Circle Gateway)") + self._logger.info(f"Nanopayments initialized (direct private key, network={network})") except Exception as e: self._logger.warning( f"Nanopayments initialization failed: {e}. Disabling nanopayments." ) - self._nano_vault = None self._nano_client = None self._nano_adapter = None @@ -369,15 +380,6 @@ def webhooks(self) -> WebhookParser: # Nanopayments (EIP-3009 Circle Gateway) # ------------------------------------------------------------------------- - @property - def vault(self) -> NanoKeyVault | None: - """ - Get the NanoKeyVault for managing EOA keys used in nanopayments. - - Returns None if nanopayments are not initialized. - """ - return self._nano_vault - @property def nanopayment_adapter(self) -> NanopaymentAdapter | None: """ @@ -424,16 +426,15 @@ async def premium(payment=Depends(omniclaw.gateway().require("$0.001"))): # For Circle, we need nanopayments initialized if (facilitator is None or facilitator == "circle") and ( - not self._nano_client or not self._nano_vault + not self._nano_client or not self._nano_adapter ): raise NanopaymentNotInitializedError() # If no seller_address provided, try to get from wallet if not seller_address: - if self._nano_vault: - # Try to get from existing wallet - with contextlib.suppress(Exception): - seller_address = await self._nano_vault.get_address(alias=None) + if self._nano_adapter: + # Direct private key mode: use adapter address + seller_address = self._nano_adapter.address if not seller_address: raise ValueError( "seller_address is required. " @@ -552,79 +553,6 @@ async def get_data(): raise ValueError("current_payment() called outside of a @sell() decorated function") return info - # ------------------------------------------------------------------------- - # Key management (delegates to NanoKeyVault) - # ------------------------------------------------------------------------- - - async def add_key(self, alias: str, private_key: str) -> str: - """ - Import an existing EOA private key into the NanoKeyVault. - - The key is encrypted and stored securely. Raw private keys are never - exposed to agents β€” they receive only the alias string. - - Args: - alias: Unique identifier for this key (e.g. "alice-nano"). - private_key: EOA private key hex (with or without 0x prefix). - - Returns: - The EOA address derived from the key. - - Raises: - DuplicateKeyAliasError: If a key with this alias already exists. - InvalidPrivateKeyError: If the private key is invalid. - """ - if not self._nano_vault: - raise NanopaymentNotInitializedError() - return await self._nano_vault.add_key(alias, private_key) - - async def generate_key(self, alias: str, network: str | None = None) -> str: - """ - Generate a new EOA keypair and store it encrypted in the vault. - - The operator must fund the generated address with USDC before - it can be used for nanopayments. - - Args: - alias: Unique identifier for this key. - network: CAIP-2 network for this key (e.g. 'eip155:5042002'). - If None, uses the default network for the environment. - - Returns: - The new EOA address. - - Raises: - DuplicateKeyAliasError: If a key with this alias already exists. - """ - if not self._nano_vault: - raise NanopaymentNotInitializedError() - return await self._nano_vault.generate_key(alias, network=network) - - async def set_default_key(self, alias: str) -> None: - """ - Set the default nanopayment key for agents that don't specify one. - - Args: - alias: The key alias to set as default. - - Raises: - KeyNotFoundError: If no key with this alias exists. - """ - if not self._nano_vault: - raise NanopaymentNotInitializedError() - await self._nano_vault.set_default_key(alias) - - async def list_keys(self) -> list[str]: - """ - List all key aliases in the vault (safe for operator use). - - Returns: - List of key aliases. Does NOT return the actual keys. - """ - if not self._nano_vault: - return [] - return await self._nano_vault.list_keys() - # ------------------------------------------------------------------------- # Gateway Wallet management (on-chain deposit/withdraw) # ------------------------------------------------------------------------- @@ -634,6 +562,8 @@ async def deposit_to_gateway( wallet_id: str, amount_usdc: str, network: str | None = None, + check_gas: bool = False, + skip_if_insufficient_gas: bool = True, ) -> DepositResult: """ Deposit USDC into the Circle Gateway Wallet for nanopayment use. @@ -646,6 +576,8 @@ async def deposit_to_gateway( amount_usdc: Amount in USDC decimal (e.g. "10.00"). network: CAIP-2 network (e.g. 'eip155:5042002'). Defaults to config nanopayments_environment. + check_gas: Whether to check gas balance before deposit. Default False. + skip_if_insufficient_gas: Skip deposit if insufficient gas. Default True. Returns: DepositResult with approval_tx_hash and deposit_tx_hash. @@ -654,21 +586,22 @@ async def deposit_to_gateway( NanopaymentNotInitializedError: If nanopayments are disabled. KeyNotFoundError: If the key alias doesn't exist. """ - if not self._nano_vault or not self._nano_client: + if not self._nano_adapter or not self._nano_client: raise NanopaymentNotInitializedError() from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - key_alias = f"wallet-{wallet_id}" - raw_key = await self._nano_vault.get_raw_key(alias=key_alias) - net = network or await self._nano_vault.get_network(alias=key_alias) + private_key = self._nano_adapter.signer.raw_key + net = network or self._nanopayment_network() manager = GatewayWalletManager( - private_key=raw_key, + private_key=private_key, network=net, rpc_url=self._config.rpc_url or "", nanopayment_client=self._nano_client, ) - return await manager.deposit(amount_usdc) + return await manager.deposit( + amount_usdc, check_gas=check_gas, skip_if_insufficient_gas=skip_if_insufficient_gas + ) async def withdraw_from_gateway( self, @@ -697,16 +630,15 @@ async def withdraw_from_gateway( Raises: NanopaymentNotInitializedError: If nanopayments are disabled. """ - if not self._nano_vault or not self._nano_client: + if not self._nano_adapter or not self._nano_client: raise NanopaymentNotInitializedError() from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - key_alias = f"wallet-{wallet_id}" - raw_key = await self._nano_vault.get_raw_key(alias=key_alias) - net = network or await self._nano_vault.get_network(alias=key_alias) + private_key = self._nano_adapter.signer.raw_key + net = network or self._nanopayment_network() manager = GatewayWalletManager( - private_key=raw_key, + private_key=private_key, network=net, rpc_url=self._config.rpc_url or "", nanopayment_client=self._nano_client, @@ -733,11 +665,30 @@ async def get_gateway_balance( Raises: NanopaymentNotInitializedError: If nanopayments are disabled. """ - if not self._nano_vault: + if not self._nano_adapter or not self._nano_client: raise NanopaymentNotInitializedError() - key_alias = f"wallet-{wallet_id}" - return await self._nano_vault.get_balance(alias=key_alias) + 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 (bypasses Circle API) + available = await manager.get_gateway_available_balance() + total = available # On-chain doesn't separate total/available the same way + from omniclaw.protocols.nanopayments.wallet import GatewayBalance + + return GatewayBalance( + total=total, + available=available, + formatted_total=f"{total / 1e6} USDC", + formatted_available=f"{available / 1e6} USDC", + ) def configure_nanopayments( self, @@ -774,32 +725,17 @@ async def create_agent( agent_name: str, blockchain: Network | str | None = None, apply_default_guards: bool = True, - nanopayment_key_alias: str | bool | None = None, ) -> tuple[WalletSetInfo, WalletInfo]: """ - Create a wallet for an AI agent with optional nanopayment key. - - This is a convenience wrapper around create_agent_wallet that also - optionally generates or assigns a NanoKeyVault key for the agent. - - Agents receive only the `nano_key_alias` string β€” raw private keys - stay in the operator's vault and are never exposed to agents. + Create a wallet for an AI agent. Args: agent_name: Unique agent name (used as wallet set name). blockchain: Blockchain network (defaults to config network). apply_default_guards: Apply configured default guards to wallet. - nanopayment_key_alias: NanoKeyVault alias for the agent. - If None, no nanopayment key is created. - If True, auto-generates "agent-{name}-nano". Returns: Tuple of (wallet_set, wallet_info). - - Raises: - NanopaymentNotInitializedError: If nanopayment_key_alias is set - but nanopayments are disabled. - DuplicateKeyAliasError: If the generated key alias already exists. """ # Create the wallet wallet_set, wallet = await self.create_agent_wallet( @@ -808,24 +744,6 @@ async def create_agent( apply_default_guards=apply_default_guards, ) - # Optionally create a nanopayment key for this agent - if nanopayment_key_alias is not None: - if not self._nano_vault: - raise NanopaymentNotInitializedError() - - # True β†’ auto-generate alias - if nanopayment_key_alias is True: - key_alias = f"agent-{agent_name}-nano" - else: - key_alias = nanopayment_key_alias - - # Generate the key (operator funds the resulting address) - address = await self._nano_vault.generate_key(key_alias) - self._logger.info( - f"Generated nanopayment key for agent '{agent_name}': " - f"alias={key_alias}, address={address}" - ) - return wallet_set, wallet async def __aenter__(self) -> OmniClaw: @@ -924,8 +842,6 @@ async def create_agent_wallet( """ Create a wallet for an AI agent, optionally applying default SDK guards. - Also creates a nanopayment key so the agent can use gateway operations. - Args: agent_name: Unique agent name (used as wallet set name) blockchain: Blockchain network (defaults to config network) @@ -946,41 +862,6 @@ async def create_agent_wallet( if apply_default_guards: await self.apply_default_guards(wallet.id) - # 10/10 RESILIENCE: Key generation can fail on slow RPCs (free Base nodes). - # We retry with exponential backoff to ensure the agent is ready. - if self._nano_vault: - key_alias = f"wallet-{wallet.id}" - max_retries = 5 - base_delay = 3 - - for attempt in range(max_retries): - try: - address = await self._nano_vault.generate_key(key_alias) - self._logger.info( - f"Generated nanopayment key for wallet '{wallet.id}': " - f"alias={key_alias}, address={address} (Attempt {attempt + 1})" - ) - break - except Exception as e: - error_msg = str(e).lower() - if "already exists" in error_msg: - self._logger.info( - f"Nanopayment key already exists for wallet '{wallet.id}': " - f"alias={key_alias}. Recovery successful." - ) - break - - if attempt == max_retries - 1: - self._logger.warning( - f"Final attempt failed to create nanopayment key: {e}. Wallet will start in Degraded mode." - ) - else: - delay = base_delay * (2**attempt) - self._logger.warning( - f"Nanopayment key generation failed (Attempt {attempt + 1}): {e}. Retrying in {delay}s..." - ) - await asyncio.sleep(delay) - return wallet_set, wallet async def apply_default_guards(self, wallet_id: str) -> None: @@ -1115,6 +996,16 @@ async def pay( if amount_decimal <= 0: raise ValidationError(f"Payment amount must be positive. Got: {amount_decimal}") + if fee_level is None: + fee_level = FeeLevel.MEDIUM + elif isinstance(fee_level, str): + try: + fee_level = FeeLevel(fee_level.upper()) + except ValueError as exc: + raise ValidationError( + f"Invalid fee_level {fee_level!r}. Use LOW, MEDIUM, or HIGH." + ) from exc + if self._config.auto_reconcile_pending_settlements: try: await self.reconcile_pending_settlements(wallet_id=wallet_id, limit=20) @@ -1136,8 +1027,12 @@ async def pay( f"Invalid EVM address: {recipient!r}. " f"Must be '0x' followed by exactly 40 hex characters." ) - # URL recipients (x402) must be valid HTTPS - elif recipient.startswith("http") and not recipient.startswith("https://"): + # URL recipients (x402) must be valid HTTPS (or dev HTTP on localhost/private) + elif ( + recipient.startswith("http") + and not recipient.startswith("https://") + and not self._is_allowed_insecure_http(recipient) + ): raise ValidationError(f"x402 recipient URL must use HTTPS. Got: {recipient!r}") if not idempotency_key: @@ -1232,12 +1127,23 @@ async def pay( # Detect payment route early to know which balance to check try: + # Try to get source network from Circle wallet first, then fall back to config default. + source_network = None + 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 + source_network = Network.ETH_SEPOLIA + detected_route = ( self._router.detect_method( recipient, - source_network=Network.from_string( - self._wallet_service.get_wallet(wallet_id).blockchain - ), + source_network=source_network, destination_chain=kwargs.get("destination_chain"), amount=amount_decimal, ) @@ -1254,24 +1160,45 @@ async def pay( # Reserve budget/limits first (atomic counters) reservation_tokens = await guards_chain.reserve(context) guards_passed = [g.name for g in guards_chain] - except ValueError as e: + except Exception as e: + from omniclaw.guards.confirm import ConfirmRequiredError + await self._ledger.update_status( ledger_entry.id, LedgerEntryStatus.BLOCKED, tx_hash=None, ) - return PaymentResult( - success=False, - transaction_id=None, - blockchain_tx=None, - amount=amount_decimal, - recipient=recipient, - method=PaymentMethod.TRANSFER, - status=PaymentStatus.BLOCKED, - error=f"Blocked by guard: {e}", - guards_passed=guards_passed, - metadata={"guard_reason": str(e)}, - ) + if isinstance(e, ConfirmRequiredError): + return PaymentResult( + success=False, + transaction_id=None, + blockchain_tx=None, + amount=amount_decimal, + recipient=recipient, + method=PaymentMethod.TRANSFER, + status=PaymentStatus.BLOCKED, + error="Confirmation required", + guards_passed=guards_passed, + metadata={ + "guard_reason": str(e), + "confirmation_id": e.confirmation_id, + "confirmation_required": True, + }, + ) + if isinstance(e, ValueError): + return PaymentResult( + success=False, + transaction_id=None, + blockchain_tx=None, + amount=amount_decimal, + recipient=recipient, + method=PaymentMethod.TRANSFER, + status=PaymentStatus.BLOCKED, + error=f"Blocked by guard: {e}", + guards_passed=guards_passed, + metadata={"guard_reason": str(e)}, + ) + raise # Acquire Fund Lock (Mutex) to prevent double-spend race conditions lock_ttl_seconds = max(60, int(self._config.transaction_poll_timeout) + 30) @@ -1318,18 +1245,28 @@ async def pay( # Check if this is a Gateway-based route route_uses_gateway = detected_route in (PaymentMethod.X402, PaymentMethod.NANOPAYMENT) - if route_uses_gateway and self._nano_vault: + if route_uses_gateway and self._nano_adapter: try: - gateway_balance = await self._nano_vault.get_balance( - alias=f"wallet-{wallet_id}" + # Get balance via GatewayWalletManager - 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, ) - # Gateway balance is available for spending (no reservations concept) - available = gateway_balance.available + # Use on-chain query (bypasses Circle API) + available = await manager.get_gateway_available_balance() balance_source = f"Gateway: {available}" - except Exception: - # Fall back to circle balance if gateway lookup fails - available = circle_balance - reserved_total - balance_source = f"Circle: {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 = 0 + balance_source = "Gateway: (check failed)" else: available = circle_balance - reserved_total balance_source = f"Circle: {available}" @@ -1610,14 +1547,26 @@ async def simulate( route_uses_gateway = detected_route in (PaymentMethod.X402, PaymentMethod.NANOPAYMENT) - if route_uses_gateway and self._nano_vault: + if route_uses_gateway and self._nano_adapter: try: - gateway_balance = await self._nano_vault.get_balance(alias=f"wallet-{wallet_id}") - available = gateway_balance.available + # 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: - available = circle_balance - reserved_total - balance_source = f"Circle: {available}" + except Exception as e: + self._logger.warning(f"Gateway balance check failed: {e}") + available = 0 + balance_source = "Gateway: (check failed)" else: available = circle_balance - reserved_total balance_source = f"Circle: {available}" @@ -1767,7 +1716,11 @@ async def create_payment_intent( f"Invalid EVM address: {recipient!r}. " f"Must be '0x' followed by exactly 40 hex characters." ) - elif recipient.startswith("http") and not recipient.startswith("https://"): + elif ( + recipient.startswith("http") + and not recipient.startswith("https://") + and not self._is_allowed_insecure_http(recipient) + ): raise ValidationError(f"x402 recipient URL must use HTTPS. Got: {recipient!r}") # Acquire lock to ensure balance isn't changing while we simulate and reserve diff --git a/src/omniclaw/core/config.py b/src/omniclaw/core/config.py index 0a35357..fff9ea1 100644 --- a/src/omniclaw/core/config.py +++ b/src/omniclaw/core/config.py @@ -26,7 +26,7 @@ class Config: """SDK configuration.""" circle_api_key: str - entity_secret: str + entity_secret: str = "" network: Network = Network.ETH storage_backend: str = "memory" redis_url: str | None = None @@ -88,11 +88,8 @@ class Config: nanopayments_micro_threshold: str = "1.00" """Amount below which nanopayments are used instead of standard transfer.""" - nanopayments_default_key_alias: str | None = None - """Default NanoKeyVault key alias for agents.""" - - nanopayments_default_network: str | None = None - """Default CAIP-2 network for nanopayments (e.g., 'eip155:1' for mainnet, 'eip155:11155111' for Sepolia).""" + nanopayments_private_key: str | None = None + """Raw EOA private key for direct nanopayment signing (no vault needed).""" payment_strict_settlement: bool = True """If true, success=True is emitted only for irreversible settlement.""" @@ -103,8 +100,13 @@ class Config: def __post_init__(self) -> None: if not self.circle_api_key: raise ValueError("circle_api_key is required") - if not self.entity_secret: - raise ValueError("entity_secret is required") + if not self.entity_secret and not self.nanopayments_private_key: + import logging + + logging.getLogger(__name__).warning( + "Neither entity_secret nor nanopayments_private_key is set. " + "Nanopayment signing will not be available." + ) @classmethod def from_env(cls, **overrides: Any) -> Config: @@ -118,8 +120,11 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: circle_api_key = override_or_env("circle_api_key", "CIRCLE_API_KEY") or _get_env_var( "CIRCLE_API_KEY", required=True ) - entity_secret = override_or_env("entity_secret", "ENTITY_SECRET") or _get_env_var( - "ENTITY_SECRET", required=True + entity_secret = override_or_env("entity_secret", "ENTITY_SECRET", default="") + + # Direct private key for nanopayments + nanopayments_private_key = override_or_env( + "nanopayments_private_key", "OMNICLAW_PRIVATE_KEY" ) # Parse network from environment @@ -182,12 +187,6 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: nanopayments_micro_threshold = override_or_env( "nanopayments_micro_threshold", "OMNICLAW_NANOPAYMENTS_MICRO_THRESHOLD", "1.00" ) - nanopayments_default_key_alias = override_or_env( - "nanopayments_default_key_alias", "OMNICLAW_NANOPAYMENTS_DEFAULT_KEY" - ) - nanopayments_default_network = override_or_env( - "nanopayments_default_network", "OMNICLAW_NANOPAYMENTS_DEFAULT_NETWORK" - ) payment_strict_settlement = ( overrides.get("payment_strict_settlement") if "payment_strict_settlement" in overrides @@ -204,7 +203,7 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: return cls( circle_api_key=circle_api_key, # type: ignore - entity_secret=entity_secret, # type: ignore + entity_secret=entity_secret or "", # type: ignore network=network, default_wallet_id=default_wallet_id, circle_api_base_url=overrides.get("circle_api_base_url", cls.circle_api_base_url), @@ -233,8 +232,7 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: nanopayments_topup_threshold=nanopayments_topup_threshold, nanopayments_topup_amount=nanopayments_topup_amount, nanopayments_micro_threshold=nanopayments_micro_threshold, - nanopayments_default_key_alias=nanopayments_default_key_alias, - nanopayments_default_network=nanopayments_default_network, + nanopayments_private_key=nanopayments_private_key, payment_strict_settlement=payment_strict_settlement, auto_reconcile_pending_settlements=auto_reconcile_pending_settlements, storage_backend=storage_backend, @@ -270,8 +268,7 @@ def with_updates(self, **updates: Any) -> Config: "nanopayments_topup_threshold": self.nanopayments_topup_threshold, "nanopayments_topup_amount": self.nanopayments_topup_amount, "nanopayments_micro_threshold": self.nanopayments_micro_threshold, - "nanopayments_default_key_alias": self.nanopayments_default_key_alias, - "nanopayments_default_network": self.nanopayments_default_network, + "nanopayments_private_key": self.nanopayments_private_key, "payment_strict_settlement": self.payment_strict_settlement, "auto_reconcile_pending_settlements": self.auto_reconcile_pending_settlements, "storage_backend": self.storage_backend, diff --git a/src/omniclaw/core/types.py b/src/omniclaw/core/types.py index 233b375..fa2a49d 100644 --- a/src/omniclaw/core/types.py +++ b/src/omniclaw/core/types.py @@ -157,6 +157,43 @@ def normalize_network(network: Network | str | None) -> Network | None: return Network.from_string(str(network)) +def network_to_caip2(network: Network | str | None) -> str | None: + """ + Convert a Network (or network string) to CAIP-2 format for Gateway nanopayments. + + Returns None when the network cannot be mapped. + """ + if network is None: + return None + if isinstance(network, str): + normalized = network.strip() + if ":" in normalized: + return normalized.lower() + try: + network = Network.from_string(normalized) + except Exception: + return None + + caip2_map = { + Network.ETH: "eip155:1", + Network.ETH_SEPOLIA: "eip155:11155111", + Network.AVAX: "eip155:43114", + Network.AVAX_FUJI: "eip155:43113", + Network.MATIC: "eip155:137", + Network.MATIC_AMOY: "eip155:80002", + Network.ARB: "eip155:42161", + Network.ARB_SEPOLIA: "eip155:421614", + Network.BASE: "eip155:8453", + Network.BASE_SEPOLIA: "eip155:84532", + Network.OP: "eip155:10", + Network.OP_SEPOLIA: "eip155:11155420", + Network.UNI: "eip155:130", + Network.UNI_SEPOLIA: "eip155:1301", + Network.ARC_TESTNET: "eip155:5042002", + } + return caip2_map.get(network) + + class PaymentStrategy(str, Enum): """Strategy for handling payment execution reliability.""" diff --git a/src/omniclaw/guards/confirm.py b/src/omniclaw/guards/confirm.py index 3599919..965dd1e 100644 --- a/src/omniclaw/guards/confirm.py +++ b/src/omniclaw/guards/confirm.py @@ -8,9 +8,21 @@ from collections.abc import Awaitable, Callable from decimal import Decimal +from typing import Any +from uuid import uuid4 from omniclaw.events import event_emitter from omniclaw.guards.base import Guard, GuardResult, PaymentContext +from omniclaw.guards.confirmations import ConfirmationStore + + +class ConfirmRequiredError(ValueError): + """Raised when a payment requires manual confirmation.""" + + def __init__(self, confirmation_id: str) -> None: + super().__init__("Payment requires confirmation") + self.confirmation_id = confirmation_id + # Type for confirmation callback ConfirmCallback = Callable[[PaymentContext], Awaitable[bool]] @@ -47,6 +59,8 @@ def __init__( self._callback = confirm_callback self._threshold = threshold self._always_confirm = always_confirm + self._storage: Any | None = None + self._confirmations: ConfirmationStore | None = None @property def name(self) -> str: @@ -56,6 +70,10 @@ def name(self) -> str: def threshold(self) -> Decimal | None: return self._threshold + def bind_storage(self, storage: Any) -> None: + self._storage = storage + self._confirmations = ConfirmationStore(storage) + def _needs_confirmation(self, amount: Decimal) -> bool: """Check if amount requires confirmation.""" if self._always_confirm: @@ -129,6 +147,51 @@ async def check(self, context: PaymentContext) -> GuardResult: }, ) + async def reserve(self, context: PaymentContext) -> str | None: + """Reserve confirmation or allow if already approved.""" + if not self._needs_confirmation(context.amount): + return None + + # Callback path (if configured) is handled in check() + if self._callback is not None: + result = await self.check(context) + if not result.allowed: + raise ValueError(result.reason) + return None + + metadata = context.metadata or {} + confirmation_id = metadata.get("confirmation_id") + if not confirmation_id: + idem = metadata.get("idempotency_key") + if idem: + confirmation_id = f"{context.wallet_id}:{idem}" + + # If confirmation_id provided, check status + if confirmation_id and self._confirmations: + record = await self._confirmations.get(confirmation_id) + if record: + status = record.get("status") + if status == "APPROVED": + await self._confirmations.consume(confirmation_id) + return None + if status == "DENIED": + raise ValueError("Payment confirmation denied") + + # Create a confirmation record + if self._confirmations: + if not confirmation_id: + confirmation_id = str(uuid4()) + await self._confirmations.create( + wallet_id=context.wallet_id, + recipient=context.recipient, + amount=str(context.amount), + purpose=context.purpose, + confirmation_id=confirmation_id, + idempotency_key=metadata.get("idempotency_key"), + ) + + raise ConfirmRequiredError(str(confirmation_id)) + def reset(self) -> None: """No state to reset.""" pass diff --git a/src/omniclaw/guards/confirmations.py b/src/omniclaw/guards/confirmations.py new file mode 100644 index 0000000..9678beb --- /dev/null +++ b/src/omniclaw/guards/confirmations.py @@ -0,0 +1,84 @@ +"""Confirmation request storage for ConfirmGuard approvals.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 + +from omniclaw.storage.base import StorageBackend + +CONFIRM_COLLECTION = "confirmations" + + +@dataclass +class ConfirmationRecord: + confirmation_id: str + wallet_id: str + recipient: str + amount: str + purpose: str | None + status: str + created_at: str + expires_at: str | None = None + idempotency_key: str | None = None + + +class ConfirmationStore: + """Persist and manage confirmation requests.""" + + def __init__(self, storage: StorageBackend) -> None: + self._storage = storage + + def _key(self, confirmation_id: str) -> str: + return confirmation_id + + async def get(self, confirmation_id: str) -> dict[str, Any] | None: + return await self._storage.get(CONFIRM_COLLECTION, self._key(confirmation_id)) + + async def create( + self, + *, + wallet_id: str, + recipient: str, + amount: str, + purpose: str | None, + confirmation_id: str | None = None, + idempotency_key: str | None = None, + ttl_minutes: int = 60, + ) -> ConfirmationRecord: + confirmation_id = confirmation_id or str(uuid4()) + now = datetime.now(timezone.utc) + expires_at = now + timedelta(minutes=ttl_minutes) + record = ConfirmationRecord( + confirmation_id=confirmation_id, + wallet_id=wallet_id, + recipient=recipient, + amount=amount, + purpose=purpose, + status="PENDING", + created_at=now.isoformat(), + expires_at=expires_at.isoformat(), + idempotency_key=idempotency_key, + ) + await self._storage.save(CONFIRM_COLLECTION, self._key(confirmation_id), record.__dict__) + return record + + async def set_status(self, confirmation_id: str, status: str) -> dict[str, Any] | None: + record = await self.get(confirmation_id) + if not record: + return None + record["status"] = status + record["updated_at"] = datetime.now(timezone.utc).isoformat() + await self._storage.save(CONFIRM_COLLECTION, self._key(confirmation_id), record) + return record + + async def approve(self, confirmation_id: str) -> dict[str, Any] | None: + return await self.set_status(confirmation_id, "APPROVED") + + async def deny(self, confirmation_id: str) -> dict[str, Any] | None: + return await self.set_status(confirmation_id, "DENIED") + + async def consume(self, confirmation_id: str) -> dict[str, Any] | None: + return await self.set_status(confirmation_id, "CONSUMED") diff --git a/src/omniclaw/payment/router.py b/src/omniclaw/payment/router.py index 2bd3a95..b8d0718 100644 --- a/src/omniclaw/payment/router.py +++ b/src/omniclaw/payment/router.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from decimal import Decimal from typing import TYPE_CHECKING, Any @@ -120,8 +121,20 @@ async def pay( amount_decimal = Decimal(str(amount)) # Resolve source network - wallet = self._wallet_service.get_wallet(wallet_id) - source_network = Network.from_string(wallet.blockchain) + # 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 adapters = self._find_adapters( recipient, diff --git a/src/omniclaw/protocols/nanopayments/__init__.py b/src/omniclaw/protocols/nanopayments/__init__.py index 387f925..17082c3 100644 --- a/src/omniclaw/protocols/nanopayments/__init__.py +++ b/src/omniclaw/protocols/nanopayments/__init__.py @@ -5,7 +5,6 @@ Built on EIP-3009 TransferWithAuthorization for off-chain payment authorization. Architecture: - - NanoKeyVault: Secure EOA key management (operator-controlled) - EIP3009Signer: Cryptographic signing of payment authorizations - NanopaymentClient: Circle Gateway REST API wrapper - NanopaymentAdapter: Buyer-side payment execution @@ -104,10 +103,6 @@ async def premium(payment=Depends(gateway.require("$0.001"))): NanopaymentHTTPClient, ) -from omniclaw.protocols.nanopayments.keys import NanoKeyStore - -from omniclaw.protocols.nanopayments.vault import NanoKeyVault - from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager from omniclaw.protocols.nanopayments.middleware import ( @@ -163,9 +158,6 @@ async def premium(payment=Depends(gateway.require("$0.001"))): # Client "NanopaymentClient", "NanopaymentHTTPClient", - # Vault - "NanoKeyStore", - "NanoKeyVault", # Wallet "GatewayWalletManager", # Adapter diff --git a/src/omniclaw/protocols/nanopayments/adapter.py b/src/omniclaw/protocols/nanopayments/adapter.py index 02be6d9..08753d0 100644 --- a/src/omniclaw/protocols/nanopayments/adapter.py +++ b/src/omniclaw/protocols/nanopayments/adapter.py @@ -46,6 +46,7 @@ UnsupportedNetworkError, UnsupportedSchemeError, ) +from omniclaw.protocols.nanopayments.signing import EIP3009Signer from omniclaw.protocols.nanopayments.types import ( NanopaymentResult, PaymentPayload, @@ -54,7 +55,12 @@ ) if TYPE_CHECKING: - from omniclaw.protocols.nanopayments.vault import NanoKeyVault + from omniclaw.protocols.nanopayments.types import ( + PaymentRequirementsKind, + ) + from omniclaw.protocols.nanopayments.types import ( + ResourceInfo as ResourceInfoType, + ) logger = logging.getLogger(__name__) @@ -199,9 +205,10 @@ class NanopaymentAdapter: - Idempotency: Uses EIP-3009 nonce as idempotency key for safe retries Args: - vault: NanoKeyVault for signing payments. + signer: EIP3009Signer for signing payments (from raw private key). nanopayment_client: NanopaymentClient for settling payments. http_client: Shared httpx.AsyncClient for HTTP requests. + network: CAIP-2 network identifier. auto_topup_enabled: If True, auto-deposit when balance is low. auto_topup_threshold: Threshold in USDC decimal (e.g. "1.00"). auto_topup_amount: Amount to deposit when topping up. @@ -212,9 +219,10 @@ class NanopaymentAdapter: def __init__( self, - vault: NanoKeyVault, + signer: EIP3009Signer, nanopayment_client: NanopaymentClient, http_client: httpx.AsyncClient, + network: str = "eip155:5042002", auto_topup_enabled: bool = True, auto_topup_threshold: str = DEFAULT_GATEWAY_AUTO_TOPUP_THRESHOLD, auto_topup_amount: str = DEFAULT_GATEWAY_AUTO_TOPUP_AMOUNT, @@ -223,7 +231,8 @@ def __init__( retry_base_delay: float = 0.5, strict_settlement: bool = True, ) -> None: - self._vault = vault + self._signer = signer + self._network = network self._client = nanopayment_client self._http = http_client self._auto_topup = auto_topup_enabled @@ -234,6 +243,85 @@ def __init__( self._retry_base_delay = retry_base_delay self._strict_settlement = strict_settlement + @property + def address(self) -> str: + """ + The EOA address derived from the private key. + + This is the address that will be recorded as the payer + in Circle Gateway's settlement records. + """ + return self._signer.address + + @property + def signer(self) -> EIP3009Signer: + """ + The EIP3009Signer instance for signing payments. + + This is used internally for on-chain operations like deposits. + """ + return self._signer + + @classmethod + def from_private_key( + cls, + private_key: str, + nanopayment_client: NanopaymentClient, + http_client: httpx.AsyncClient, + network: str = "eip155:5042002", + **kwargs: Any, + ) -> NanopaymentAdapter: + """ + Create adapter from a raw private key. + + Args: + private_key: Raw EOA private key hex. + nanopayment_client: Circle Gateway API client. + http_client: Shared httpx client. + network: CAIP-2 network (default Arc Testnet). + **kwargs: Passed to __init__ (e.g. auto_topup_enabled). + """ + signer = EIP3009Signer(private_key) + return cls( + signer=signer, + nanopayment_client=nanopayment_client, + http_client=http_client, + network=network, + **kwargs, + ) + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + def _get_address(self) -> str: + """Get payer EOA address from signer.""" + return self._signer.address + + def _sign( + self, + requirements: PaymentRequirementsKind, + resource: ResourceInfoType | None = None, + amount_atomic: int | None = None, + ) -> PaymentPayload: + """Sign a payment using the EIP-3009 signer.""" + if amount_atomic is None: + amount_atomic = int(requirements.amount) + payload = self._signer.sign_transfer_with_authorization( + requirements=requirements, + amount_atomic=amount_atomic, + ) + # Attach resource info if provided + if resource is not None: + payload = PaymentPayload( + x402_version=payload.x402_version, + scheme=payload.scheme, + network=payload.network, + payload=payload.payload, + resource=resource, + ) + return payload + # ------------------------------------------------------------------------- # x402 URL payment # ------------------------------------------------------------------------- @@ -244,7 +332,6 @@ async def pay_x402_url( method: str = "GET", headers: dict | None = None, body: bytes | None = None, - nano_key_alias: str | None = None, ) -> NanopaymentResult: """ Pay for a URL-based resource via x402 with Gateway batching. @@ -257,7 +344,7 @@ async def pay_x402_url( 5. If not found: raise UnsupportedSchemeError (router falls back) 6. Get verifying_contract from NanopaymentClient if not in requirements 7. Auto-topup if balance is low - 8. Call vault.sign() to create signed PaymentPayload + 8. Call signer to create signed PaymentPayload 9. Retry request with PAYMENT-SIGNATURE header 10. Settle payment with Circle Gateway 11. Return NanopaymentResult @@ -271,7 +358,7 @@ async def pay_x402_url( method: HTTP method (GET, POST, etc.). headers: Additional headers for the request. body: Request body for POST/PUT methods. - nano_key_alias: Which vault key to use. + Returns: NanopaymentResult with payment details and response data. @@ -400,23 +487,23 @@ async def pay_x402_url( verifying_contract=verifying_contract, ), ) - updated_req = PaymentRequirements( - x402_version=requirements.x402_version, - accepts=(updated_kind,), + # 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, ) - - # Step 6: Auto-topup - if self._auto_topup: - try: - await self._check_and_topup(alias=nano_key_alias) - except Exception as exc: - logger.warning(f"Auto-topup failed: {exc}. Proceeding with payment anyway.") + payment_amount_atomic = int(updated_kind.amount) + if balance.available < payment_amount_atomic: + raise InsufficientBalanceError( + reason=f"Insufficient balance: available {balance.available}, required {payment_amount_atomic}", + payer=payer_address, + ) # Step 7: Sign payment (pass resource for Circle Gateway) - payer_address = await self._vault.get_address(alias=nano_key_alias) - payload = await self._vault.sign( + payer_address = self._get_address() + payload = self._sign( requirements=updated_kind, - alias=nano_key_alias, resource=resource, ) @@ -448,59 +535,44 @@ async def pay_x402_url( response_body=str(exc), ) from exc - # Step 9: Settle payment with Circle Gateway + # Step 9: Seller-side settlement via PAYMENT-RESPONSE header (x402 v2) content_delivered = _is_success_status(retry_resp.status_code) - settle_resp = None - settlement_error: str | None = None + payment_response = retry_resp.headers.get("payment-response") or retry_resp.headers.get( + "PAYMENT-RESPONSE" + ) + settlement_succeeded = False transaction = "" - - # Check circuit breaker before attempting settlement - if not self._circuit_breaker.is_available(): - settlement_error = "Circuit breaker is open" - logger.warning( - f"Nanopayment circuit breaker is open. " - f"payer={payer_address}, amount={updated_kind.amount}" - ) - if not content_delivered: - raise CircuitOpenError(recovery_seconds=self._circuit_breaker._recovery_seconds) - - try: - settle_resp = await self._settle_with_retry( - payload=payload, - requirements=updated_req, - ) - transaction = settle_resp.transaction or "" if settle_resp else "" - if settle_resp: - self._circuit_breaker.record_success() - except (CircuitOpenError, GatewayTimeoutError, GatewayConnectionError) as exc: - # Circuit breaker or transient error after all retries exhausted - settlement_error = str(exc) - self._circuit_breaker.record_failure() - if not content_delivered: + if payment_response: + try: + decoded = base64.b64decode(payment_response) + data = json.loads(decoded) + settlement_succeeded = bool(data.get("success")) + transaction = str(data.get("transaction") or "") + except Exception as exc: + if self._strict_settlement and not content_delivered: + raise SettlementError( + reason=f"Invalid PAYMENT-RESPONSE header: {exc}", + transaction=None, + payer=payer_address, + ) from exc + logger.warning( + "Invalid PAYMENT-RESPONSE header (payer=%s): %s", + payer_address, + exc, + ) + else: + if self._strict_settlement and not content_delivered: raise SettlementError( - reason=f"Settlement failed after retries: {settlement_error}", + reason="Missing PAYMENT-RESPONSE header", transaction=None, payer=payer_address, - ) from exc - logger.warning( - "Settlement warning after content delivery: %s (payer=%s)", - settlement_error, - payer_address, - ) - except SettlementError as exc: - settlement_error = str(exc) - if isinstance(exc, (NonceReusedError, InsufficientBalanceError)): - raise - if self._strict_settlement and not content_delivered: - raise + ) logger.warning( - "Settlement warning in non-strict mode: %s (payer=%s)", - settlement_error, + "Missing PAYMENT-RESPONSE header (payer=%s)", payer_address, ) # Step 10: Determine final success status - settlement_succeeded = settle_resp is not None and settle_resp.success # If content was delivered, we treat the user request as successful even when # settlement is delayed/degraded. Reconciliation can retry settlement later. final_success = ( @@ -534,7 +606,6 @@ async def pay_direct( seller_address: str, amount_usdc: str, network: str, - nano_key_alias: str | None = None, ) -> NanopaymentResult: """ Pay a direct address via Gateway nanopayment. @@ -548,7 +619,7 @@ async def pay_direct( 2. Get verifying contract and USDC address from client 3. Build PaymentRequirements from scratch 4. Auto-topup if balance is low - 5. Call vault.sign() to create signed PaymentPayload + 5. Call signer to create signed PaymentPayload 6. Call NanopaymentClient.settle() 7. Return NanopaymentResult @@ -556,7 +627,7 @@ async def pay_direct( seller_address: The seller's EOA address. amount_usdc: Amount in USDC decimal string (e.g. "0.001"). network: CAIP-2 network identifier. - nano_key_alias: Which vault key to use. + Returns: NanopaymentResult with payment details. @@ -594,18 +665,22 @@ async def pay_direct( accepts=(kind,), ) - # Step 3: Auto-topup - if self._auto_topup: - try: - await self._check_and_topup(alias=nano_key_alias) - except Exception as exc: - logger.warning(f"Auto-topup failed: {exc}. Proceeding with payment anyway.") + # Step 3: Check balance - FAIL if insufficient + payer_address = self._get_address() + balance = await self._client.check_balance( + address=payer_address, + network=network, + ) + if balance.available < amount_atomic: + raise InsufficientBalanceError( + reason=f"Insufficient balance: available {balance.available}, required {amount_atomic}", + payer=payer_address, + ) # Step 4: Get payer address - payer_address = await self._vault.get_address(alias=nano_key_alias) + payer_address = self._get_address() # Step 5: Build resource info (required by Circle Gateway) - # For direct payments, use seller address as resource identifier from omniclaw.protocols.nanopayments.types import ResourceInfo resource = ResourceInfo( @@ -615,10 +690,9 @@ async def pay_direct( ) # Step 6: Sign - payload = await self._vault.sign( + payload = self._sign( requirements=kind, amount_atomic=amount_atomic, - alias=nano_key_alias, resource=resource, ) @@ -765,7 +839,6 @@ def get_circuit_breaker_state(self) -> str: async def _check_and_topup( self, - alias: str | None = None, threshold: str | None = None, topup_amount: str | None = None, ) -> bool: @@ -788,7 +861,11 @@ async def _check_and_topup( topup_amount = topup_amount or self._topup_amount try: - balance = await self._vault.get_balance(alias=alias) + payer_address = self._get_address() + balance = await self._client.check_balance( + address=payer_address, + network=self._network, + ) except Exception as exc: logger.warning(f"Failed to check balance for auto-topup: {exc}") return False @@ -901,8 +978,8 @@ def supports( return False def get_priority(self) -> int: - """Priority 10 β€” checked before other adapters (default 100).""" - return 10 + """Priority 5 β€” must run before generic x402 for URL payments.""" + return 5 async def execute( self, @@ -916,7 +993,6 @@ async def execute( source_network: str | None = None, wait_for_completion: bool = False, timeout_seconds: float | None = None, - nano_key_alias: str | None = None, **kwargs: Any, ) -> PaymentResult: """ @@ -926,7 +1002,6 @@ async def execute( wallet_id: Source wallet ID (used for ledger/tracking only). recipient: URL or EVM address. amount: Payment amount in USDC decimal. - nano_key_alias: Which vault key to use for signing. **kwargs: Additional parameters (ignored). Returns: @@ -937,18 +1012,16 @@ async def execute( if _is_url(recipient): result = await self._adapter.pay_x402_url( url=recipient, - nano_key_alias=nano_key_alias, ) else: # Address payment below micro threshold network = destination_chain or source_network if not network: - network = await self._adapter._vault.get_network(alias=nano_key_alias) + network = self._adapter._network result = await self._adapter.pay_direct( seller_address=recipient, amount_usdc=str(amount), network=str(network), - nano_key_alias=nano_key_alias, ) return PaymentResult( diff --git a/src/omniclaw/protocols/nanopayments/client.py b/src/omniclaw/protocols/nanopayments/client.py index 2025717..008aa77 100644 --- a/src/omniclaw/protocols/nanopayments/client.py +++ b/src/omniclaw/protocols/nanopayments/client.py @@ -159,8 +159,17 @@ def _to_int(value: Any) -> int: return 0 if isinstance(value, int): return value + if isinstance(value, str): + # Handle decimal strings like "10.000000" (USDC 6 decimals) + if "." in value: + # Convert decimal to atomic units (6 decimals for USDC) + parts = value.split(".") + whole = parts[0] + frac = (parts[1] + "000000")[:6] + return int(whole) * 1_000_000 + int(frac) + return int(value) try: - return int(str(value)) + return int(value) except (ValueError, TypeError): return 0 @@ -526,17 +535,19 @@ async def get_verifying_contract(self, network: str) -> str: network: CAIP-2 identifier (e.g. 'eip155:5042002'). Returns: - The Gateway Wallet contract address on that network. + The Gateway Wallet contract address on that network (checksummed). Raises: UnsupportedNetworkError: If the network is not supported. """ + from web3 import Web3 + supported = await self.get_supported() for kind in supported: if kind.network == network: addr = kind.verifying_contract if addr: - return addr + return Web3.to_checksum_address(addr) raise UnsupportedNetworkError(network=network) async def get_usdc_address(self, network: str) -> str: @@ -547,17 +558,19 @@ async def get_usdc_address(self, network: str) -> str: network: CAIP-2 identifier. Returns: - The USDC contract address on that network. + The USDC contract address on that network (checksummed). Raises: UnsupportedNetworkError: If the network is not supported. """ + from web3 import Web3 + supported = await self.get_supported() for kind in supported: if kind.network == network: addr = kind.usdc_address if addr: - return addr + return Web3.to_checksum_address(addr) raise UnsupportedNetworkError(network=network) # ------------------------------------------------------------------------- @@ -687,10 +700,17 @@ async def settle( raise _map_settlement_error(error_reason, payer=payer) if not httpx.codes.is_success(resp.status_code): + body_text = resp.text + preview = body_text + if body_text and len(body_text) > 500: + preview = body_text[:500] + "...(truncated)" raise GatewayAPIError( - message=f"Gateway /settle returned {resp.status_code}", + message=( + f"Gateway /settle returned {resp.status_code}" + + (f": {preview}" if preview else "") + ), status_code=resp.status_code, - response_body=resp.text, + response_body=body_text, ) data = resp.json() @@ -734,12 +754,12 @@ async def check_balance( """ await self.get_supported() - circle_domain_id = _caip2_to_circle_domain_id(network) + _caip2_to_circle_domain_id(network) body: dict[str, Any] = { "token": "USDC", "sources": [ { - "domain": circle_domain_id, + "network": network, "depositor": address, } ], diff --git a/src/omniclaw/protocols/nanopayments/exceptions.py b/src/omniclaw/protocols/nanopayments/exceptions.py index c69b710..0c37b07 100644 --- a/src/omniclaw/protocols/nanopayments/exceptions.py +++ b/src/omniclaw/protocols/nanopayments/exceptions.py @@ -391,7 +391,7 @@ class KeyManagementError(NanopaymentError): class KeyNotFoundError(KeyManagementError): - """Raised when a requested key alias does not exist in the vault.""" + """Raised when a requested key alias does not exist.""" def __init__(self, alias: str) -> None: super().__init__( diff --git a/src/omniclaw/protocols/nanopayments/keys.py b/src/omniclaw/protocols/nanopayments/keys.py deleted file mode 100644 index 1858a99..0000000 --- a/src/omniclaw/protocols/nanopayments/keys.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -NanoKeyStore: Internal key encryption/decryption for NanoKeyVault. - -This module is NOT exposed to agents. It lives inside NanoKeyVault and -provides the cryptographic primitives for encrypting/decrypting EOA private keys. - -Security Design: - - Master key derived from entity_secret via PBKDF2 (32-byte AES key) - - EOA private keys encrypted with AES-256-GCM - - Each encryption uses a random 12-byte nonce - - Encrypted blobs are base64-encoded for safe storage - - Raw private key is NEVER stored unencrypted -""" - -from __future__ import annotations - -import base64 -import os - -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - -from omniclaw.protocols.nanopayments.exceptions import ( - KeyEncryptionError, -) -from omniclaw.protocols.nanopayments.signing import ( - EIP3009Signer, - generate_eoa_keypair, -) - -# PBKDF2 parameters -_PBKDF2_ITERATIONS: int = 480000 -"""OWASP recommended minimum for PBKDF2-SHA256 (2023).""" - -_PBKDF2_SALT: bytes = b"OmniClaw-NanoKeyVault-v1" -"""Static salt β€” acceptable since entity_secret is already high-entropy.""" - - -class NanoKeyStore: - """ - Internal key storage and decryption engine. - - Lives inside NanoKeyVault. NOT exposed to agents. - - Args: - entity_secret: Circle's entity secret (high-entropy master key). - Must be at least 32 bytes of entropy. - """ - - def __init__( - self, - entity_secret: str, - ) -> None: - if not entity_secret or len(entity_secret) < 16: - raise KeyEncryptionError( - operation="init", - reason="entity_secret too short (minimum 16 characters required)", - ) - self._entity_secret = entity_secret - - def _get_master_key(self) -> bytes: - """ - Derive a 32-byte AES key from the entity secret using PBKDF2. - - The entity secret is high-entropy (Circle generates it), so a static - salt is acceptable per OWASP guidance. - """ - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=_PBKDF2_SALT, - iterations=_PBKDF2_ITERATIONS, - ) - return kdf.derive(self._entity_secret.encode("utf-8")) - - def encrypt_key(self, private_key: str) -> str: - """ - Encrypt an EOA private key using AES-256-GCM. - - Args: - private_key: Raw EOA private key hex (with or without 0x prefix). - - Returns: - Base64-encoded ciphertext: 12-byte nonce + ciphertext + 16-byte tag. - - Raises: - KeyEncryptionError: If encryption fails. - """ - try: - # Normalize: remove 0x prefix if present - key_hex = private_key - if key_hex.startswith("0x"): - key_hex = key_hex[2:] - key_bytes = bytes.fromhex(key_hex) - - master_key = self._get_master_key() - aesgcm = AESGCM(master_key) - nonce = os.urandom(12) # 96-bit nonce for GCM - - # Prepend nonce to ciphertext so it can be extracted during decryption - encrypted = aesgcm.encrypt(nonce, key_bytes, None) # None = no associated data - return base64.b64encode(nonce + encrypted).decode("ascii") - except Exception as exc: - raise KeyEncryptionError( - operation="encrypt", - reason=str(exc), - ) from exc - - def decrypt_key(self, encrypted_key: str) -> str: - """ - Decrypt an encrypted EOA private key. - - Args: - encrypted_key: Base64-encoded ciphertext from encrypt_key(). - - Returns: - Raw private key hex (with 0x prefix). - - Raises: - KeyEncryptionError: If decryption fails. - """ - try: - ciphertext = base64.b64decode(encrypted_key) - nonce = ciphertext[:12] - actual_ciphertext = ciphertext[12:] - - master_key = self._get_master_key() - aesgcm = AESGCM(master_key) - - key_bytes = aesgcm.decrypt(nonce, actual_ciphertext, None) # no associated data - return "0x" + key_bytes.hex() - except Exception as exc: - raise KeyEncryptionError( - operation="decrypt", - reason=str(exc), - ) from exc - - def create_signer(self, encrypted_key: str) -> EIP3009Signer: - """ - Decrypt a key and create an EIP3009Signer. - - The raw private key is held only within the EIP3009Signer instance - and is never exposed to the caller. - - Args: - encrypted_key: Encrypted private key blob. - - Returns: - EIP3009Signer instance ready to sign. - - Raises: - KeyEncryptionError: If decryption fails. - """ - raw_key = self.decrypt_key(encrypted_key) - return EIP3009Signer(raw_key) - - # ------------------------------------------------------------------------- - # Key generation helpers - # ------------------------------------------------------------------------- - - def generate_and_encrypt_key(self) -> tuple[str, str]: - """ - Generate a new EOA keypair and encrypt the private key. - - Returns: - Tuple of (encrypted_key, address). - The operator must fund the address before use. - """ - private_key, address = generate_eoa_keypair() - encrypted = self.encrypt_key(private_key) - return encrypted, address diff --git a/src/omniclaw/protocols/nanopayments/signing.py b/src/omniclaw/protocols/nanopayments/signing.py index 432befb..2143df4 100644 --- a/src/omniclaw/protocols/nanopayments/signing.py +++ b/src/omniclaw/protocols/nanopayments/signing.py @@ -294,7 +294,7 @@ class EIP3009Signer: - Never log or expose the private key - Dispose of the signer when done to clear memory - In production, keys should be stored encrypted and only - decrypted when needed (see NanoKeyVault) + decrypted when needed (legacy vault) """ def __init__(self, private_key: str) -> None: @@ -360,7 +360,16 @@ def _raw_key(self) -> str: INTERNAL: The raw private key hex string. This is for use by GatewayWalletManager for on-chain transaction signing. - Do NOT expose this to agents. Agents should use NanoKeyVault.sign(). + Do NOT expose this to agents. Use the signing interface instead. + """ + return self._private_key + + @property + def raw_key(self) -> str: + """ + The raw private key hex string (with 0x prefix). + + This is used for on-chain operations like deposit/withdraw. """ return self._private_key diff --git a/src/omniclaw/protocols/nanopayments/vault.py b/src/omniclaw/protocols/nanopayments/vault.py deleted file mode 100644 index c24be8a..0000000 --- a/src/omniclaw/protocols/nanopayments/vault.py +++ /dev/null @@ -1,568 +0,0 @@ -""" -NanoKeyVault: Secure EOA key management for OmniClaw nanopayments. - -This is the SDK-level vault that lives on the OmniClaw instance (not on agents). -Agents hold only `nano_key_alias` (a string reference) β€” never the raw key. - -Key Security Flow: - Operator adds/generates key: - private_key -> NanoKeyStore.encrypt_key(entity_secret) -> encrypted_blob - encrypted_blob -> StorageBackend.save() - - Signing happens: - StorageBackend.get() -> encrypted_blob - NanoKeyStore.decrypt_key(entity_secret) -> raw_key - raw_key -> EIP3009Signer -> sign() -> PaymentPayload - (raw_key NEVER leaves the vault) - -Usage: - vault = NanoKeyVault(entity_secret="...", storage_backend=..., circle_api_key="...") - vault.generate_key("alice-nano") - payload = vault.sign(requirements=req, amount_atomic=1000, alias="alice-nano") -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - DuplicateKeyAliasError, - KeyNotFoundError, - NoDefaultKeyError, -) -from omniclaw.protocols.nanopayments.keys import NanoKeyStore - -if TYPE_CHECKING: - from omniclaw.protocols.nanopayments.types import ( - GatewayBalance, - PaymentPayload, - PaymentRequirementsKind, - ResourceInfo, - ) - from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - from omniclaw.storage.base import StorageBackend - -logger = logging.getLogger(__name__) - -# Storage collection name for encrypted keys -_NANO_KEYS_COLLECTION: str = "nano_keys" -"""StorageBackend collection name for encrypted EOA keys.""" - -# Default networks by environment -_DEFAULT_NETWORKS = { - "mainnet": "eip155:1", # Ethereum mainnet - "testnet": "eip155:5042002", # Arc testnet (Circle Gateway default) -} - - -class NanoKeyVault: - """ - Secure EOA key vault for Circle Gateway nanopayments. - - Lives on the SDK instance (not on agents). Manages encryption, - storage, and signing of EOA private keys. - - Args: - entity_secret: Circle's entity secret (master encryption key). - storage_backend: Pluggable storage backend for encrypted blobs. - circle_api_key: Circle API key for Gateway balance queries. - nanopayments_environment: 'testnet' or 'mainnet'. - default_network: Default CAIP-2 network (e.g., 'eip155:1'). - If None, determined from environment. - """ - - def __init__( - self, - entity_secret: str, - storage_backend: StorageBackend, - circle_api_key: str, - nanopayments_environment: str = "testnet", - default_network: str | None = None, - ) -> None: - self._keystore = NanoKeyStore(entity_secret=entity_secret) - self._storage = storage_backend - self._environment = nanopayments_environment - self._client = NanopaymentClient( - environment=nanopayments_environment, - api_key=circle_api_key, - ) - # Key metadata: alias -> {encrypted_key, address, created_at, network} - self._default_key_alias: str | None = None - - # Default network - determined from environment or explicitly set - if default_network: - self._default_network = default_network - else: - self._default_network = _DEFAULT_NETWORKS.get( - nanopayments_environment, _DEFAULT_NETWORKS["testnet"] - ) - - @property - def default_network(self) -> str: - """Get the default CAIP-2 network.""" - return self._default_network - - @property - def environment(self) -> str: - """Get the nanopayments environment (testnet/mainnet).""" - return self._environment - - # ------------------------------------------------------------------------- - # Key management - # ------------------------------------------------------------------------- - - async def add_key( - self, - alias: str, - private_key: str, - network: str | None = None, - ) -> str: - """ - Import an existing EOA private key into the vault. - - Encrypts the key and stores it under the given alias. - - Args: - alias: Unique identifier for this key (e.g. "alice-nano"). - private_key: EOA private key hex (with or without 0x prefix). - network: CAIP-2 network for this key. If None, uses default network. - - Returns: - The EOA address derived from the key. - - Raises: - DuplicateKeyAliasError: If a key with this alias already exists. - """ - from eth_account import Account - - # Derive address from private key directly (no need for full EIP3009Signer) - try: - # Normalize key: add 0x if missing - key_hex = private_key if private_key.startswith("0x") else f"0x{private_key}" - account = Account.from_key(key_hex) - address = account.address - except Exception as exc: - from omniclaw.protocols.nanopayments.exceptions import InvalidPrivateKeyError - - raise InvalidPrivateKeyError(reason=str(exc)) from exc - - existing = await self._storage.get(_NANO_KEYS_COLLECTION, alias) - if existing is not None: - raise DuplicateKeyAliasError(alias=alias) - - encrypted = self._keystore.encrypt_key(private_key) - - # Use provided network or default - key_network = network or self._default_network - - await self._storage.save( - _NANO_KEYS_COLLECTION, - alias, - { - "encrypted_key": encrypted, - "address": address, - "network": key_network, - }, - ) - - logger.info(f"Added key '{alias}' for address {address} on network {key_network}") - - return address - - async def generate_key( - self, - alias: str, - network: str | None = None, - ) -> str: - """ - Generate a new EOA keypair and store it encrypted in the vault. - - Args: - alias: Unique identifier for this key. - network: CAIP-2 network for this key. If None, uses default network. - - Returns: - The new EOA address. The operator must fund this address - before it can be used for payments. - - Raises: - DuplicateKeyAliasError: If a key with this alias already exists. - """ - existing = await self._storage.get(_NANO_KEYS_COLLECTION, alias) - if existing is not None: - raise DuplicateKeyAliasError(alias=alias) - - encrypted, address = self._keystore.generate_and_encrypt_key() - - # Use provided network or default - key_network = network or self._default_network - - await self._storage.save( - _NANO_KEYS_COLLECTION, - alias, - { - "encrypted_key": encrypted, - "address": address, - "network": key_network, - }, - ) - - logger.info(f"Generated key '{alias}' for address {address} on network {key_network}") - - return address - - async def set_default_key(self, alias: str) -> None: - """ - Set the default key for agents that don't specify a nano_key_alias. - - Args: - alias: The key alias to set as default. - - Raises: - KeyNotFoundError: If no key with this alias exists. - """ - record = await self._storage.get(_NANO_KEYS_COLLECTION, alias) - if record is None: - raise KeyNotFoundError(alias=alias) - self._default_key_alias = alias - - async def get_network(self, alias: str | None = None) -> str: - """ - Get the CAIP-2 network for a key. - - Args: - alias: Key alias. If None, uses the default key. - - Returns: - The CAIP-2 network (e.g., 'eip155:1'). - - Raises: - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - resolved_alias = alias or self._default_key_alias - if resolved_alias is None: - raise NoDefaultKeyError() - - record = await self._storage.get(_NANO_KEYS_COLLECTION, resolved_alias) - if record is None: - raise KeyNotFoundError(alias=resolved_alias) - - # Return stored network or fall back to default - return record.get("network") or self._default_network - - async def update_key_network(self, alias: str, network: str) -> None: - """ - Update the network for an existing key. - - Args: - alias: The key alias. - network: The new CAIP-2 network. - - Raises: - KeyNotFoundError: If no key with this alias exists. - """ - record = await self._storage.get(_NANO_KEYS_COLLECTION, alias) - if record is None: - raise KeyNotFoundError(alias=alias) - - record["network"] = network - await self._storage.save(_NANO_KEYS_COLLECTION, alias, record) - - logger.info(f"Updated network for key '{alias}' to {network}") - - # ------------------------------------------------------------------------- - # Address lookups - # ------------------------------------------------------------------------- - - async def get_address(self, alias: str | None = None) -> str: - """ - Get the EOA address for a key alias. - - Args: - alias: Key alias. If None, uses the default key. - - Returns: - The EOA address (checksummed). - - Raises: - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - resolved_alias = alias or self._default_key_alias - if resolved_alias is None: - raise NoDefaultKeyError() - - record = await self._storage.get(_NANO_KEYS_COLLECTION, resolved_alias) - if record is None: - raise KeyNotFoundError(alias=resolved_alias) - return record["address"] - - async def list_keys(self) -> list[str]: - """ - List all key aliases in the vault. - - Returns: - List of key aliases (safe to expose to operators). - Does NOT return the actual keys. - """ - records = await self._storage.query(_NANO_KEYS_COLLECTION, limit=1000) - aliases: list[str] = [] - for record in records: - alias = record.get("_key") or record.get("key") or record.get("alias") - if alias: - aliases.append(str(alias)) - return aliases - - async def has_key(self, alias: str | None = None) -> bool: - """ - Check whether a key exists in the vault. - - Args: - alias: Key alias. If None, checks for a default key. - - Returns: - True if the key exists, False otherwise. - """ - try: - await self.get_address(alias) - return True - except (KeyNotFoundError, NoDefaultKeyError): - return False - - # ------------------------------------------------------------------------- - # Signing (raw key never exposed) - # ------------------------------------------------------------------------- - - async def sign( - self, - requirements: PaymentRequirementsKind, - amount_atomic: int | None = None, - alias: str | None = None, - network: str | None = None, - resource: ResourceInfo | None = None, - ) -> PaymentPayload: - """ - Sign an EIP-3009 payment authorization. - - Decrypts the key internally, creates a signer, signs the authorization, - and returns the PaymentPayload. The raw private key is held only within - the signer and is never exposed. - - Args: - requirements: The PaymentRequirementsKind from the seller's 402 response. - amount_atomic: Payment amount in USDC atomic units. - If None, uses requirements.amount. - alias: Key alias. If None, uses the default key. - network: Override the network for this payment. - If None, uses the key's stored network. - resource: ResourceInfo for the payment. Required by Circle Gateway. - - Returns: - Signed PaymentPayload ready for Gateway settlement. - - Raises: - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - resolved_alias = alias or self._default_key_alias - if resolved_alias is None: - raise NoDefaultKeyError() - - record = await self._storage.get(_NANO_KEYS_COLLECTION, resolved_alias) - if record is None: - raise KeyNotFoundError(alias=resolved_alias) - - encrypted_key = record["encrypted_key"] - signer = self._keystore.create_signer(encrypted_key) - key_network = network or record.get("network") or self._default_network - if key_network != requirements.network: - raise ValueError( - f"Network mismatch for key '{resolved_alias}': key={key_network}, " - f"requirements={requirements.network}" - ) - - # Amount: use provided value or fall back to requirements.amount - if amount_atomic is None: - amount_atomic = int(requirements.amount) - - payload = signer.sign_transfer_with_authorization( - requirements=requirements, - amount_atomic=amount_atomic, - ) - - # Attach resource info (required by Circle Gateway) - if resource is not None: - # PaymentPayload is frozen, so we need to recreate it - from omniclaw.protocols.nanopayments.types import PaymentPayload - - payload = PaymentPayload( - x402_version=payload.x402_version, - scheme=payload.scheme, - network=payload.network, - payload=payload.payload, - resource=resource, - ) - - return payload - - # ------------------------------------------------------------------------- - # Key rotation - # ------------------------------------------------------------------------- - - async def rotate_key(self, alias: str, network: str | None = None) -> str: - """ - Generate a new key and replace the existing one. - - The old key remains stored (for pending payment finalization). - The new key is returned; the operator must fund the new address. - - Args: - alias: The key to rotate. - network: New network for the rotated key. If None, keeps old network. - - Returns: - The new EOA address. - - Raises: - KeyNotFoundError: If no key with this alias exists. - """ - existing = await self._storage.get(_NANO_KEYS_COLLECTION, alias) - if existing is None: - raise KeyNotFoundError(alias=alias) - - # Keep old network unless explicitly changed - key_network = network or existing.get("network") or self._default_network - - new_encrypted, new_address = self._keystore.generate_and_encrypt_key() - - await self._storage.save( - _NANO_KEYS_COLLECTION, - alias, - { - "encrypted_key": new_encrypted, - "address": new_address, - "network": key_network, - }, - ) - - logger.info(f"Rotated key '{alias}' to new address {new_address}") - - return new_address - - # ------------------------------------------------------------------------- - # Balance queries - # ------------------------------------------------------------------------- - - async def get_balance(self, alias: str | None = None) -> GatewayBalance: - """ - Get the Gateway wallet balance for a key's address. - - Args: - alias: Key alias. If None, uses the default key. - - Returns: - GatewayBalance with total, available, and formatted amounts. - - Raises: - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - address = await self.get_address(alias) - network = await self.get_network(alias) - return await self._client.check_balance(address=address, network=network) - - async def get_raw_key(self, alias: str | None = None) -> str: - """ - Get the raw (decrypted) private key for gateway operations. - - WARNING: This exposes the raw private key in memory. Only use this - for gateway on-chain operations (deposit/withdraw). The raw key - never leaves the server/process. - - Args: - alias: Key alias. If None, uses the default key. - - Returns: - The raw EOA private key hex (with 0x prefix). - - Raises: - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - resolved_alias = alias or self._default_key_alias - if resolved_alias is None: - raise NoDefaultKeyError() - - record = await self._storage.get(_NANO_KEYS_COLLECTION, resolved_alias) - if record is None: - raise KeyNotFoundError(alias=resolved_alias) - - encrypted_key = record["encrypted_key"] - return self._keystore.decrypt_key(encrypted_key) - - # ------------------------------------------------------------------------- - # Gateway Wallet Manager integration - # ------------------------------------------------------------------------- - - async def create_wallet_manager( - self, - alias: str | None = None, - rpc_url: str | None = None, - gateway_address: str | None = None, - usdc_address: str | None = None, - cctp_gateway_address: str | None = None, - ) -> GatewayWalletManager: - """ - Create a GatewayWalletManager for on-chain operations. - - This creates a wallet manager bound to a specific key, using - the key's stored network. - - Args: - alias: Key alias. If None, uses the default key. - rpc_url: RPC endpoint for the key's network. - gateway_address: Gateway contract address (fetched from API if None). - usdc_address: USDC contract address (fetched from API if None). - cctp_gateway_address: CCTP gateway for cross-chain withdrawals. - - Returns: - GatewayWalletManager configured for this key. - - Raises: - RuntimeError: If RPC URL is not provided and no default is configured. - KeyNotFoundError: If the alias doesn't exist. - NoDefaultKeyError: If alias is None and no default key is set. - """ - import os - - from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - - # Get the real private key from the vault - private_key = await self.get_raw_key(alias) - - if rpc_url is None: - # Try to get from environment - key_network = await self.get_network(alias) - - # Check for network-specific RPC URLs in environment - env_rpc = os.environ.get(f"RPC_URL_{key_network.replace(':', '_').upper()}") - rpc_url = env_rpc or os.environ.get("RPC_URL") - - if rpc_url is None: - raise ValueError( - "rpc_url is required. Provide it explicitly or set " - "RPC_URL or RPC_URL_EIP155_CHAINID environment variable." - ) - - return GatewayWalletManager( - private_key=private_key, - network=await self.get_network(alias), - rpc_url=rpc_url, - nanopayment_client=self._client, - gateway_address=gateway_address, - usdc_address=usdc_address, - cctp_gateway_address=cctp_gateway_address, - ) diff --git a/src/omniclaw/protocols/nanopayments/wallet.py b/src/omniclaw/protocols/nanopayments/wallet.py index c8e0005..ca89ab9 100644 --- a/src/omniclaw/protocols/nanopayments/wallet.py +++ b/src/omniclaw/protocols/nanopayments/wallet.py @@ -16,11 +16,12 @@ Note: The GatewayWalletManager uses a raw EOA private key for signing on-chain - transactions. This key must be the same one stored in NanoKeyVault. + transactions. This key must be the same one used for nanopayment signing. """ from __future__ import annotations +import asyncio import logging import re from typing import Any @@ -168,6 +169,16 @@ "stateMutability": "view", "type": "function", }, + { + "inputs": [ + {"name": "token", "type": "address"}, + {"name": "depositor", "type": "address"}, + ], + "name": "withdrawalBlock", + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, ] @@ -189,7 +200,7 @@ class GatewayWalletManager: The private key is used to sign on-chain transactions. On-chain operations cost gas. Args: - private_key: Raw EOA private key hex (same as NanoKeyVault). + private_key: Raw EOA private key hex used for nanopayment signing. network: CAIP-2 network identifier (e.g. 'eip155:1'). rpc_url: RPC endpoint for the network. nanopayment_client: NanopaymentClient for API operations and balance queries. @@ -214,6 +225,12 @@ def __init__( chain_id = int(network.split(":")[1]) self._chain_id = chain_id self._w3 = web3.Web3(web3.HTTPProvider(rpc_url)) + + # Fix for POA chains (Polygon, etc.) - use legacy buildTransaction + from web3.middleware import geth_poa_middleware + + self._w3.middleware_onion.inject(geth_poa_middleware, layer=0) + self._client = nanopayment_client self._gateway_address = gateway_address self._usdc_address = usdc_address @@ -257,6 +274,22 @@ def _get_gateway_contract(self, gateway_address: str) -> web3.Contract: ) return self._gateway_contract + def _encode_gateway_call(self, gateway: web3.Contract, fn_name: str, args: list[Any]) -> str: + """Encode a gateway contract call across web3 versions.""" + if hasattr(gateway, "encode_abi"): + return gateway.encode_abi(fn_name=fn_name, args=args) # type: ignore[attr-defined] + if hasattr(gateway, "encodeABI"): + return gateway.encodeABI(fn_name=fn_name, args=args) # type: ignore[attr-defined] + fn = getattr(gateway.functions, fn_name)(*args) + if hasattr(fn, "build_transaction"): + tx = fn.build_transaction({"from": self._address}) + data = tx.get("data") + if data: + return data + if hasattr(fn, "_encode_transaction_data"): + return fn._encode_transaction_data() # type: ignore[attr-defined] + raise RuntimeError(f"Unable to encode gateway call: {fn_name}") + # ------------------------------------------------------------------------- # USDC helpers # ------------------------------------------------------------------------- @@ -320,6 +353,7 @@ def _build_tx( base_fee = None if base_fee is not None: + # Use a more reasonable priority fee (1-2 gwei instead of 50) max_priority_fee = self._w3.to_wei(2, "gwei") tx["maxPriorityFeePerGas"] = int(max_priority_fee) tx["maxFeePerGas"] = int(base_fee * 2 + max_priority_fee) @@ -331,16 +365,24 @@ def _build_tx( def _sign_and_send(self, tx: dict[str, Any], error_type: type = DepositError) -> str: """Sign a transaction and send it, returning the tx hash.""" try: + # Get the private key and ensure it has 0x prefix for web3.py + private_key = self._signer._private_key + if not private_key.startswith("0x"): + private_key = f"0x{private_key}" + signed = self._w3.eth.account.sign_transaction( tx, - private_key=self._signer._private_key, + private_key=private_key, ) - tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction) + raw_tx = getattr(signed, "rawTransaction", None) or getattr( + signed, "raw_transaction", None + ) + tx_hash = self._w3.eth.send_raw_transaction(raw_tx) receipt = self._w3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] != 1: raise error_type( reason=f"Transaction failed: {tx_hash.hex()}", - transaction=tx_hash.hex(), + tx_hash=tx_hash.hex(), ) return tx_hash.hex() except (DepositError, WithdrawError, ERC20ApprovalError): @@ -422,43 +464,32 @@ async def deposit( usdc = self._usdc_contract(usdc_address) - # Check current allowance - current_allowance = usdc.functions.allowance( - self._address, - gateway_address, - ).call() - + # Skip allowance check - just approve directly for simplicity approval_tx_hash: str | None = None - if current_allowance < amount: - # Need to approve - approve EXACT amount only (security best practice) - try: - approve_tx = self._build_tx( - to=usdc_address, - data=usdc.encode_abi( - fn_name="approve", - args=[gateway_address, amount], - ), - ) - approval_tx_hash = self._sign_and_send( - approve_tx, - error_type=ERC20ApprovalError, - ) - logger.info(f"USDC approval tx: {approval_tx_hash}") - except Exception as exc: - logger.error(f"Approval failed: {exc}") - raise ERC20ApprovalError(reason=f"Failed to approve USDC: {exc}") from exc + try: + # web3.py 6.x uses functions.approve().build_transaction() + approve_func = usdc.functions.approve(gateway_address, amount) + approve_tx = self._build_tx( + to=usdc_address, + data=approve_func.build_transaction({"gas": 50000})["data"], + ) + approval_tx_hash = self._sign_and_send( + approve_tx, + error_type=ERC20ApprovalError, + ) + logger.info(f"USDC approval tx: {approval_tx_hash}") + except Exception as exc: + logger.error(f"Approval failed: {exc}") + raise ERC20ApprovalError(reason=f"Failed to approve USDC: {exc}") from exc # Build deposit transaction # CORRECT ABI: deposit(address token, uint256 value) gateway = self._get_gateway_contract(gateway_address) - deposit_data = gateway.encode_abi( - fn_name="deposit", - args=[usdc_address, amount], # token address AND amount - ) - + # web3.py 6.x uses functions.deposit().build_transaction() + deposit_func = gateway.functions.deposit(usdc_address, amount) deposit_tx = self._build_tx( to=gateway_address, - data=deposit_data, + data=deposit_func.build_transaction({"gas": 100000})["data"], value=0, ) @@ -532,9 +563,10 @@ async def initiate_trustless_withdrawal(self, amount_usdc: str) -> str: ) # Initiate withdrawal - withdraw_data = gateway.encode_abi( - fn_name="initiateWithdrawal", - args=[usdc_address, amount], + withdraw_data = self._encode_gateway_call( + gateway, + "initiateWithdrawal", + [usdc_address, amount], ) withdraw_tx = self._build_tx( @@ -593,9 +625,10 @@ async def complete_trustless_withdrawal(self) -> str: ) # Complete withdrawal - withdraw_data = gateway.encode_abi( - fn_name="withdraw", - args=[usdc_address], + withdraw_data = self._encode_gateway_call( + gateway, + "withdraw", + [usdc_address], ) withdraw_tx = self._build_tx( @@ -701,6 +734,21 @@ async def _transfer_via_gateway_settlement( ) requirements = PaymentRequirements(x402_version=2, accepts=(req_kind,)) payload = self._signer.sign_transfer_with_authorization(req_kind) + # Circle Gateway requires a resource descriptor on payment payloads. + from omniclaw.protocols.nanopayments.types import PaymentPayload, ResourceInfo + + resource = ResourceInfo( + url=f"direct:{recipient_address}", + description=f"Gateway transfer to {recipient_address} on {destination_chain}", + mime_type="application/json", + ) + payload = PaymentPayload( + x402_version=payload.x402_version, + scheme=payload.scheme, + network=payload.network, + payload=payload.payload, + resource=resource, + ) settle = await self._client.settle(payload=payload, requirements=requirements) except Exception as exc: raise WithdrawError(reason=f"Gateway settlement transfer failed: {exc}") from exc @@ -857,30 +905,34 @@ def estimate_gas_cost_eth(self) -> str: def check_gas_reserve(self) -> tuple[bool, str]: """ - Check if the wallet has enough ETH for a deposit transaction. + Check if the wallet has enough USDC for gas on Arc network. - This checks if the ETH balance is sufficient to pay for gas - (with a safety buffer of 2x estimated cost). + For Arc (and other chains that use USDC as gas), check USDC balance + instead of ETH for deposit transaction gas. Returns: Tuple of (has_sufficient_gas, message). - has_sufficient_gas is True if balance >= 2x estimated gas cost. - message describes the current balance and estimated cost. + has_sufficient_gas is True if USDC balance >= 2x estimated gas cost. """ - balance_wei = self.get_gas_balance_wei() - required_wei = self.estimate_gas_cost_wei() * 2 # 2x buffer - balance_eth = self._w3.from_wei(balance_wei, "ether") - required_eth = self._w3.from_wei(required_wei, "ether") - cost_eth = self.estimate_gas_cost_eth() - - has_sufficient = balance_wei >= required_wei - message = ( - f"ETH balance: {balance_eth:.6f} ETH, " - f"Estimated gas cost: {cost_eth} ETH " - f"(recommended reserve: {required_eth:.6f} ETH)" - ) + # For Arc - use USDC as gas token + try: + usdc_addr = asyncio.get_event_loop().run_until_complete(self._resolve_usdc_address()) + usdc = self._usdc_contract(usdc_addr) + usdc_balance = usdc.functions.balanceOf(self._address).call() + + # Estimate gas cost in USDC (approximate) + gas_cost_wei = self.estimate_gas_cost_wei() + gas_cost_usdc = gas_cost_wei / 1e6 # Convert to USDC + + balance_usdc = usdc_balance / 1e6 + has_sufficient = usdc_balance >= (gas_cost_wei * 2) + + msg = f"USDC balance: {balance_usdc:.6f}, Gas cost: {gas_cost_usdc:.6f}" - return has_sufficient, message + return has_sufficient, msg + except Exception as e: + # If can't check, allow anyway + return True, f"Could not verify gas: {e}" def has_sufficient_gas_for_deposit(self) -> bool: """ diff --git a/src/omniclaw/storage/__init__.py b/src/omniclaw/storage/__init__.py index f844563..6f6399d 100644 --- a/src/omniclaw/storage/__init__.py +++ b/src/omniclaw/storage/__init__.py @@ -4,16 +4,18 @@ Provides pluggable persistence for ledger, guards, and other stateful components. Configuration via environment: - OMNICLAW_STORAGE_BACKEND=memory # or 'redis' + OMNICLAW_STORAGE_BACKEND=file # 'file', 'memory', or 'redis' OMNICLAW_REDIS_URL=redis://localhost:6379/0 + OMNICLAW_STORAGE_DIR=~/.omniclaw/data # for file storage Example: - >>> from omniclaw.storage import get_storage, InMemoryStorage, RedisStorage + >>> from omniclaw.storage import get_storage, InMemoryStorage, RedisStorage, FileStorage >>> >>> # Get storage from environment >>> storage = get_storage() >>> >>> # Or create specific backend + >>> storage = FileStorage() >>> storage = InMemoryStorage() >>> storage = RedisStorage(redis_url="redis://localhost:6379") """ @@ -36,6 +38,11 @@ except ImportError: RedisStorage = None # type: ignore +# Import and register FileStorage +from omniclaw.storage.file import FileStorage + +register_storage_backend("file", FileStorage) + def get_storage(backend_name: str | None = None) -> StorageBackend: """ @@ -57,7 +64,7 @@ def get_storage(backend_name: str | None = None) -> StorageBackend: import warnings if backend_name is None: - backend_name = os.environ.get("OMNICLAW_STORAGE_BACKEND", "memory") + backend_name = os.environ.get("OMNICLAW_STORAGE_BACKEND", "file") backend_class = get_storage_backend(backend_name) @@ -78,18 +85,11 @@ def get_storage(backend_name: str | None = None) -> StorageBackend: ) elif env not in ("", "test", "development", "dev"): warnings.warn( - f"Using memory storage backend with OMNICLAW_ENV={env}. " + f"Using memory storage with OMNICLAW_ENV={env}. " "For production use, configure Redis: OMNICLAW_STORAGE_BACKEND=redis", UserWarning, stacklevel=2, ) - else: - warnings.warn( - "Using memory storage backend - state will be lost on process restart. " - "For production, use Redis: OMNICLAW_STORAGE_BACKEND=redis", - UserWarning, - stacklevel=2, - ) return backend_class() @@ -97,6 +97,7 @@ def get_storage(backend_name: str | None = None) -> StorageBackend: __all__ = [ "StorageBackend", "InMemoryStorage", + "FileStorage", "RedisStorage", "get_storage", "get_storage_backend", diff --git a/src/omniclaw/storage/file.py b/src/omniclaw/storage/file.py new file mode 100644 index 0000000..bbbec4e --- /dev/null +++ b/src/omniclaw/storage/file.py @@ -0,0 +1,242 @@ +""" +File-based storage backend for OmniClaw. + +Stores data in JSON files in ~/.omniclaw/data/ +""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from decimal import Decimal +from pathlib import Path +from typing import Any + +from omniclaw.storage.base import StorageBackend + + +class FileStorage(StorageBackend): + """ + File-based storage using JSON files. + + Data is stored in ~/.omniclaw/data/{collection}.json + Each collection is a separate JSON file with key-value pairs. + + Supports locks via file-based locking. + """ + + def __init__(self, base_dir: Path | None = None): + self._base_dir = base_dir or (Path.home() / ".omniclaw" / "data") + self._base_dir.mkdir(parents=True, exist_ok=True) + self._lock = asyncio.Lock() + self._locks_dir = self._base_dir / "_locks" + self._locks_dir.mkdir(parents=True, exist_ok=True) + + def _get_file_path(self, collection: str) -> Path: + safe_name = collection.replace("/", "_").replace(":", "_") + return self._base_dir / f"{safe_name}.json" + + async def _read_collection(self, collection: str) -> dict[str, Any]: + file_path = self._get_file_path(collection) + if not file_path.exists(): + return {} + try: + return json.loads(file_path.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + async def _write_collection(self, collection: str, data: dict[str, Any]) -> None: + file_path = self._get_file_path(collection) + file_path.write_text(json.dumps(data, indent=2)) + + async def save( + self, + collection: str, + key: str, + data: dict[str, Any], + ) -> None: + async with self._lock: + existing = await self._read_collection(collection) + existing[key] = data + await self._write_collection(collection, existing) + + async def get( + self, + collection: str, + key: str, + ) -> dict[str, Any] | None: + data = await self._read_collection(collection) + return data.get(key) + + async def delete( + self, + collection: str, + key: str, + ) -> bool: + async with self._lock: + data = await self._read_collection(collection) + if key in data: + del data[key] + await self._write_collection(collection, data) + return True + return False + + async def list_keys( + self, + collection: str, + ) -> list[str]: + data = await self._read_collection(collection) + return list(data.keys()) + + async def query( + self, + collection: str, + filters: dict[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: + data = await self._read_collection(collection) + + results = [] + for key, value in data.items(): + if filters: + match = True + for filter_key, filter_value in filters.items(): + if value.get(filter_key) != filter_value: + match = False + break + if not match: + continue + + result = dict(value) + result["_key"] = key + results.append(result) + + results = results[offset:] + if limit is not None: + results = results[:limit] + + return results + + async def update( + self, + collection: str, + key: str, + data: dict[str, Any], + ) -> bool: + async with self._lock: + existing = await self._read_collection(collection) + if key not in existing: + return False + existing[key].update(data) + await self._write_collection(collection, existing) + return True + + async def count( + self, + collection: str, + filters: dict[str, Any] | None = None, + ) -> int: + if filters: + results = await self.query(collection, filters) + return len(results) + data = await self._read_collection(collection) + return len(data) + + async def clear(self, collection: str) -> int: + async with self._lock: + data = await self._read_collection(collection) + count = len(data) + await self._write_collection(collection, {}) + return count + + async def atomic_add( + self, + collection: str, + key: str, + amount: str, + ) -> str: + async with self._lock: + data = await self._read_collection(collection) + + current_val = data.get(key, "0") + try: + current = Decimal(str(current_val)) + except Exception: + current = Decimal("0") + + added = Decimal(str(amount)) + new_val = current + added + data[key] = str(new_val) + + await self._write_collection(collection, data) + return str(new_val) + + async def acquire_lock( + self, + key: str, + ttl: int = 30, + ) -> str | None: + lock_file = self._locks_dir / f"{key}.lock" + token = str(uuid.uuid4()) + + async with self._lock: + if lock_file.exists(): + try: + content = json.loads(lock_file.read_text()) + import time + + if content.get("expires", 0) > time.time(): + return None + except Exception: + pass + + import time + + lock_file.write_text(json.dumps({"token": token, "expires": time.time() + ttl})) + return token + + async def release_lock( + self, + key: str, + token: str | None = None, + ) -> bool: + lock_file = self._locks_dir / f"{key}.lock" + + async with self._lock: + if not lock_file.exists(): + return False + + try: + content = json.loads(lock_file.read_text()) + if token is None or content.get("token") == token: + lock_file.unlink() + return True + return False + except Exception: + return False + + async def refresh_lock( + self, + key: str, + token: str, + ttl: int = 30, + ) -> bool: + lock_file = self._locks_dir / f"{key}.lock" + + async with self._lock: + if not lock_file.exists(): + return False + + try: + content = json.loads(lock_file.read_text()) + if content.get("token") == token: + import time + + content["expires"] = time.time() + ttl + lock_file.write_text(json.dumps(content)) + return True + return False + except Exception: + return False diff --git a/tests/conftest.py b/tests/conftest.py index 1dac053..8d579ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,5 @@ def mock_circle_client(monkeypatch): # Patch the class in omniclaw.core.circle_client # We patch it where it is imported/used, which is effectively the class definition with monkeypatch.context() as m: - m.setattr( - "omniclaw.core.circle_client.CircleClient", MagicMock(return_value=mock_client) - ) + m.setattr("omniclaw.core.circle_client.CircleClient", MagicMock(return_value=mock_client)) yield mock_client diff --git a/tests/test_cctp_constants.py b/tests/test_cctp_constants.py index a8bf825..ee2df03 100644 --- a/tests/test_cctp_constants.py +++ b/tests/test_cctp_constants.py @@ -3,43 +3,51 @@ """ import pytest -from omniclaw.core.types import Network + from omniclaw.core.cctp_constants import ( CCTP_DOMAIN_IDS, - TOKEN_MESSENGER_V2_CONTRACTS, MESSAGE_TRANSMITTER_V2_CONTRACTS, + TOKEN_MESSENGER_V2_CONTRACTS, USDC_CONTRACTS, - is_cctp_supported, - get_token_messenger_v2, - get_message_transmitter_v2, get_iris_url, get_iris_v2_attestation_url, + get_message_transmitter_v2, + get_token_messenger_v2, + is_cctp_supported, ) +from omniclaw.core.types import Network class TestCCTPDomainIDs: """Test CCTP domain ID mappings.""" - + def test_all_networks_have_domain_ids(self): """Verify all CCTP-supported networks have domain IDs.""" expected_networks = [ - Network.ETH, Network.ETH_SEPOLIA, - Network.AVAX, Network.AVAX_FUJI, - Network.OP, Network.OP_SEPOLIA, - Network.ARB, Network.ARB_SEPOLIA, - Network.SOL, Network.SOL_DEVNET, - Network.BASE, Network.BASE_SEPOLIA, - Network.MATIC, Network.MATIC_AMOY, + Network.ETH, + Network.ETH_SEPOLIA, + Network.AVAX, + Network.AVAX_FUJI, + Network.OP, + Network.OP_SEPOLIA, + Network.ARB, + Network.ARB_SEPOLIA, + Network.SOL, + Network.SOL_DEVNET, + Network.BASE, + Network.BASE_SEPOLIA, + Network.MATIC, + Network.MATIC_AMOY, Network.ARC_TESTNET, ] - + for network in expected_networks: assert network in CCTP_DOMAIN_IDS, f"{network} missing domain ID" - + def test_arc_testnet_domain_id(self): """Verify ARC testnet has correct domain ID.""" assert CCTP_DOMAIN_IDS[Network.ARC_TESTNET] == 26 - + def test_ethereum_domain_id(self): """Verify Ethereum networks have correct domain ID.""" assert CCTP_DOMAIN_IDS[Network.ETH] == 0 @@ -48,7 +56,7 @@ def test_ethereum_domain_id(self): class TestCCTPContracts: """Test CCTP contract address mappings.""" - + def test_token_messenger_contracts(self): """Verify TokenMessenger contracts are configured.""" # Testnet networks @@ -56,14 +64,14 @@ def test_token_messenger_contracts(self): for network in testnets: assert network in TOKEN_MESSENGER_V2_CONTRACTS assert TOKEN_MESSENGER_V2_CONTRACTS[network].startswith("0x") - + def test_message_transmitter_contracts(self): """Verify MessageTransmitter contracts are configured.""" testnets = ["ETH-SEPOLIA", "BASE-SEPOLIA", "ARC-TESTNET"] for network in testnets: assert network in MESSAGE_TRANSMITTER_V2_CONTRACTS assert MESSAGE_TRANSMITTER_V2_CONTRACTS[network].startswith("0x") - + def test_usdc_contracts(self): """Verify USDC contract addresses.""" assert USDC_CONTRACTS["ETH-SEPOLIA"] == "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" @@ -74,50 +82,46 @@ def test_usdc_contracts(self): class TestCCTPUtilities: """Test CCTP utility functions.""" - + def test_is_cctp_supported(self): """Test CCTP network support detection.""" # Supported networks assert is_cctp_supported(Network.ETH_SEPOLIA) is True assert is_cctp_supported(Network.BASE_SEPOLIA) is True assert is_cctp_supported(Network.ARC_TESTNET) is True - + # Unsupported networks assert is_cctp_supported(Network.NEAR) is False assert is_cctp_supported(Network.APTOS) is False - + def test_get_token_messenger_v2(self): """Test TokenMessenger contract retrieval.""" contract = get_token_messenger_v2(Network.ETH_SEPOLIA) assert contract is not None assert contract.startswith("0x") - + contract = get_token_messenger_v2(Network.ARC_TESTNET) assert contract is not None - + def test_get_message_transmitter_v2(self): """Test MessageTransmitter contract retrieval.""" contract = get_message_transmitter_v2(Network.BASE_SEPOLIA) assert contract is not None assert contract.startswith("0x") - + def test_get_iris_url(self): """Test Iris API URL selection.""" # Testnet should use sandbox url = get_iris_url(Network.ETH_SEPOLIA) assert "sandbox" in url - + # Mainnet should use production url = get_iris_url(Network.ETH) assert "sandbox" not in url - + def test_get_iris_v2_attestation_url(self): """Test attestation URL generation.""" - url = get_iris_v2_attestation_url( - Network.ETH_SEPOLIA, - 0, - "0x1234567890abcdef" - ) + url = get_iris_v2_attestation_url(Network.ETH_SEPOLIA, 0, "0x1234567890abcdef") assert "iris-api-sandbox.circle.com" in url assert "v2/messages" in url assert "0x1234567890abcdef" in url diff --git a/tests/test_cctp_manual_mint.py b/tests/test_cctp_manual_mint.py index 5d68d29..415f6d5 100644 --- a/tests/test_cctp_manual_mint.py +++ b/tests/test_cctp_manual_mint.py @@ -1,22 +1,22 @@ - -import asyncio -from unittest.mock import MagicMock, AsyncMock, patch from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from omniclaw.core.config import Config from omniclaw.core.types import ( - Network, - TransactionInfo, - TransactionState, + AccountType, + CustodyType, + Network, + TransactionInfo, + TransactionState, WalletInfo, WalletState, - CustodyType, - AccountType ) from omniclaw.protocols.gateway import GatewayAdapter from omniclaw.wallet.service import WalletService + @pytest.fixture def mock_config(): return Config( @@ -26,27 +26,32 @@ def mock_config(): default_wallet_id="wallet-123", ) + @pytest.fixture def mock_wallet_service(mock_config): service = AsyncMock(spec=WalletService) service._config = mock_config service._circle = MagicMock() # Ensure nested _circle is usable - + # helper for creating transaction response def create_tx(id, state, tx_hash=None): return TransactionInfo( - id=id, - state=state, + id=id, + state=state, tx_hash=tx_hash, blockchain="ARC-TESTNET", amounts=["100"], - fee_level=None + fee_level=None, ) - service._circle.get_transaction.side_effect = lambda tx_id: create_tx(tx_id, TransactionState.COMPLETE, "0xhash") - service._circle.create_contract_execution.return_value = create_tx("tx-mint", TransactionState.PENDING) - + service._circle.get_transaction.side_effect = lambda tx_id: create_tx( + tx_id, TransactionState.COMPLETE, "0xhash" + ) + service._circle.create_contract_execution.return_value = create_tx( + "tx-mint", TransactionState.PENDING + ) + # Mock list_wallets to return a destination wallet dest_wallet = WalletInfo( id="dest-wallet-123", @@ -55,29 +60,28 @@ def create_tx(id, state, tx_hash=None): state=WalletState.LIVE, wallet_set_id="ws-123", custody_type=CustodyType.DEVELOPER, - account_type=AccountType.EOA + account_type=AccountType.EOA, ) service.list_wallets.return_value = [dest_wallet] - + return service + @pytest.mark.asyncio async def test_mint_usdc_success(mock_config, mock_wallet_service): """Test _mint_usdc successfully finds wallet and calls contract.""" adapter = GatewayAdapter(mock_config, mock_wallet_service) - + result = await adapter._mint_usdc( - attestation_message="0xmsg", - attestation_signature="0xsig", - dest_network=Network.ARC_TESTNET + attestation_message="0xmsg", attestation_signature="0xsig", dest_network=Network.ARC_TESTNET ) - + assert result["success"] is True assert result["tx_hash"] == "0xhash" - + # Verify list_wallets called for destination network mock_wallet_service.list_wallets.assert_called_with(blockchain=Network.ARC_TESTNET) - + # Verify create_contract_execution called with correct params mock_wallet_service._circle.create_contract_execution.assert_called_once() args = mock_wallet_service._circle.create_contract_execution.call_args[1] @@ -85,79 +89,77 @@ async def test_mint_usdc_success(mock_config, mock_wallet_service): assert "receiveMessage" in args["abi_function_signature"] assert args["abi_parameters"] == ["0xmsg", "0xsig"] + @pytest.mark.asyncio async def test_mint_usdc_no_wallet(mock_config, mock_wallet_service): """Test _mint_usdc fails if no wallet found.""" mock_wallet_service.list_wallets.return_value = [] - + adapter = GatewayAdapter(mock_config, mock_wallet_service) - + result = await adapter._mint_usdc( - attestation_message="0xmsg", - attestation_signature="0xsig", - dest_network=Network.ARC_TESTNET + attestation_message="0xmsg", attestation_signature="0xsig", dest_network=Network.ARC_TESTNET ) - + assert result["success"] is False assert "No wallet found" in result["error"] mock_wallet_service._circle.create_contract_execution.assert_not_called() + @pytest.mark.asyncio async def test_execute_cctp_forces_mint_on_arc(mock_config, mock_wallet_service): """Test that _execute_cctp_transfer forces mint calls for ARC_TESTNET destination.""" adapter = GatewayAdapter(mock_config, mock_wallet_service) - + # Mock internal methods to skip network calls adapter._mint_usdc = AsyncMock(return_value={"success": True, "tx_hash": "0xmint"}) - + # Mock httpx response for attestation polling with patch("httpx.AsyncClient") as mock_client: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "messages": [ - {"status": "complete", "message": "0xmsg", "attestation": "0xsig"} - ] + "messages": [{"status": "complete", "message": "0xmsg", "attestation": "0xsig"}] } mock_client.return_value.__aenter__.return_value.get.return_value = mock_response - + # Mock gas check import or method with patch("omniclaw.protocols.gateway.check_gas_requirements", create=True) as mock_gas: - mock_gas.return_value = (True, None) # has gas - - # Call execute - # We need to ensure is_cctp_supported returns True and addresses are found - # Since we are using real constants, ensure ARC_TESTNET is in types.py (it is) - - source_network = Network.ETH_SEPOLIA - dest_network = Network.ARC_TESTNET - - # Mock initial txs - mock_wallet_service._circle.create_contract_execution.return_value = TransactionInfo( - id="tx-1", state=TransactionState.COMPLETE, tx_hash="0xburn" - ) - mock_wallet_service._circle.get_transaction.return_value = TransactionInfo( - id="tx-1", state=TransactionState.COMPLETE, tx_hash="0xburn" - ) - - # We need to mock GatewayAdapter._execute_cctp_transfer or call execute and let it flow? - # Calling execute is complex due to dependencies. - # Call _execute_cctp_transfer directly. - - result = await adapter._execute_cctp_transfer( - wallet_id="source-123", - source_network=source_network, - dest_network=dest_network, - destination_address="0xdest", - amount=Decimal("10"), - fee_level=None - ) - - assert result.success is True - assert result.status.value == "settled" - # Check metadata contains mint info - assert result.metadata["cctp_flow"] == "burn_attestation_mint" - assert result.metadata["mint_tx_hash"] == "0xmint" - - # Verify _mint_usdc was called - adapter._mint_usdc.assert_called_once() + mock_gas.return_value = (True, None) # has gas + + # Call execute + # We need to ensure is_cctp_supported returns True and addresses are found + # Since we are using real constants, ensure ARC_TESTNET is in types.py (it is) + + source_network = Network.ETH_SEPOLIA + dest_network = Network.ARC_TESTNET + + # Mock initial txs + mock_wallet_service._circle.create_contract_execution.return_value = TransactionInfo( + id="tx-1", state=TransactionState.COMPLETE, tx_hash="0xburn" + ) + mock_wallet_service._circle.get_transaction.return_value = TransactionInfo( + id="tx-1", state=TransactionState.COMPLETE, tx_hash="0xburn" + ) + + # We need to mock GatewayAdapter._execute_cctp_transfer or call execute and let it flow? + # Calling execute is complex due to dependencies. + # Call _execute_cctp_transfer directly. + + result = await adapter._execute_cctp_transfer( + wallet_id="source-123", + source_network=source_network, + dest_network=dest_network, + destination_address="0xdest", + amount=Decimal("10"), + fee_level=None, + ) + + assert result.success is True + assert result.status.value == "settled" + # Check metadata contains mint info + assert result.metadata["cctp_flow"] == "burn_attestation_mint" + assert result.metadata["mint_tx_hash"] == "0xmint" + + # Verify _mint_usdc was called + adapter._mint_usdc.assert_called_once() diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py index 37e9079..aafeda4 100644 --- a/tests/test_circuit_breaker.py +++ b/tests/test_circuit_breaker.py @@ -6,7 +6,6 @@ """ import asyncio -import time import pytest diff --git a/tests/test_client.py b/tests/test_client.py index 60b708d..dd6f9e0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -96,9 +96,10 @@ def test_init_production_requires_hardening_envs(self): "OMNICLAW_ENV": "production", }, clear=True, + ), pytest.raises( + ConfigurationError, match="Missing required production environment variables" ): - with pytest.raises(ConfigurationError, match="Missing required production environment variables"): - OmniClaw(network=Network.ARC_TESTNET) + OmniClaw(network=Network.ARC_TESTNET) def test_init_production_requires_strict_settlement(self): with patch.dict( @@ -113,9 +114,8 @@ def test_init_production_requires_strict_settlement(self): "OMNICLAW_STRICT_SETTLEMENT": "false", }, clear=True, - ): - with pytest.raises(ConfigurationError, match="OMNICLAW_STRICT_SETTLEMENT must be true"): - OmniClaw(network=Network.ARC_TESTNET) + ), pytest.raises(ConfigurationError, match="OMNICLAW_STRICT_SETTLEMENT must be true"): + OmniClaw(network=Network.ARC_TESTNET) class TestGuardManager: @@ -277,7 +277,7 @@ async def test_simulate_requires_wallet_id(self, client): @pytest.mark.asyncio async def test_simulate_blocked_by_guard(self, client): client._wallet_service.get_usdc_balance_amount = lambda wid: Decimal("100.00") - + # Add guard for this wallet await client.guards.add_guard( "wallet-123", diff --git a/tests/test_config.py b/tests/test_config.py index f4d9cb9..71c61ca 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -55,13 +55,14 @@ def test_missing_api_key_raises(self) -> None: entity_secret="test_secret", ) - def test_missing_entity_secret_raises(self) -> None: - """Test missing entity secret raises ValueError.""" - with pytest.raises(ValueError, match="entity_secret is required"): - Config( - circle_api_key="test_key", - entity_secret="", - ) + def test_missing_entity_secret_warns(self) -> None: + """Test missing entity secret logs warning (no longer required).""" + # entity_secret is now optional β€” Config should NOT raise + config = Config( + circle_api_key="test_key", + entity_secret="", + ) + assert config.entity_secret == "" def test_from_env(self) -> None: """Test loading config from environment variables.""" @@ -96,17 +97,16 @@ def test_from_env_missing_api_key_raises(self) -> None: ): Config.from_env() - def test_from_env_missing_entity_secret_raises(self) -> None: - """Test from_env raises when entity secret not set.""" + def test_from_env_missing_entity_secret_warns(self) -> None: + """Test from_env with missing entity secret logs warning (no longer required).""" env_vars = { "CIRCLE_API_KEY": "test_key", } - with ( - patch.dict(os.environ, env_vars, clear=True), - pytest.raises(ValueError, match="ENTITY_SECRET"), - ): - Config.from_env() + with patch.dict(os.environ, env_vars, clear=True): + config = Config.from_env() + assert config.entity_secret == "" + assert config.circle_api_key == "test_key" def test_from_env_with_overrides(self) -> None: """Test from_env with override values.""" diff --git a/tests/test_e2e_seller_buyer.py b/tests/test_e2e_seller_buyer.py index 945373f..385ecd0 100644 --- a/tests/test_e2e_seller_buyer.py +++ b/tests/test_e2e_seller_buyer.py @@ -19,18 +19,16 @@ pytest tests/test_e2e_seller_buyer.py -v -s """ -import asyncio import base64 import json -import pytest +import os import signal import subprocess import sys import time -import os import httpx - +import pytest # ============================================================================= # CONFIGURATION @@ -60,7 +58,7 @@ def is_server_running(): result = sock.connect_ex(("127.0.0.1", 4022)) sock.close() return result == 0 - except: + except Exception: return False @@ -109,7 +107,7 @@ def ensure_test_server(): # ============================================================================= -class TestStep1_RequestWithoutPayment: +class TestStep1RequestWithoutPayment: """Step 1: Buyer requests without payment β†’ Seller returns 402.""" def test_seller_returns_402(self): @@ -142,7 +140,7 @@ def test_seller_returns_402(self): # ============================================================================= -class TestStep2_Parse402Response: +class TestStep2Parse402Response: """Step 2: Buyer parses 402 to see what seller accepts.""" def test_parse_payment_required_header(self): @@ -160,7 +158,7 @@ def test_parse_payment_required_header(self): header = response.headers["payment-required"] decoded = json.loads(base64.b64decode(header)) - print(f"\n Parsed 402 response:") + print("\n Parsed 402 response:") print(f" x402Version: {decoded.get('x402Version')}") print(f" Error: {decoded.get('error')}") @@ -194,7 +192,7 @@ def test_parse_payment_required_header(self): # ============================================================================= -class TestStep3_SmartRouting: +class TestStep3SmartRouting: """Step 3: Buyer decides which payment method to use.""" def test_route_to_basic_x402(self): @@ -242,7 +240,7 @@ def test_route_to_basic_x402(self): # ============================================================================= -class TestStep4_DifferentPrices: +class TestStep4DifferentPrices: """Step 4: Verify different endpoints have different prices.""" def test_weather_price(self): @@ -286,7 +284,7 @@ def test_premium_price(self): # ============================================================================= -class TestStep5_NetworkCompatibility: +class TestStep5NetworkCompatibility: """Step 5: Verify buyer and seller are on same network.""" def test_same_network(self): @@ -310,7 +308,7 @@ def test_same_network(self): compatible = buyer_network == seller_network print(f"\n Compatible: {compatible}") - assert compatible == True + assert compatible # ============================================================================= @@ -318,7 +316,7 @@ def test_same_network(self): # ============================================================================= -class TestStep6_FullSimulation: +class TestStep6FullSimulation: """Step 6: Simulate complete payment flow.""" @pytest.mark.asyncio @@ -347,27 +345,24 @@ async def test_complete_flow_simulation(self): accepts = decoded["accepts"] schemes = [a.get("scheme") for a in accepts] - print(f"\n [2] Buyer parses 402") + print("\n [2] Buyer parses 402") print(f" Seller accepts: {schemes}") # === STEP 3: Route decision === supports_circle = "GatewayWalletBatched" in schemes - print(f"\n [3] Routing decision") + print("\n [3] Routing decision") print(f" Supports Circle: {supports_circle}") - if supports_circle: - route = "Circle Nanopayment" - else: - route = "Basic x402" + route = "Circle Nanopayment" if supports_circle else "Basic x402" print(f" β†’ Using: {route}") # === STEP 4: Create payment (simulated) === - print(f"\n [4] Create payment") + print("\n [4] Create payment") print(f" From: {BUYER_ADDRESS[:20]}...") print(f" To: {SELLER_ADDRESS[:20]}...") - print(f" Amount: 1000 atomic ($0.001)") + print(" Amount: 1000 atomic ($0.001)") # In real flow, buyer would: # 1. Sign EIP-3009 authorization @@ -401,11 +396,11 @@ async def test_complete_flow_simulation(self): payment_header = b64.b64encode(json.dumps(payload).encode()).decode() - print(f"\n [5] Send payment header") + print("\n [5] Send payment header") print(f" Header length: {len(payment_header)} chars") # === STEP 6: Verify (shows what seller would do) === - print(f"\n [6] Seller verifies payment") + print("\n [6] Seller verifies payment") # Check timeout current = int(time.time()) @@ -425,7 +420,7 @@ async def test_complete_flow_simulation(self): # === RESULT === all_valid = is_valid_time and is_valid_amount and is_valid_recipient - print(f"\n βœ“ Flow completed successfully!") + print("\n βœ“ Flow completed successfully!") print(f" Payment would be: {'VALID' if all_valid else 'INVALID'}") assert response.status_code == 402 @@ -436,7 +431,7 @@ async def test_complete_flow_simulation(self): # ============================================================================= -class TestStep7_RealServerTest: +class TestStep7RealServerTest: """Test against real running server.""" def test_health_endpoint(self): diff --git a/tests/test_external_qa.py b/tests/test_external_qa.py deleted file mode 100644 index b2af58a..0000000 --- a/tests/test_external_qa.py +++ /dev/null @@ -1,432 +0,0 @@ -""" -External QA Test Suite for OmniClaw SDK. - -This tests the OmniClaw SDK as an external user would: -- Installing from PyPI / local build -- Using in a real Python project -- Calling real SDK methods - -Uses existing fixtures from conftest.py + additional fixtures below. - -Run with: - pytest tests/test_external_qa.py -v -s -""" - -import asyncio -import os -import tempfile -from decimal import Decimal -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import httpx - -from omniclaw import ( - OmniClaw, - generate_entity_secret, - Config, - Network, - PaymentMethod, - PaymentStatus, - WalletInfo, - PaymentRequest, - PaymentResult, - BudgetGuard, - SingleTxGuard, - RecipientGuard, - RateLimitGuard, - ConfirmGuard, - GuardChain, - GuardResult, - PaymentContext, - TrustGate, - TrustPolicy, - TrustVerdict, - TrustCheckResult, - AgentIdentity, - ReputationScore, -) -from omniclaw.core.exceptions import ( - ConfigurationError, - WalletError, - PaymentError, - GuardError, - ValidationError, -) -from omniclaw.onboarding import ( - get_config_dir, - find_recovery_file, -) - - -# ============================================================================= -# EXTERNAL QA FIXTURES - Simulates real user environment -# ============================================================================= - - -@pytest.fixture -def external_env(): - """Simulates a clean external environment without pre-existing config.""" - with tempfile.TemporaryDirectory() as tmpdir: - xdg_config = Path(tmpdir) / "config" - xdg_config.mkdir() - - with patch.dict( - os.environ, - { - "XDG_CONFIG_HOME": str(xdg_config), - "CIRCLE_API_KEY": "test-external-api-key", - "ENTITY_SECRET": "test-entity-secret-32-chars-minimum!!", - }, - clear=False, - ): - yield xdg_config - - -@pytest.fixture -def external_client(external_env): - """Create client as external user would - WITH entity secret to skip auto-setup.""" - client = OmniClaw( - circle_api_key="test-external-api-key", - entity_secret="test-entity-secret-32-chars-minimum!!", - network=Network.ARC_TESTNET, - ) - return client - - -@pytest.fixture -def funded_external_client(external_client, mock_circle_client): - """Client with mocked funded wallet.""" - mock_circle_client.get_wallet_balance.return_value = {"amount": "10000.00", "currency": "USD"} - return external_client - - -# ============================================================================= -# TEST SUITE: External SDK Usage -# ============================================================================= - - -class TestExternalInstallation: - """Verify SDK can be imported and used like a real external package.""" - - def test_import_omniclaw_package(self): - """Can import omniclaw package.""" - import omniclaw - - assert hasattr(omniclaw, "__version__") - - def test_import_main_client(self): - """Can import OmniClaw client.""" - from omniclaw import OmniClaw - - assert OmniClaw is not None - - def test_import_all_types(self): - """Can import all public types.""" - from omniclaw import Network, PaymentMethod, PaymentStatus - from omniclaw import WalletInfo - - assert Network.ARC_TESTNET - assert PaymentMethod.X402 - - def test_import_guards(self): - """Can import all guard classes.""" - from omniclaw import BudgetGuard, SingleTxGuard, RecipientGuard - from omniclaw import RateLimitGuard, ConfirmGuard, GuardChain - - assert BudgetGuard - assert GuardChain - - -class TestExternalClientInitialization: - """Test client initialization as external user would.""" - - def test_init_with_explicit_credentials(self): - """Initialize with explicit API key.""" - client = OmniClaw( - circle_api_key="my-secret-key", - entity_secret="my-entity-secret-32-chars-min!!", - network=Network.ARC_TESTNET, - ) - assert client.config.circle_api_key == "my-secret-key" - assert client.config.network == Network.ARC_TESTNET - - def test_init_with_entity_secret(self): - """Initialize with entity secret in env.""" - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-minimum!!", - ) - assert client.config.circle_api_key == "test-key" - - def test_init_default_network(self): - """Default network is ARC_TESTNET.""" - client = OmniClaw( - circle_api_key="key", - entity_secret="secret-32-chars-minimum!!!", - ) - assert client.config.network == Network.ARC_TESTNET - - -class TestExternalOnboarding: - """Test onboarding utilities as external user would.""" - - def test_generate_entity_secret(self): - """Generate a valid entity secret.""" - secret = generate_entity_secret() - assert isinstance(secret, str) - assert len(secret) >= 32 - - def test_get_config_dir(self): - """Get config directory.""" - config_dir = get_config_dir() - # Should return a Path or be callable - assert config_dir is not None - - def test_store_and_retrieve_credentials(self, external_env): - """Can store and retrieve managed credentials.""" - # Skip - requires valid API key with Circle - pass - - def test_doctor_check(self, external_env): - """doctor runs without error - requires valid API key.""" - pass - - def test_find_recovery_file(self): - """Find recovery file.""" - # May or may not exist depending on setup - result = find_recovery_file() - # Result can be None or Path - assert result is None or isinstance(result, Path) - - -class TestExternalWalletOperations: - """Test wallet operations as external user would.""" - - @pytest.mark.asyncio - async def test_client_has_get_wallet(self, external_client): - """Client has get_wallet method.""" - assert hasattr(external_client, "get_wallet") - assert callable(external_client.get_wallet) - - @pytest.mark.asyncio - async def test_client_has_get_balance(self, external_client): - """Client has get_balance method.""" - assert hasattr(external_client, "get_balance") - assert callable(external_client.get_balance) - - -class TestExternalPaymentFlow: - """Test payment flows as external user would.""" - - @pytest.mark.asyncio - async def test_client_has_pay_method(self, external_client): - """Client has pay method.""" - assert hasattr(external_client, "pay") - assert callable(external_client.pay) - - @pytest.mark.asyncio - async def test_client_has_simulate_method(self, external_client): - """Client has simulate method.""" - assert hasattr(external_client, "simulate") - assert callable(external_client.simulate) - - -class TestExternalGuards: - """Test guards as external user would.""" - - @pytest.mark.asyncio - async def test_budget_guard_daily_limit(self, external_client): - """Budget guard can set daily limit.""" - budget_guard = BudgetGuard(daily_limit=Decimal("50.00")) - assert budget_guard is not None - - @pytest.mark.asyncio - async def test_recipient_guard_whitelist(self, external_client): - """Recipient guard with whitelist.""" - recipient_guard = RecipientGuard( - mode="whitelist", - addresses=["0x1111111111111111111111111111111111111111"], - ) - assert recipient_guard is not None - - @pytest.mark.asyncio - async def test_guard_chain(self, external_client): - """Can use GuardChain for multiple guards.""" - budget = BudgetGuard(daily_limit=Decimal("100.00")) - single = SingleTxGuard(max_amount=Decimal("50.00")) - - chain = GuardChain([budget, single]) - result = await chain.check( - PaymentContext( - wallet_id="wallet-123", - recipient="0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1", - amount=Decimal("10.00"), - ) - ) - assert result.allowed is True - - -class TestExternalTrustLayer: - """Test ERC-8004 Trust Layer as external user would.""" - - @pytest.mark.asyncio - async def test_trust_policy_creation(self): - """Create a trust policy.""" - policy = TrustPolicy( - policy_id="test-policy", - name="Test Policy", - min_wts=50, - min_feedback_count=3, - ) - assert policy.policy_id == "test-policy" - assert policy.min_wts == 50 - - @pytest.mark.asyncio - async def test_reputation_score(self): - """Create reputation score.""" - from omniclaw.identity.types import ReputationScore - - score = ReputationScore( - wts=85, - sample_size=10, - new_agent=False, - ) - assert score.wts == 85 - - @pytest.mark.asyncio - async def test_trust_gate_creation(self, external_client): - """Create TrustGate with storage.""" - from omniclaw.storage import get_storage - - storage = get_storage() - gate = TrustGate(storage=storage) - assert gate is not None - - -class TestExternalErrorHandling: - """Test error handling as external user would.""" - - def test_config_error_on_missing_key(self): - """ConfigurationError when API key missing.""" - # When no API key, Config.from_env raises ValueError - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(ValueError): - OmniClaw() - - def test_client_has_wallet_methods(self, external_client): - """Client has wallet methods.""" - assert hasattr(external_client, "get_wallet") - assert callable(external_client.get_wallet) - - -class TestExternalConfiguration: - """Test configuration as external user would.""" - - def test_config_creation(self): - """Create a config directly.""" - config = Config( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-minimum!!", - network=Network.ARC_TESTNET, - ) - assert config.circle_api_key == "test-key" - assert config.network == Network.ARC_TESTNET - - -class TestExternalTypeValidation: - """Test type validation as external user would.""" - - def test_network_enum(self): - """Network enum works.""" - assert Network.ETH.value == "ETH" - assert Network.ARC_TESTNET.value == "ARC-TESTNET" - assert Network.ARB.value == "ARB" - - def test_payment_method_enum(self): - """PaymentMethod enum works.""" - assert PaymentMethod.X402.value == "x402" - assert PaymentMethod.TRANSFER.value == "transfer" - assert PaymentMethod.CROSSCHAIN.value == "crosschain" - assert PaymentMethod.NANOPAYMENT.value == "nanopayment" - - def test_payment_status_enum(self): - """PaymentStatus enum works.""" - assert PaymentStatus.PENDING.value == "pending" - assert PaymentStatus.COMPLETED.value == "completed" - assert PaymentStatus.FAILED.value == "failed" - - def test_wallet_info_type(self): - """WalletInfo type works.""" - from datetime import datetime - - wallet = WalletInfo( - id="wallet-123", - address="0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1", - blockchain="MATIC-MUMBAI", - state="ACTIVE", - wallet_set_id="ws-123", - custody_type="DEVELOPER", - account_type="EOA", - ) - assert wallet.id == "wallet-123" - - -class TestExternalPaymentIntents: - """Test payment intents as external user would.""" - - @pytest.mark.asyncio - async def test_client_has_create_intent_method(self, external_client): - """Client has create_payment_intent method.""" - assert hasattr(external_client, "create_payment_intent") - assert callable(external_client.create_payment_intent) - - @pytest.mark.asyncio - async def test_client_has_confirm_method(self, external_client): - """Client has confirm_payment_intent method.""" - assert hasattr(external_client, "confirm_payment_intent") - assert callable(external_client.confirm_payment_intent) - - @pytest.mark.asyncio - async def test_client_has_get_intent_method(self, external_client): - """Client has get_payment_intent method.""" - assert hasattr(external_client, "get_payment_intent") - assert callable(external_client.get_payment_intent) - - -class TestExternalNanopayments: - """Test nanopayments as external user would.""" - - @pytest.mark.asyncio - async def test_import_nanopayment_types(self): - """Can import nanopayment types.""" - from omniclaw.protocols.nanopayments import ( - NanoKeyVault, - NanoKeyStore, - GatewayWalletManager, - NanopaymentClient, - ) - - assert NanoKeyVault - assert NanoKeyStore - - @pytest.mark.asyncio - async def test_nanopayment_client_creation(self): - """Create nanopayment client.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - client = NanopaymentClient( - api_key="test-key", - environment="testnet", - ) - assert client is not None - - -# ============================================================================= -# RUN EXTERNAL TESTS -# ============================================================================= - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/tests/test_facilitator_e2e.py b/tests/test_facilitator_e2e.py index 8cab310..0108b83 100644 --- a/tests/test_facilitator_e2e.py +++ b/tests/test_facilitator_e2e.py @@ -4,11 +4,10 @@ This tests the actual HTTP flow to verify the facilitator integration works. """ -import pytest -import asyncio -from unittest.mock import patch, AsyncMock import httpx -from omniclaw.seller import create_seller, create_facilitator +import pytest + +from omniclaw.seller import create_facilitator, create_seller class MockFacilitatorServer: @@ -131,8 +130,6 @@ async def test_facilitator_settle_endpoint(): def test_all_facilitators_have_correct_interface(): """Verify all facilitators implement the same interface.""" - from omniclaw.seller import SUPPORTED_FACILITATORS - providers = ["circle", "coinbase", "ordern", "rbx", "thirdweb"] for provider in providers: diff --git a/tests/test_facilitator_integration.py b/tests/test_facilitator_integration.py index 35f9532..89caeef 100644 --- a/tests/test_facilitator_integration.py +++ b/tests/test_facilitator_integration.py @@ -10,11 +10,12 @@ 6. Seller settles via facilitator """ -import pytest -import asyncio from unittest.mock import Mock -from omniclaw.seller import create_seller, CircleGatewayFacilitator -from omniclaw.seller.facilitator import VerifyResult, SettleResult + +import pytest + +from omniclaw.seller import CircleGatewayFacilitator, create_seller +from omniclaw.seller.facilitator import SettleResult, VerifyResult def create_mock_facilitator(verify_result=None, settle_result=None): @@ -300,7 +301,7 @@ def test_create_facilitator_requires_api_key(self): def test_create_all_facilitators(self): """Test creating all supported facilitators.""" - from omniclaw.seller import create_facilitator, SUPPORTED_FACILITATORS + from omniclaw.seller import SUPPORTED_FACILITATORS, create_facilitator for name in SUPPORTED_FACILITATORS: f = create_facilitator(provider=name, api_key="test_key") diff --git a/tests/test_facilitator_live_integration.py b/tests/test_facilitator_live_integration.py index 98587c0..dbe00d1 100644 --- a/tests/test_facilitator_live_integration.py +++ b/tests/test_facilitator_live_integration.py @@ -18,9 +18,8 @@ """ import os + import pytest -import asyncio -from typing import Optional # Test payment payload/requirements for verification TEST_PAYLOAD = { @@ -48,7 +47,7 @@ } -def get_api_key(provider: str) -> Optional[str]: +def get_api_key(provider: str) -> str | None: """Get API key from environment or return None if not set.""" env_vars = { "coinbase": "COINBASE_API_KEY", diff --git a/tests/test_fund_lock.py b/tests/test_fund_lock.py index 586f9bd..6026710 100644 --- a/tests/test_fund_lock.py +++ b/tests/test_fund_lock.py @@ -7,7 +7,6 @@ from omniclaw.ledger.lock import FundLockService from omniclaw.storage.memory import InMemoryStorage -from omniclaw.storage.redis import RedisStorage # Since Redis requires a running instance, we'll primarily test # the logic with InMemoryStorage and mock/skip Redis if not available. @@ -81,7 +80,7 @@ async def test_retry_mechanism(lock_service): # Acquire lock lock_token = await lock_service.acquire(wallet_id, amount) - + # Run a background task to release it after 0.2s async def delayed_release(): await asyncio.sleep(0.2) @@ -92,7 +91,7 @@ async def delayed_release(): # This should block, then succeed when background task releases it lock_token_2 = await lock_service.acquire(wallet_id, amount, retry_count=5, retry_delay=0.1) assert lock_token_2 is not None - + await lock_service.release_with_key(wallet_id, lock_token_2) diff --git a/tests/test_gas.py b/tests/test_gas.py index 9b1cbc9..a9a0717 100644 --- a/tests/test_gas.py +++ b/tests/test_gas.py @@ -2,20 +2,22 @@ Test suite for gas estimation utilities. """ -import pytest from decimal import Decimal + +import pytest + from omniclaw.core.types import Network from omniclaw.utils.gas import ( - get_network_gas_token, + GAS_REQUIREMENTS, check_gas_requirements, estimate_cctp_gas_cost, - GAS_REQUIREMENTS, + get_network_gas_token, ) class TestNetworkGasToken: """Test gas token identification.""" - + def test_ethereum_networks(self): """Test Ethereum-based networks use ETH.""" assert get_network_gas_token(Network.ETH) == "ETH" @@ -23,12 +25,12 @@ def test_ethereum_networks(self): assert get_network_gas_token(Network.OP) == "ETH" assert get_network_gas_token(Network.ARB) == "ETH" assert get_network_gas_token(Network.BASE) == "ETH" - + def test_other_networks(self): """Test other networks use their native tokens.""" assert get_network_gas_token(Network.AVAX) == "AVAX" assert get_network_gas_token(Network.MATIC) == "MATIC" - + def test_arc_uses_usdc(self): """Test ARC network uses USDC for gas.""" assert get_network_gas_token(Network.ARC_TESTNET) == "USDC" @@ -36,68 +38,60 @@ def test_arc_uses_usdc(self): class TestGasRequirements: """Test gas requirement checks.""" - + def test_sufficient_gas_ethereum(self): """Test check passes with sufficient ETH.""" has_gas, error = check_gas_requirements( Network.ETH_SEPOLIA, Decimal("0.02"), # More than required 0.01 - "test operation" + "test operation", ) - + assert has_gas is True assert error == "" - + def test_insufficient_gas_ethereum(self): """Test check fails with insufficient ETH.""" has_gas, error = check_gas_requirements( Network.ETH_SEPOLIA, Decimal("0.005"), # Less than required 0.01 - "test operation" + "test operation", ) - + assert has_gas is False assert "Insufficient ETH" in error assert "0.01 ETH" in error - + def test_l2_has_lower_requirements(self): """Test L2 networks have lower gas requirements.""" eth_req = GAS_REQUIREMENTS[Network.ETH] base_req = GAS_REQUIREMENTS[Network.BASE] - + assert base_req < eth_req - + def test_arc_always_sufficient(self): """Test ARC doesn't need gas checks (uses USDC).""" # Even with 0 balance, should pass (USDC checked separately) - has_gas, error = check_gas_requirements( - Network.ARC_TESTNET, - Decimal("0"), - "CCTP transfer" - ) - + has_gas, error = check_gas_requirements(Network.ARC_TESTNET, Decimal("0"), "CCTP transfer") + assert has_gas is True assert error == "" - + def test_exact_requirement(self): """Test check passes with exact required amount.""" required = GAS_REQUIREMENTS[Network.BASE_SEPOLIA] - has_gas, error = check_gas_requirements( - Network.BASE_SEPOLIA, - required, - "test" - ) - + has_gas, error = check_gas_requirements(Network.BASE_SEPOLIA, required, "test") + assert has_gas is True - + def test_error_message_details(self): """Test error message contains helpful details.""" has_gas, error = check_gas_requirements( Network.AVAX_FUJI, Decimal("0.05"), # Less than required 0.1 - "CCTP transfer" + "CCTP transfer", ) - + assert has_gas is False assert "AVAX" in error assert "AVAX-FUJI" in error @@ -107,39 +101,39 @@ def test_error_message_details(self): class TestGasCostEstimation: """Test CCTP gas cost estimation.""" - + def test_arc_estimate_uses_usdc(self): """Test ARC estimates are in USDC.""" estimate = estimate_cctp_gas_cost(Network.ARC_TESTNET) - + assert estimate["token"] == "USDC" assert estimate["total"] > 0 assert estimate["approval"] > 0 assert estimate["burn"] > 0 - + def test_l2_cheaper_than_l1(self): """Test L2 gas estimates are lower than L1.""" eth_estimate = estimate_cctp_gas_cost(Network.ETH) base_estimate = estimate_cctp_gas_cost(Network.BASE) - + assert base_estimate["total"] < eth_estimate["total"] - + def test_estimate_has_all_fields(self): """Test estimate contains all expected fields.""" estimate = estimate_cctp_gas_cost(Network.ETH_SEPOLIA) - + assert "approval" in estimate assert "burn" in estimate assert "total" in estimate assert "token" in estimate - + # Total should equal sum of components assert estimate["total"] == estimate["approval"] + estimate["burn"] - + def test_reasonable_arc_costs(self): """Test ARC gas costs are reasonable.""" estimate = estimate_cctp_gas_cost(Network.ARC_TESTNET) - + # Should be small fraction of USDC assert estimate["total"] < Decimal("0.01") # Less than 1 cent assert estimate["total"] > Decimal("0.001") # More than 0.1 cent diff --git a/tests/test_gateway.py b/tests/test_gateway.py index afa447b..f4ce4b1 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -11,7 +11,6 @@ import pytest from omniclaw.core.types import ( - FeeLevel, Network, PaymentMethod, PaymentStatus, @@ -43,10 +42,13 @@ class TestGatewaySupports: """Test routing detection.""" def test_supports_when_destination_chain_provided(self, adapter): - assert adapter.supports( - "0x742d35Cc6634C0532925a3b844Bc9e7595f1E123", - destination_chain=Network.ARB_SEPOLIA, - ) is True + assert ( + adapter.supports( + "0x742d35Cc6634C0532925a3b844Bc9e7595f1E123", + destination_chain=Network.ARB_SEPOLIA, + ) + is True + ) def test_does_not_support_invalid_recipient(self, adapter): assert adapter.supports("0xabc", destination_chain=Network.ARB_SEPOLIA) is False diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index 4e6b706..b209d1c 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -12,4 +12,3 @@ def test_derive_idempotency_key_normalizes_equivalent_numeric_inputs() -> None: assert key_from_decimal == key_from_string assert key_from_string == key_from_string_with_whitespace - diff --git a/tests/test_intent_lifecycle.py b/tests/test_intent_lifecycle.py index fe43642..2a8c97e 100644 --- a/tests/test_intent_lifecycle.py +++ b/tests/test_intent_lifecycle.py @@ -6,23 +6,20 @@ """ import asyncio -from datetime import datetime, timedelta from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest from omniclaw.client import OmniClaw from omniclaw.core.types import ( Network, - PaymentIntent, PaymentIntentStatus, PaymentMethod, PaymentResult, PaymentStatus, SimulationResult, ) -from omniclaw.storage.memory import InMemoryStorage @pytest.fixture @@ -218,7 +215,7 @@ async def test_reservation_prevents_double_spend(client): # Balance is 500.00 # Create first intent for 300 - intent1 = await client.intent.create( + await client.intent.create( wallet_id="wallet-1", recipient="0x742d35Cc6634C0532925a3b844Bc9e7595f5e4a0", amount="300.00", diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 683d573..e520268 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -33,4 +33,3 @@ def test_backfill_ledger_entries_counts_changes(): assert normalized[0]["metadata"]["settlement_final"] is True assert normalized[1]["metadata"]["settlement_final"] is False assert "settlement_final" not in normalized[2]["metadata"] - diff --git a/tests/test_nanopayments_adapter.py b/tests/test_nanopayments_adapter.py deleted file mode 100644 index 240d319..0000000 --- a/tests/test_nanopayments_adapter.py +++ /dev/null @@ -1,456 +0,0 @@ -""" -Tests for NanopaymentAdapter (Phase 6: buyer-side execution engine). - -Tests verify: -- x402 URL payment flow with GatewayWalletBatched -- Graceful fallback when GatewayWalletBatched not supported -- Direct address payment -- Error handling -""" - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from omniclaw.protocols.nanopayments import NanopaymentAdapter -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - GatewayAPIError, - UnsupportedSchemeError, -) -from omniclaw.protocols.nanopayments.types import ( - EIP3009Authorization, - PaymentPayload, - PaymentPayloadInner, - PaymentRequirementsExtra, - PaymentRequirementsKind, -) -from omniclaw.protocols.nanopayments.vault import NanoKeyVault - - -# ============================================================================= -# TEST HELPERS -# ============================================================================= - - -def _make_requirements_dict() -> dict: - """Build a valid 402 response requirements dict.""" - return { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0xUsdcArcTestnet", - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - -def _make_payload_dict() -> dict: - """Build a valid PaymentPayload dict.""" - return { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:5042002", - "payload": { - "signature": "0x" + "d" * 130, - "authorization": { - "from": "0x" + "a" * 40, - "to": "0x" + "b" * 40, - "value": "1000000", - "validAfter": "0", - "validBefore": "9999999999", - "nonce": "0x" + "c" * 64, - }, - }, - } - - -def _mock_http(free: bool = False, status: int = 402) -> MagicMock: - """Mock httpx.AsyncClient.""" - mock = AsyncMock() - resp = MagicMock() - resp.status_code = 200 if free else status - resp.text = '{"data": "resource data"}' - resp.headers = {} - - if not free and status == 402: - req_dict = _make_requirements_dict() - import base64 - - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - - mock.request.return_value = resp - return mock - - -def _mock_vault( - address: str = "0x" + "a" * 40, - payload: PaymentPayload | None = None, -) -> MagicMock: - """Mock NanoKeyVault.""" - mock = MagicMock(spec=NanoKeyVault) - mock.get_address = AsyncMock(return_value=address) - if payload is None: - authorization = EIP3009Authorization.create( - from_address=address, - to="0x" + "b" * 40, - value="1000000", - valid_before=9999999999, - nonce="0x" + "c" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "d" * 130, - authorization=authorization, - ), - ) - mock.sign = AsyncMock(return_value=payload) - mock.get_balance = AsyncMock( - return_value=MagicMock( - total=5_000_000, - available=5_000_000, - formatted_total="5.000000 USDC", - formatted_available="5.000000 USDC", - available_decimal="5.000000", - ) - ) - return mock - - -def _mock_client() -> MagicMock: - """Mock NanopaymentClient.""" - mock = MagicMock(spec=NanopaymentClient) - mock.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock.get_usdc_address = AsyncMock(return_value="0xUsdcArcTestnet") - mock.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="batch-ref-123", - payer="0x" + "a" * 40, - ) - ) - return mock - - -# ============================================================================= -# PAY_X402_URL TESTS -# ============================================================================= - - -class TestPayX402Url: - @pytest.mark.asyncio - async def test_free_resource_returns_non_nanopayment(self): - """Non-402 response means free resource.""" - mock_http = _mock_http(free=True) - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url("https://api.example.com/data") - - assert result.success is True - assert result.is_nanopayment is False - assert result.amount_usdc == "0" - - @pytest.mark.asyncio - async def test_402_with_gateway_batched_succeeds(self): - """Full flow: 402 response -> sign -> retry -> settle.""" - mock_http = _mock_http(free=False, status=402) - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url("https://api.example.com/data") - - # Sign was called - mock_vault.sign.assert_called_once() - # Settle was called - mock_client.settle.assert_called_once() - # Result is nanopayment - assert result.is_nanopayment is True - assert result.success is True - assert result.transaction == "batch-ref-123" - - @pytest.mark.asyncio - async def test_402_without_gateway_batched_raises(self): - """No GatewayWalletBatched scheme: raise for router fallback.""" - import base64 - - req_dict = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0xUsdc", - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "OtherScheme", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_http = AsyncMock() - resp = MagicMock() - resp.status_code = 402 - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - mock_http.request.return_value = resp - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(UnsupportedSchemeError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_402_missing_payment_required_header_raises(self): - """402 response without PAYMENT-REQUIRED header.""" - mock_http = AsyncMock() - resp = MagicMock() - resp.status_code = 402 - resp.headers = {} - resp.text = "{}" - mock_http.request.return_value = resp - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc_info: - await adapter.pay_x402_url("https://api.example.com/data") - assert "PAYMENT-REQUIRED" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_402_bad_base64_raises(self): - """Invalid base64 in PAYMENT-REQUIRED header.""" - mock_http = AsyncMock() - resp = MagicMock() - resp.status_code = 402 - resp.headers = {"payment-required": "not-valid-base64!!!"} - resp.text = "{}" - mock_http.request.return_value = resp - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc_info: - await adapter.pay_x402_url("https://api.example.com/data") - assert "parse" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_auto_topup_skipped_when_disabled(self): - """When auto_topup_enabled=False, balance is not checked.""" - mock_http = _mock_http(free=False, status=402) - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - await adapter.pay_x402_url("https://api.example.com/data") - - # get_balance should NOT be called (auto_topup disabled) - mock_vault.get_balance.assert_not_called() - - @pytest.mark.asyncio - async def test_auto_topup_skipped_when_balance_sufficient(self): - """High balance: no topup needed.""" - mock_http = _mock_http(free=False, status=402) - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - total=5_000_000, - available=5_000_000, - formatted_total="5.000000 USDC", - formatted_available="5.000000 USDC", - available_decimal="5.000000", # Above $1.00 threshold - ) - ) - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - ) - - await adapter.pay_x402_url("https://api.example.com/data") - - # sign was still called (balance check passed) - mock_vault.sign.assert_called_once() - - -# ============================================================================= -# PAY_DIRECT TESTS -# ============================================================================= - - -class TestPayDirect: - @pytest.mark.asyncio - async def test_pay_direct_succeeds(self): - """Direct address payment: build requirements, sign, settle.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - - assert result.success is True - assert result.is_nanopayment is True - assert result.amount_usdc == "0.001" - assert result.amount_atomic == "1000" - assert result.transaction == "batch-ref-123" - mock_vault.sign.assert_called_once() - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_pay_direct_converts_amount_to_atomic(self): - """Verify $0.001 = 1000 atomic units.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - - # Check the requirements passed to sign - sign_call = mock_vault.sign.call_args - assert sign_call.kwargs["amount_atomic"] == 1000 - - @pytest.mark.asyncio - async def test_pay_direct_gets_contract_addresses(self): - """pay_direct fetches verifying_contract and usdc_address.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.5", - network="eip155:5042002", - ) - - mock_client.get_verifying_contract.assert_called_once_with("eip155:5042002") - mock_client.get_usdc_address.assert_called_once_with("eip155:5042002") - - -# ============================================================================= -# AUTO TOPUP TESTS -# ============================================================================= - - -class TestAutoTopup: - @pytest.mark.asyncio - async def test_check_and_topup_returns_false_when_balance_sufficient(self): - """Balance above threshold: no action.""" - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - available_decimal="10.000000", - ) - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - - result = await adapter._check_and_topup() - - assert result is False - - @pytest.mark.asyncio - async def test_configure_auto_topup(self): - """configure_auto_topup updates settings.""" - mock_vault = _mock_vault() - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - - adapter.configure_auto_topup( - enabled=False, - threshold="5.00", - amount="20.00", - ) - - assert adapter._auto_topup is False - assert adapter._topup_threshold == "5.00" - assert adapter._topup_amount == "20.00" diff --git a/tests/test_nanopayments_adapter_coverage.py b/tests/test_nanopayments_adapter_coverage.py deleted file mode 100644 index 8b002d0..0000000 --- a/tests/test_nanopayments_adapter_coverage.py +++ /dev/null @@ -1,1531 +0,0 @@ -""" -Comprehensive tests for NanopaymentAdapter - targeting uncovered code paths. - -Tests cover: -- Circuit breaker state transitions and thresholds -- Settlement retry logic with exponential backoff -- Auto-topup with wallet manager -- Helper functions (_is_url, _is_address) -- NanopaymentProtocolAdapter (router integration) -- Error handling for HTTP requests -""" - -import json -import time -from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from omniclaw.protocols.nanopayments import NanopaymentAdapter -from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentCircuitBreaker, - NanopaymentProtocolAdapter, - _is_address, - _is_url, -) -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - GatewayAPIError, - GatewayConnectionError, - GatewayTimeoutError, - InsufficientBalanceError, - NonceReusedError, - SettlementError, -) -from omniclaw.protocols.nanopayments.types import ( - EIP3009Authorization, - PaymentPayload, - PaymentPayloadInner, - PaymentRequirements, -) -from omniclaw.protocols.nanopayments.vault import NanoKeyVault - - -# ============================================================================= -# TEST HELPERS (reused from existing tests + new ones) -# ============================================================================= - - -def _make_requirements_dict() -> dict: - """Build a valid 402 response requirements dict.""" - return { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0xUsdcArcTestnet", - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - -def _make_payload_dict() -> dict: - """Build a valid PaymentPayload dict.""" - return { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:5042002", - "payload": { - "signature": "0x" + "d" * 130, - "authorization": { - "from": "0x" + "a" * 40, - "to": "0x" + "b" * 40, - "value": "1000000", - "validAfter": "0", - "validBefore": "9999999999", - "nonce": "0x" + "c" * 64, - }, - }, - } - - -def _mock_http(free: bool = False, status: int = 402) -> MagicMock: - """Mock httpx.AsyncClient.""" - mock = AsyncMock() - resp = MagicMock() - resp.status_code = 200 if free else status - resp.text = '{"data": "resource data"}' - resp.headers = {} - - if not free and status == 402: - req_dict = _make_requirements_dict() - import base64 - - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - - mock.request.return_value = resp - return mock - - -def _mock_vault( - address: str = "0x" + "a" * 40, - payload: PaymentPayload | None = None, -) -> MagicMock: - """Mock NanoKeyVault.""" - mock = MagicMock(spec=NanoKeyVault) - mock.get_address = AsyncMock(return_value=address) - if payload is None: - authorization = EIP3009Authorization.create( - from_address=address, - to="0x" + "b" * 40, - value="1000000", - valid_before=9999999999, - nonce="0x" + "c" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "d" * 130, - authorization=authorization, - ), - ) - mock.sign = AsyncMock(return_value=payload) - mock.get_balance = AsyncMock( - return_value=MagicMock( - total=5_000_000, - available=5_000_000, - formatted_total="5.000000 USDC", - formatted_available="5.000000 USDC", - available_decimal="5.000000", - ) - ) - return mock - - -def _mock_client() -> MagicMock: - """Mock NanopaymentClient.""" - mock = MagicMock(spec=NanopaymentClient) - mock.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock.get_usdc_address = AsyncMock(return_value="0xUsdcArcTestnet") - mock.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="batch-ref-123", - payer="0x" + "a" * 40, - ) - ) - return mock - - -def _make_requirements() -> PaymentRequirements: - """Create a PaymentRequirements object for _settle_with_retry tests.""" - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsExtra, - PaymentRequirementsKind, - ) - - return PaymentRequirements( - x402_version=2, - accepts=( - PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0xUsdcArcTestnet", - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ), - ), - ) - - -# ============================================================================= -# CIRCUIT BREAKER TESTS -# ============================================================================= - - -class TestNanopaymentCircuitBreaker: - """Tests for NanopaymentCircuitBreaker class.""" - - def test_circuit_starts_closed(self): - """Circuit breaker starts in closed state.""" - cb = NanopaymentCircuitBreaker() - assert cb.state == "closed" - assert cb.is_available() is True - - def test_record_failure_trips_after_threshold(self): - """After 5 failures (default threshold), state becomes open.""" - cb = NanopaymentCircuitBreaker(failure_threshold=5) - - # 4 failures should keep it closed - for _ in range(4): - cb.record_failure() - assert cb.state == "closed" - assert cb.is_available() is True - - # 5th failure should trip it open - cb.record_failure() - assert cb.state == "open" - assert cb.is_available() is False - - def test_record_error_trips_after_threshold(self): - """record_error() also trips circuit after threshold.""" - cb = NanopaymentCircuitBreaker(failure_threshold=3) - - # 2 errors should keep it closed - cb.record_error() - cb.record_error() - assert cb.state == "closed" - - # 3rd error should trip it open - cb.record_error() - assert cb.state == "open" - assert cb.is_available() is False - - def test_half_open_after_recovery_seconds(self): - """State becomes half_open after recovery period elapses.""" - cb = NanopaymentCircuitBreaker(failure_threshold=2, recovery_seconds=1.0) - - # Trip the circuit open - cb.record_failure() - cb.record_failure() - assert cb.state == "open" - - # Immediately after trip, still open - assert cb.state == "open" - - # Mock time to simulate recovery period passing - with patch("time.monotonic") as mock_time: - # Set time to just before recovery period - mock_time.return_value = cb._last_failure_time + 0.5 - assert cb.state == "open" - - # Set time to after recovery period - mock_time.return_value = cb._last_failure_time + 1.0 - assert cb.state == "half_open" - - def test_half_open_success_closes_circuit(self): - """After half_open, record_success resets to closed.""" - cb = NanopaymentCircuitBreaker(failure_threshold=2, recovery_seconds=1.0) - - # Trip the circuit open - cb.record_failure() - cb.record_failure() - assert cb.state == "open" - - # Move to half_open - with patch("time.monotonic") as mock_time: - mock_time.return_value = cb._last_failure_time + 1.0 - assert cb.state == "half_open" - - # Record success in half_open state - cb.record_success() - assert cb.state == "closed" - assert cb.is_available() is True - assert cb._consecutive_failures == 0 - - def test_half_open_failure_reopens(self): - """After half_open, record_failure reopens the circuit.""" - cb = NanopaymentCircuitBreaker(failure_threshold=2, recovery_seconds=1.0) - - # Trip the circuit open - cb.record_failure() - cb.record_failure() - assert cb.state == "open" - - # Move to half_open - with patch("time.monotonic") as mock_time: - mock_time.return_value = cb._last_failure_time + 1.0 - assert cb.state == "half_open" - - # Record failure in half_open state - cb.record_failure() - assert cb.state == "open" - assert cb.is_available() is False - - def test_record_success_in_closed_state_resets_failures(self): - """record_success resets consecutive failure count.""" - cb = NanopaymentCircuitBreaker(failure_threshold=5) - - # Record some failures (but not enough to trip) - cb.record_failure() - cb.record_failure() - assert cb._consecutive_failures == 2 - - # Success should reset the counter - cb.record_success() - assert cb._consecutive_failures == 0 - assert cb.state == "closed" - - def test_reset_closes_circuit(self): - """reset() closes the circuit regardless of current state.""" - cb = NanopaymentCircuitBreaker(failure_threshold=2) - - # Trip the circuit open - cb.record_failure() - cb.record_failure() - assert cb.state == "open" - - # Reset should close it - cb.reset() - assert cb.state == "closed" - assert cb.is_available() is True - assert cb._consecutive_failures == 0 - assert cb._last_failure_time is None - - def test_is_available_false_when_open(self): - """is_available returns False when circuit is open.""" - cb = NanopaymentCircuitBreaker(failure_threshold=1) - - # Closed: available - assert cb.is_available() is True - - # Trip open - cb.record_failure() - assert cb.is_available() is False - - def test_is_available_true_when_half_open(self): - """is_available returns True when circuit is half_open (trial request allowed).""" - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=0.5) - - # Trip open - cb.record_failure() - assert cb.state == "open" - assert cb.is_available() is False - - # Move to half_open - with patch("time.monotonic") as mock_time: - mock_time.return_value = cb._last_failure_time + 0.5 - assert cb.state == "half_open" - assert cb.is_available() is True - - -# ============================================================================= -# SETTLE RETRY TESTS -# ============================================================================= - - -class TestSettleWithRetry: - """Tests for _settle_with_retry method.""" - - @pytest.mark.asyncio - async def test_settle_with_retry_success_first_attempt(self): - """Settle succeeds on first try.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - result = await adapter._settle_with_retry(payload=payload, requirements=requirements) - - assert result is not None - assert result.success is True - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_settle_with_retry_exhausts_retries_on_timeout(self): - """GatewayTimeoutError exhausts all retries.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock(side_effect=GatewayTimeoutError(endpoint="/x402/v1/settle")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=3, - retry_base_delay=0.1, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - with pytest.raises(GatewayTimeoutError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # Should have slept for retries: attempts 0, 1, 2 (3 retries) - assert mock_sleep.call_count == 3 - # settle called once per attempt (total retry_attempts + 1) - assert mock_client.settle.call_count == 4 - - @pytest.mark.asyncio - async def test_settle_with_retry_exhausts_retries_on_connection_error(self): - """GatewayConnectionError exhausts all retries.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=GatewayConnectionError(reason="Connection refused") - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=2, - retry_base_delay=0.1, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - with pytest.raises(GatewayConnectionError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # Should have slept for retries: attempts 0, 1 (2 retries) - assert mock_sleep.call_count == 2 - # settle called retry_attempts + 1 times - assert mock_client.settle.call_count == 3 - - @pytest.mark.asyncio - async def test_settle_with_retry_circuit_open_raises_immediately(self): - """If circuit is open, raises CircuitOpenError immediately (no retries).""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - # Pre-trip the circuit breaker - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=1) - circuit_breaker.record_failure() # Trip it open - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with pytest.raises(CircuitOpenError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # settle should NOT have been called - mock_client.settle.assert_not_called() - - @pytest.mark.asyncio - async def test_settle_with_retry_nonce_reused_record_failure(self): - """NonceReusedError records failure and does not retry.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=NonceReusedError(reason="nonce_already_used", payer="0x" + "a" * 40) - ) - - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=5) - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with pytest.raises(NonceReusedError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # settle should have been called once (no retries for nonce reused) - mock_client.settle.assert_called_once() - # Circuit breaker should have recorded failure - assert circuit_breaker._consecutive_failures == 1 - - @pytest.mark.asyncio - async def test_settle_with_retry_insufficient_balance_record_failure(self): - """InsufficientBalanceError records failure and does not retry.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=InsufficientBalanceError( - reason="insufficient_balance", payer="0x" + "a" * 40 - ) - ) - - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=5) - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with pytest.raises(InsufficientBalanceError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - mock_client.settle.assert_called_once() - assert circuit_breaker._consecutive_failures == 1 - - @pytest.mark.asyncio - async def test_settle_with_retry_settlement_error_with_timeout_retries(self): - """SettlementError containing 'timeout' retries.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=SettlementError( - reason="timeout occurred during processing", transaction=None, payer="0x" + "a" * 40 - ) - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=2, - retry_base_delay=0.1, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - with pytest.raises(SettlementError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # Should have slept for retries - assert mock_sleep.call_count == 2 - assert mock_client.settle.call_count == 3 - - @pytest.mark.asyncio - async def test_settle_with_retry_settlement_error_other_raises(self): - """SettlementError without 'timeout'/'connection' does not retry.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=SettlementError( - reason="invalid signature", transaction=None, payer="0x" + "a" * 40 - ) - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with pytest.raises(SettlementError) as exc: - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - assert "invalid signature" in str(exc.value) - # settle called only once (no retries) - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_settle_with_retry_half_open_success(self): - """After half_open, settle succeeds and closes circuit.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - - # Pre-configure circuit breaker to be half_open - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=0.5) - circuit_breaker.record_failure() # Trip open - - # Move to half_open - with patch("time.monotonic") as mock_time: - mock_time.return_value = circuit_breaker._last_failure_time + 0.5 - assert circuit_breaker.state == "half_open" - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - result = await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # Should succeed and close the circuit - assert result is not None - assert result.success is True - assert circuit_breaker.state == "closed" - - -# ============================================================================= -# AUTO-TOPUP WITH WALLET MANAGER TESTS -# ============================================================================= - - -class TestCheckAndTopup: - """Tests for _check_and_topup method with wallet manager.""" - - @pytest.mark.asyncio - async def test_check_and_topup_with_wallet_manager_low_balance(self): - """When wallet manager set, balance below threshold triggers deposit.""" - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - available_decimal="0.500000", # Below $1.00 threshold - ) - ) - - mock_wallet_manager = MagicMock() - mock_wallet_manager.deposit = AsyncMock(return_value=MagicMock(deposit_tx_hash="0xtx123")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wallet_manager) - - result = await adapter._check_and_topup() - - assert result is True - mock_wallet_manager.deposit.assert_called_once_with("10.00") # Default topup amount - - @pytest.mark.asyncio - async def test_check_and_topup_with_wallet_manager_high_balance(self): - """When wallet manager set but balance above threshold, returns False.""" - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - available_decimal="10.000000", # Above $1.00 threshold - ) - ) - - mock_wallet_manager = MagicMock() - mock_wallet_manager.deposit = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wallet_manager) - - result = await adapter._check_and_topup() - - assert result is False - mock_wallet_manager.deposit.assert_not_called() - - @pytest.mark.asyncio - async def test_check_and_topup_no_wallet_manager_returns_false(self): - """Without wallet manager, returns False.""" - mock_vault = _mock_vault() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - # Note: no set_wallet_manager called - - result = await adapter._check_and_topup() - - assert result is False - - @pytest.mark.asyncio - async def test_check_and_topup_balance_fetch_fails_returns_false(self): - """If vault.get_balance raises, returns False.""" - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock(side_effect=Exception("Balance check failed")) - - mock_wallet_manager = MagicMock() - mock_wallet_manager.deposit = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wallet_manager) - - result = await adapter._check_and_topup() - - assert result is False - mock_wallet_manager.deposit.assert_not_called() - - @pytest.mark.asyncio - async def test_check_and_topup_deposit_fails_returns_false(self): - """If wallet_manager.deposit raises, returns False.""" - mock_vault = _mock_vault() - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - available_decimal="0.500000", # Below threshold - ) - ) - - mock_wallet_manager = MagicMock() - mock_wallet_manager.deposit = AsyncMock(side_effect=Exception("Deposit failed")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wallet_manager) - - result = await adapter._check_and_topup() - - assert result is False - - -# ============================================================================= -# SET WALLET MANAGER TESTS -# ============================================================================= - - -class TestSetWalletManager: - """Tests for set_wallet_manager method.""" - - def test_set_wallet_manager_stores_manager(self): - """set_wallet_manager stores the manager.""" - mock_vault = _mock_vault() - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - - mock_wallet_manager = MagicMock() - adapter.set_wallet_manager(mock_wallet_manager) - - assert adapter._wallet_manager is mock_wallet_manager - - -# ============================================================================= -# HELPER FUNCTION TESTS -# ============================================================================= - - -class TestIsUrl: - """Tests for _is_url helper function.""" - - def test_is_url_true_for_http(self): - """_is_url returns True for http:// URLs.""" - assert _is_url("http://example.com") is True - assert _is_url("http://api.example.com/path") is True - - def test_is_url_true_for_https(self): - """_is_url returns True for https:// URLs.""" - assert _is_url("https://example.com") is True - assert _is_url("https://api.example.com/data?key=value") is True - - def test_is_url_false_for_address(self): - """_is_url returns False for EVM addresses.""" - assert _is_url("0x" + "a" * 40) is False - assert _is_url("0x1234567890123456789012345678901234567890") is False - - -class TestIsAddress: - """Tests for _is_address helper function.""" - - def test_is_address_true_for_valid_42_char(self): - """_is_address returns True for valid 42-char 0x address.""" - valid_address = "0x" + "a" * 40 - assert _is_address(valid_address) is True - - def test_is_address_false_for_url(self): - """_is_address returns False for URLs.""" - assert _is_address("https://example.com") is False - assert _is_address("http://api.example.com") is False - - def test_is_address_false_for_short_address(self): - """_is_address returns False for address shorter than 42 chars.""" - short_address = "0x" + "a" * 39 - assert len(short_address) == 41 - assert _is_address(short_address) is False - - def test_is_address_false_for_long_address(self): - """_is_address returns False for address longer than 42 chars.""" - long_address = "0x" + "a" * 41 - assert len(long_address) == 43 - assert _is_address(long_address) is False - - -# ============================================================================= -# ROUTER TESTS (NanopaymentProtocolAdapter) -# ============================================================================= - - -class TestNanopaymentProtocolAdapterSupports: - """Tests for NanopaymentProtocolAdapter.supports method.""" - - def test_supports_url_returns_true(self): - """supports returns True for URL recipients.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - assert router.supports("https://api.example.com/data") is True - assert router.supports("http://api.example.com/data") is True - - def test_supports_address_below_threshold_returns_true(self): - """supports returns True for EVM address with amount below threshold.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, micro_threshold_usdc="1.00" - ) - - assert router.supports("0x" + "a" * 40, amount="0.50") is True - assert router.supports("0x" + "b" * 40, amount="0.001") is True - - def test_supports_address_above_threshold_returns_false(self): - """supports returns False for EVM address with amount above threshold.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, micro_threshold_usdc="1.00" - ) - - assert router.supports("0x" + "a" * 40, amount="1.00") is False - assert router.supports("0x" + "b" * 40, amount="5.00") is False - - def test_supports_non_nanopayment_returns_false(self): - """supports returns False for non-URL/non-address recipients.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - assert router.supports("invalid-recipient") is False - assert router.supports("mailto:test@example.com") is False - - def test_supports_address_without_amount_returns_false(self): - """supports returns False for address when amount is not provided.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - # Address without amount can't be checked against threshold - assert router.supports("0x" + "a" * 40) is False - - -class TestNanopaymentProtocolAdapterGetPriority: - """Tests for NanopaymentProtocolAdapter.get_priority method.""" - - def test_get_priority_returns_10(self): - """get_priority returns 10 (highest priority).""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - assert router.get_priority() == 10 - - -class TestNanopaymentProtocolAdapterExecute: - """Tests for NanopaymentProtocolAdapter.execute method.""" - - @pytest.mark.asyncio - async def test_execute_url_calls_pay_x402_url(self): - """execute with URL calls pay_x402_url.""" - from omniclaw.protocols.nanopayments.types import NanopaymentResult - - mock_adapter = MagicMock() - mock_result = NanopaymentResult( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-123", - amount_usdc="0.001", - amount_atomic="1000", - network="eip155:5042002", - response_data=None, - is_nanopayment=True, - ) - mock_adapter.pay_x402_url = AsyncMock(return_value=mock_result) - - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - result = await router.execute( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - mock_adapter.pay_x402_url.assert_called_once() - assert result.success is True - assert result.method.value == "nanopayment" - - @pytest.mark.asyncio - async def test_execute_address_calls_pay_direct(self): - """execute with 0x address calls pay_direct.""" - from omniclaw.protocols.nanopayments.types import NanopaymentResult - - mock_adapter = MagicMock() - mock_result = NanopaymentResult( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-456", - amount_usdc="0.50", - amount_atomic="500000", - network="eip155:5042002", - response_data=None, - is_nanopayment=True, - ) - mock_adapter.pay_direct = AsyncMock(return_value=mock_result) - - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - result = await router.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.50"), - destination_chain="eip155:5042002", - ) - - mock_adapter.pay_direct.assert_called_once() - call_kwargs = mock_adapter.pay_direct.call_args.kwargs - assert call_kwargs["seller_address"] == "0x" + "b" * 40 - assert call_kwargs["amount_usdc"] == "0.50" - assert call_kwargs["network"] == "eip155:5042002" - assert result.success is True - - @pytest.mark.asyncio - async def test_execute_uses_destination_chain_as_network(self): - """execute uses destination_chain parameter as network for pay_direct.""" - from omniclaw.protocols.nanopayments.types import NanopaymentResult - - mock_adapter = MagicMock() - mock_result = NanopaymentResult( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-789", - amount_usdc="0.25", - amount_atomic="250000", - network="eip155:1", - response_data=None, - is_nanopayment=True, - ) - mock_adapter.pay_direct = AsyncMock(return_value=mock_result) - - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - await router.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.25"), - destination_chain="eip155:1", # Ethereum mainnet - ) - - call_kwargs = mock_adapter.pay_direct.call_args.kwargs - assert call_kwargs["network"] == "eip155:1" - - @pytest.mark.asyncio - async def test_execute_graceful_degradation_on_error(self): - """If adapter raises, returns FAILED result (not propagated).""" - mock_adapter = MagicMock() - mock_adapter.pay_x402_url = AsyncMock(side_effect=Exception("Network error")) - - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - result = await router.execute( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - # Should return failed result, not raise - assert result.success is False - assert result.status.value == "failed" - assert "falling back" in result.error - - -class TestNanopaymentProtocolAdapterSimulate: - """Tests for NanopaymentProtocolAdapter.simulate method.""" - - @pytest.mark.asyncio - async def test_simulate_returns_would_succeed(self): - """simulate returns dict with would_succeed=True.""" - mock_adapter = MagicMock() - router = NanopaymentProtocolAdapter(nanopayment_adapter=mock_adapter) - - result = await router.simulate( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - assert result["would_succeed"] is True - assert result["method"] == "nanopayment" - assert result["recipient"] == "https://api.example.com/data" - assert result["amount"] == "0.001" - assert result["estimated_fee"] == "0" - - -# ============================================================================= -# PAY_X402_URL ERROR HANDLING TESTS -# ============================================================================= - - -class TestPayX402UrlErrorHandling: - """Tests for pay_x402_url error handling.""" - - @pytest.mark.asyncio - async def test_pay_x402_url_initial_request_timeout_raises(self): - """httpx.TimeoutException on initial request raises GatewayAPIError.""" - mock_http = AsyncMock() - mock_http.request.side_effect = httpx.TimeoutException("Request timed out") - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc: - await adapter.pay_x402_url("https://api.example.com/data") - - assert "timed out" in str(exc.value).lower() - - @pytest.mark.asyncio - async def test_pay_x402_url_initial_request_connection_error_raises(self): - """httpx.RequestError on initial request raises GatewayAPIError.""" - mock_http = AsyncMock() - mock_http.request.side_effect = httpx.RequestError("Connection failed") - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc: - await adapter.pay_x402_url("https://api.example.com/data") - - assert "failed" in str(exc.value).lower() - - @pytest.mark.asyncio - async def test_pay_x402_url_retry_request_timeout_raises(self): - """httpx.TimeoutException on retry request raises GatewayAPIError.""" - import base64 - - # First request returns 402 - initial_resp = MagicMock() - initial_resp.status_code = 402 - initial_resp.headers = { - "payment-required": base64.b64encode( - json.dumps(_make_requirements_dict()).encode() - ).decode() - } - - # Retry request times out - mock_http = AsyncMock() - mock_http.request.side_effect = [ - initial_resp, # First call - initial request - httpx.TimeoutException("Retry timed out"), # Second call - retry - ] - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc: - await adapter.pay_x402_url("https://api.example.com/data") - - assert "timed out" in str(exc.value).lower() - assert "retry" in str(exc.value).lower() - - @pytest.mark.asyncio - async def test_pay_x402_url_retry_request_connection_error_raises(self): - """httpx.RequestError on retry request raises GatewayAPIError.""" - import base64 - - # First request returns 402 - initial_resp = MagicMock() - initial_resp.status_code = 402 - initial_resp.headers = { - "payment-required": base64.b64encode( - json.dumps(_make_requirements_dict()).encode() - ).decode() - } - - # Retry request fails - mock_http = AsyncMock() - mock_http.request.side_effect = [ - initial_resp, - httpx.RequestError("Connection reset"), - ] - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(GatewayAPIError) as exc: - await adapter.pay_x402_url("https://api.example.com/data") - - assert "failed" in str(exc.value).lower() - assert "retry" in str(exc.value).lower() - - @pytest.mark.asyncio - async def test_pay_x402_url_circuit_open_raises(self): - """When circuit breaker is open and content NOT delivered, raises CircuitOpenError.""" - import base64 - - # First request returns 402 - initial_resp = MagicMock() - initial_resp.status_code = 402 - initial_resp.headers = { - "payment-required": base64.b64encode( - json.dumps(_make_requirements_dict()).encode() - ).decode() - } - - # Retry request returns non-success (no content delivered) - retry_resp = MagicMock() - retry_resp.status_code = 500 - retry_resp.content = b"" - retry_resp.text = "Server Error" - - mock_http = AsyncMock() - mock_http.request.side_effect = [initial_resp, retry_resp] - - # Pre-trip the circuit breaker - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=1) - circuit_breaker.record_failure() # Trip open - - adapter = NanopaymentAdapter( - vault=_mock_vault(), - nanopayment_client=_mock_client(), - http_client=mock_http, - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - with pytest.raises(CircuitOpenError): - await adapter.pay_x402_url("https://api.example.com/data") - - -# ============================================================================= -# ADDITIONAL CIRCUIT BREAKER TESTS -# ============================================================================= - - -class TestCircuitBreakerAdditional: - """Additional circuit breaker edge case tests.""" - - def test_record_failure_multiple_times_to_threshold(self): - """Recording failures up to threshold doesn't trip until reached.""" - cb = NanopaymentCircuitBreaker(failure_threshold=3) - - assert cb.state == "closed" - cb.record_failure() - assert cb.state == "closed" - cb.record_failure() - assert cb.state == "closed" - - cb.record_failure() - assert cb.state == "open" - - def test_record_error_counts_toward_threshold_separately(self): - """record_error and record_failure both count toward threshold.""" - cb = NanopaymentCircuitBreaker(failure_threshold=3) - - cb.record_failure() - cb.record_failure() - assert cb.state == "closed" - - cb.record_error() - assert cb.state == "open" - - def test_success_during_half_open_closes_circuit(self): - """Success during half_open state closes circuit and resets failures.""" - cb = NanopaymentCircuitBreaker(failure_threshold=2, recovery_seconds=1.0) - - # Trip the circuit - cb.record_failure() - cb.record_failure() - assert cb.state == "open" - - # Move to half_open - with patch("time.monotonic") as mock_time: - mock_time.return_value = cb._last_failure_time + 1.0 - assert cb.state == "half_open" - - # Success should close - cb.record_success() - assert cb.state == "closed" - assert cb._consecutive_failures == 0 - - def test_state_property_transitions_open_to_half_open(self): - """State property correctly transitions from open to half_open.""" - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=2.0) - - # Trip the circuit - cb.record_failure() - assert cb.state == "open" - failure_time = cb._last_failure_time - - # Before recovery period - with patch("time.monotonic") as mock_time: - mock_time.return_value = failure_time + 1.0 - assert cb.state == "open" - - # After recovery period - mock_time.return_value = failure_time + 2.0 - assert cb.state == "half_open" - - -# ============================================================================= -# GET CIRCUIT BREAKER STATE TEST -# ============================================================================= - - -class TestGetCircuitBreakerState: - """Tests for get_circuit_breaker_state method.""" - - @pytest.mark.asyncio - async def test_get_circuit_breaker_state_closed(self): - """get_circuit_breaker_state returns 'closed' initially.""" - mock_vault = _mock_vault() - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - assert adapter.get_circuit_breaker_state() == "closed" - - @pytest.mark.asyncio - async def test_get_circuit_breaker_state_after_failures(self): - """get_circuit_breaker_state returns 'open' after threshold exceeded.""" - mock_vault = _mock_vault() - circuit_breaker = NanopaymentCircuitBreaker(failure_threshold=2) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=_mock_client(), - http_client=AsyncMock(), - auto_topup_enabled=False, - circuit_breaker=circuit_breaker, - ) - - circuit_breaker.record_failure() - circuit_breaker.record_failure() - - assert adapter.get_circuit_breaker_state() == "open" - - -# ============================================================================= -# SETTLEMENT ERROR WITH CONNECTION KEYWORD TESTS -# ============================================================================= - - -class TestSettlementErrorConnectionRetries: - """Tests for SettlementError with 'connection' keyword triggering retries.""" - - @pytest.mark.asyncio - async def test_settle_with_retry_settlement_error_with_connection_retries(self): - """SettlementError containing 'connection' retries.""" - mock_vault = _mock_vault() - mock_client = _mock_client() - mock_client.settle = AsyncMock( - side_effect=SettlementError( - reason="connection reset by peer", - transaction=None, - payer="0x" + "a" * 40, - ) - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=2, - retry_base_delay=0.1, - ) - - requirements = _make_requirements() - payload = await mock_vault.sign(requirements=requirements.accepts[0]) - - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - with pytest.raises(SettlementError): - await adapter._settle_with_retry(payload=payload, requirements=requirements) - - # Should have slept for retries - assert mock_sleep.call_count == 2 - assert mock_client.settle.call_count == 3 - - -# ============================================================================= -# ADDITIONAL ADAPTER COVERAGE -# ============================================================================= - - -class TestPayX402UrlAdditionalCoverage: - """Additional pay_x402_url coverage tests.""" - - @pytest.mark.asyncio - async def test_pay_x402_url_gets_verifying_contract_when_missing(self): - """Line 345: get_verifying_contract called when verifying_contract is missing.""" - import base64 - - req_dict = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0xUsdc", - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - # NO verifyingContract! - }, - }, - ], - } - - mock_http = AsyncMock() - resp = MagicMock() - resp.status_code = 402 - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - mock_http.request.return_value = resp - - mock_vault = _mock_vault() - mock_client = _mock_client() - # Make get_verifying_contract return a value - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url("https://api.example.com/data") - - # Should have fetched verifying contract - mock_client.get_verifying_contract.assert_called_once_with("eip155:5042002") - assert result.success is True - - @pytest.mark.asyncio - async def test_pay_x402_url_auto_topup_exception_caught(self): - """Lines 377-378: Auto-topup exception is caught and logged, payment proceeds.""" - import base64 - - req_dict = _make_requirements_dict() - - mock_http = AsyncMock() - resp = MagicMock() - resp.status_code = 402 - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - mock_http.request.return_value = resp - - mock_vault = _mock_vault() - mock_client = _mock_client() - - # Make vault.get_balance raise to trigger the exception in _check_and_topup - mock_vault.get_balance = AsyncMock(side_effect=Exception("Balance check failed")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, # Auto-topup ON - ) - - # Should NOT raise despite auto-topup failure - result = await adapter.pay_x402_url("https://api.example.com/data") - assert result.success is True # Payment still proceeds - - -class TestCircuitBreakerOpenWithContent: - """Test circuit breaker open but content delivered (lines 430-434).""" - - @pytest.mark.asyncio - async def test_circuit_open_but_content_delivered_continues(self): - """Lines 430-434: Circuit open but HTTP success β†’ no exception raised.""" - import base64 - - req_dict = _make_requirements_dict() - - # First request: 402 response - # Second request: 200 success (content delivered despite circuit open) - first_resp = MagicMock() - first_resp.status_code = 402 - first_resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - first_resp.text = "{}" - first_resp.content = b"{}" - - success_resp = MagicMock() - success_resp.status_code = 200 - success_resp.text = '{"data": "premium content"}' - success_resp.content = b'{"data": "premium content"}' - - mock_http = AsyncMock() - mock_http.request.side_effect = [first_resp, success_resp] - - mock_vault = _mock_vault() - mock_client = _mock_client() - - # Trip the circuit breaker first - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=60.0) - cb.record_failure() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - circuit_breaker=cb, - retry_attempts=0, # No retries - ) - - # Should NOT raise β€” content was delivered despite circuit open - result = await adapter.pay_x402_url("https://api.example.com/data") - # In strict settlement mode, HTTP content delivery alone is not final success. - assert result.success is False - assert result.is_nanopayment is True - assert result.response_data == '{"data": "premium content"}' - - -class TestRouterExecuteNetworkFallback: - """Test router execute network fallback (lines 931-940).""" - - @pytest.mark.asyncio - async def test_execute_address_uses_destination_chain(self): - """Execute with address uses destination_chain as network.""" - from decimal import Decimal - - mock_vault = _mock_vault() - mock_client = _mock_client() - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - router = NanopaymentProtocolAdapter(nanopayment_adapter=adapter, micro_threshold_usdc="1.00") - - mock_vault.sign = AsyncMock(return_value=mock_vault.sign()) - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - - result = await router.execute( - wallet_id="w123", - recipient="0x" + "b" * 40, - amount=Decimal("0.1"), - destination_chain="eip155:1", - ) - - # Should have used destination_chain as network - assert result.success is True - - @pytest.mark.asyncio - async def test_execute_address_falls_back_to_env_network(self): - """Lines 931-940: No destination_chain β†’ falls back to Config or env var.""" - from decimal import Decimal - import os - - mock_vault = _mock_vault() - mock_client = _mock_client() - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - router = NanopaymentProtocolAdapter(nanopayment_adapter=adapter, micro_threshold_usdc="1.00") - - mock_vault.sign = AsyncMock(return_value=mock_vault.sign()) - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - - # Set env var for fallback - with patch.dict(os.environ, {"NANOPAYMENTS_DEFAULT_NETWORK": "eip155:10"}): - result = await router.execute( - wallet_id="w123", - recipient="0x" + "b" * 40, - amount=Decimal("0.1"), - ) - - # Should have used env var network - assert result.success is True diff --git a/tests/test_nanopayments_client.py b/tests/test_nanopayments_client.py deleted file mode 100644 index e1d17d7..0000000 --- a/tests/test_nanopayments_client.py +++ /dev/null @@ -1,814 +0,0 @@ -""" -Tests for NanopaymentClient (Phase 3: Circle Gateway REST API client). - -Phase 3: NanopaymentClient - -All tests use mocked HTTP responses to avoid network calls. -""" - -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx - -import pytest -from eth_account import Account - -from omniclaw.protocols.nanopayments import ( - NanopaymentClient, - NanopaymentHTTPClient, -) -from omniclaw.protocols.nanopayments.exceptions import ( - GatewayAPIError, - GatewayConnectionError, - GatewayTimeoutError, - InsufficientBalanceError, - InsufficientGatewayBalanceError, - NonceReusedError, - SettlementError, - UnsupportedNetworkError, -) -from omniclaw.protocols.nanopayments.signing import generate_eoa_keypair -from omniclaw.protocols.nanopayments.types import ( - EIP3009Authorization, - PaymentPayload, - PaymentPayloadInner, - PaymentRequirements, - PaymentRequirementsExtra, - PaymentRequirementsKind, - SettleResponse, - SupportedKind, - VerifyResponse, -) - - -# ============================================================================= -# TEST HELPERS -# ============================================================================= - - -def _make_supported_response( - networks=None, -) -> dict: - """Default /x402/v1/supported response fixture.""" - if networks is None: - networks = [ - { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:5042002", - "extra": { - "verifyingContract": "0xVerifyingContractArcTestnet", - "usdcAddress": "0xUsdcArcTestnet", - }, - }, - { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:1", - "extra": { - "verifyingContract": "0xVerifyingContractMainnet", - "usdcAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - }, - }, - ] - return {"kinds": networks} - - -def _make_payload() -> PaymentPayload: - """Minimal valid PaymentPayload for settle/verify tests.""" - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "b" * 40, - value="1000000", - valid_before=int(time.time()) + 345600, - nonce="0x" + "c" * 64, - ) - return PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "d" * 130, - authorization=authorization, - ), - ) - - -def _make_requirements() -> PaymentRequirements: - return PaymentRequirements( - x402_version=2, - accepts=( - PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0xUsdcArcTestnet", - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0xVerifyingContractArcTestnet", - ), - ), - ), - ) - - -# ============================================================================= -# NanopaymentHTTPClient TESTS -# ============================================================================= - - -class TestNanopaymentHTTPClientInit: - def test_stores_config(self): - client = NanopaymentHTTPClient( - base_url="https://api.example.com", - api_key="test-key", - timeout=15.0, - ) - assert client._base_url == "https://api.example.com" - assert client._api_key == "test-key" - assert client._timeout == 15.0 - - def test_strips_trailing_slash(self): - client = NanopaymentHTTPClient( - base_url="https://api.example.com/", - api_key="key", - ) - assert client._base_url == "https://api.example.com" - - -class TestNanopaymentHTTPClientContextManager: - @pytest.mark.asyncio - async def test_opens_and_closes_client(self): - client = NanopaymentHTTPClient( - base_url="https://api.example.com", - api_key="key", - ) - async with client as c: - assert c._client is not None - assert client._client is None - - @pytest.mark.asyncio - async def test_get_returns_response(self): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"kinds": []} - mock_response.text = "" - - with patch("httpx.AsyncClient") as MockAsyncClient: - instance = AsyncMock() - instance.get.return_value = mock_response - instance.aclose.return_value = None - MockAsyncClient.return_value = instance - - async with NanopaymentHTTPClient( - base_url="https://api.example.com", - api_key="key", - ) as http: - resp = await http.get("/test") - - assert resp.status_code == 200 - instance.get.assert_called_once() - - @pytest.mark.asyncio - async def test_get_raises_timeout_on_timeout(self): - with patch("httpx.AsyncClient") as MockAsyncClient: - instance = AsyncMock() - instance.get.side_effect = httpx.TimeoutException("read timeout") - instance.aclose.return_value = None - MockAsyncClient.return_value = instance - - async with NanopaymentHTTPClient( - base_url="https://api.example.com", - api_key="key", - ) as http: - with pytest.raises(GatewayTimeoutError) as exc_info: - await http.get("/test") - assert exc_info.value.code == "GATEWAY_TIMEOUT" - - @pytest.mark.asyncio - async def test_post_raises_connection_error(self): - with patch("httpx.AsyncClient") as MockAsyncClient: - instance = AsyncMock() - instance.post.side_effect = httpx.ConnectError("connection refused") - instance.aclose.return_value = None - MockAsyncClient.return_value = instance - - async with NanopaymentHTTPClient( - base_url="https://api.example.com", - api_key="key", - ) as http: - with pytest.raises(GatewayConnectionError) as exc_info: - await http.post("/test", json={}) - assert exc_info.value.code == "GATEWAY_CONNECTION_ERROR" - - -# ============================================================================= -# NanopaymentClient CONSTRUCTOR TESTS -# ============================================================================= - - -class TestNanopaymentClientInit: - def test_defaults_to_testnet(self): - with patch.dict("os.environ", {}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(api_key="key") - assert client._environment == "testnet" - assert client._base_url == "https://gateway-api-testnet.circle.com" - - def test_mainnet_url(self): - with patch.dict("os.environ", {}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(environment="mainnet", api_key="key") - assert client._environment == "mainnet" - assert client._base_url == "https://gateway-api.circle.com" - - def test_reads_api_key_from_env(self): - with patch.dict("os.environ", {"CIRCLE_API_KEY": "env-api-key"}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient() - assert client._api_key == "env-api-key" - - def test_explicit_api_key_overrides_env(self): - with patch.dict("os.environ", {"CIRCLE_API_KEY": "env-key"}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(api_key="explicit-key") - assert client._api_key == "explicit-key" - - def test_reads_environment_from_env(self): - with patch.dict("os.environ", {"NANOPAYMENTS_ENVIRONMENT": "mainnet"}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(api_key="key") - assert client._environment == "mainnet" - assert client._base_url == "https://gateway-api.circle.com" - - def test_rejects_invalid_environment(self): - with patch.dict("os.environ", {}, clear=True): - with pytest.raises(ValueError) as exc_info: - NanopaymentClient(environment="invalid", api_key="key") - assert "invalid" in str(exc_info.value) - - def test_custom_base_url(self): - with patch.dict("os.environ", {}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(base_url="https://mock.local/ gateway", api_key="key") - assert client._base_url == "https://mock.local/ gateway" - - def test_custom_timeout(self): - with patch.dict("os.environ", {}, clear=True): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient"): - client = NanopaymentClient(timeout=60.0, api_key="key") - assert client._timeout == 60.0 - - -# ============================================================================= -# GET_SUPPORTED TESTS -# ============================================================================= - - -class TestGetSupported: - @pytest.mark.asyncio - async def test_parses_supported_kinds(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - kinds = await client.get_supported() - - assert len(kinds) == 2 - assert kinds[0].network == "eip155:5042002" - assert kinds[0].verifying_contract == "0xVerifyingContractArcTestnet" - assert kinds[0].usdc_address == "0xUsdcArcTestnet" - assert kinds[1].network == "eip155:1" - assert kinds[1].verifying_contract == "0xVerifyingContractMainnet" - - @pytest.mark.asyncio - async def test_uses_cache_on_second_call(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - kinds1 = await client.get_supported() - kinds2 = await client.get_supported() - - assert kinds1 == kinds2 - # Only one HTTP call - mock_ctx.get.assert_called_once() - - @pytest.mark.asyncio - async def test_force_refresh_bypasses_cache(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - await client.get_supported() - await client.get_supported(force_refresh=True) - - # Two HTTP calls despite cache - assert mock_ctx.get.call_count == 2 - - @pytest.mark.asyncio - async def test_handles_empty_kinds_list(self): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = {"kinds": []} - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - kinds = await client.get_supported() - - assert kinds == [] - - @pytest.mark.asyncio - async def test_raises_on_http_error(self): - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 500 - mock_resp.text = "Internal Server Error" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(GatewayAPIError) as exc_info: - await client.get_supported() - assert exc_info.value.status_code == 500 - - -# ============================================================================= -# GET_VERIFYING_CONTRACT / GET_USDC_ADDRESS TESTS -# ============================================================================= - - -class TestGetVerifyingContract: - @pytest.mark.asyncio - async def test_returns_contract_for_known_network(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - addr = await client.get_verifying_contract("eip155:5042002") - - assert addr == "0xVerifyingContractArcTestnet" - - @pytest.mark.asyncio - async def test_raises_for_unknown_network(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.get_verifying_contract("eip155:99999999") - assert exc_info.value.network == "eip155:99999999" - - -class TestGetUsdcAddress: - @pytest.mark.asyncio - async def test_returns_usdc_for_known_network(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - addr = await client.get_usdc_address("eip155:1") - - assert addr == "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - - @pytest.mark.asyncio - async def test_raises_for_unknown_network(self): - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = response_data - mock_resp.text = "" - mock_ctx.get.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(UnsupportedNetworkError): - await client.get_usdc_address("eip155:99999999") - - -# ============================================================================= -# VERIFY TESTS -# ============================================================================= - - -class TestVerify: - @pytest.mark.asyncio - async def test_returns_verify_response_valid(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "isValid": True, - "payer": "0x" + "a" * 40, - "invalidReason": None, - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - result = await client.verify(payload, requirements) - - assert isinstance(result, VerifyResponse) - assert result.is_valid is True - assert result.payer == "0x" + "a" * 40 - assert result.invalid_reason is None - - @pytest.mark.asyncio - async def test_returns_verify_response_invalid(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "isValid": False, - "payer": None, - "invalidReason": "invalid_signature", - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - result = await client.verify(payload, requirements) - - assert result.is_valid is False - assert result.invalid_reason == "invalid_signature" - - @pytest.mark.asyncio - async def test_raises_on_http_error(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 401 - mock_resp.text = "Unauthorized" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(GatewayAPIError) as exc_info: - await client.verify(payload, requirements) - assert exc_info.value.status_code == 401 - - -# ============================================================================= -# SETTLE TESTS -# ============================================================================= - - -class TestSettle: - @pytest.mark.asyncio - async def test_returns_settle_response_on_success(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "success": True, - "transaction": "batch-ref-123", - "payer": "0x" + "a" * 40, - "errorReason": None, - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - result = await client.settle(payload, requirements) - - assert isinstance(result, SettleResponse) - assert result.success is True - assert result.transaction == "batch-ref-123" - assert result.payer == "0x" + "a" * 40 - - @pytest.mark.asyncio - async def test_raises_settlement_error_on_failure(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "success": False, - "transaction": None, - "payer": "0x" + "a" * 40, - "errorReason": "insufficient_balance", - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(InsufficientBalanceError): - await client.settle(payload, requirements) - - @pytest.mark.asyncio - async def test_raises_on_http_402_with_body_error(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.json.return_value = { - "errorReason": "nonce_already_used", - "payer": "0x" + "a" * 40, - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(NonceReusedError): - await client.settle(payload, requirements) - - @pytest.mark.asyncio - async def test_raises_gateway_api_error_on_http_failure(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 403 - mock_resp.text = "Forbidden" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(GatewayAPIError) as exc_info: - await client.settle(payload, requirements) - assert exc_info.value.status_code == 403 - - @pytest.mark.asyncio - async def test_maps_unknown_error_reason_to_settlement_error(self): - payload = _make_payload() - requirements = _make_requirements() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "success": False, - "transaction": "tx-999", - "errorReason": "unknown_gateway_error", - } - mock_resp.text = "" - mock_ctx.post.return_value = mock_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(SettlementError) as exc_info: - await client.settle(payload, requirements) - assert exc_info.value.reason == "unknown_gateway_error" - assert exc_info.value.transaction == "tx-999" - - -# ============================================================================= -# CHECK_BALANCE TESTS -# ============================================================================= - - -class TestCheckBalance: - @pytest.mark.asyncio - async def test_returns_gateway_balance(self): - supported_response = _make_supported_response() - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - supported_resp = MagicMock() - supported_resp.status_code = 200 - supported_resp.json.return_value = supported_response - supported_resp.text = "" - balance_resp = MagicMock() - balance_resp.status_code = 200 - balance_resp.json.return_value = { - "balances": [{"balance": "5000000"}], - } - balance_resp.text = "" - mock_ctx.get.return_value = supported_resp - mock_ctx.post.return_value = balance_resp - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - balance = await client.check_balance( - address="0x" + "a" * 40, - network="eip155:5042002", - ) - - assert balance.total == 5_000_000 - assert balance.available == 5_000_000 - assert balance.formatted_total == "5.000000 USDC" - assert balance.formatted_available == "5.000000 USDC" - assert balance.total_decimal == "5.000000" - assert balance.available_decimal == "5.000000" - - @pytest.mark.asyncio - async def test_raises_unsupported_network_on_404(self): - supported_response = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - supported_resp = MagicMock(status_code=200, text="") - supported_resp.json.return_value = supported_response - mock_ctx.get.return_value = supported_resp - mock_ctx.post.return_value = MagicMock(status_code=404, text="Not Found") - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(UnsupportedNetworkError): - await client.check_balance( - address="0x" + "a" * 40, - network="eip155:5042002", - ) - - @pytest.mark.asyncio - async def test_raises_gateway_api_error_on_http_failure(self): - supported_response = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - supported_resp = MagicMock(status_code=200, text="") - supported_resp.json.return_value = supported_response - mock_ctx.get.return_value = supported_resp - mock_ctx.post.return_value = MagicMock(status_code=500, text="Internal Server Error") - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(GatewayAPIError) as exc_info: - await client.check_balance( - address="0x" + "a" * 40, - network="eip155:5042002", - ) - assert exc_info.value.status_code == 500 - - -# ============================================================================= -# EDGE CASE TESTS -# ============================================================================= - - -class TestNanopaymentClientEdgeCases: - @pytest.mark.asyncio - async def test_get_supported_after_insufficient_balance_error(self): - """Cache is still populated after a settle failure.""" - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.get.return_value = MagicMock( - status_code=200, - json=lambda: response_data, - text="", - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - - # get_supported populates cache - kinds = await client.get_supported() - assert len(kinds) == 2 - - # Cache hit - kinds2 = await client.get_supported() - assert kinds2 == kinds - mock_ctx.get.assert_called_once() - - @pytest.mark.asyncio - async def test_multiple_networks_pick_first_match(self): - """get_verifying_contract returns first matching network.""" - response_data = _make_supported_response() - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.get.return_value = MagicMock( - status_code=200, - json=lambda: response_data, - text="", - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - addr = await client.get_verifying_contract("eip155:5042002") - - assert addr == "0xVerifyingContractArcTestnet" diff --git a/tests/test_nanopayments_exceptions.py b/tests/test_nanopayments_exceptions.py index b3cbfd5..c4303eb 100644 --- a/tests/test_nanopayments_exceptions.py +++ b/tests/test_nanopayments_exceptions.py @@ -4,8 +4,6 @@ Phase 1: Foundation """ -import pytest - from omniclaw.protocols.nanopayments import ( AuthorizationExpiredError, DepositError, @@ -20,9 +18,7 @@ InvalidPrivateKeyError, InvalidSignatureError, KeyEncryptionError, - KeyManagementError, KeyNotFoundError, - MiddlewareError, NanopaymentError, NetworkMismatchError, NoDefaultKeyError, diff --git a/tests/test_nanopayments_integration.py b/tests/test_nanopayments_integration.py deleted file mode 100644 index 8a0faf6..0000000 --- a/tests/test_nanopayments_integration.py +++ /dev/null @@ -1,5599 +0,0 @@ -""" -Integration tests for OmniClaw Nanopayments (Phase 9). - -Tests cover the complete nanopayments stack: -- NanoKeyVault: Key generation, encryption, signing, vault operations -- NanopaymentAdapter: Buyer-side x402 URL and direct address payments -- NanopaymentProtocolAdapter: PaymentRouter integration and routing -- GatewayWalletManager: On-chain deposit/withdraw -- GatewayMiddleware: Seller-side x402 gate -- OmniClaw client: SDK-level integration with all nanopayments components - -IMPORTANT: Import submodules DIRECTLY (not via omniclaw.protocols.nanopayments). -Importing from the package __init__ triggers omniclaw.__init__ which imports -OmniClaw β†’ CircleClient β†’ circle.web3 (not installed in test env). -Existing nanopayments tests follow this same pattern. -""" - -from __future__ import annotations - -import base64 -import json -from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -# Import submodules DIRECTLY β€” never through package __init__ -from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentProtocolAdapter, -) -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.constants import ( - DEFAULT_GATEWAY_AUTO_TOPUP_AMOUNT, - DEFAULT_GATEWAY_AUTO_TOPUP_THRESHOLD, -) -from omniclaw.protocols.nanopayments.exceptions import ( - DepositError, - DuplicateKeyAliasError, - ERC20ApprovalError, - InsufficientGasError, - InvalidPrivateKeyError, - KeyNotFoundError, - NanopaymentNotInitializedError, - NoDefaultKeyError, - UnsupportedNetworkError, - UnsupportedSchemeError, - WithdrawError, -) -from omniclaw.protocols.nanopayments.keys import NanoKeyStore -from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - parse_price, -) -from omniclaw.protocols.nanopayments.signing import EIP3009Signer, generate_eoa_keypair -from omniclaw.protocols.nanopayments.types import ( - DepositResult, - EIP3009Authorization, - GatewayBalance, - PaymentPayload, - PaymentPayloadInner, - PaymentRequirements, - PaymentRequirementsExtra, - PaymentRequirementsKind, - ResourceInfo, - SettleResponse, - SupportedKind, -) -from omniclaw.protocols.nanopayments.vault import NanoKeyVault -from omniclaw.storage.base import StorageBackend -from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager - - -# ============================================================================= -# MOCK STORAGE BACKEND -# ============================================================================= - - -class MockStorageBackend: - """In-memory mock for StorageBackend.""" - - def __init__(self) -> None: - self._data: dict[str, dict[str, dict]] = {} - - async def save(self, collection: str, key: str, data: dict) -> None: - if collection not in self._data: - self._data[collection] = {} - self._data[collection][key] = data.copy() if isinstance(data, dict) else data - - async def get(self, collection: str, key: str) -> dict | None: - return self._data.get(collection, {}).get(key) - - async def delete(self, collection: str, key: str) -> bool: - if collection in self._data and key in self._data[collection]: - del self._data[collection][key] - return True - return False - - async def query( - self, - collection: str, - filters: dict | None = None, - limit: int | None = None, - offset: int = 0, - ) -> list[dict]: - """Query all records in a collection.""" - records = [] - for key, data in self._data.get(collection, {}).items(): - if filters: - if not all(data.get(k) == v for k, v in filters.items()): - continue - records.append({"key": key, **data}) - return records[offset : offset + (limit or len(records))] - if collection in self._data and key in self._data[collection]: - del self._data[collection][key] - return True - return False - - -# ============================================================================= -# FIXTURES -# ============================================================================= - - -@pytest.fixture -def mock_storage() -> MockStorageBackend: - """In-memory storage for tests.""" - return MockStorageBackend() - - -@pytest.fixture -def mock_keystore() -> NanoKeyStore: - """NanoKeyStore with test entity secret.""" - return NanoKeyStore(entity_secret="test-entity-secret-for-integration-tests-32ch") - - -@pytest.fixture -def mock_vault(mock_storage: MockStorageBackend) -> NanoKeyVault: - """NanoKeyVault with real encryption and mock storage.""" - return NanoKeyVault( - entity_secret="test-entity-secret-for-integration-tests-32ch", - storage_backend=mock_storage, - circle_api_key="test-api-key", - nanopayments_environment="testnet", - ) - - -@pytest.fixture -def mock_client() -> MagicMock: - """Mock NanopaymentClient.""" - mock = MagicMock(spec=NanopaymentClient) - mock.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - mock.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="batch-tx-integration-123", - ) - ) - mock.check_balance = AsyncMock( - return_value=MagicMock( - total=5_000_000, - available=5_000_000, - formatted_total="5.000000 USDC", - formatted_available="5.000000 USDC", - available_decimal="5.000000", - ) - ) - return mock - - -@pytest.fixture -def mock_http_client() -> MagicMock: - """Mock httpx.AsyncClient for x402 URL payments.""" - return AsyncMock() - - -@pytest.fixture -def mock_web3() -> MagicMock: - """Mock web3.Web3 for on-chain operations.""" - mock = MagicMock() - mock.eth = MagicMock() - mock.eth.get_transaction_count = MagicMock(return_value=1) - mock.eth.account = MagicMock() - mock.eth.contract = MagicMock() - return mock - - -# ============================================================================= -# REQUIREATION BUILDER -# ============================================================================= - - -def make_402_requirements( - scheme: str = "exact", - network: str = "eip155:5042002", - amount: str = "1000000", - verifying_contract: str | None = None, - name: str = "GatewayWalletBatched", -) -> dict: - """Build a valid 402 response requirements dict.""" - vc = verifying_contract or ("0x" + "c" * 40) - return { - "x402Version": 2, - "accepts": [ - { - "scheme": scheme, - "network": network, - "asset": "0x" + "d" * 40, - "amount": amount, - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": name, - "version": "1", - "verifyingContract": vc, - }, - }, - ], - } - - -def make_signed_payload( - from_addr: str, - to_addr: str, - value: str = "1000000", - valid_before: int = 9999999999, -) -> PaymentPayload: - """Create a real signed PaymentPayload for integration tests.""" - authorization = EIP3009Authorization.create( - from_address=from_addr, - to=to_addr, - value=value, - valid_before=valid_before, - nonce="0x" + "e" * 64, - ) - # Sign the authorization - structured_data = authorization.to_eip712_structured_data() - signed = EIP3009Signer.from_authorization(authorization) - return PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature=signed.signature, - authorization=authorization, - ), - ) - - -# ============================================================================= -# TEST: NanoKeyVault β€” Full Key Lifecycle -# ============================================================================= - - -class TestNanoKeyVaultIntegration: - """End-to-end tests for NanoKeyVault key management.""" - - @pytest.mark.asyncio - async def test_generate_key_full_lifecycle(self, mock_vault: NanoKeyVault): - """Generate key β†’ store β†’ retrieve address β†’ sign β†’ verify.""" - # Generate a key - address = await mock_vault.generate_key("test-agent-key") - assert address.startswith("0x") - assert len(address) == 42 - # eth_account returns checksummed (mixed-case) addresses - assert address[:2] == "0x" - - # Can retrieve the address - retrieved = await mock_vault.get_address("test-agent-key") - assert retrieved == address - - # Vault knows about the key - assert await mock_vault.has_key("test-agent-key") is True - assert await mock_vault.has_key("nonexistent-key") is False - - @pytest.mark.asyncio - async def test_add_key_import(self, mock_vault: NanoKeyVault): - """Import an existing private key.""" - # Generate a key externally (returns (private_key_hex, address)) - private_key, expected_address = generate_eoa_keypair() - signer = EIP3009Signer(private_key) - - # Import it - address = await mock_vault.add_key("imported-key", private_key) - assert address == signer.address == expected_address - - # Can retrieve it - retrieved = await mock_vault.get_address("imported-key") - assert retrieved == signer.address - - @pytest.mark.asyncio - async def test_add_key_invalid_private_key(self, mock_vault: NanoKeyVault): - """Importing an invalid private key raises InvalidPrivateKeyError.""" - with pytest.raises(InvalidPrivateKeyError): - await mock_vault.add_key("bad-key", "0xnot-a-valid-key") - - @pytest.mark.asyncio - async def test_add_key_duplicate_alias(self, mock_vault: NanoKeyVault): - """Adding a key with duplicate alias raises DuplicateKeyAliasError.""" - await mock_vault.generate_key("duplicate-test") - with pytest.raises(DuplicateKeyAliasError): - await mock_vault.generate_key("duplicate-test") - - @pytest.mark.asyncio - async def test_set_default_key(self, mock_vault: NanoKeyVault): - """Set default key, then get_address(None) returns it.""" - addr1 = await mock_vault.generate_key("key-one") - addr2 = await mock_vault.generate_key("key-two") - await mock_vault.set_default_key("key-one") - - # Without alias argument, uses default - assert await mock_vault.get_address(None) == addr1 - - # Switch default - await mock_vault.set_default_key("key-two") - assert await mock_vault.get_address(None) == addr2 - - @pytest.mark.asyncio - async def test_get_address_no_default_raises(self, mock_vault: NanoKeyVault): - """get_address(None) with no default set raises NoDefaultKeyError.""" - with pytest.raises(NoDefaultKeyError): - await mock_vault.get_address(None) - - @pytest.mark.asyncio - async def test_get_address_unknown_alias_raises(self, mock_vault: NanoKeyVault): - """get_address(unknown) raises KeyNotFoundError.""" - with pytest.raises(KeyNotFoundError): - await mock_vault.get_address("totally-unknown-key-12345") - - @pytest.mark.asyncio - async def test_get_raw_key_returns_decrypted_key(self, mock_vault: NanoKeyVault): - """get_raw_key decrypts and returns the raw private key.""" - private_key, expected_address = generate_eoa_keypair() - signer = EIP3009Signer(private_key) - await mock_vault.add_key("raw-key-test", private_key) - - raw = await mock_vault.get_raw_key("raw-key-test") - # decrypt_key returns with 0x prefix (same as generate_eoa_keypair output) - assert raw == private_key - - # The raw key should produce the same address - recovered_signer = EIP3009Signer(raw) - assert recovered_signer.address == signer.address == expected_address - - @pytest.mark.asyncio - async def test_rotate_key(self, mock_vault: NanoKeyVault): - """rotate_key generates new key, stores it, returns new address.""" - old_addr = await mock_vault.generate_key("rotate-test") - new_addr = await mock_vault.rotate_key("rotate-test") - - assert new_addr != old_addr - assert await mock_vault.get_address("rotate-test") == new_addr - - @pytest.mark.asyncio - async def test_sign_creates_valid_payload(self, mock_vault: NanoKeyVault): - """sign() produces a valid PaymentPayload with a real signature.""" - # Set up - await mock_vault.generate_key("sign-test") - await mock_vault.set_default_key("sign-test") - - # Build requirements - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - # Sign - payload = await mock_vault.sign(requirements=kind, amount_atomic=500_000) - - assert payload.x402_version == 2 - assert payload.scheme == "exact" - assert payload.network == "eip155:5042002" - assert payload.payload.signature.startswith("0x") - assert len(payload.payload.signature) == 132 # 65 bytes = 130 hex chars + 0x - - @pytest.mark.asyncio - async def test_sign_with_specific_alias(self, mock_vault: NanoKeyVault): - """sign(alias=...) uses the specified key, not the default.""" - await mock_vault.generate_key("key-a") - addr_b = await mock_vault.generate_key("key-b") - await mock_vault.set_default_key("key-a") - - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - # Sign with key-b - payload = await mock_vault.sign(requirements=kind, alias="key-b") - - # The "from" address in the payload should be key-b's address - assert payload.payload.authorization.from_address == addr_b - - -# ============================================================================= -# TEST: NanopaymentAdapter β€” End-to-End Buyer Payments -# ============================================================================= - - -class TestNanopaymentAdapterIntegration: - """End-to-end tests for buyer-side nanopayment execution.""" - - @pytest.mark.asyncio - async def test_pay_x402_url_full_flow( - self, - mock_vault: NanoKeyVault, - mock_client: MagicMock, - mock_http_client: MagicMock, - ): - """URL payment: free resource β†’ 402 β†’ sign β†’ retry β†’ settle.""" - # Set up a real vault key - await mock_vault.generate_key("buyer-key") - payer_addr = await mock_vault.get_address("buyer-key") - - # Build 402 requirements - req_dict = make_402_requirements(amount="1000000") - import base64 - - # Mock HTTP: first request gets 402, retry gets 200 - first_resp = MagicMock() - first_resp.status_code = 402 - first_resp.text = "{}" - first_resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - retry_resp = MagicMock() - retry_resp.status_code = 200 - retry_resp.text = '{"data": "premium content"}' - - mock_http_client.request = AsyncMock(side_effect=[first_resp, retry_resp]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http_client, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url( - url="https://api.seller.com/premium", - nano_key_alias="buyer-key", - ) - - assert result.success is True - assert result.is_nanopayment is True - assert result.payer == payer_addr - # amount_usdc may be "1.0" or "1.000000" depending on formatting - assert float(result.amount_usdc) == 1.0 - assert result.transaction == "batch-tx-integration-123" - - # HTTP was called twice: initial + retry - assert mock_http_client.request.call_count == 2 - # Settle was called once - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_pay_x402_url_free_resource( - self, - mock_vault: NanoKeyVault, - mock_client: MagicMock, - mock_http_client: MagicMock, - ): - """Non-402 response means free resource, no nanopayment.""" - free_resp = MagicMock() - free_resp.status_code = 200 - free_resp.text = '{"data": "free content"}' - mock_http_client.request = AsyncMock(return_value=free_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http_client, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url("https://api.seller.com/free") - - assert result.success is True - assert result.is_nanopayment is False - assert result.amount_usdc == "0" - # No settlement - mock_client.settle.assert_not_called() - - @pytest.mark.asyncio - async def test_pay_x402_url_unsupported_scheme_falls_back( - self, - mock_vault: NanoKeyVault, - mock_client: MagicMock, - mock_http_client: MagicMock, - ): - """Seller doesn't support GatewayWalletBatched β†’ UnsupportedSchemeError.""" - req_dict = make_402_requirements(name="StandardUSDC") - import base64 - - resp = MagicMock() - resp.status_code = 402 - resp.text = "{}" - resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - mock_http_client.request = AsyncMock(return_value=resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http_client, - auto_topup_enabled=False, - ) - - with pytest.raises(UnsupportedSchemeError): - await adapter.pay_x402_url("https://api.legacy-seller.com/data") - - @pytest.mark.asyncio - async def test_pay_direct_full_flow( - self, - mock_vault: NanoKeyVault, - mock_client: MagicMock, - mock_http_client: MagicMock, - ): - """Direct address nanopayment: build requirements β†’ sign β†’ settle.""" - await mock_vault.generate_key("buyer-direct") - payer_addr = await mock_vault.get_address("buyer-direct") - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http_client, - auto_topup_enabled=False, - ) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.000001", # Minimum nanopayment - network="eip155:5042002", - nano_key_alias="buyer-direct", - ) - - assert result.success is True - assert result.is_nanopayment is True - assert result.payer == payer_addr - assert result.seller == "0x" + "b" * 40 - assert result.amount_usdc == "0.000001" - assert result.amount_atomic == "1" # Minimum: 1 atomic unit - assert result.network == "eip155:5042002" - - # Settlement was called - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_pay_direct_auto_topup( - self, - mock_vault: NanoKeyVault, - mock_client: MagicMock, - mock_http_client: MagicMock, - ): - """auto_topup_enabled=True triggers balance check before payment.""" - await mock_vault.generate_key("buyer-topup") - - # Override vault's get_balance to return low balance - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - total=100_000, # $0.10 β€” below $1.00 threshold - available=100_000, - formatted_total="0.100000 USDC", - formatted_available="0.100000 USDC", - available_decimal="0.100000", - ) - ) - - # Mock wallet_manager for auto-topup - mock_wallet_manager = AsyncMock() - mock_wallet_manager.deposit = AsyncMock( - return_value=MagicMock( - approval_tx_hash="0xapproval123", - deposit_tx_hash="0xdeposit123", - amount=1_000_000, - formatted_amount="1.000000 USDC", - ) - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http_client, - auto_topup_enabled=True, - ) - # Set wallet_manager so _check_and_topup actually runs - adapter.set_wallet_manager(mock_wallet_manager) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.01", - network="eip155:5042002", - nano_key_alias="buyer-topup", - ) - - # Payment still succeeds (auto_topup didn't prevent it) - assert result.success is True - # Balance was checked (vault.get_balance was called for auto_topup check) - mock_vault.get_balance.assert_called() - - -# ============================================================================= -# TEST: NanopaymentProtocolAdapter β€” Router Integration -# ============================================================================= - - -class TestNanopaymentProtocolAdapterIntegration: - """Tests for NanopaymentProtocolAdapter routing in PaymentRouter.""" - - def test_supports_https_url(self, mock_client: MagicMock): - """URL recipients are supported.""" - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ), - micro_threshold_usdc="1.00", - ) - assert adapter.supports("https://api.example.com/data") is True - assert adapter.supports("http://api.example.com/data") is True - - def test_supports_address_below_threshold(self, mock_client: MagicMock): - """EVM address below micro_threshold is supported.""" - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ), - micro_threshold_usdc="1.00", - ) - assert adapter.supports("0x" + "a" * 40, amount="0.50") is True - assert adapter.supports("0x" + "a" * 40, amount="0.999999") is True - - def test_supports_address_above_threshold_rejected(self, mock_client: MagicMock): - """EVM address at/above micro_threshold is NOT supported.""" - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ), - micro_threshold_usdc="1.00", - ) - assert adapter.supports("0x" + "a" * 40, amount="1.00") is False - assert adapter.supports("0x" + "a" * 40, amount="10.00") is False - - def test_priority_is_10(self, mock_client: MagicMock): - """Priority 10 means checked before other adapters (default 100).""" - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ), - ) - assert adapter.get_priority() == 10 - - @pytest.mark.asyncio - async def test_execute_url_routes_to_pay_x402_url(self, mock_client: MagicMock): - """execute() with URL calls pay_x402_url().""" - mock_adapter = AsyncMock() - mock_adapter.pay_x402_url = AsyncMock( - return_value=MagicMock( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-123", - amount_usdc="1.0", - amount_atomic="1000000", - network="eip155:5042002", - is_nanopayment=True, - ) - ) - - protocol_adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - result = await protocol_adapter.execute( - wallet_id="wallet-123", - recipient="https://api.seller.com/resource", - amount=Decimal("1.0"), - ) - - mock_adapter.pay_x402_url.assert_called_once() - assert result.success is True - assert result.method.value == "nanopayment" - - @pytest.mark.asyncio - async def test_execute_address_routes_to_pay_direct(self, mock_client: MagicMock): - """execute() with address calls pay_direct().""" - mock_adapter = AsyncMock() - mock_adapter.pay_direct = AsyncMock( - return_value=MagicMock( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-456", - amount_usdc="0.001", - amount_atomic="1000", - network="eip155:5042002", - is_nanopayment=True, - ) - ) - - protocol_adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - result = await protocol_adapter.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.001"), - ) - - mock_adapter.pay_direct.assert_called_once() - assert result.success is True - assert result.method.value == "nanopayment" - - @pytest.mark.asyncio - async def test_execute_graceful_degradation_on_error(self, mock_client: MagicMock): - """execute() catches exceptions and returns failed PaymentResult.""" - mock_adapter = AsyncMock() - mock_adapter.pay_x402_url = AsyncMock(side_effect=Exception("Network error")) - - protocol_adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - result = await protocol_adapter.execute( - wallet_id="wallet-123", - recipient="https://api.seller.com/resource", - amount=Decimal("1.0"), - ) - - assert result.success is False - assert result.status.value == "failed" - assert "Nanopayment failed" in result.error - - -# ============================================================================= -# TEST: GatewayWalletManager β€” On-Chain Deposit/Withdraw -# ============================================================================= - - -class TestGatewayWalletManagerIntegration: - """Tests for GatewayWalletManager on-chain operations.""" - - @pytest.mark.asyncio - async def test_deposit_creates_approval_and_deposit_txs(self, mock_client: MagicMock): - """deposit() approves USDC and calls deposit on gateway contract.""" - # Generate a real key - private_key, address = generate_eoa_keypair() - - with patch("omniclaw.protocols.nanopayments.wallet.web3") as mock_web3_module: - # Mock web3 components - mock_w3 = MagicMock() - mock_web3_module.Web3.return_value = mock_w3 - mock_w3.eth.get_transaction_count.return_value = 5 - - # Gas reserve check mocks - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH in wei - mock_w3.eth.gas_price = 30_000_000_000 # 30 gwei (attribute) - mock_w3.from_wei = lambda v, unit: v / 1e18 if unit == "ether" else v - - mock_account = MagicMock() - mock_web3_module.HTTPProvider.return_value = MagicMock() - mock_w3.eth.account = mock_account - - # Mock signed transaction - signed_tx = MagicMock() - signed_tx.raw_transaction = b"raw_tx_bytes" - mock_account.sign_transaction.return_value = signed_tx - - # Mock transaction receipt (success) - mock_receipt = {"status": 1, "transactionHash": b"tx_hash_bytes"} - mock_w3.eth.send_raw_transaction.return_value = b"tx_hash_bytes" - mock_w3.eth.wait_for_transaction_receipt.return_value = mock_receipt - - # Mock USDC contract - mock_usdc = MagicMock() - mock_w3.eth.contract.return_value = mock_usdc - mock_usdc.functions.allowance.return_value.call.return_value = 0 - mock_usdc.functions.approve.return_value = MagicMock() - - # Mock contract for deposit - mock_gateway = MagicMock() - mock_w3.eth.contract.side_effect = [mock_usdc, mock_gateway] - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.testnet", - nanopayment_client=mock_client, - ) - - result = await manager.deposit("10.00") - - assert result.approval_tx_hash is not None - assert result.deposit_tx_hash is not None - assert result.amount == 10_000_000 # 10 USDC in atomic units - assert "10.00" in result.formatted_amount - - @pytest.mark.asyncio - async def test_withdraw_creates_withdrawal_tx(self, mock_client: MagicMock): - """withdraw() returns structured transfer result via Gateway settlement.""" - private_key, address = generate_eoa_keypair() - - with patch("omniclaw.protocols.nanopayments.wallet.web3") as mock_web3_module: - mock_w3 = MagicMock() - mock_web3_module.Web3.return_value = mock_w3 - mock_w3.eth.get_transaction_count.return_value = 3 - - mock_web3_module.HTTPProvider.return_value = MagicMock() - mock_w3.eth.account = MagicMock() - - signed_tx = MagicMock() - signed_tx.raw_transaction = b"raw_tx_bytes" - mock_w3.eth.account.sign_transaction.return_value = signed_tx - - mock_receipt = {"status": 1, "transactionHash": b"withdraw_hash"} - mock_w3.eth.send_raw_transaction.return_value = b"withdraw_hash" - mock_w3.eth.wait_for_transaction_receipt.return_value = mock_receipt - - # Mock gateway contract - mock_gateway = MagicMock() - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.testnet", - nanopayment_client=mock_client, - ) - - result = await manager.withdraw( - amount_usdc="5.00", - destination_chain=None, - recipient="0x" + "b" * 40, - ) - assert result.amount == 5_000_000 - assert result.destination_chain == "eip155:5042002" - - -# ============================================================================= -# TEST: GatewayMiddleware β€” Seller-Side x402 Gate -# ============================================================================= - - -class TestGatewayMiddlewareIntegration: - """Tests for GatewayMiddleware (seller-side payment gate).""" - - def test_parse_price_dollar_amounts(self): - """parse_price correctly parses $1.00 = 1_000_000 atomic.""" - assert parse_price("$1.00") == 1_000_000 - assert parse_price("$0.001") == 1_000 - assert parse_price("$0.000001") == 1 # Minimum - assert parse_price("$100.50") == 100_500_000 - - def test_parse_price_numeric_string(self): - """parse_price handles plain numeric strings.""" - assert parse_price("1.00") == 1_000_000 - assert parse_price("0.5") == 500_000 - assert parse_price("0.000001") == 1 - - def test_parse_price_atomic_threshold(self): - """parse_price treats >= 1_000_000 as atomic units.""" - assert parse_price("1000000") == 1_000_000 # Exactly 1M = $1.00 atomic - assert parse_price("1000001") == 1_000_001 # > 1M = atomic - - def test_parse_price_small_integer_as_dollars(self): - """parse_price treats < 1_000_000 as whole dollars (Γ—1_000_000).""" - assert parse_price("5") == 5_000_000 # $5 = 5M atomic - - def test_parse_price_rejects_invalid(self): - """Invalid price formats raise InvalidPriceError.""" - from omniclaw.protocols.nanopayments.exceptions import InvalidPriceError - - with pytest.raises(InvalidPriceError): - parse_price("not a price") - with pytest.raises(InvalidPriceError): - parse_price("") - - def test_parse_price_zero_dollar(self): - """parse_price handles $0 as 0 atomic units.""" - assert parse_price("$0") == 0 - - def test_middleware_build_accepts_array(self, mock_client: MagicMock): - """_build_accepts_array produces correct accepts entries.""" - seller_addr = "0x" + "f" * 40 - mw = GatewayMiddleware( - seller_address=seller_addr, - nanopayment_client=mock_client, - supported_kinds=[ - MagicMock( - network="eip155:5042002", - verifying_contract="0x" + "c" * 40, - usdc_address="0x" + "d" * 40, - ) - ], - ) - - accepts = mw._build_accepts_array(price_atomic=10_000) - - assert len(accepts) >= 1 - entry = next(a for a in accepts if a["extra"]["name"] == "GatewayWalletBatched") - assert entry["scheme"] == "exact" - assert entry["amount"] == "10000" - assert entry["payTo"] == seller_addr - - @pytest.mark.asyncio - async def test_middleware_handle_without_signature_raises_402(self, mock_client: MagicMock): - """handle() without payment signature raises PaymentRequiredHTTPError.""" - mw = GatewayMiddleware( - seller_address="0x" + "f" * 40, - nanopayment_client=mock_client, - ) - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await mw.handle(request_headers={}, price_usd="$0.01") - - assert exc_info.value.status_code == 402 - - -# ============================================================================= -# TEST: NanopaymentHTTPClient HTTP Error Paths -# ============================================================================= - - -class TestHTTPClientErrorPaths: - """Test httpx error handling in NanopaymentHTTPClient.""" - - @pytest.mark.asyncio - async def test_get_timeout_exception(self): - """Lines 127-135: get() raises GatewayTimeoutError on TimeoutException.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "get", side_effect=httpx.TimeoutException("timeout") - ): - with pytest.raises(Exception): # GatewayTimeoutError - await client.get("/path") - - @pytest.mark.asyncio - async def test_get_connect_error(self): - """Lines 127-135: get() raises GatewayConnectionError on ConnectError.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "get", side_effect=httpx.ConnectError("connect") - ): - with pytest.raises(Exception): # GatewayConnectionError - await client.get("/path") - - @pytest.mark.asyncio - async def test_get_request_error(self): - """Lines 127-135: get() raises GatewayConnectionError on RequestError.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "get", side_effect=httpx.RequestError("request") - ): - with pytest.raises(Exception): # GatewayConnectionError - await client.get("/path") - - @pytest.mark.asyncio - async def test_post_timeout_exception(self): - """Lines 152-163: post() raises GatewayTimeoutError on TimeoutException.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "post", side_effect=httpx.TimeoutException("timeout") - ): - with pytest.raises(Exception): - await client.post("/path") - - @pytest.mark.asyncio - async def test_post_connect_error(self): - """Lines 152-163: post() raises GatewayConnectionError on ConnectError.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "post", side_effect=httpx.ConnectError("connect") - ): - with pytest.raises(Exception): - await client.post("/path") - - @pytest.mark.asyncio - async def test_post_request_error(self): - """Lines 152-163: post() raises GatewayConnectionError on RequestError.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - import httpx - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object( - client._client.__class__, "post", side_effect=httpx.RequestError("request") - ): - with pytest.raises(Exception): - await client.post("/path") - - @pytest.mark.asyncio - async def test_post_with_idempotency_key(self): - """Lines 152-163: post() includes Idempotency-Key header when provided.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - - captured_headers = {} - - class FakeResponse: - status_code = 200 - text = "" - - def json(self): - return {} - - async def mock_post(path, headers=None, **kwargs): - captured_headers.update(headers or {}) - return FakeResponse() - - async with NanopaymentHTTPClient(base_url="http://localhost", api_key="test") as client: - with patch.object(client._client.__class__, "post", side_effect=mock_post): - await client.post("/path", idempotency_key="test-key-123") - - assert captured_headers.get("Idempotency-Key") == "test-key-123" - - -# ============================================================================= -# TEST: NanopaymentClient HTTP error paths -# ============================================================================= - - -class TestNanopaymentClientHTTPErrorPaths: - @pytest.mark.asyncio - async def test_get_supported_http_error_raises(self): - """Lines 246-251: get_supported() raises GatewayAPIError on non-success status.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.get.return_value = MagicMock( - status_code=500, - text="Internal Server Error", - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(Exception) as exc_info: - await client.get_supported() - assert "500" in str(exc_info.value) or "Gateway" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_verify_http_error_raises(self): - """Lines 342-347: verify() raises GatewayAPIError on non-success status.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - payload = MagicMock() - payload.to_dict.return_value = {} - req = MagicMock() - req.to_dict.return_value = {} - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.post.return_value = MagicMock( - status_code=403, - text="Forbidden", - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(Exception) as exc_info: - await client.verify(payload, req) - assert "403" in str(exc_info.value) or "Gateway" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_settle_http_error_raises(self): - """Lines 421-426: settle() raises GatewayAPIError on non-success non-402 status.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - payload = MagicMock() - payload.to_dict.return_value = {} - req = MagicMock() - req.to_dict.return_value = {} - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.post.return_value = MagicMock( - status_code=500, - text="Internal Server Error", - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(Exception) as exc_info: - await client.settle(payload, req) - assert "500" in str(exc_info.value) or "Gateway" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_settle_402_raises_mapped_error(self): - """Lines 414-419: settle() raises mapped error on 402 status.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - payload = MagicMock() - payload.to_dict.return_value = {} - req = MagicMock() - req.to_dict.return_value = {} - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.post.return_value = MagicMock( - status_code=402, - json=lambda: {"errorReason": "insufficient_balance", "payer": "0x" + "a" * 40}, - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(Exception) as exc_info: - await client.settle(payload, req) - assert "insufficient_balance" in str(exc_info.value) or "Insufficient" in str( - exc_info.value - ) - - @pytest.mark.asyncio - async def test_settle_non_success_body_raises_mapped_error(self): - """Lines 434-435: settle() raises mapped error when success=false in body.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - payload = MagicMock() - payload.to_dict.return_value = {} - req = MagicMock() - req.to_dict.return_value = {} - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - mock_ctx.post.return_value = MagicMock( - status_code=200, - json=lambda: { - "success": False, - "errorReason": "invalid_signature", - "payer": "0x" + "a" * 40, - }, - ) - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - with pytest.raises(Exception) as exc_info: - await client.settle(payload, req) - assert "invalid_signature" in str(exc_info.value) or "signature" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_settle_idempotency_key_from_nonce(self): - """Lines 393-401: settle() uses EIP-3009 nonce as idempotency key.""" - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - captured_key = {} - - class FakeResponse: - status_code = 200 - text = "" - - def json(self): - return {"success": True, "transaction": "tx123"} - - payload = MagicMock() - payload.to_dict.return_value = {} - auth = MagicMock() - auth.nonce = "0x" + "deadbeef" * 8 # 32 bytes hex - payload.payload.authorization = auth - req = MagicMock() - req.to_dict.return_value = {} - - with patch("omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient") as MockHTTP: - mock_ctx = AsyncMock() - mock_ctx.__aenter__.return_value = mock_ctx - mock_ctx.__aexit__.return_value = None - - async def capture_post(path, json=None, idempotency_key=None, **kwargs): - captured_key["key"] = idempotency_key - return FakeResponse() - - mock_ctx.post = capture_post - MockHTTP.return_value = mock_ctx - - client = NanopaymentClient(api_key="key") - await client.settle(payload, req) - - # Nonce should be used as idempotency key - assert captured_key.get("key") is not None - - -# ============================================================================= -# TEST: _map_settlement_error - all error codes -# ============================================================================= - - -class TestMapSettlementError: - """Test _map_settlement_error maps all error codes correctly.""" - - @pytest.mark.asyncio - async def test_invalid_signature_error(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import InvalidSignatureError - - exc = _map_settlement_error("invalid_signature", payer="0x" + "a" * 40) - assert isinstance(exc, InvalidSignatureError) - - @pytest.mark.asyncio - async def test_authorization_not_yet_valid(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("authorization_not_yet_valid", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_authorization_expired(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("authorization_expired", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_authorization_validity_too_short(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("authorization_validity_too_short", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_self_transfer(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("self_transfer", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_insufficient_balance(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import InsufficientBalanceError - - exc = _map_settlement_error("insufficient_balance", payer="0x" + "a" * 40) - assert isinstance(exc, InsufficientBalanceError) - - @pytest.mark.asyncio - async def test_nonce_already_used(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import NonceReusedError - - exc = _map_settlement_error("nonce_already_used", payer="0x" + "a" * 40) - assert isinstance(exc, NonceReusedError) - - @pytest.mark.asyncio - async def test_unsupported_asset(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("unsupported_asset", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_invalid_payload(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("invalid_payload", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_address_mismatch(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("address_mismatch", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_amount_mismatch(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("amount_mismatch", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_unsupported_domain(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("unsupported_domain", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_wallet_not_found(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import VerificationError - - exc = _map_settlement_error("wallet_not_found", payer="0x" + "a" * 40) - assert isinstance(exc, VerificationError) - - @pytest.mark.asyncio - async def test_unexpected_error(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import SettlementError - - exc = _map_settlement_error("unexpected_error", payer="0x" + "a" * 40) - assert isinstance(exc, SettlementError) - - @pytest.mark.asyncio - async def test_unknown_error_code_defaults_to_settlement_error(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import SettlementError - - exc = _map_settlement_error("completely_unknown_code", payer="0x" + "a" * 40) - assert isinstance(exc, SettlementError) - - @pytest.mark.asyncio - async def test_none_error_code_defaults_to_settlement_error(self): - from omniclaw.protocols.nanopayments.client import _map_settlement_error - from omniclaw.protocols.nanopayments.exceptions import SettlementError - - exc = _map_settlement_error(None, payer="0x" + "a" * 40) - assert isinstance(exc, SettlementError) - - -# ============================================================================= -# TEST: NanopaymentAdapter pay_x402_url() Error Paths -# ============================================================================= - - -class TestAdapterPayX402URLErrorPaths: - """Test error handling in pay_x402_url() (lines 280-520).""" - - @pytest.mark.asyncio - async def test_initial_request_timeout_raises(self): - """Lines 288-293: Initial request TimeoutException raises GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - import httpx - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=httpx.TimeoutException("timeout")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_initial_request_request_error_raises(self): - """Lines 294-299: Initial request RequestError raises GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - import httpx - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=httpx.RequestError("connection refused")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_402_missing_payment_required_header(self): - """Lines 320-325: 402 response without PAYMENT-REQUIRED header raises.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.headers = {} # No payment-required header - mock_resp.text = "Payment Required" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_402_malformed_base64_header(self): - """Lines 331-336: Invalid base64 in PAYMENT-REQUIRED header raises GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.headers = {"payment-required": "not-valid-base64!!!"} - mock_resp.text = "Payment Required" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_gateway_kind_not_found(self): - """Lines 340-343: Unsupported scheme raises UnsupportedSchemeError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import UnsupportedSchemeError - - req_data = make_402_requirements(name="NotGatewayBatched") - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp_402) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(UnsupportedSchemeError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_missing_verifying_contract_fetches_from_client(self): - """Lines 347-350: Missing verifying_contract calls get_verifying_contract().""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req_data = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": None, - }, - }, - ], - } - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"data" - mock_resp_retry.text = "data" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - await adapter.pay_x402_url("https://api.example.com/data") - mock_client.get_verifying_contract.assert_called() - - @pytest.mark.asyncio - async def test_retry_request_timeout_raises(self): - """Lines 405-416: Retry request TimeoutException raises GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - import httpx - - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock( - side_effect=[mock_resp_402, httpx.TimeoutException("timeout")] - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_circuit_breaker_open_raises_when_content_not_delivered(self): - """Lines 426-439: Circuit open + non-success status raises CircuitOpenError.""" - from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 500 - mock_resp_retry.content = b"error" - mock_resp_retry.text = "error" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(CircuitOpenError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_non_recoverable_settlement_error_raises(self): - """Lines 466-484: NonceReusedError + non-success content raises immediately.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import NonceReusedError - - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 500 - mock_resp_retry.content = b"error" - mock_resp_retry.text = "error" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.settle = AsyncMock(side_effect=NonceReusedError()) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(NonceReusedError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_auto_topup_failure_in_pay_x402_url_continues(self): - """Lines 378-381: Auto-topup failure logs warning but continues.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"ok" - mock_resp_retry.text = "ok" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - - mock_wm = MagicMock() - mock_wm.deposit = AsyncMock(side_effect=Exception("Deposit failed")) - - mock_client = MagicMock() - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wm) - - result = await adapter.pay_x402_url("https://api.example.com/data") - assert result.success is True - - @pytest.mark.asyncio - async def test_settlement_success_after_retry(self): - """Lines 638-739: Settlement succeeds after transient timeout retry.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import GatewayTimeoutError - - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"ok" - mock_resp_retry.text = "ok" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.settle = AsyncMock( - side_effect=[ - GatewayTimeoutError("timeout"), - MagicMock(success=True, transaction="tx123"), - ] - ) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=1, - retry_base_delay=0.001, - ) - - result = await adapter.pay_x402_url("https://api.example.com/data") - assert result.success is True - assert mock_client.settle.call_count == 2 - - @pytest.mark.asyncio - async def test_circuit_breaker_open_in_settle_with_retry(self): - """Lines 665-667: _settle_with_retry raises CircuitOpenError when circuit is open.""" - from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() # Trip circuit - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(CircuitOpenError): - await adapter._settle_with_retry(payload=MagicMock(), requirements=MagicMock()) - - -# ============================================================================= -# TEST: NanopaymentAdapter pay_direct() Error Paths -# ============================================================================= - - -class TestAdapterPayDirectErrorPaths: - """Test error handling in pay_direct() (lines 526-632).""" - - @pytest.mark.asyncio - async def test_pay_direct_auto_topup_failure_continues(self): - """Lines 591-596: Auto-topup failure in pay_direct logs warning but continues.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - - mock_wm = MagicMock() - mock_wm.deposit = AsyncMock(side_effect=Exception("Deposit failed")) - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wm) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - assert result.success is True - - @pytest.mark.asyncio - async def test_pay_direct_circuit_breaker_open(self): - """Lines 609-619: Circuit open in pay_direct raises SettlementError.""" - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentCircuitBreaker, - SettlementError, - ) - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - - mock_http = AsyncMock() - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() # Trip circuit - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(SettlementError) as exc_info: - await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - assert "circuit" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_pay_direct_no_wallet_manager_returns_false_topup(self): - """Lines 763-777: _check_and_topup with no wallet manager returns False.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, # Enabled but no wallet manager - ) - # No set_wallet_manager() called - - result = await adapter._check_and_topup() - assert result is False - - -# ============================================================================= -# TEST: EIP3009Signer Error Paths (signing.py coverage) -# ============================================================================= - - -class TestEIP3009SignerErrorPaths: - """Test EIP3009Signer error paths for signing.py coverage.""" - - def test_build_eip712_domain_empty_verifying_contract(self): - """Lines 112-116: Empty verifying_contract raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=1, verifying_contract="") - assert exc_info.value.code == "MISSING_VERIFYING_CONTRACT" - - def test_build_eip712_domain_invalid_prefix(self): - """Lines 118-122: Invalid address prefix raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=1, verifying_contract="abc123") - assert exc_info.value.code == "INVALID_ADDRESS_FORMAT" - - def test_build_eip712_domain_invalid_chain_id(self): - """Lines 124-128: Invalid chain_id raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=0, verifying_contract="0x" + "a" * 40) - assert exc_info.value.code == "INVALID_CHAIN_ID" - - def test_build_eip712_message_invalid_from_address(self): - """Lines 170-174: Invalid from_address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="not-an-address", - to_address="0x" + "b" * 40, - value=1000, - ) - assert exc_info.value.code == "INVALID_FROM_ADDRESS" - - def test_build_eip712_message_invalid_to_address(self): - """Lines 176-180: Invalid to_address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="also-not-address", - value=1000, - ) - assert exc_info.value.code == "INVALID_TO_ADDRESS" - - def test_build_eip712_message_self_transfer(self): - """Lines 182-186: Same from/to address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - addr = "0x" + "a" * 40 - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address=addr, - to_address=addr, - value=1000, - ) - assert exc_info.value.code == "SELF_TRANSFER" - - def test_build_eip712_message_negative_value(self): - """Lines 188-193: Negative value raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=-100, - ) - assert exc_info.value.code == "INVALID_VALUE" - - def test_build_eip712_message_valid_before_too_soon(self): - """Lines 199-206: valid_before too soon raises SigningError.""" - import time - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - valid_before=int(time.time()) + 100, # Too soon (< 3 days) - ) - assert exc_info.value.code == "VALID_BEFORE_TOO_SOON" - - def test_build_eip712_message_invalid_nonce_prefix(self): - """Lines 213-217: Nonce without 0x prefix raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="deadbeef" * 8, # No 0x prefix - ) - assert exc_info.value.code == "INVALID_NONCE_FORMAT" - - def test_build_eip712_message_invalid_nonce_length(self): - """Lines 220-224: Nonce wrong length raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="0x" + "ab" * 10, # 20 bytes, not 32 - ) - assert exc_info.value.code == "INVALID_NONCE_LENGTH" - - def test_build_eip712_message_invalid_nonce_hex(self): - """Lines 227-233: Nonce with invalid hex raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="0x" + "g" * 64, # 'g' is invalid hex - ) - assert exc_info.value.code == "INVALID_NONCE_HEX" - - def test_eip3009_signer_invalid_key_length(self): - """Lines 317-320: Private key wrong length raises InvalidPrivateKeyError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import InvalidPrivateKeyError - - with pytest.raises(InvalidPrivateKeyError): - EIP3009Signer("0x" + "ab" * 31) # 62 chars, not 64 - - def test_eip3009_signer_invalid_key_hex(self): - """Lines 322-326: Private key invalid hex raises InvalidPrivateKeyError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import InvalidPrivateKeyError - - with pytest.raises(InvalidPrivateKeyError): - EIP3009Signer("0x" + "g" * 64) # 'g' is invalid hex - - def test_eip3009_signer_wrong_scheme(self): - """Lines 402-406: Wrong scheme raises UnsupportedSchemeError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedSchemeError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="wrong-scheme", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="NotGateway", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedSchemeError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_missing_verifying_contract(self): - """Lines 408-415: Missing verifying_contract raises MissingVerifyingContractError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import MissingVerifyingContractError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=None, - ), - ) - - with pytest.raises(MissingVerifyingContractError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_amount_exceeds_requirement(self): - """Lines 421-427: amount_atomic > required raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import SigningError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(SigningError) as exc_info: - signer.sign_transfer_with_authorization(kind, amount_atomic=2000) # More than required - assert exc_info.value.code == "AMOUNT_EXCEEDS_REQUIREMENT" - - def test_eip3009_signer_invalid_network_format(self): - """Lines 432-435: Non-eip155 network raises UnsupportedNetworkError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedNetworkError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="cosmos:stargaze", # Not eip155 - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedNetworkError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_missing_verifying_contract(self): - """Lines 408-415: Missing verifying_contract raises MissingVerifyingContractError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import MissingVerifyingContractError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=None, - ), - ) - - with pytest.raises(MissingVerifyingContractError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_amount_exceeds_requirement(self): - """Lines 421-427: amount_atomic > required raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import SigningError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(SigningError) as exc_info: - signer.sign_transfer_with_authorization(kind, amount_atomic=2000) # More than required - assert exc_info.value.code == "AMOUNT_EXCEEDS_REQUIREMENT" - - def test_eip3009_signer_invalid_network_format(self): - """Lines 432-435: Non-eip155 network raises UnsupportedNetworkError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedNetworkError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - key = "0x" + "1" * 64 - signer = EIP3009Signer(key) - - kind = PaymentRequirementsKind( - scheme="exact", - network="cosmos:stargaze", # Not eip155 - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedNetworkError): - signer.sign_transfer_with_authorization(kind) - - def test_generate_eoa_keypair_returns_valid(self): - """Lines 635: generate_eoa_keypair() returns valid (key, address) pair.""" - from omniclaw.protocols.nanopayments.signing import generate_eoa_keypair - - private_key, address = generate_eoa_keypair() - assert private_key.startswith("0x") - assert len(private_key) == 66 # 0x + 64 hex - assert address.startswith("0x") - assert len(address) == 42 - - def test_parse_caip2_chain_id_invalid_format(self): - """Lines 571-572: parse_caip2_chain_id raises ValueError for invalid format.""" - from omniclaw.protocols.nanopayments.signing import parse_caip2_chain_id - - with pytest.raises(ValueError) as exc_info: - parse_caip2_chain_id("cosmos:stargaze") - assert "Invalid CAIP-2 format" in str(exc_info.value) - - def test_parse_caip2_chain_id_invalid_chain_id(self): - """Lines 574-577: parse_caip2_chain_id raises ValueError for invalid chain ID.""" - from omniclaw.protocols.nanopayments.signing import parse_caip2_chain_id - - with pytest.raises(ValueError) as exc_info: - parse_caip2_chain_id("eip155:not-a-number") - assert "Invalid chain ID" in str(exc_info.value) - - -# ============================================================================= -# TEST: GatewayWalletManager Coverage (wallet.py) -# ============================================================================= - - -class TestGatewayWalletManagerCoverage: - """Additional tests for GatewayWalletManager.""" - - def _make_wallet_manager(self): - """Helper to create a partially mocked GatewayWalletManager.""" - with patch("omniclaw.protocols.nanopayments.wallet.web3.Web3"): - with patch("omniclaw.protocols.nanopayments.wallet.EIP3009Signer"): - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - - with patch.object(GatewayWalletManager, "_sign_and_send"): - mgr = GatewayWalletManager( - private_key="0x" + "1" * 64, - network="eip155:5042002", - rpc_url="http://localhost", - nanopayment_client=mock_client, - ) - return mgr - - def test_check_gas_reserve_returns_tuple(self): - """Lines 812-837: check_gas_reserve() returns (bool, str) tuple.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 1_000_000_000_000_000_000_000_000_000 # 1000 ETH - mgr._w3.eth.gas_price.return_value = 10_000_000_000 # 10 gwei - # Make from_wei return a proper type - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: x / 1e18) - - result = mgr.check_gas_reserve() - assert isinstance(result, tuple) - assert len(result) == 2 - assert isinstance(result[0], bool) - assert isinstance(result[1], str) - - def test_has_sufficient_gas_for_deposit(self): - """Lines 839-847: has_sufficient_gas_for_deposit() returns bool.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 1_000_000_000_000_000_000_000_000_000 # 1000 ETH - mgr._w3.eth.gas_price.return_value = 10_000_000_000 # 10 gwei - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: x / 1e18) - - result = mgr.has_sufficient_gas_for_deposit() - assert isinstance(result, bool) - - def test_ensure_gas_reserve_raises_insufficient_gas(self): - """Lines 849-864: ensure_gas_reserve() raises InsufficientGasError when low balance.""" - from omniclaw.protocols.nanopayments.exceptions import InsufficientGasError - - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 100_000_000_000_000 # 0.0001 ETH - mgr._w3.eth.gas_price = 1_000_000_000_000_000_000 # 1000 gwei (direct value) - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: float(x) / 1e18) - - with pytest.raises(InsufficientGasError): - mgr.ensure_gas_reserve() - - def test_estimate_gas_cost_wei(self): - """Lines 790-800: estimate_gas_cost_wei() returns integer.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.gas_price = 20_000_000_000 # 20 gwei (direct value) - - result = mgr.estimate_gas_cost_wei() - assert isinstance(result, int) - assert result > 0 - - def test_estimate_gas_for_deposit(self): - """Lines 774-788: estimate_gas_for_deposit() returns fixed 200000.""" - mgr = self._make_wallet_manager() - - result = mgr.estimate_gas_for_deposit() - assert result == 200_000 - - def test_get_gas_balance_eth(self): - """Lines 763-772: get_gas_balance_eth() returns string.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 1_000_000_000_000_000_000 # 1 ETH - - result = mgr.get_gas_balance_eth() - assert isinstance(result, str) - - def test_atomic_to_decimal(self): - """Lines 276-278: _atomic_to_decimal() formats correctly.""" - mgr = self._make_wallet_manager() - - result = mgr._atomic_to_decimal(1_500_000) - assert result == "1.5" - - def test_decimal_to_atomic_valid(self): - """Lines 266-274: _decimal_to_atomic() with valid input.""" - mgr = self._make_wallet_manager() - - result = mgr._decimal_to_atomic("10.50") - assert result == 10_500_000 - - def test_decimal_to_atomic_invalid(self): - """Lines 266-274: _decimal_to_atomic() with invalid input raises ValueError.""" - mgr = self._make_wallet_manager() - - with pytest.raises(ValueError): - mgr._decimal_to_atomic("not-a-number") - - def test_sign_and_send_time_exhausted(self): - """Lines 312-315: _sign_and_send handles TimeExhausted.""" - import web3 as w3_module - - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.account.sign_transaction = MagicMock() - mgr._w3.eth.send_raw_transaction = MagicMock() - mgr._w3.eth.wait_for_transaction_receipt = MagicMock( - side_effect=w3_module.exceptions.TimeExhausted() - ) - - with pytest.raises(Exception) as exc_info: - mgr._sign_and_send({}) - assert ( - "timed out" in str(exc_info.value).lower() - or "TimeExhausted" in type(exc_info.value).__name__ - ) - - def test_sign_and_send_transaction_not_found(self): - """Lines 316-319: _sign_and_send handles TransactionNotFound.""" - import web3 as w3_module - - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.account.sign_transaction = MagicMock() - mgr._w3.eth.send_raw_transaction = MagicMock() - mgr._w3.eth.wait_for_transaction_receipt = MagicMock( - side_effect=w3_module.exceptions.TransactionNotFound( - "Transaction not found after broadcast" - ) - ) - - with pytest.raises(Exception): - mgr._sign_and_send({}) - - def test_get_gateway_contract_caching(self): - """Lines 246-256: _get_gateway_contract reuses cached contract.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mock_contract = MagicMock() - mock_contract.address = "0x" + "c" * 40 - mgr._w3.eth.contract.return_value = mock_contract - mgr._gateway_contract = None # Ensure clean state - - addr = "0x" + "c" * 40 - contract1 = mgr._get_gateway_contract(addr) - contract2 = mgr._get_gateway_contract(addr) - - # Same address -> same contract (caching) - assert contract1 is contract2 - assert mgr._w3.eth.contract.call_count == 1 - - # Different address -> new contract - addr2 = "0x" + "d" * 40 - mgr._gateway_contract = None # Reset to force new contract - mgr._w3.eth.contract.return_value = MagicMock(address=addr2) - mgr._get_gateway_contract(addr2) - assert mgr._w3.eth.contract.call_count == 2 - - @pytest.mark.asyncio - async def test_withdraw_to_address_calls_transfer(self): - """Lines 586-628: withdraw() delegates to transfer_to_address().""" - mgr = self._make_wallet_manager() - mgr.transfer_to_address = AsyncMock(return_value=MagicMock()) - mgr.transfer_crosschain = AsyncMock(return_value=MagicMock()) - - # Same chain -> calls transfer_to_address - await mgr.withdraw("1.00", destination_chain="eip155:5042002", recipient="0x" + "b" * 40) - mgr.transfer_to_address.assert_called_once() - mgr.transfer_crosschain.assert_not_called() - - @pytest.mark.asyncio - async def test_withdraw_crosschain_calls_transfer(self): - """Lines 586-628: withdraw() with different chain calls transfer_crosschain().""" - mgr = self._make_wallet_manager() - mgr.transfer_to_address = AsyncMock(return_value=MagicMock()) - mgr.transfer_crosschain = AsyncMock(return_value=MagicMock()) - - # Different chain -> calls transfer_crosschain - await mgr.withdraw("1.00", destination_chain="eip155:1", recipient="0x" + "b" * 40) - mgr.transfer_crosschain.assert_called_once() - - -# ============================================================================= -# TEST: NanopaymentProtocolAdapter execute() with no wallet manager -# ============================================================================= - - -class TestNanopaymentProtocolAdapterExecute: - """Test NanopaymentProtocolAdapter.execute() fallback paths.""" - - @pytest.mark.asyncio - async def test_execute_pay_direct_no_network_uses_env_var(self): - """Lines 929-943: execute() with no network uses NANOPAYMENTS_DEFAULT_NETWORK env.""" - import os - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentProtocolAdapter, - ) - - mock_adapter = AsyncMock() - mock_adapter.pay_direct = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx123", - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - amount_usdc="0.001", - amount_atomic="1000", - network="eip155:5042002", - is_nanopayment=True, - ) - ) - - protocol = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - with patch.dict(os.environ, {"NANOPAYMENTS_DEFAULT_NETWORK": "eip155:5042002"}): - result = await protocol.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.001"), - # No destination_chain or source_network - ) - - mock_adapter.pay_direct.assert_called_once() - assert result.success is True - - @pytest.mark.asyncio - async def test_pay_x402_url_no_destination_uses_env(self): - """execute() with URL recipient and no network uses env var.""" - import os - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentProtocolAdapter, - ) - - mock_adapter = AsyncMock() - mock_adapter.pay_x402_url = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx123", - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - amount_usdc="0", - amount_atomic="0", - network="", - is_nanopayment=False, - ) - ) - - protocol = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - with patch.dict(os.environ, {"NANOPAYMENTS_DEFAULT_NETWORK": "eip155:5042002"}): - result = await protocol.execute( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - mock_adapter.pay_x402_url.assert_called_once() - - -# ============================================================================= -# TEST: OmniClaw Client β€” SDK Integration -# ============================================================================= - - -class TestOmniClawNanopaymentsIntegration: - """Tests for OmniClaw client nanopayments integration.""" - - @pytest.mark.asyncio - async def test_client_vault_and_adapter_properties_exist(self): - """OmniClaw has vault and nanopayment_adapter properties.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - # Patch httpx to avoid actual HTTP client creation - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - # Nanopayment components should be initialized - assert client._nano_vault is not None - assert client._nano_adapter is not None - assert client.vault is client._nano_vault - assert client.nanopayment_adapter is client._nano_adapter - - @pytest.mark.asyncio - async def test_add_key_delegates_to_vault(self): - """OmniClaw.add_key() correctly delegates to NanoKeyVault.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - private_key, _ = generate_eoa_keypair() - address = await client.add_key("test-key", private_key) - assert address.startswith("0x") - assert len(address) == 42 - - @pytest.mark.asyncio - async def test_generate_key_delegates_to_vault(self): - """OmniClaw.generate_key() correctly delegates to NanoKeyVault.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - address = await client.generate_key("gen-test-key") - assert address.startswith("0x") - - # Second generation with same alias raises - with pytest.raises(DuplicateKeyAliasError): - await client.generate_key("gen-test-key") - - @pytest.mark.asyncio - async def test_set_default_key_delegates_to_vault(self): - """OmniClaw.set_default_key() correctly delegates to NanoKeyVault.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - await client.generate_key("default-test") - await client.set_default_key("default-test") - - # Unknown key raises - with pytest.raises(KeyNotFoundError): - await client.set_default_key("unknown-key") - - @pytest.mark.asyncio - async def test_list_keys_returns_aliases(self): - """OmniClaw.list_keys() returns all key aliases.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - await client.generate_key("list-key-1") - await client.generate_key("list-key-2") - - keys = await client.list_keys() - assert "list-key-1" in keys - assert "list-key-2" in keys - - @pytest.mark.asyncio - async def test_configure_nanopayments_updates_adapter(self): - """configure_nanopayments() updates the NanopaymentAdapter's auto-topup.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - client.configure_nanopayments( - auto_topup_enabled=False, - auto_topup_threshold="5.00", - auto_topup_amount="50.00", - ) - - assert client._nano_adapter is not None - assert client._nano_adapter._auto_topup is False - assert client._nano_adapter._topup_threshold == "5.00" - assert client._nano_adapter._topup_amount == "50.00" - - @pytest.mark.asyncio - async def test_create_agent_with_nanopayment_key(self): - """create_agent() with nanopayment_key_alias=True creates a NanoKeyVault key.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - # Mock create_agent_wallet to avoid Circle API calls - with patch.object( - client, "create_agent_wallet", autospec=True - ) as mock_create_wallet: - mock_wallet_set = MagicMock() - mock_wallet_info = MagicMock() - mock_create_wallet.return_value = (mock_wallet_set, mock_wallet_info) - - wallet_set, wallet = await client.create_agent( - agent_name="test-agent", - nanopayment_key_alias="agent-test-agent-nano", - ) - - # Wallet was created - mock_create_wallet.assert_called_once() - assert wallet_set == mock_wallet_set - assert wallet == mock_wallet_info - - @pytest.mark.asyncio - async def test_payment_router_has_nanopayment_adapter(self): - """OmniClaw's router includes NanopaymentProtocolAdapter.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - # Router should have adapters - adapters = client._router.get_adapters() - assert len(adapters) >= 4 # Transfer + X402 + Gateway + Nanopayment - - # NanopaymentProtocolAdapter should be registered - method_names = [getattr(a, "method", None) for a in adapters] - assert "nanopayment" in method_names - - -# ============================================================================= -# TEST: End-to-End Buyer-Seller Flow (Conceptual) -# ============================================================================= - - -class TestEndToEndFlow: - """Conceptual end-to-end tests showing the complete buyer-seller flow.""" - - @pytest.mark.asyncio - async def test_complete_buyer_flow_with_real_vault(self): - """ - Complete buyer flow: - 1. Operator generates a key in NanoKeyVault - 2. Operator funds the resulting EOA address with USDC - 3. Buyer agent uses the key alias to pay for a URL resource - 4. Signature is created, settlement is triggered - - This uses real cryptographic operations (signing, encryption) - with mocked HTTP to avoid network calls. - """ - storage = MockStorageBackend() - vault = NanoKeyVault( - entity_secret="real-entity-secret-for-e2e-tests-here", - storage_backend=storage, - circle_api_key="test-api-key", - ) - - # 1. Generate a real key - buyer_address = await vault.generate_key("e2e-buyer") - assert buyer_address.startswith("0x") - - # 2. Set as default - await vault.set_default_key("e2e-buyer") - - # 3. Mock the client for payment - mock_client = MagicMock(spec=NanopaymentClient) - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - mock_client.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="e2e-settlement-tx-123", - ) - ) - - # 4. Build a real signed payload using the vault - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - real_payload = await vault.sign(requirements=kind, alias="e2e-buyer") - assert real_payload.payload.signature.startswith("0x") - - # Create mock vault with the real payload - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value=buyer_address) - mock_vault.sign = AsyncMock(return_value=real_payload) - mock_vault.get_balance = AsyncMock( - return_value=MagicMock( - available_decimal="10.000000", - ) - ) - - # 5. Build 402 response - import base64 - - req_dict = make_402_requirements(amount="1000000") - first_resp = MagicMock() - first_resp.status_code = 402 - first_resp.headers = { - "payment-required": base64.b64encode(json.dumps(req_dict).encode()).decode() - } - retry_resp = MagicMock() - retry_resp.status_code = 200 - retry_resp.text = '{"premium": true}' - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[first_resp, retry_resp]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - # 6. Execute the payment - result = await adapter.pay_x402_url( - url="https://api.ai-provider.com/gpt4-answer", - nano_key_alias="e2e-buyer", - ) - - # 7. Verify result - assert result.success is True - assert result.is_nanopayment is True - assert result.payer == buyer_address - assert result.transaction == "e2e-settlement-tx-123" - assert float(result.amount_usdc) == 1.0 - - # HTTP was called twice (initial + retry with signature) - assert mock_http.request.call_count == 2 - # Settlement was triggered - mock_client.settle.assert_called_once() - - @pytest.mark.asyncio - async def test_micro_payment_flow(self): - """ - Micro-payment flow: $0.001 direct address payment via Gateway. - Shows that amounts below $1.00 can be paid instantly with no gas. - """ - storage = MockStorageBackend() - vault = NanoKeyVault( - entity_secret="real-entity-secret-for-micro-tests-here", - storage_backend=storage, - circle_api_key="test-api-key", - ) - - # Generate key - buyer_address = await vault.generate_key("micro-buyer") - - mock_client = MagicMock(spec=NanopaymentClient) - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - mock_client.settle = AsyncMock( - return_value=MagicMock(success=True, transaction="micro-settlement-456") - ) - - adapter = NanopaymentAdapter( - vault=vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - ) - - # Pay $0.001 to a seller address - result = await adapter.pay_direct( - seller_address="0x" + "e" * 40, - amount_usdc="0.001", # One-thousandth of a dollar - network="eip155:5042002", - nano_key_alias="micro-buyer", - ) - - assert result.success is True - assert result.is_nanopayment is True - assert result.payer == buyer_address - assert result.amount_usdc == "0.001" - assert result.amount_atomic == "1000" - - def test_key_security_raw_key_never_exposed(self, mock_vault: NanoKeyVault): - """ - Security test: Raw private key is NEVER exposed outside the vault. - - Agents receive only an alias string - - get_address() returns only the address, never the key - - get_raw_key() returns the key but is only callable by the operator - """ - import asyncio - - async def run(): - addr = await mock_vault.generate_key("security-test") - - # Address is returned, but it's just an address - assert addr.startswith("0x") - - # The address should NOT be the private key - record = await mock_vault._storage.get("nano_keys", "security-test") - assert "encrypted_key" in record - assert "address" in record - # Encrypted key is not the raw private key - assert record["encrypted_key"] != record["address"] - - # get_address does not return the private key - retrieved = await mock_vault.get_address("security-test") - assert retrieved != record["encrypted_key"] - - asyncio.run(run()) - - -# ============================================================================= -# TEST: Roundtrip Serialization (All Nanopayments Types) -# ============================================================================= - - -class TestNanopaymentsRoundtrip: - """Verify all nanopayment types serialize/deserialize correctly.""" - - def test_payment_payload_roundtrip(self): - """PaymentPayload.to_dict() -> from_dict() preserves all fields.""" - payload_dict = { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:5042002", - "payload": { - "signature": "0x" + "a" * 130, - "authorization": { - "from": "0x" + "b" * 40, - "to": "0x" + "c" * 40, - "value": "1000000", - "validAfter": "0", - "validBefore": "9999999999", - "nonce": "0x" + "d" * 64, - }, - }, - } - payload = PaymentPayload.from_dict(payload_dict) - assert payload.x402_version == 2 - assert payload.scheme == "exact" - assert payload.network == "eip155:5042002" - assert payload.payload.authorization.from_address == "0x" + "b" * 40 - - # Roundtrip - roundtripped = payload.to_dict() - assert roundtripped["x402Version"] == 2 - - def test_payment_requirements_roundtrip(self): - """PaymentRequirements.to_dict() -> from_dict() roundtrips correctly.""" - req_dict = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "a" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - req = PaymentRequirements.from_dict(req_dict) - assert req.x402_version == 2 - assert len(req.accepts) == 1 - assert req.accepts[0].amount == "1000000" - assert req.accepts[0].extra.name == "GatewayWalletBatched" - - # Roundtrip - roundtripped = req.to_dict() - assert roundtripped["x402Version"] == 2 - assert roundtripped["accepts"][0]["extra"]["name"] == "GatewayWalletBatched" - - -# ============================================================================= -# TEST: Circuit Breaker and Idempotency -# ============================================================================= - - -class TestCircuitBreaker: - """Tests for NanopaymentCircuitBreaker.""" - - def test_circuit_starts_closed(self): - """Circuit breaker starts in closed state.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker() - assert cb.state == "closed" - assert cb.is_available() is True - - def test_circuit_trips_after_threshold(self): - """Circuit trips open after consecutive failure threshold.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=3, recovery_seconds=60) - cb.record_failure() - cb.record_failure() - assert cb.state == "closed" - assert cb.is_available() is True - - cb.record_failure() # Third failure - assert cb.state == "open" - assert cb.is_available() is False - - def test_circuit_success_resets(self): - """Successful settlement resets consecutive failure counter.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=3) - cb.record_failure() - cb.record_failure() - cb.record_success() - assert cb._consecutive_failures == 0 - assert cb.state == "closed" - - def test_circuit_half_open_after_recovery(self): - """Circuit goes half-open after recovery period.""" - import time - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=0.1) - cb.record_failure() # Immediately trips - assert cb.state == "open" - - time.sleep(0.15) # Wait for recovery period - assert cb.state == "half_open" - assert cb.is_available() is True - - def test_circuit_half_open_success_closes(self): - """Half-open success closes the circuit.""" - import time - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=0.1) - cb.record_failure() - assert cb.state == "open" - - time.sleep(0.15) - assert cb.state == "half_open" - - cb.record_success() # Success in half-open closes - assert cb.state == "closed" - assert cb._consecutive_failures == 0 - - def test_circuit_manual_reset(self): - """Manual reset closes the circuit.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=2) - cb.record_failure() - cb.record_failure() # Open - assert cb.state == "open" - - cb.reset() - assert cb.state == "closed" - assert cb._consecutive_failures == 0 - - def test_circuit_open_error_message(self): - """CircuitOpenError has correct message.""" - from omniclaw.protocols.nanopayments.adapter import CircuitOpenError - - err = CircuitOpenError(recovery_seconds=30.0) - assert "30" in str(err) - assert err.recovery_seconds == 30.0 - - -class TestIdempotencyKey: - """Tests for idempotency key handling in settlement.""" - - @pytest.mark.asyncio - async def test_settle_uses_authorization_nonce_as_idempotency_key(self, mock_client: MagicMock): - """settle() uses EIP-3009 nonce as idempotency key via Idempotency-Key header.""" - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - from omniclaw.protocols.nanopayments.types import ( - EIP3009Authorization, - PaymentPayload, - PaymentPayloadInner, - PaymentRequirements, - PaymentRequirementsExtra, - PaymentRequirementsKind, - ) - - # Build a real payload with a known nonce - nonce = "0x" + "deadbeef" * 8 # 32 bytes as hex string - auth = EIP3009Authorization( - from_address="0x" + "a" * 40, - to="0x" + "b" * 40, - value="1000000", - valid_after="0", - valid_before="9999999999", - nonce=nonce, - ) - - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=auth, - ), - ) - - req = PaymentRequirements( - x402_version=2, - accepts=( - PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "a" * 40, - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ), - ), - ) - - # Track the idempotency key that gets sent - captured_headers: list[dict] = [] - - async def capture_post(path: str, idempotency_key=None, **kwargs): - captured_headers.append( - {"path": path, "idempotency_key": idempotency_key, "kwargs": kwargs} - ) - # Return a mock 200 response - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = '{"success": true}' - mock_resp.content = b'{"success": true}' - return mock_resp - - # Patch NanopaymentHTTPClient.post to capture the idempotency key - with patch.object(NanopaymentHTTPClient, "post", side_effect=capture_post): - # Create a client that will actually use the patched HTTP client - from omniclaw.protocols.nanopayments.client import NanopaymentClient - - client = NanopaymentClient( - environment="testnet", - api_key="test_key", - base_url="https://api.test.circle.com", - ) - await client.settle(payload=payload, requirements=req) - - # Verify the idempotency key was set to the nonce - assert len(captured_headers) == 1 - assert captured_headers[0]["path"] == "/v1/x402/settle" - # The nonce should be used as the idempotency key - assert captured_headers[0]["idempotency_key"] is not None - assert captured_headers[0]["idempotency_key"] == nonce - - @pytest.mark.asyncio - async def test_circuit_breaker_prevents_settlement_when_open(self, mock_client: MagicMock): - """Settlement raises CircuitOpenError when circuit is open.""" - from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - mock_vault.get_balance = AsyncMock() - - # Create adapter with already-open circuit - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=60) - cb.record_failure() # Trip the circuit - mock_http = MagicMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=3, - circuit_breaker=cb, - ) - - from omniclaw.protocols.nanopayments.types import ( - PaymentPayload, - PaymentRequirements, - ) - - payload = MagicMock(spec=PaymentPayload) - req = MagicMock(spec=PaymentRequirements) - - with pytest.raises(CircuitOpenError): - await adapter._settle_with_retry(payload=payload, requirements=req) - - -# ============================================================================= -# TEST: Protocol Adapter β€” Full Router Coverage -# ============================================================================= - - -class TestNanopaymentProtocolAdapterCoverage: - """Additional tests for NanopaymentProtocolAdapter coverage.""" - - def test_supports_rejects_address_above_threshold(self): - """Address at/above micro threshold is NOT supported.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=AsyncMock(), - micro_threshold_usdc="1.00", - ) - # Amount exactly at threshold - assert adapter.supports("0x" + "a" * 40, amount="1.00") is False - # Amount above threshold - assert adapter.supports("0x" + "a" * 40, amount="10.00") is False - - def test_supports_rejects_non_url_non_address(self): - """Non-URL, non-address recipients are NOT supported.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=AsyncMock(), - ) - assert adapter.supports("invalid-recipient") is False - assert adapter.supports("chain:0x123") is False - - def test_priority_is_10(self): - """Priority is 10 (highest).""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - adapter = NanopaymentProtocolAdapter(nanopayment_adapter=AsyncMock()) - assert adapter.get_priority() == 10 - - @pytest.mark.asyncio - async def test_execute_url_full_flow(self, mock_client: MagicMock): - """execute() with URL calls pay_x402_url with all params.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - mock_adapter = AsyncMock() - mock_adapter.pay_x402_url = AsyncMock( - return_value=MagicMock( - success=True, - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - transaction="tx-123", - amount_usdc="0.001", - amount_atomic="1000", - network="eip155:5042002", - is_nanopayment=True, - ) - ) - - protocol_adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - result = await protocol_adapter.execute( - wallet_id="wallet-123", - recipient="https://api.seller.com/v1/data", - amount=Decimal("0.001"), - purpose="data_access", - nano_key_alias="my-key", - ) - - mock_adapter.pay_x402_url.assert_called_once() - assert result.success is True - assert result.method.value == "nanopayment" - assert result.metadata["nanopayment"] is True - - @pytest.mark.asyncio - async def test_execute_graceful_degradation_returns_failed_result(self, mock_client: MagicMock): - """execute() catches exceptions and returns failed PaymentResult.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - mock_adapter = AsyncMock() - mock_adapter.pay_direct = AsyncMock(side_effect=Exception("Network error")) - - protocol_adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - result = await protocol_adapter.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.001"), - ) - - assert result.success is False - assert result.status.value == "failed" - assert "Nanopayment failed" in result.error - - @pytest.mark.asyncio - async def test_simulate_returns_would_succeed(self, mock_client: MagicMock): - """simulate() returns would_succeed=True for nanopayment.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentProtocolAdapter - - adapter = NanopaymentProtocolAdapter( - nanopayment_adapter=AsyncMock(), - micro_threshold_usdc="1.00", - ) - - result = await adapter.simulate( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - assert result["would_succeed"] is True - assert result["method"] == "nanopayment" - assert result["estimated_fee"] == "0" # Gasless - - -# ============================================================================= -# TEST: Retry Logic & Circuit Breaker Coverage -# ============================================================================= - - -class TestRetryLogicCoverage: - """Additional tests for retry logic and circuit breaker.""" - - @pytest.mark.asyncio - async def test_settle_with_retry_timeout_then_success(self, mock_client: MagicMock): - """Timeout retries with backoff, then succeeds.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import GatewayTimeoutError - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - mock_vault.get_balance = AsyncMock() - - # Fail once, then succeed - mock_client.settle = AsyncMock( - side_effect=[ - GatewayTimeoutError("timeout"), - MagicMock(success=True, transaction="tx123"), - ] - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=3, - retry_base_delay=0.01, - ) - - payload = MagicMock() - req = MagicMock() - result = await adapter._settle_with_retry(payload=payload, requirements=req) - - assert mock_client.settle.call_count == 2 - assert result.success is True - - @pytest.mark.asyncio - async def test_settle_with_retry_all_retries_fail(self, mock_client: MagicMock): - """All retries fail, raises GatewayTimeoutError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import GatewayTimeoutError - - mock_vault = MagicMock() - mock_client.settle = AsyncMock(side_effect=GatewayTimeoutError("timeout")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=2, - retry_base_delay=0.01, - ) - - payload = MagicMock() - req = MagicMock() - - with pytest.raises(GatewayTimeoutError): - await adapter._settle_with_retry(payload=payload, requirements=req) - - # Should have retried 3 times (initial + 2 retries) - assert mock_client.settle.call_count == 3 - - @pytest.mark.asyncio - async def test_settle_with_retry_nonce_reused_no_retry(self, mock_client: MagicMock): - """NonceReusedError does NOT retry, raises immediately.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import NonceReusedError - - mock_vault = MagicMock() - mock_client.settle = AsyncMock(side_effect=NonceReusedError()) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=AsyncMock(), - auto_topup_enabled=False, - retry_attempts=3, - ) - - payload = MagicMock() - req = MagicMock() - - with pytest.raises(NonceReusedError): - await adapter._settle_with_retry(payload=payload, requirements=req) - - # Should NOT have retried - assert mock_client.settle.call_count == 1 - - def test_circuit_breaker_record_error(self): - """record_error() increments counter and trips circuit.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=2) - assert cb.state == "closed" - - cb.record_error() # 1 error - assert cb.state == "closed" - - cb.record_error() # 2 errors - trips - assert cb.state == "open" - - def test_circuit_breaker_get_state(self): - """get_circuit_breaker_state() returns current state.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - ) - - assert adapter.get_circuit_breaker_state() == "closed" - - -# ============================================================================= -# TEST: Auto-Topup Coverage -# ============================================================================= - - -class TestAutoTopupCoverage: - """Additional tests for auto-topup coverage.""" - - @pytest.mark.asyncio - async def test_check_and_topup_balance_check_fails(self): - """Balance check failure returns False without crashing.""" - mock_vault = MagicMock() - mock_vault.get_balance = AsyncMock(side_effect=Exception("Network error")) - - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(MagicMock()) - - result = await adapter._check_and_topup(alias="test-key") - assert result is False - - @pytest.mark.asyncio - async def test_check_and_topup_deposit_fails(self): - """Deposit failure returns False without crashing.""" - mock_vault = MagicMock() - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - mock_manager = AsyncMock() - mock_manager.deposit = AsyncMock(side_effect=Exception("Tx failed")) - - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_manager) - - result = await adapter._check_and_topup(alias="test-key") - assert result is False - - @pytest.mark.asyncio - async def test_check_and_topup_succeeds(self): - """Successful deposit returns True.""" - mock_vault = MagicMock() - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - mock_manager = AsyncMock() - mock_manager.deposit = AsyncMock(return_value=MagicMock(deposit_tx_hash="0xtx123")) - - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_manager) - - result = await adapter._check_and_topup(alias="test-key") - assert result is True - - @pytest.mark.asyncio - async def test_auto_topup_no_wallet_manager(self): - """Auto-topup with no wallet manager returns False (no-op).""" - mock_vault = MagicMock() - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - auto_topup_enabled=True, - ) - # No set_wallet_manager() called - - result = await adapter._check_and_topup(alias="test-key") - assert result is False - - def test_configure_auto_topup_full(self): - """configure_auto_topup() updates all settings.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - auto_topup_enabled=True, - auto_topup_threshold="1.00", - auto_topup_amount="10.00", - ) - - adapter.configure_auto_topup( - enabled=False, - threshold="5.00", - amount="50.00", - ) - - assert adapter._auto_topup is False - assert adapter._topup_threshold == "5.00" - assert adapter._topup_amount == "50.00" - - def test_set_wallet_manager(self): - """set_wallet_manager() stores the manager.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - adapter = NanopaymentAdapter( - vault=MagicMock(), - nanopayment_client=MagicMock(), - http_client=AsyncMock(), - ) - - mock_manager = MagicMock() - adapter.set_wallet_manager(mock_manager) - - assert adapter._wallet_manager is mock_manager - - -class TestOmniClawSellerDecorator: - """Tests for OmniClaw.gateway(), OmniClaw.sell(), and OmniClaw.current_payment().""" - - @pytest.fixture - def seller_client(self, mock_storage: MockStorageBackend): - """Create an OmniClaw instance configured as a seller with one key.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="seller-api-key", - entity_secret="seller-entity-secret-32-chars-long", - network=Network.ARC_TESTNET, - ) - - return client - - @pytest.fixture - def buyer_storage(self): - """Separate storage for buyer OmniClaw.""" - return MockStorageBackend() - - @pytest.fixture - def buyer_client(self, buyer_storage: MockStorageBackend): - """Create an OmniClaw instance configured as a buyer with one key.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_get_storage.return_value = buyer_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="buyer-api-key", - entity_secret="buyer-entity-secret-32-chars-long!!", - network=Network.ARC_TESTNET, - ) - - return client - - # ------------------------------------------------------------------------- - # Tests: gateway() lazy initialization - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_gateway_returns_gateway_middleware(self, seller_client): - """gateway() returns a GatewayMiddleware instance.""" - await seller_client.generate_key("seller-key") - await seller_client.set_default_key("seller-key") - - gateway_mw = await seller_client.gateway() - - assert gateway_mw is not None - assert isinstance(gateway_mw, GatewayMiddleware) - - @pytest.mark.asyncio - async def test_gateway_caches_middleware(self, seller_client): - """gateway() only creates the middleware once (cached).""" - await seller_client.generate_key("seller-key") - await seller_client.set_default_key("seller-key") - - g1 = await seller_client.gateway() - g2 = await seller_client.gateway() - - assert g1 is g2 - - @pytest.mark.asyncio - async def test_gateway_uses_default_key_address(self, seller_client): - """gateway() uses the vault's default key as seller_address.""" - addr = await seller_client.generate_key("seller-default") - await seller_client.set_default_key("seller-default") - - gateway_mw = await seller_client.gateway() - - assert gateway_mw._seller_address == addr.lower() - - @pytest.mark.asyncio - async def test_gateway_raises_when_nanopayments_disabled(self): - """gateway() raises NanopaymentNotInitializedError when nanopayments disabled.""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - client._nano_client = None - - with pytest.raises(NanopaymentNotInitializedError): - await client.gateway() - - # ------------------------------------------------------------------------- - # Tests: sell() decorator - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_sell_returns_fastapi_depends(self, seller_client): - """sell() returns a FastAPI Depends object with the correct dependency.""" - await seller_client.generate_key("seller-key") - await seller_client.set_default_key("seller-key") - - pytest.importorskip("fastapi") - depends = seller_client.sell("$0.001") - - assert depends is not None - assert hasattr(depends, "dependency") - assert hasattr(depends, "use_cache") - - @pytest.mark.asyncio - async def test_sell_returns_depends_even_when_uninitialized(self): - """sell() returns a Depends even if nanopayments is disabled (error deferred to route access).""" - with patch("omniclaw.client.CircuitBreaker"): - with patch("omniclaw.client.get_storage") as mock_get_storage: - mock_storage = MockStorageBackend() - mock_get_storage.return_value = mock_storage - - from omniclaw.client import OmniClaw - from omniclaw.core.types import Network - - with patch("httpx.AsyncClient", new_callable=AsyncMock): - client = OmniClaw( - circle_api_key="test-key", - entity_secret="test-secret-32-chars-long-here", - network=Network.ARC_TESTNET, - ) - - client._nano_client = None - - pytest.importorskip("fastapi") - depends = client.sell("$0.001") - assert depends is not None - assert hasattr(depends, "dependency") - - # ------------------------------------------------------------------------- - # Tests: current_payment() context - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_current_payment_raises_outside_sell_context(self, seller_client): - """current_payment() raises ValueError when called outside @sell() context.""" - await seller_client.generate_key("seller-key") - await seller_client.set_default_key("seller-key") - - with pytest.raises(ValueError, match="outside of a @sell"): - seller_client.current_payment() - - @pytest.mark.asyncio - async def test_current_payment_returns_info_in_sell_context(self, seller_client): - """current_payment() returns PaymentInfo when called inside @sell() context.""" - from omniclaw.protocols.nanopayments.types import PaymentInfo - - await seller_client.generate_key("seller-key") - await seller_client.set_default_key("seller-key") - - from omniclaw.client import _current_payment_info - - info = PaymentInfo( - verified=True, - payer="0x" + "a" * 40, - amount="1000000", - network="eip155:5042002", - transaction="tx-123", - ) - _current_payment_info.set(info) - - result = seller_client.current_payment() - - assert result.payer == "0x" + "a" * 40 - assert result.amount == "1000000" - assert result.verified is True - - _current_payment_info.set(None) - - -class TestFastAPIIntegrationSellerDecorator: - """ - Full FastAPI integration tests for the @agent.sell() decorator. - - These tests simulate the complete two-party EIP-3009 payment flow: - - Seller: FastAPI app with gateway.require() or sell() - - Buyer (OmniClaw): Uses vault.sign() to create PaymentPayload - - Buyer (External): Uses EIP3009Signer directly (no OmniClaw) - - Tests cover: 402 response, valid payment, settlement, content delivery. - Uses httpx.AsyncClient with ASGITransport for real ASGI testing. - """ - - SELLER_KEY = "0x250716a653d2155d15bfb1e1ded08b6764937ca6ab3cdd7e2f0510c975fb5652" - SELLER_ADDR = "0xb9Ee214552fF51AB41955b3DAfD7A340b5459629" - BUYER_KEY = "0x" + "1" * 64 - NETWORK = "eip155:5042002" - VERIFYING_CONTRACT = "0x" + "c" * 40 - USDC_ADDRESS = "0x" + "d" * 40 - PRICE_ATOMIC = 1000 - PRICE_USD = "$0.001" - - @pytest.fixture - def mock_nano_client(self) -> MagicMock: - """Mock NanopaymentClient with realistic responses.""" - mock = MagicMock(spec=NanopaymentClient) - mock.get_supported = AsyncMock( - return_value=[ - MagicMock( - network=self.NETWORK, - verifying_contract=self.VERIFYING_CONTRACT, - usdc_address=self.USDC_ADDRESS, - ) - ] - ) - mock.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="settled-batch-tx-abc123", - payer="0x" + "1" * 40, - ) - ) - return mock - - def _build_payment_payload(self) -> str: - """Build a valid EIP-3009 PaymentPayload as base64-encoded JSON.""" - import base64 - import json - import time - - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsExtra, - PaymentRequirementsKind, - ) - - kind = PaymentRequirementsKind( - scheme="exact", - network=self.NETWORK, - asset=self.USDC_ADDRESS, - amount=str(self.PRICE_ATOMIC), - max_timeout_seconds=345600, - pay_to=self.SELLER_ADDR, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=self.VERIFYING_CONTRACT, - ), - ) - - signer = EIP3009Signer(self.BUYER_KEY) - payload = signer.sign_transfer_with_authorization( - requirements=kind, - amount_atomic=self.PRICE_ATOMIC, - valid_before=int(time.time()) + 86400 * 4, - ) - - return base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - - # ------------------------------------------------------------------------- - # Test: gateway.handle() returns 402 without payment header - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_gateway_handle_returns_402_without_payment(self, mock_nano_client): - """gateway.handle() returns 402 PaymentRequiredHTTPError with no header.""" - from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await gw.handle({}, self.PRICE_USD) - - assert exc_info.value.status_code == 402 - body = exc_info.value.detail - assert body["x402Version"] == 2 - assert len(body["accepts"]) >= 1 - assert "PAYMENT-REQUIRED" in exc_info.value.headers - - # ------------------------------------------------------------------------- - # Test: gateway.handle() settles with valid payment - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_gateway_handle_settles_with_valid_payment(self, mock_nano_client): - """gateway.handle() verifies and settles payment, returns PaymentInfo.""" - from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - payment_header = self._build_payment_payload() - - result = await gw.handle({"payment-signature": payment_header}, self.PRICE_USD) - - assert result.verified is True - assert result.payer is not None - mock_nano_client.settle.assert_called_once() - - call_kwargs = mock_nano_client.settle.call_args.kwargs - payload = call_kwargs["payload"] - assert payload.network == self.NETWORK - - # ------------------------------------------------------------------------- - # Test: FastAPI route pattern - no payment β†’ 402, with payment β†’ 200 - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_fastapi_route_pattern_no_payment_402(self, mock_nano_client): - """ - FastAPI route with gateway.handle() returns 402 when no PAYMENT-SIGNATURE header. - Tests the FastAPI integration pattern by directly simulating ASGI calls. - """ - from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - ) - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - async def premium_handler(request_headers: dict, price_usd: str): - try: - info = await gw.handle(request_headers, price_usd) - return {"status": 200, "content": "premium data", "payer": info.payer} - except PaymentRequiredHTTPError as exc: - return {"status": exc.status_code, "body": exc.detail} - - result = await premium_handler({}, self.PRICE_USD) - assert result["status"] == 402 - assert result["body"]["x402Version"] == 2 - - @pytest.mark.asyncio - async def test_fastapi_route_pattern_with_valid_payment_200(self, mock_nano_client): - """FastAPI route with gateway.handle() serves content when valid payment is provided.""" - from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - ) - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - async def premium_handler(request_headers: dict, price_usd: str): - try: - info = await gw.handle(request_headers, price_usd) - return {"status": 200, "content": "premium data", "payer": info.payer} - except PaymentRequiredHTTPError as exc: - return {"status": exc.status_code, "body": exc.detail} - - payment_header = self._build_payment_payload() - result = await premium_handler({"payment-signature": payment_header}, self.PRICE_USD) - assert result["status"] == 200 - assert result["content"] == "premium data" - assert result["payer"].startswith("0x") - mock_nano_client.settle.assert_called_once() - - # ------------------------------------------------------------------------- - # Test: OmniClaw buyer β†’ seller (direct handle call) - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_omniclaw_buyer_to_seller_flow(self, mock_nano_client): - """ - OmniClaw buyer creates PaymentPayload via vault.sign() and sends to - seller gateway. Settlement succeeds, PaymentInfo is returned. - """ - from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - ) - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - payment_header = self._build_payment_payload() - - with pytest.raises(PaymentRequiredHTTPError): - await gw.handle({}, self.PRICE_USD) - - result = await gw.handle({"payment-signature": payment_header}, self.PRICE_USD) - - assert result.verified is True - assert result.payer is not None - assert result.network == self.NETWORK - assert result.transaction == "settled-batch-tx-abc123" - mock_nano_client.settle.assert_called_once() - - # ------------------------------------------------------------------------- - # Test: External (raw EIP-3009) buyer - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_external_raw_eip3009_buyer_flow(self, mock_nano_client): - """ - External non-OmniClaw client uses EIP3009Signer directly to create - a valid PaymentPayload. Proves OmniClaw nanopayments are EIP-3009 - interoperable with ANY EIP-3009 wallet or library. - """ - from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - ) - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - payment_header = self._build_payment_payload() - - result = await gw.handle({"payment-signature": payment_header}, self.PRICE_USD) - - assert result.verified is True - assert result.transaction == "settled-batch-tx-abc123" - - settle_call = mock_nano_client.settle.call_args - settled_payload = settle_call.kwargs["payload"] - assert settled_payload.network == self.NETWORK - assert settled_payload.payload.authorization.from_address is not None - - # ------------------------------------------------------------------------- - # Test: current_payment() context var - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_current_payment_in_context_var(self, mock_nano_client): - """_current_payment_info context var works correctly for current_payment().""" - from omniclaw.client import _current_payment_info - from omniclaw.protocols.nanopayments.types import PaymentInfo - - info = PaymentInfo( - verified=True, - payer="0x" + "f" * 40, - amount=str(self.PRICE_ATOMIC), - network=self.NETWORK, - transaction="tx-manual-999", - ) - _current_payment_info.set(info) - - try: - result = _current_payment_info.get() - assert result.verified is True - assert result.payer == "0x" + "f" * 40 - assert result.amount == str(self.PRICE_ATOMIC) - finally: - _current_payment_info.set(None) - - # ------------------------------------------------------------------------- - # Test: Invalid payment signature β†’ 402 - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_invalid_payment_signature_returns_402(self, mock_nano_client): - """Invalid/malformed PAYMENT-SIGNATURE header returns 402.""" - from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await gw.handle({"payment-signature": "not-valid-base64!!!"}, self.PRICE_USD) - - assert exc_info.value.status_code == 402 - - # ------------------------------------------------------------------------- - # Test: Settlement failure β†’ 402 - # ------------------------------------------------------------------------- - - @pytest.mark.asyncio - async def test_settlement_failure_returns_402(self, mock_nano_client): - """When settle() fails (e.g., insufficient balance), returns 402.""" - from omniclaw.protocols.nanopayments.exceptions import InsufficientBalanceError - from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware - - mock_nano_client.settle = AsyncMock( - side_effect=InsufficientBalanceError( - reason="insufficient_balance", - payer="0x" + "1" * 40, - ) - ) - - gw = GatewayMiddleware( - seller_address=self.SELLER_ADDR, - nanopayment_client=mock_nano_client, - supported_kinds=await mock_nano_client.get_supported(), - auto_fetch_networks=False, - ) - - payment_header = self._build_payment_payload() - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await gw.handle({"payment-signature": payment_header}, self.PRICE_USD) - - -# ============================================================================= -# TEST: GatewayWalletManager additional paths (wallet.py) -# ============================================================================= - - -class TestGatewayWalletManagerAdditional: - """Additional coverage for GatewayWalletManager methods.""" - - def _make_wallet_manager(self): - """Helper to create a partially mocked GatewayWalletManager.""" - with patch("omniclaw.protocols.nanopayments.wallet.web3.Web3"): - with patch("omniclaw.protocols.nanopayments.wallet.EIP3009Signer"): - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - - with patch.object(GatewayWalletManager, "_sign_and_send"): - mgr = GatewayWalletManager( - private_key="0x" + "1" * 64, - network="eip155:5042002", - rpc_url="http://localhost", - nanopayment_client=mock_client, - ) - return mgr - - @pytest.mark.asyncio - async def test_deposit_skips_when_insufficient_gas(self): - """Lines 369-379: deposit() with check_gas=True, skip_if_insufficient_gas=True.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 0 # No ETH - mgr._w3.eth.gas_price.return_value = 50_000_000_000 # 50 gwei - mgr._w3.eth.account.sign_transaction = MagicMock() - mgr._w3.eth.send_raw_transaction = MagicMock() - mgr._w3.eth.wait_for_transaction_receipt = MagicMock(return_value={"status": 1}) - # Mock from_wei to avoid format string issues - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: float(x) / 1e18) - # Also mock estimate_gas_for_deposit which is called by check_gas_reserve - mgr.estimate_gas_for_deposit = MagicMock(return_value=21000) - - result = await mgr.deposit("10.00", check_gas=True, skip_if_insufficient_gas=True) - # Should return without raising and without tx hash - assert result.deposit_tx_hash is None - assert result.approval_tx_hash is None - - @pytest.mark.asyncio - async def test_deposit_approval_error_propagates(self): - """Lines 411-413: deposit() with ERC20ApprovalError.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 1_000_000_000_000_000_000_000_000_000 - mgr._w3.eth.gas_price.return_value = 10_000_000_000 - mgr._w3.eth.account.sign_transaction = MagicMock() - mgr._w3.eth.send_raw_transaction = MagicMock() - mgr._w3.eth.wait_for_transaction_receipt = MagicMock(return_value={"status": 1}) - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: float(x) / 1e18) - mgr.estimate_gas_for_deposit = MagicMock(return_value=21000) - - usdc_mock = MagicMock() - usdc_mock.functions.allowance.return_value.call.return_value = 0 - mgr._usdc_contract = MagicMock(return_value=usdc_mock) - - mgr._sign_and_send = MagicMock(side_effect=ERC20ApprovalError(reason="Approval rejected")) - - with pytest.raises(ERC20ApprovalError): - await mgr.deposit("10.00", check_gas=False) - - @pytest.mark.asyncio - async def test_deposit_success_with_approval(self): - """Lines 395-410: deposit() with approval transaction succeeds.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.get_balance.return_value = 1_000_000_000_000_000_000_000_000_000 - mgr._w3.eth.gas_price.return_value = 10_000_000_000 - mgr._w3.eth.account.sign_transaction = MagicMock() - mgr._w3.eth.send_raw_transaction = MagicMock() - mgr._w3.eth.wait_for_transaction_receipt = MagicMock(return_value={"status": 1}) - mgr._w3.from_wei = MagicMock(side_effect=lambda x, y: float(x) / 1e18) - mgr.estimate_gas_for_deposit = MagicMock(return_value=21000) - - usdc_mock = MagicMock() - usdc_mock.functions.allowance.return_value.call.return_value = 0 # needs approval - usdc_mock.functions.approve.return_value.build_transaction.return_value = {} - mgr._usdc_contract = MagicMock(return_value=usdc_mock) - - # Mock _sign_and_send for both approval and deposit - def sign_send_side_effect(tx, error_type=None): - return "0xtxhash" if error_type else "0xdeposithash" - - mgr._sign_and_send = MagicMock(side_effect=sign_send_side_effect) - mgr._build_tx = MagicMock(return_value={}) - - result = await mgr.deposit("10.00", check_gas=False) - assert result.approval_tx_hash == "0xtxhash" - assert result.deposit_tx_hash == "0xdeposithash" - - @pytest.mark.asyncio - async def test_get_withdrawal_delay(self): - """Lines 459-461: get_withdrawal_delay() calls contract.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mock_contract = MagicMock() - mock_contract.functions.withdrawalDelay.return_value.call.return_value = 5065 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - - delay = await mgr.get_withdrawal_delay() - assert delay == 5065 - - @pytest.mark.asyncio - async def test_initiate_trustless_withdrawal_insufficient_balance(self): - """Lines 482-496: initiate_trustless_withdrawal() with insufficient balance.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mock_contract = MagicMock() - mock_contract.functions.availableBalance.return_value.call.return_value = 0 # 0 available - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - - with pytest.raises(WithdrawError) as exc_info: - await mgr.initiate_trustless_withdrawal("100.00") - assert "insufficient" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_initiate_trustless_withdrawal_success(self): - """Lines 498-519: initiate_trustless_withdrawal() success path.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mock_contract = MagicMock() - mock_contract.functions.availableBalance.return_value.call.return_value = 10_000_000_000 - mock_contract.functions.withdrawalDelay.return_value.call.return_value = 5000 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - mgr._sign_and_send = MagicMock(return_value="0xtxhash123") - mgr._build_tx = MagicMock(return_value={}) - - tx_hash = await mgr.initiate_trustless_withdrawal("10.00") - assert tx_hash == "0xtxhash123" - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_not_ready(self): - """Lines 539-557: complete_trustless_withdrawal() when not ready.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.block_number = 100 - mock_contract = MagicMock() - mock_contract.functions.withdrawalBlock.return_value.call.return_value = 200 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - mgr._build_tx = MagicMock(return_value={}) - - with pytest.raises(WithdrawError) as exc_info: - await mgr.complete_trustless_withdrawal() - assert "not ready" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_no_initiated(self): - """Lines 539-553: complete_trustless_withdrawal() when nothing initiated.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.block_number = 10000 - mock_contract = MagicMock() - mock_contract.functions.withdrawalBlock.return_value.call.return_value = 0 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - - with pytest.raises(WithdrawError) as exc_info: - await mgr.complete_trustless_withdrawal() - assert "no withdrawal initiated" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_success(self): - """Lines 559-580: complete_trustless_withdrawal() success.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mgr._w3.eth.block_number = 10000 - mock_contract = MagicMock() - mock_contract.functions.withdrawalBlock.return_value.call.return_value = 9999 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - mgr._build_tx = MagicMock(return_value={}) - mgr._sign_and_send = MagicMock(return_value="0xcompletedsuccess") - - tx_hash = await mgr.complete_trustless_withdrawal() - assert tx_hash == "0xcompletedsuccess" - - @pytest.mark.asyncio - async def test_get_balance(self): - """Line 717: get_balance() delegates to client.""" - mgr = self._make_wallet_manager() - mock_result = GatewayBalance( - total=100_000_000, - available=50_000_000, - formatted_total="100.00 USDC", - formatted_available="50.00 USDC", - ) - mgr._client.check_balance = AsyncMock(return_value=mock_result) - - balance = await mgr.get_balance() - assert balance.total == 100_000_000 - assert balance.available == 50_000_000 - mgr._client.check_balance.assert_called_once() - - @pytest.mark.asyncio - async def test_get_onchain_balance(self): - """Lines 732-734: get_onchain_balance() calls USDC contract.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - usdc_mock = MagicMock() - usdc_mock.functions.balanceOf.return_value.call.return_value = 1_000_000_000 - mgr._usdc_contract = MagicMock(return_value=usdc_mock) - - balance = await mgr.get_onchain_balance() - assert balance == 1_000_000_000 - - @pytest.mark.asyncio - async def test_get_gateway_available_balance(self): - """Lines 745-748: get_gateway_available_balance() calls contract.""" - mgr = self._make_wallet_manager() - mgr._w3 = MagicMock() - mgr._w3.eth = MagicMock() - mock_contract = MagicMock() - mock_contract.functions.availableBalance.return_value.call.return_value = 500_000_000 - mgr._get_gateway_contract = MagicMock(return_value=mock_contract) - - balance = await mgr.get_gateway_available_balance() - assert balance == 500_000_000 - - def test_address_property(self): - """Line 223: address property returns _address.""" - mgr = self._make_wallet_manager() - assert mgr.address == mgr._address - - def test_network_property(self): - """Line 228: network property returns _network.""" - mgr = self._make_wallet_manager() - assert mgr.network == mgr._network - - @pytest.mark.asyncio - async def test_resolve_gateway_address_uses_cached(self): - """Line 237: _resolve_gateway_address() returns cached value.""" - mgr = self._make_wallet_manager() - mgr._gateway_address = "0x" + "a" * 40 - result = await mgr._resolve_gateway_address() - assert result == "0x" + "a" * 40 - mgr._client.get_verifying_contract.assert_not_called() - - @pytest.mark.asyncio - async def test_resolve_usdc_address_uses_cached(self): - """Line 243: _resolve_usdc_address() returns cached value.""" - mgr = self._make_wallet_manager() - mgr._usdc_address = "0x" + "b" * 40 - result = await mgr._resolve_usdc_address() - assert result == "0x" + "b" * 40 - mgr._client.get_usdc_address.assert_not_called() - - -# ============================================================================= -# TEST: NanopaymentClient additional paths (client.py) -# ============================================================================= - - -class TestNanopaymentClientAdditional: - """Additional coverage for NanopaymentClient methods.""" - - @pytest.mark.asyncio - async def test_get_supported_uses_cache(self): - """Lines 236-237: get_supported() returns cached result.""" - from omniclaw.protocols.nanopayments.client import SUPPORTED_NETWORKS_CACHE_TTL_SECONDS - import time - - mock_http = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "kinds": [ - { - "network": "eip155:5042002", - "verifyingContract": "0x" + "a" * 40, - "usdcAddress": "0x" + "b" * 40, - } - ] - } - mock_http.get = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - # First call - cache miss - result1 = await client.get_supported() - # Simulate cache hit by setting cache values - client._supported_cache = result1 - client._supported_cache_time = time.time() - - # Second call - should use cache - result2 = await client.get_supported() - assert result2 == result1 - # HTTP should NOT be called again - mock_http.get.assert_called_once() - - @pytest.mark.asyncio - async def test_get_verifying_contract_unsupported_network(self): - """Lines 275-281: get_verifying_contract() raises for unsupported network.""" - mock_http = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "kinds": [ - { - "network": "eip155:1", - "verifyingContract": "0x" + "a" * 40, - "usdcAddress": "0x" + "b" * 40, - } - ] - } - mock_http.get = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.get_verifying_contract("eip155:999999") - assert exc_info.value.network == "eip155:999999" - - @pytest.mark.asyncio - async def test_get_usdc_address_unsupported_network(self): - """Lines 296-302: get_usdc_address() raises for unsupported network.""" - mock_http = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "kinds": [ - { - "network": "eip155:1", - "verifyingContract": "0x" + "a" * 40, - "usdcAddress": "0x" + "b" * 40, - } - ] - } - mock_http.get = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.get_usdc_address("eip155:999999") - assert exc_info.value.network == "eip155:999999" - - @pytest.mark.asyncio - async def test_verify_response_parsing(self): - """Lines 349-350: verify() parses isValid and invalidReason.""" - # Create proper PaymentRequirements object using from_dict - req = PaymentRequirements.from_dict( - { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - } - ], - } - ) - - mock_http = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "isValid": True, - "payer": "0x" + "1" * 40, - "invalidReason": None, - } - mock_response.text = "{}" - mock_http.post = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - - payload = MagicMock(spec=PaymentPayload) - payload.to_dict.return_value = {} - resp = await client.verify(payload, req) - assert resp.is_valid is True - assert resp.payer == "0x" + "1" * 40 - assert resp.invalid_reason is None - - @pytest.mark.asyncio - async def test_check_balance_404_raises_unsupported_network(self): - """Lines 468-483: check_balance() with 404 raises UnsupportedNetworkError.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - # Mock get_supported to return empty list (unsupported network) - client.get_supported = AsyncMock(return_value=[]) - - mock_response_balances = MagicMock() - mock_response_balances.status_code = 404 - mock_response_balances.text = "Not found" - - mock_http = MagicMock() - mock_http.post = AsyncMock(return_value=mock_response_balances) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.check_balance( - address="0x" + "1" * 40, - network="eip155:999999", - ) - assert exc_info.value.network == "eip155:999999" - - @pytest.mark.asyncio - async def test_check_balance_success(self): - """Lines 492-498: check_balance() success response parsing.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - # Mock get_supported so check_balance doesn't make an HTTP call for it - client.get_supported = AsyncMock(return_value=[]) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "token": "USDC", - "balances": [ - { - "domain": 26, - "depositor": "0x" + "1" * 40, - "balance": "100000000", # string atomic units - } - ], - } - mock_http = MagicMock() - mock_http.post = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - balance = await client.check_balance( - address="0x" + "1" * 40, - network="eip155:5042002", - ) - assert balance.total == 100_000_000 - # Circle's Gateway has no separate "available" field; available == total - assert balance.available == 100_000_000 - assert balance.formatted_total == "100.000000 USDC" - - def test_init_invalid_environment(self): - """Line 192: __init__ raises on invalid environment.""" - with pytest.raises(ValueError) as exc_info: - NanopaymentClient(environment="invalid") - assert "environment" in str(exc_info.value).lower() - - -# ============================================================================= -# TEST: NanopaymentClient coverage (client.py) -# ============================================================================= - - -class TestNanopaymentClientCoverage: - """Coverage for NanopaymentClient methods not tested elsewhere.""" - - @pytest.mark.asyncio - async def test_get_verifying_contract_success(self): - """Lines 520-539: get_verifying_contract() returns contract for supported network.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - client.get_supported = AsyncMock( - return_value=[ - SupportedKind( - x402_version=2, - scheme="exact", - network="eip155:5042002", - extra={ - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "a" * 40, - "usdcAddress": "0x" + "b" * 40, - }, - ) - ] - ) - - addr = await client.get_verifying_contract("eip155:5042002") - assert addr == "0x" + "a" * 40 - - @pytest.mark.asyncio - async def test_get_verifying_contract_unsupported_network(self): - """Lines 533-539: get_verifying_contract() raises for unsupported network.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - client.get_supported = AsyncMock(return_value=[]) - - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.get_verifying_contract("eip155:999999") - assert exc_info.value.network == "eip155:999999" - - @pytest.mark.asyncio - async def test_get_usdc_address_success(self): - """Lines 541-560: get_usdc_address() returns USDC address for supported network.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - client.get_supported = AsyncMock( - return_value=[ - SupportedKind( - x402_version=2, - scheme="exact", - network="eip155:5042002", - extra={ - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "a" * 40, - "usdcAddress": "0x" + "b" * 40, - }, - ) - ] - ) - - addr = await client.get_usdc_address("eip155:5042002") - assert addr == "0x" + "b" * 40 - - @pytest.mark.asyncio - async def test_get_usdc_address_unsupported_network(self): - """Lines 554-560: get_usdc_address() raises for unsupported network.""" - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - client.get_supported = AsyncMock(return_value=[]) - - with pytest.raises(UnsupportedNetworkError) as exc_info: - await client.get_usdc_address("eip155:999999") - assert exc_info.value.network == "eip155:999999" - - @pytest.mark.asyncio - async def test_get_supported_parses_usdc_from_assets(self): - """Lines 494-500: get_supported() extracts USDC address from assets array.""" - from unittest.mock import AsyncMock - from omniclaw.protocols.nanopayments.client import NanopaymentHTTPClient - - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "kinds": [ - { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:5042002", - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "assets": [ - { - "address": "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", - "symbol": "USDC", - "decimals": 6, - }, - { - "address": "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", - "symbol": "OTHER", - "decimals": 6, - }, - ], - }, - } - ] - } - - mock_http = MagicMock() - mock_http.get = AsyncMock(return_value=mock_resp) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - kinds = await client.get_supported(force_refresh=True) - assert len(kinds) == 1 - assert kinds[0].network == "eip155:5042002" - assert kinds[0].usdc_address == "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" - assert kinds[0].verifying_contract == "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - - @pytest.mark.asyncio - async def test_check_balance_empty_balances_returns_zero(self): - """Line 758: check_balance() returns zero when balances array is empty.""" - from unittest.mock import AsyncMock - - client = NanopaymentClient( - environment="testnet", - base_url="https://api.test.circle.cn", - api_key="test-key", - ) - client.get_supported = AsyncMock(return_value=[]) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "token": "USDC", - "balances": [], # empty - } - mock_http = MagicMock() - mock_http.post = AsyncMock(return_value=mock_response) - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - with patch( - "omniclaw.protocols.nanopayments.client.NanopaymentHTTPClient", - return_value=mock_http, - ): - balance = await client.check_balance( - address="0x" + "1" * 40, - network="eip155:5042002", - ) - assert balance.total == 0 - assert balance.available == 0 - assert balance.formatted_total == "0 USDC" - - def test_caip2_to_circle_network_valid(self): - """Lines 73-82: _caip2_to_circle_network() converts known CAIP-2 to circle name.""" - from omniclaw.protocols.nanopayments.client import _caip2_to_circle_network - - assert _caip2_to_circle_network("eip155:5042002") == "arc-testnet" - assert _caip2_to_circle_network("eip155:8453") == "base" - - def test_caip2_to_circle_network_unknown_returns_chain_id(self): - """Lines 73-82: _caip2_to_circle_network() returns chain ID for unknown networks.""" - from omniclaw.protocols.nanopayments.client import _caip2_to_circle_network - - # Unknown CAIP-2 that has no mapping - result = _caip2_to_circle_network("eip155:999999") - assert result == "999999" - - def test_parse_caip2_chain_id_valid(self): - """Lines 85-98: _parse_caip2_chain_id() parses valid CAIP-2.""" - from omniclaw.protocols.nanopayments.client import _parse_caip2_chain_id - - assert _parse_caip2_chain_id("eip155:5042002") == 5042002 - assert _parse_caip2_chain_id("eip155:8453") == 8453 - assert _parse_caip2_chain_id("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") == 0 # non-numeric - - def test_parse_caip2_chain_id_invalid(self): - """Lines 85-98: _parse_caip2_chain_id() returns 0 for invalid format.""" - from omniclaw.protocols.nanopayments.client import _parse_caip2_chain_id - - assert _parse_caip2_chain_id("invalid") == 0 - assert _parse_caip2_chain_id("eip155:abc") == 0 # non-numeric - - def test_caip2_to_gateway_network(self): - """Lines 121-134: _caip2_to_gateway_network() converts CAIP-2 to gateway name.""" - from omniclaw.protocols.nanopayments.client import _caip2_to_gateway_network - - assert _caip2_to_gateway_network("eip155:5042002") == "arc-testnet" - assert _caip2_to_gateway_network("eip155:8453") == "base" - - def test_gateway_network_to_caip2(self): - """Lines 137-150: _gateway_network_to_caip2() converts gateway name to CAIP-2.""" - from omniclaw.protocols.nanopayments.client import _gateway_network_to_caip2 - - assert _gateway_network_to_caip2("arc-testnet") == "eip155:5042002" - assert _gateway_network_to_caip2("base") == "eip155:8453" - # numeric fallback - assert _gateway_network_to_caip2("12345") == "eip155:12345" - # passthrough for unknown - assert _gateway_network_to_caip2("unknown-network") == "unknown-network" - - def test_to_int_valid(self): - """Lines 161-170: _to_int() converts valid values.""" - from omniclaw.protocols.nanopayments.client import _to_int - - assert _to_int("1000000") == 1000000 - assert _to_int(42) == 42 - assert _to_int("0") == 0 - - def test_to_int_invalid_returns_zero(self): - """Lines 161-170: _to_int() returns 0 for invalid values.""" - from omniclaw.protocols.nanopayments.client import _to_int - - assert _to_int(None) == 0 - assert _to_int("invalid") == 0 - - -# ============================================================================= -# TEST: NanoKeyVault additional paths (vault.py) -# ============================================================================= - - -class TestNanoKeyVaultAdditional: - """Additional coverage for NanoKeyVault methods.""" - - def _make_vault(self): - """Create a vault with mocked storage and keystore.""" - mock_storage = MagicMock(spec=StorageBackend) - mock_storage.get = AsyncMock(return_value=None) - mock_storage.save = AsyncMock() - return NanoKeyVault( - entity_secret="test-secret-key-32-chars-long!!", - storage_backend=mock_storage, - circle_api_key="test-api-key", - nanopayments_environment="testnet", - ) - - @pytest.mark.asyncio - async def test_add_key_duplicate_alias_raises(self): - """Line 147: add_key() with existing alias raises DuplicateKeyAliasError.""" - vault = self._make_vault() - # Pre-existing key - vault._storage.get = AsyncMock( - return_value={ - "encrypted_key": "some_encrypted", - "address": "0x" + "1" * 40, - "network": "eip155:5042002", - } - ) - - with pytest.raises(DuplicateKeyAliasError): - await vault.add_key( - alias="existing-key", - private_key="0x" + "2" * 64, - ) - - @pytest.mark.asyncio - async def test_get_network_uses_default(self): - """Lines 239-248: get_network() with no stored network falls back to default.""" - vault = self._make_vault() - # No default key set - with pytest.raises(NoDefaultKeyError): - await vault.get_network(alias=None) - - @pytest.mark.asyncio - async def test_get_network_returns_recorded(self): - """Lines 243-248: get_network() returns stored network.""" - vault = self._make_vault() - vault._default_key_alias = "test-key" - vault._storage.get = AsyncMock( - return_value={ - "encrypted_key": "enc", - "address": "0x" + "1" * 40, - "network": "eip155:137", - } - ) - - network = await vault.get_network(alias="test-key") - assert network == "eip155:137" - - @pytest.mark.asyncio - async def test_update_key_network(self): - """Lines 261-268: update_key_network() saves new network.""" - vault = self._make_vault() - vault._storage.get = AsyncMock( - return_value={ - "encrypted_key": "enc", - "address": "0x" + "1" * 40, - "network": "eip155:1", - } - ) - vault._storage.save = AsyncMock() - - await vault.update_key_network(alias="test-key", network="eip155:137") - vault._storage.save.assert_called_once() - saved_data = vault._storage.save.call_args - assert saved_data[0][2]["network"] == "eip155:137" - - @pytest.mark.asyncio - async def test_get_balance_delegates_to_client(self): - """Lines 440-445: get_balance() delegates to nanopayment client.""" - vault = self._make_vault() - vault._default_key_alias = "test-key" - vault._storage.get = AsyncMock( - return_value={ - "encrypted_key": "enc", - "address": "0x" + "1" * 40, - "network": "eip155:5042002", - } - ) - vault._client.check_balance = AsyncMock( - return_value=GatewayBalance( - total=100_000_000, - available=50_000_000, - formatted_total="100 USDC", - formatted_available="50 USDC", - ) - ) - - balance = await vault.get_balance(alias="test-key") - assert balance.total == 100_000_000 - vault._client.check_balance.assert_called_once() - - def test_default_network_property(self): - """Lines 96-99: default_network property.""" - vault = self._make_vault() - assert vault.default_network is not None - - def test_environment_property(self): - """Lines 102-104: environment property.""" - vault = self._make_vault() - assert vault.environment == "testnet" - - @pytest.mark.asyncio - async def test_get_raw_key_no_default(self): - """Lines 465-467: get_raw_key() with no default raises.""" - vault = self._make_vault() - with pytest.raises(NoDefaultKeyError): - await vault.get_raw_key(alias=None) - - @pytest.mark.asyncio - async def test_get_raw_key_returns_decrypted(self): - """Lines 469-474: get_raw_key() returns None for missing key.""" - vault = self._make_vault() - vault._default_key_alias = "test-key" - vault._storage.get = AsyncMock(return_value=None) - - with pytest.raises(KeyNotFoundError): - await vault.get_raw_key(alias="test-key") - - -# ============================================================================= -# TEST: Adapter settlement paths (adapter.py) -# ============================================================================= - - -class TestAdapterSettlementPaths: - """Coverage for adapter settlement/error paths.""" - - def _make_mock_http_client(self): - mock_http = MagicMock() - mock_http.post = AsyncMock() - mock_http.request = AsyncMock() - return mock_http - - @pytest.mark.asyncio - async def test_settle_success_path(self): - """Lines 447-448: settlement succeeds β†’ record_success.""" - mock_vault = MagicMock(spec=NanoKeyVault) - mock_vault.get_address = AsyncMock(return_value="0x" + "1" * 40) - mock_client = MagicMock(spec=NanopaymentClient) - mock_client.get_supported = AsyncMock(return_value=[]) - mock_http = self._make_mock_http_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - mock_client.settle = AsyncMock( - return_value=SettleResponse( - success=True, - transaction="0xsuccess", - payer="0x" + "1" * 40, - error_reason=None, - ) - ) - - payload = MagicMock(spec=PaymentPayload) - payload.to_dict.return_value = {} - req = make_402_requirements() - - result = await adapter._settle_with_retry(payload=payload, requirements=req) - assert result.success is True - - @pytest.mark.asyncio - async def test_auto_topup_failure_proceeds(self): - """Lines 594-596: auto-topup failure doesn't break settlement.""" - mock_vault = MagicMock(spec=NanoKeyVault) - mock_client = MagicMock(spec=NanopaymentClient) - mock_http = self._make_mock_http_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - auto_topup_threshold="0.1", - auto_topup_amount="1.0", - ) - adapter._wallet_manager = MagicMock() - adapter._wallet_manager.deposit = AsyncMock(side_effect=RuntimeError("RPC error")) - - mock_client.check_balance = AsyncMock( - return_value=GatewayBalance( - total=10_000_000, - available=50_000, - formatted_total="10.00 USDC", - formatted_available="0.05 USDC", - ) - ) - - mock_client.settle = AsyncMock( - return_value=SettleResponse( - success=True, - transaction="0xtx", - payer="0x" + "1" * 40, - error_reason=None, - ) - ) - - payload = MagicMock(spec=PaymentPayload) - payload.to_dict.return_value = {} - req = make_402_requirements() - - # Should not raise even though topup fails - result = await adapter._settle_with_retry(payload=payload, requirements=req) - assert result.success is True - - @pytest.mark.asyncio - async def test_check_and_topup_low_balance(self): - """Lines 763-781: _check_and_topup with low balance calls deposit.""" - mock_vault = MagicMock(spec=NanoKeyVault) - mock_client = MagicMock(spec=NanopaymentClient) - mock_http = self._make_mock_http_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - auto_topup_threshold="0.1", - auto_topup_amount="1.0", - ) - adapter._wallet_manager = MagicMock() - adapter._wallet_manager.deposit = AsyncMock( - return_value=DepositResult( - approval_tx_hash=None, - deposit_tx_hash="0xtxhash", - amount=1_000_000, - formatted_amount="1.00 USDC", - ) - ) - # _check_and_topup calls self._vault.get_balance() - mock_vault.get_balance = AsyncMock( - return_value=GatewayBalance( - total=10_000_000, - available=50_000, - formatted_total="10.00 USDC", - formatted_available="0.05 USDC", - ) - ) - - did_topup = await adapter._check_and_topup(alias="default") - assert did_topup is True - adapter._wallet_manager.deposit.assert_called_once() - - @pytest.mark.asyncio - async def test_check_and_topup_balance_ok(self): - """Line 780: _check_and_topup returns False when balance is sufficient.""" - mock_vault = MagicMock(spec=NanoKeyVault) - mock_client = MagicMock(spec=NanopaymentClient) - mock_http = self._make_mock_http_client() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - auto_topup_threshold="1.0", - ) - mock_vault.get_balance = AsyncMock( - return_value=GatewayBalance( - total=10_000_000, - available=5_000_000, - formatted_total="10.00 USDC", - formatted_available="5.00 USDC", - ) - ) - - did_topup = await adapter._check_and_topup(alias="default") - assert did_topup is False - - -# ============================================================================= -# TEST: NanopaymentAdapter additional coverage (adapter.py) -# ============================================================================= - - -class TestNanopaymentAdapterAdditionalCoverage: - """Additional coverage for NanopaymentAdapter.""" - - def _make_mock_http(self): - mock = MagicMock() - mock.post = AsyncMock() - mock.request = AsyncMock() - return mock - - @pytest.mark.asyncio - async def test_pay_x402_url_resource_from_body(self): - """Line 346: resource extracted from 402 response body, not constructed from URL.""" - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - # 402 response body contains a resource field - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = json.dumps( - { - "resource": { - "url": "https://seller.com/api/data", - "description": "Premium data access", - "mimeType": "application/json", - } - } - ) - - retry_resp = MagicMock() - retry_resp.status_code = 200 - retry_resp.content = b'{"data": "premium"}' - retry_resp.text = '{"data": "premium"}' - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock( - return_value=MagicMock( - spec=PaymentPayload, - to_dict=lambda: {}, - ) - ) - - mock_client = MagicMock() - mock_client.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx-from-body-resource", - ) - ) - - mock_http = MagicMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, retry_resp]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - result = await adapter.pay_x402_url("https://api.seller.com/data") - assert result.success is True - - # Verify ResourceInfo was constructed from body (not URL) - call_args = mock_vault.sign.call_args - resource_arg = call_args.kwargs.get("resource") or call_args[1].get("resource") - assert resource_arg is not None - assert resource_arg.url == "https://seller.com/api/data" - - @pytest.mark.asyncio - async def test_pay_x402_url_resource_body_parse_exception_fallback(self): - """Lines 347-348: body parse exception β†’ falls back to URL-based resource.""" - req_data = make_402_requirements() - encoded = base64.b64encode(json.dumps(req_data).encode()).decode() - - # 402 body has malformed JSON that causes parse exception - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = {"payment-required": encoded} - mock_resp_402.text = "not valid json{" # Will cause json.loads to raise - - retry_resp = MagicMock() - retry_resp.status_code = 200 - retry_resp.content = b"ok" - retry_resp.text = "ok" - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock( - return_value=MagicMock( - spec=PaymentPayload, - to_dict=lambda: {}, - ) - ) - - mock_client = MagicMock() - mock_client.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx-fallback", - ) - ) - - mock_http = MagicMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, retry_resp]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - # Should not raise - falls back to URL-based resource - result = await adapter.pay_x402_url("https://fallback.test/item") - assert result.success is True - - # Verify fallback resource was used (from URL, not body) - call_args = mock_vault.sign.call_args - resource_arg = call_args.kwargs.get("resource") or call_args[1].get("resource") - assert resource_arg is not None - assert resource_arg.url == "https://fallback.test/item" - - @pytest.mark.asyncio - async def test_settle_with_retry_connection_error_then_success(self): - """Lines 730-743: GatewayConnectionError retries with backoff then succeeds.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import GatewayConnectionError - - mock_vault = MagicMock() - mock_client = MagicMock() - - # Fail once with connection error, then succeed - mock_client.settle = AsyncMock( - side_effect=[ - GatewayConnectionError("Connection refused"), - MagicMock(success=True, transaction="tx-after-conn-retry"), - ] - ) - - mock_http = self._make_mock_http() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=3, - retry_base_delay=0.01, - ) - - payload = MagicMock(spec=PaymentPayload) - req = MagicMock(spec=PaymentRequirements) - - result = await adapter._settle_with_retry(payload=payload, requirements=req) - - assert result.success is True - assert mock_client.settle.call_count == 2 - # Circuit breaker should have recorded one error - assert adapter.get_circuit_breaker_state() == "closed" - - @pytest.mark.asyncio - async def test_settle_with_retry_insufficient_balance_raises(self): - """Lines 748-751: InsufficientBalanceError is non-recoverable, no retry.""" - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - from omniclaw.protocols.nanopayments.exceptions import InsufficientBalanceError - - mock_vault = MagicMock() - mock_client = MagicMock() - - mock_client.settle = AsyncMock( - side_effect=InsufficientBalanceError(reason="insufficient_balance") - ) - - mock_http = self._make_mock_http() - cb = NanopaymentCircuitBreaker(failure_threshold=1) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=3, - circuit_breaker=cb, - ) - - payload = MagicMock() - req = MagicMock() - - with pytest.raises(InsufficientBalanceError): - await adapter._settle_with_retry(payload=payload, requirements=req) - - # Should NOT have retried - assert mock_client.settle.call_count == 1 - # Circuit breaker should have recorded a failure (threshold=1) - assert adapter.get_circuit_breaker_state() == "open" - - @pytest.mark.asyncio - async def test_settle_with_retry_transient_settlement_error_then_success(self): - """Lines 752-768: SettlementError with 'timeout' retries, then succeeds.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import SettlementError - - mock_vault = MagicMock() - mock_client = MagicMock() - - # Transient SettlementError first, then success - mock_client.settle = AsyncMock( - side_effect=[ - SettlementError("Gateway timeout - try again"), - MagicMock(success=True, transaction="tx-after-settlement-retry"), - ] - ) - - mock_http = self._make_mock_http() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=3, - retry_base_delay=0.01, - ) - - payload = MagicMock(spec=PaymentPayload) - req = MagicMock(spec=PaymentRequirements) - - result = await adapter._settle_with_retry(payload=payload, requirements=req) - - assert result.success is True - assert mock_client.settle.call_count == 2 - - @pytest.mark.asyncio - async def test_settle_with_retry_non_transient_settlement_error_raises(self): - """Lines 769-772: SettlementError without 'timeout'/'connection' is non-recoverable.""" - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - from omniclaw.protocols.nanopayments.exceptions import SettlementError - - mock_vault = MagicMock() - mock_client = MagicMock() - - # Non-transient SettlementError - mock_client.settle = AsyncMock( - side_effect=SettlementError("Invalid authorization signature") - ) - - mock_http = self._make_mock_http() - cb = NanopaymentCircuitBreaker(failure_threshold=1) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=3, - circuit_breaker=cb, - ) - - payload = MagicMock() - req = MagicMock() - - with pytest.raises(SettlementError) as exc_info: - await adapter._settle_with_retry(payload=payload, requirements=req) - - assert "Invalid authorization signature" in str(exc_info.value) - assert mock_client.settle.call_count == 1 - # Circuit breaker should have recorded a failure (threshold=1) - assert adapter.get_circuit_breaker_state() == "open" - - @pytest.mark.asyncio - async def test_circuit_breaker_half_open_to_open(self): - """Circuit breaker transitions: closed β†’ error threshold β†’ open.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=2) - - cb.record_error() # 1 error - still closed - assert cb.state == "closed" - - cb.record_error() # 2 errors - trips to open - assert cb.state == "open" - - cb.record_error() # additional errors in open state - assert cb.state == "open" # stays open - - def test_circuit_breaker_half_open_recovery(self): - """Circuit breaker recovers from open to half-open via state property.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentCircuitBreaker - - cb = NanopaymentCircuitBreaker(failure_threshold=1, recovery_seconds=0.1) - - cb.record_failure() # trips to open - assert cb.state == "open" - - # Advance time past recovery window - import time - - time.sleep(0.15) - - # State property auto-transitions to half_open - assert cb.state == "half_open" - # Success in half_open resets to closed - cb.record_success() - assert cb.state == "closed" - - @pytest.mark.asyncio - async def test_pay_direct_auto_topup_failure_continues(self): - """Lines 622-623: pay_direct auto-topup failure logs but continues.""" - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.get_balance = AsyncMock( - return_value=GatewayBalance( - total=100_000, - available=50_000, - formatted_total="0.1 USDC", - formatted_available="0.05 USDC", - ) - ) - mock_vault.sign = AsyncMock( - return_value=MagicMock( - spec=PaymentPayload, - to_dict=lambda: {}, - ) - ) - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "b" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "c" * 40) - mock_client.settle = AsyncMock( - return_value=SettleResponse( - success=True, - transaction="0xtx", - payer="0x" + "a" * 40, - error_reason=None, - ) - ) - - mock_http = self._make_mock_http() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - auto_topup_threshold="0.001", # very low so topup is attempted - auto_topup_amount="1.0", - ) - # No wallet manager configured β†’ _check_and_topup returns False (line 806) - # but the exception is caught by the try/except in pay_direct (lines 622-623) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - - assert result.success is True - # Verify settlement was called despite auto-topup early return - mock_client.settle.assert_called_once() diff --git a/tests/test_nanopayments_keys.py b/tests/test_nanopayments_keys.py deleted file mode 100644 index d2a390e..0000000 --- a/tests/test_nanopayments_keys.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Tests for NanoKeyStore (Phase 4: key encryption/decryption). - -Tests verify: -- AES-256-GCM encryption/decryption roundtrip -- Encrypted keys are NOT decryptable with wrong entity_secret -- Keys are stored as base64-encoded blobs -- NanoKeyStore never exposes raw key -""" - -import pytest - -from omniclaw.protocols.nanopayments.exceptions import KeyEncryptionError -from omniclaw.protocols.nanopayments.keys import NanoKeyStore - - -# ============================================================================= -# INIT TESTS -# ============================================================================= - - -class TestNanoKeyStoreInit: - def test_accepts_valid_entity_secret(self): - store = NanoKeyStore(entity_secret="my-super-secret-entity-key-32chars!") - assert store._entity_secret == "my-super-secret-entity-key-32chars!" - - def test_rejects_short_entity_secret(self): - with pytest.raises(KeyEncryptionError) as exc_info: - NanoKeyStore(entity_secret="short") - assert "too short" in str(exc_info.value).lower() - - def test_rejects_empty_entity_secret(self): - with pytest.raises(KeyEncryptionError): - NanoKeyStore(entity_secret="") - with pytest.raises(KeyEncryptionError): - NanoKeyStore(entity_secret=None) # type: ignore - - -# ============================================================================= -# ENCRYPT/DECRYPT TESTS -# ============================================================================= - - -class TestEncryptionRoundtrip: - def test_encrypt_produces_base64_output(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - encrypted = store.encrypt_key("0x" + "ab" * 32) - assert isinstance(encrypted, str) - # Valid base64 - import base64 - - base64.b64decode(encrypted) - # nonce(12) + key(32) + tag(16) = 60 bytes -> 80 base64 chars - assert len(encrypted) >= 64 # must be valid base64 - - def test_decrypt_recovers_original_key(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "12" * 32 - encrypted = store.encrypt_key(private_key) - decrypted = store.decrypt_key(encrypted) - assert decrypted == private_key - - def test_decrypt_without_0x_prefix(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "fe" * 32 - encrypted = store.encrypt_key(private_key) - # encrypt_key normalizes the input - decrypted = store.decrypt_key(encrypted) - assert decrypted == private_key - - def test_different_ciphertexts_for_same_plaintext(self): - """AES-GCM with random nonce produces different ciphertexts.""" - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "99" * 32 - enc1 = store.encrypt_key(private_key) - enc2 = store.encrypt_key(private_key) - assert enc1 != enc2 # Different nonces - # Both decrypt to same value - assert store.decrypt_key(enc1) == store.decrypt_key(enc2) - - -class TestWrongEntitySecret: - def test_encrypt_with_wrong_secret_produces_different_blob(self): - store1 = NanoKeyStore(entity_secret="secret-one-for-testing!") - store2 = NanoKeyStore(entity_secret="secret-two-for-testing!") - private_key = "0x" + "1a" * 32 - enc1 = store1.encrypt_key(private_key) - enc2 = store2.encrypt_key(private_key) - assert enc1 != enc2 - - def test_decrypt_fails_with_wrong_secret(self): - store1 = NanoKeyStore(entity_secret="correct-secret-testing!") - store2 = NanoKeyStore(entity_secret="wrong-secret-testing!!") - private_key = "0x" + "2b" * 32 - encrypted = store1.encrypt_key(private_key) - with pytest.raises(KeyEncryptionError) as exc_info: - store2.decrypt_key(encrypted) - assert "decrypt" in str(exc_info.value).lower() - - def test_corrupted_ciphertext_fails(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - encrypted = store.encrypt_key("0x" + "cc" * 32) - # Corrupt the base64 string - corrupted = encrypted[:-4] + ("AAAA" if encrypted[-4:] != "AAAA" else "BBBB") - with pytest.raises(KeyEncryptionError): - store.decrypt_key(corrupted) - - def test_truncated_ciphertext_fails(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - encrypted = store.encrypt_key("0x" + "dd" * 32) - # Truncate to half - with pytest.raises(KeyEncryptionError): - store.decrypt_key(encrypted[: len(encrypted) // 2]) - - -# ============================================================================= -# CREATE SIGNER TESTS -# ============================================================================= - - -class TestCreateSigner: - def test_creates_valid_signer(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "3e" * 32 - encrypted = store.encrypt_key(private_key) - signer = store.create_signer(encrypted) - assert signer.address is not None - assert signer.address.startswith("0x") - assert len(signer.address) == 42 - - def test_signer_signs_successfully(self): - from omniclaw.protocols.nanopayments.signing import generate_eoa_keypair - - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key, _ = generate_eoa_keypair() - encrypted = store.encrypt_key(private_key) - signer = store.create_signer(encrypted) - assert signer.address is not None - - -# ============================================================================= -# GENERATE AND ENCRYPT TESTS -# ============================================================================= - - -class TestGenerateAndEncrypt: - def test_generates_valid_keypair(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - encrypted, address = store.generate_and_encrypt_key() - # Decrypt and verify - decrypted = store.decrypt_key(encrypted) - assert decrypted.startswith("0x") - assert len(decrypted) == 66 - # Address is derivable - from eth_account import Account - - derived = Account.from_key(decrypted).address - assert derived.lower() == address.lower() - - def test_generates_unique_keypairs(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - results = [store.generate_and_encrypt_key() for _ in range(5)] - addresses = [r[1] for r in results] - assert len(set(addresses)) == 5 # All unique - - -# ============================================================================= -# EDGE CASES -# ============================================================================= - - -class TestKeyStoreEdgeCases: - def test_encrypt_key_with_0x_prefix(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "4f" * 32 - encrypted = store.encrypt_key(private_key) - decrypted = store.decrypt_key(encrypted) - assert decrypted == private_key - - def test_encrypt_key_without_0x_prefix(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - private_key = "0x" + "5a" * 32 - encrypted = store.encrypt_key(private_key) - decrypted = store.decrypt_key(encrypted) - assert decrypted == private_key - - def test_encrypt_none_raises_error(self): - store = NanoKeyStore(entity_secret="entity-secret-for-testing!") - with pytest.raises(KeyEncryptionError): - store.encrypt_key(None) # type: ignore diff --git a/tests/test_nanopayments_middleware.py b/tests/test_nanopayments_middleware.py index b40487c..6f362d8 100644 --- a/tests/test_nanopayments_middleware.py +++ b/tests/test_nanopayments_middleware.py @@ -9,8 +9,8 @@ - Payment handling """ -import json import base64 +import json from unittest.mock import AsyncMock, MagicMock import pytest @@ -33,7 +33,6 @@ SupportedKind, ) - # ============================================================================= # PARSE_PRICE TESTS # ============================================================================= diff --git a/tests/test_nanopayments_middleware_coverage.py b/tests/test_nanopayments_middleware_coverage.py deleted file mode 100644 index 95afeb6..0000000 --- a/tests/test_nanopayments_middleware_coverage.py +++ /dev/null @@ -1,1714 +0,0 @@ -""" -Additional tests for GatewayMiddleware to cover uncovered lines. - -Covers: -- parse_price with invalid Decimal (lines 90-96) -- parse_price with plain integer fallback (lines 98-107) -- seller_address validation in __init__ (lines 138-150) -- _get_supported_kinds() (lines 161-168) -- handle() error handling for networks (lines 361-378) -- handle() settlement exception (lines 399-410) -- require() FastAPI dependency (lines 424-450) -""" - -import json -import base64 -from decimal import Decimal, InvalidOperation -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - InvalidPriceError, - NoNetworksAvailableError, -) -from omniclaw.protocols.nanopayments.middleware import ( - GatewayMiddleware, - PaymentRequiredHTTPError, - parse_price, - NoNetworksAvailableError as MiddlewareNoNetworksAvailableError, -) -from omniclaw.protocols.nanopayments.types import ( - EIP3009Authorization, - PaymentPayload, - PaymentPayloadInner, - SupportedKind, -) - - -# ============================================================================= -# PARSE_PRICE TESTS - UNCOVERED LINES -# ============================================================================= - - -class TestParsePriceUncovered: - def test_invalid_decimal_raises(self): - """Lines 90-96: Invalid Decimal raises InvalidPriceError.""" - # Test with an invalid decimal string that fails Decimal parsing - # The code catches (ValueError, InvalidOperation, ArithmeticError) - with pytest.raises(InvalidPriceError): - parse_price("abc") # Invalid decimal - not a number - - with pytest.raises(InvalidPriceError): - parse_price("1..2") # Multiple decimal points - - with pytest.raises(InvalidPriceError): - parse_price("") # Empty string after dollar removed - - def test_plain_integer_dollar_fallback(self): - """Lines 98-107: Plain integer < 1M treated as dollars.""" - # Test that integers below 1M are multiplied by 1M - assert parse_price("100") == 100_000_000 # $100 = 100M atomic - assert parse_price("500") == 500_000_000 - assert parse_price("0") == 0 - - # Test integers >= 1M are treated as atomic - assert parse_price("1000000") == 1_000_000 - assert parse_price("5000000") == 5_000_000 - - -# ============================================================================= -# HELPER FUNCTIONS -# ============================================================================= - - -def _make_kinds() -> list[SupportedKind]: - """Real SupportedKind objects for testing.""" - return [ - SupportedKind( - x402_version=2, - scheme="exact", - network="eip155:5042002", - extra={ - "verifyingContract": "0x" + "c" * 40, - "usdcAddress": "0xUsdcArcTestnet", - }, - ), - SupportedKind( - x402_version=2, - scheme="exact", - network="eip155:1", - extra={ - "verifyingContract": "0x" + "d" * 40, - "usdcAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - }, - ), - ] - - -def _make_kinds_with_missing_contracts() -> list[SupportedKind]: - """SupportedKinds with missing contract addresses.""" - return [ - SupportedKind( - x402_version=2, - scheme="exact", - network="eip155:5042002", - extra={ - "verifyingContract": None, # Missing - "usdcAddress": None, # Missing - }, - ), - ] - - -def _make_client(return_value: list[SupportedKind] | None = None) -> MagicMock: - """NanopaymentClient mock.""" - mock = MagicMock(spec=NanopaymentClient) - if return_value is not None: - mock.get_supported = AsyncMock(return_value=return_value) - return mock - - -# ============================================================================= -# SELLER_ADDRESS VALIDATION TESTS -# ============================================================================= - - -class TestSellerAddressValidation: - def test_empty_string_raises(self): - """Lines 138-139: Empty string raises ValueError.""" - with pytest.raises(ValueError) as exc_info: - GatewayMiddleware( - seller_address="", - nanopayment_client=_make_client(), - ) - assert "seller_address is required" in str(exc_info.value) - - def test_missing_0x_prefix_raises(self): - """Lines 140-141: Not starting with 0x raises ValueError.""" - with pytest.raises(ValueError) as exc_info: - GatewayMiddleware( - seller_address="abc123def4567890123456789012345678901", - nanopayment_client=_make_client(), - ) - assert "must be an EVM address" in str(exc_info.value) - - def test_wrong_length_raises(self): - """Lines 142-145: Wrong length raises ValueError.""" - # Too short - with pytest.raises(ValueError) as exc_info: - GatewayMiddleware( - seller_address="0x" + "a" * 30, - nanopayment_client=_make_client(), - ) - assert "must be 42 characters" in str(exc_info.value) - - # Too long - with pytest.raises(ValueError) as exc_info: - GatewayMiddleware( - seller_address="0x" + "a" * 50, - nanopayment_client=_make_client(), - ) - assert "must be 42 characters" in str(exc_info.value) - - def test_invalid_hex_chars_raises(self): - """Lines 146-150: Invalid hex characters raise ValueError.""" - with pytest.raises(ValueError) as exc_info: - GatewayMiddleware( - seller_address="0x" + "g" * 40, # 'g' is not valid hex - nanopayment_client=_make_client(), - ) - assert "invalid hex characters" in str(exc_info.value) - - -# ============================================================================= -# _GET_SUPPORTED_KINDS TESTS -# ============================================================================= - - -class TestGetSupportedKinds: - @pytest.mark.asyncio - async def test_returns_cached_if_set(self): - """Lines 163-164: Returns cached _supported_kinds if set.""" - kinds = _make_kinds() - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=kinds, - ) - - result = await middleware._get_supported_kinds() - assert result == kinds - # Should not call client - middleware._client.get_supported.assert_not_called() - - @pytest.mark.asyncio - async def test_fetches_if_none(self): - """Lines 165-167: Fetches from client if _supported_kinds is None.""" - mock_client = _make_client(_make_kinds()) - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=mock_client, - supported_kinds=None, # Not pre-set - ) - - result = await middleware._get_supported_kinds() - - assert len(result) == 2 - mock_client.get_supported.assert_called_once_with(force_refresh=True) - - @pytest.mark.asyncio - async def test_empty_fetch_raises(self): - """Lines 166-167: Empty fetch raises NoNetworksAvailableError.""" - mock_client = _make_client([]) # Empty list - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=mock_client, - supported_kinds=None, - ) - - with pytest.raises(MiddlewareNoNetworksAvailableError): - await middleware._get_supported_kinds() - - @pytest.mark.asyncio - async def test_none_fetch_raises(self): - """Lines 166-167: None return raises NoNetworksAvailableError.""" - mock_client = MagicMock(spec=NanopaymentClient) - mock_client.get_supported = AsyncMock(return_value=None) # Explicitly returns None - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=mock_client, - supported_kinds=None, - ) - - with pytest.raises(MiddlewareNoNetworksAvailableError): - await middleware._get_supported_kinds() - - -# ============================================================================= -# HANDLE ERROR HANDLING TESTS -# ============================================================================= - - -class TestHandleErrorHandling: - @pytest.mark.asyncio - async def test_no_networks_available_raises_no_networks_error(self): - """When _get_supported_kinds() returns empty, NoNetworksAvailableError propagates.""" - # This test verifies that when get_supported returns empty list, - # _get_supported_kinds raises NoNetworksAvailableError (line 166-167) - # which propagates up from handle() - this is the actual code path - mock_client = MagicMock(spec=NanopaymentClient) - mock_client.get_supported = AsyncMock(return_value=[]) - - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=mock_client, - supported_kinds=None, # Will trigger fetch - ) - - # Try to handle with a payment - should raise NoNetworksAvailableError - # from _get_supported_kinds() before we reach the 402 response logic - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "a" * 40, - value="1000", - valid_before=9999999999, - nonce="0x" + "b" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=authorization, - ), - ) - - sig_header = base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - - # The actual behavior: NoNetworksAvailableError is raised by _get_supported_kinds - with pytest.raises(NoNetworksAvailableError): - await middleware.handle( - {"payment-signature": sig_header}, - "$0.001", - ) - - @pytest.mark.asyncio - async def test_empty_supported_kinds_preloaded_raises_502(self): - """Lines 366-371: When supported_kinds=[], handle returns 502.""" - # When supported_kinds is pre-set to empty list (not None), - # _get_supported_kinds returns [] directly without calling client, - # and the code at lines 366-371 runs - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=[], # Pre-set to empty - bypasses client call - ) - - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "a" * 40, - value="1000", - valid_before=9999999999, - nonce="0x" + "b" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=authorization, - ), - ) - - sig_header = base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await middleware.handle( - {"payment-signature": sig_header}, - "$0.001", - ) - - assert exc_info.value.status_code == 502 - assert "No supported payment networks available" in exc_info.value.detail["error"] - - @pytest.mark.asyncio - async def test_missing_contract_addresses_raises_502(self): - """Lines 373-378: Missing verifying_contract or usdc_address returns 502.""" - # Create a payload with a network that has no contracts - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=_make_kinds_with_missing_contracts(), - ) - - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "a" * 40, - value="1000", - valid_before=9999999999, - nonce="0x" + "b" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=authorization, - ), - ) - - sig_header = base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await middleware.handle( - {"payment-signature": sig_header}, - "$0.001", - ) - - assert exc_info.value.status_code == 502 - assert "Missing contract addresses" in exc_info.value.detail["error"] - - @pytest.mark.asyncio - async def test_settlement_exception_returns_402(self): - """Lines 399-410: Settlement exception returns 402.""" - mock_client = _make_client(_make_kinds()) - mock_client.settle = AsyncMock(side_effect=Exception("Settlement failed")) - - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=mock_client, - supported_kinds=_make_kinds(), - ) - - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "a" * 40, - value="1000", - valid_before=9999999999, - nonce="0x" + "b" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=authorization, - ), - ) - - sig_header = base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - - with pytest.raises(PaymentRequiredHTTPError) as exc_info: - await middleware.handle( - {"payment-signature": sig_header}, - "$0.001", - ) - - assert exc_info.value.status_code == 402 - assert "Settlement failed" in exc_info.value.detail["error"] - - -# ============================================================================= -# REQUIRE FASTAPI DEPENDENCY TESTS -# ============================================================================= - - -class TestRequire: - @pytest.mark.asyncio - async def test_require_returns_dependency_function(self): - """Lines 424-450: require() returns a function that can be used as FastAPI dependency.""" - pytest.importorskip("fastapi") - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(_make_kinds()), - supported_kinds=_make_kinds(), - ) - - result = middleware.require("$0.001") - - # Should return a callable - assert callable(result) - - # The returned function should accept a request and call handle() - # Let's test that it internally calls parse_price - # by checking the behavior when request has no payment - - # Create a mock request - mock_request = MagicMock() - mock_request.headers = {} # No payment signature - - # When called, should raise HTTPException (not PaymentRequiredHTTPError) - # because the dependency wraps it - - # The dependency should call parse_price internally - # If parse_price fails, it should propagate - # But we can't easily test this without FastAPI's Depends - # Let's verify the function structure at least - - # Verify it's a coroutine function - import inspect - - assert inspect.iscoroutinefunction(result) - - @pytest.mark.asyncio - async def test_require_dependency_calls_handle(self): - """The dependency calls handle() with headers and price.""" - pytest.importorskip("fastapi") - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(_make_kinds()), - supported_kinds=_make_kinds(), - ) - - dependency = middleware.require("$0.001") - - # Mock request with valid payment - mock_request = MagicMock() - - # Build a valid payment payload - authorization = EIP3009Authorization.create( - from_address="0x" + "a" * 40, - to="0x" + "a" * 40, - value="1000", - valid_before=9999999999, - nonce="0x" + "b" * 64, - ) - payload = PaymentPayload( - x402_version=2, - scheme="exact", - network="eip155:5042002", - payload=PaymentPayloadInner( - signature="0x" + "c" * 130, - authorization=authorization, - ), - ) - - mock_client = _make_client(_make_kinds()) - mock_client.settle = AsyncMock( - return_value=MagicMock( - success=True, - transaction="batch-123", - payer="0x" + "a" * 40, - ) - ) - - # Replace client - middleware._client = mock_client - - # Create dependency with mocked request - mock_request.headers = { - "payment-signature": base64.b64encode(json.dumps(payload.to_dict()).encode()).decode() - } - - # Call the dependency - result = await dependency(mock_request) - - # Should return PaymentInfo - assert result.verified is True - assert result.transaction == "batch-123" - - @pytest.mark.asyncio - async def test_require_dependency_wraps_402_to_http_exception(self): - """Lines 443-448: Wraps PaymentRequiredHTTPError to HTTPException.""" - pytest.importorskip("fastapi") - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(_make_kinds()), - supported_kinds=_make_kinds(), - ) - - dependency = middleware.require("$0.001") - - # Mock request without payment - mock_request = MagicMock() - mock_request.headers = {} - - from fastapi import HTTPException - - # Should raise HTTPException with 402 - with pytest.raises(HTTPException) as exc_info: - await dependency(mock_request) - - assert exc_info.value.status_code == 402 - assert "accepts" in exc_info.value.detail # 402 response body - - -# ============================================================================= -# ADDITIONAL COVERAGE TESTS -# ============================================================================= - - -class TestBuildAcceptsArray: - def test_skips_kinds_without_contracts(self): - """_build_accepts_array skips kinds without verifying_contract or usdc_address.""" - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=_make_kinds_with_missing_contracts(), - ) - - accepts = middleware._build_accepts_array(1000) - - # Should be empty because both kinds have missing contracts - assert accepts == [] - - def test_build_accepts_array_with_none_kinds(self): - """Lines 196-198: Returns empty list if kinds is None.""" - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=None, - ) - - accepts = middleware._build_accepts_array(1000, kinds=None) - assert accepts == [] - - -class TestParsePaymentSignature: - def test_invalid_base64_raises(self): - """Invalid base64 in signature raises ValueError.""" - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=_make_kinds(), - ) - - with pytest.raises(ValueError) as exc_info: - middleware._parse_payment_signature("not-valid-base64!!!") - - assert "Failed to parse PAYMENT-SIGNATURE" in str(exc_info.value) - - def test_invalid_json_raises(self): - """Invalid JSON in signature raises ValueError.""" - middleware = GatewayMiddleware( - seller_address="0x" + "a" * 40, - nanopayment_client=_make_client(), - supported_kinds=_make_kinds(), - ) - - # Valid base64 but not JSON - invalid_json = base64.b64encode(b"not json").decode() - - with pytest.raises(ValueError) as exc_info: - middleware._parse_payment_signature(invalid_json) - - assert "Failed to parse PAYMENT-SIGNATURE" in str(exc_info.value) - - -# ============================================================================= -# ADAPTER ERROR PATH TESTS (adapter.py uncovered lines) -# ============================================================================= - - -class TestAdapterX402URLErrorPaths: - """Test adapter pay_x402_url() error paths for adapter.py coverage.""" - - @pytest.mark.asyncio - async def test_initial_request_timeout(self): - """Lines 288-293: Initial request TimeoutException β†’ GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - import httpx - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=httpx.TimeoutException("timeout")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception) as exc_info: - await adapter.pay_x402_url("https://api.example.com/data") - assert "timed out" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_initial_request_request_error(self): - """Lines 294-299: Initial request RequestError β†’ GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - import httpx - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=httpx.RequestError("failed")) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_402_missing_payment_required_header(self): - """Lines 320-325: 402 without PAYMENT-REQUIRED header β†’ GatewayAPIError.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.headers = {} # No header - mock_resp.text = "Payment Required" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_402_malformed_base64(self): - """Lines 331-336: Invalid base64 in PAYMENT-REQUIRED β†’ GatewayAPIError.""" - import base64 - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.headers = {"payment-required": "not-valid-base64!!!"} - mock_resp.text = "" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_gateway_kind_not_found(self): - """Lines 340-343: No GatewayWalletBatched scheme β†’ UnsupportedSchemeError.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import UnsupportedSchemeError - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "NotGateway", # Wrong name - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp = MagicMock() - mock_resp.status_code = 402 - mock_resp.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp.text = "" - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(return_value=mock_resp) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(UnsupportedSchemeError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_missing_verifying_contract_fetches_from_client(self): - """Lines 347-350: Missing verifying_contract calls get_verifying_contract().""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": None, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"ok" - mock_resp_retry.text = "ok" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - await adapter.pay_x402_url("https://api.example.com/data") - mock_client.get_verifying_contract.assert_called_once() - - @pytest.mark.asyncio - async def test_auto_topup_failure_in_pay_x402_url_continues(self): - """Lines 378-381: Auto-topup failure in pay_x402_url logs but continues.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"ok" - mock_resp_retry.text = "ok" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - - mock_wm = MagicMock() - mock_wm.deposit = AsyncMock(side_effect=Exception("Deposit failed")) - - mock_client = MagicMock() - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wm) - - # Should NOT raise - result = await adapter.pay_x402_url("https://api.example.com/data") - assert result.success is True - - @pytest.mark.asyncio - async def test_retry_request_timeout(self): - """Lines 405-416: Retry request TimeoutException β†’ GatewayAPIError.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.types import PaymentPayload - import httpx - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock( - side_effect=[mock_resp_402, httpx.TimeoutException("timeout")] - ) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(Exception): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_circuit_breaker_open_raises_when_content_not_delivered(self): - """Lines 426-439: Circuit open + non-success status β†’ CircuitOpenError.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 500 - mock_resp_retry.content = b"error" - mock_resp_retry.text = "error" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(CircuitOpenError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_non_recoverable_settlement_error_raises(self): - """Lines 466-484: NonceReusedError + non-success β†’ raises immediately.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import NonceReusedError - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 500 - mock_resp_retry.content = b"error" - mock_resp_retry.text = "error" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.settle = AsyncMock(side_effect=NonceReusedError()) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - ) - - with pytest.raises(NonceReusedError): - await adapter.pay_x402_url("https://api.example.com/data") - - @pytest.mark.asyncio - async def test_settlement_success_after_retry(self): - """Lines 638-739: Settlement succeeds after transient timeout retry.""" - import base64 - import json - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - from omniclaw.protocols.nanopayments.exceptions import GatewayTimeoutError - from omniclaw.protocols.nanopayments.types import PaymentPayload - - req = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:5042002", - "asset": "0x" + "d" * 40, - "amount": "1000000", - "maxTimeoutSeconds": 345600, - "payTo": "0x" + "b" * 40, - "extra": { - "name": "GatewayWalletBatched", - "version": "1", - "verifyingContract": "0x" + "c" * 40, - }, - }, - ], - } - - mock_resp_402 = MagicMock() - mock_resp_402.status_code = 402 - mock_resp_402.headers = { - "payment-required": base64.b64encode(json.dumps(req).encode()).decode() - } - mock_resp_402.text = "" - - mock_resp_retry = MagicMock() - mock_resp_retry.status_code = 200 - mock_resp_retry.content = b"ok" - mock_resp_retry.text = "ok" - - mock_payload = MagicMock(spec=PaymentPayload) - mock_payload.to_dict.return_value = {} - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock(return_value=mock_payload) - - mock_client = MagicMock() - mock_client.settle = AsyncMock( - side_effect=[ - GatewayTimeoutError("timeout"), - MagicMock(success=True, transaction="tx123"), - ] - ) - - mock_http = AsyncMock() - mock_http.request = AsyncMock(side_effect=[mock_resp_402, mock_resp_retry]) - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=False, - retry_attempts=1, - retry_base_delay=0.001, - ) - - result = await adapter.pay_x402_url("https://api.example.com/data") - assert result.success is True - assert mock_client.settle.call_count == 2 - - @pytest.mark.asyncio - async def test_circuit_breaker_open_in_settle_with_retry(self): - """Lines 665-667: _settle_with_retry raises CircuitOpenError when open.""" - from omniclaw.protocols.nanopayments.adapter import ( - CircuitOpenError, - NanopaymentAdapter, - NanopaymentCircuitBreaker, - ) - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(CircuitOpenError): - await adapter._settle_with_retry(payload=MagicMock(), requirements=MagicMock()) - - -# ============================================================================= -# ADAPTER pay_direct ERROR PATH TESTS -# ============================================================================= - - -class TestAdapterPayDirectErrorPaths: - """Test adapter pay_direct() error paths for adapter.py coverage.""" - - @pytest.mark.asyncio - async def test_pay_direct_auto_topup_failure_continues(self): - """Lines 591-596: Auto-topup failure in pay_direct logs but continues.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - mock_vault.get_balance = AsyncMock(return_value=MagicMock(available_decimal="0.01")) - - mock_wm = MagicMock() - mock_wm.deposit = AsyncMock(side_effect=Exception("Deposit failed")) - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - mock_client.settle = AsyncMock(return_value=MagicMock(success=True, transaction="tx123")) - - mock_http = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, - ) - adapter.set_wallet_manager(mock_wm) - - result = await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - assert result.success is True - - @pytest.mark.asyncio - async def test_pay_direct_circuit_breaker_open(self): - """Lines 609-619: Circuit open in pay_direct β†’ SettlementError.""" - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentAdapter, - NanopaymentCircuitBreaker, - SettlementError, - ) - - mock_vault = MagicMock() - mock_vault.get_address = AsyncMock(return_value="0x" + "a" * 40) - mock_vault.sign = AsyncMock() - - mock_client = MagicMock() - mock_client.get_verifying_contract = AsyncMock(return_value="0x" + "c" * 40) - mock_client.get_usdc_address = AsyncMock(return_value="0x" + "d" * 40) - - mock_http = AsyncMock() - - cb = NanopaymentCircuitBreaker(failure_threshold=1) - cb.record_failure() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - circuit_breaker=cb, - auto_topup_enabled=False, - ) - - with pytest.raises(SettlementError) as exc_info: - await adapter.pay_direct( - seller_address="0x" + "b" * 40, - amount_usdc="0.001", - network="eip155:5042002", - ) - assert "circuit" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_pay_direct_no_wallet_manager_returns_false_topup(self): - """Lines 763-777: _check_and_topup with no wallet manager returns False.""" - from omniclaw.protocols.nanopayments.adapter import NanopaymentAdapter - - mock_vault = MagicMock() - mock_client = MagicMock() - mock_http = AsyncMock() - - adapter = NanopaymentAdapter( - vault=mock_vault, - nanopayment_client=mock_client, - http_client=mock_http, - auto_topup_enabled=True, # Enabled but no wallet manager - ) - - result = await adapter._check_and_topup() - assert result is False - - -# ============================================================================= -# PROTOCOL ADAPTER execute() TESTS -# ============================================================================= - - -class TestProtocolAdapterExecute: - """Test NanopaymentProtocolAdapter.execute() fallback paths.""" - - @pytest.mark.asyncio - async def test_execute_pay_direct_no_network_uses_env_var(self): - """Lines 929-943: execute() with no network uses NANOPAYMENTS_DEFAULT_NETWORK.""" - import os - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentProtocolAdapter, - ) - from decimal import Decimal - - mock_adapter = AsyncMock() - mock_adapter.pay_direct = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx123", - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - amount_usdc="0.001", - amount_atomic="1000", - network="eip155:5042002", - is_nanopayment=True, - ) - ) - - protocol = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - with patch.dict(os.environ, {"NANOPAYMENTS_DEFAULT_NETWORK": "eip155:5042002"}): - result = await protocol.execute( - wallet_id="wallet-123", - recipient="0x" + "b" * 40, - amount=Decimal("0.001"), - ) - - mock_adapter.pay_direct.assert_called_once() - assert result.success is True - - @pytest.mark.asyncio - async def test_execute_pay_x402_url_no_network_uses_env_var(self): - """Lines 929-943: execute() with URL recipient and no network uses env var.""" - import os - from omniclaw.protocols.nanopayments.adapter import ( - NanopaymentProtocolAdapter, - ) - from decimal import Decimal - - mock_adapter = AsyncMock() - mock_adapter.pay_x402_url = AsyncMock( - return_value=MagicMock( - success=True, - transaction="tx123", - payer="0x" + "a" * 40, - seller="0x" + "b" * 40, - amount_usdc="0", - amount_atomic="0", - network="", - is_nanopayment=False, - ) - ) - - protocol = NanopaymentProtocolAdapter( - nanopayment_adapter=mock_adapter, - micro_threshold_usdc="1.00", - ) - - with patch.dict(os.environ, {"NANOPAYMENTS_DEFAULT_NETWORK": "eip155:5042002"}): - result = await protocol.execute( - wallet_id="wallet-123", - recipient="https://api.example.com/data", - amount=Decimal("0.001"), - ) - - mock_adapter.pay_x402_url.assert_called_once() - - -# ============================================================================= -# SIGNING MODULE COVERAGE TESTS -# ============================================================================= - - -class TestSigningModuleCoverage: - """Test signing module functions for signing.py coverage.""" - - def test_build_eip712_domain_empty_verifying_contract(self): - """Lines 112-116: Empty verifying_contract raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=1, verifying_contract="") - assert exc_info.value.code == "MISSING_VERIFYING_CONTRACT" - - def test_build_eip712_domain_invalid_prefix(self): - """Lines 118-122: Invalid address prefix raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=1, verifying_contract="abc123") - assert exc_info.value.code == "INVALID_ADDRESS_FORMAT" - - def test_build_eip712_domain_invalid_chain_id(self): - """Lines 124-128: Invalid chain_id raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_domain, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_domain(chain_id=0, verifying_contract="0x" + "a" * 40) - assert exc_info.value.code == "INVALID_CHAIN_ID" - - def test_build_eip712_message_invalid_from_address(self): - """Lines 170-174: Invalid from_address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="not-an-address", - to_address="0x" + "b" * 40, - value=1000, - ) - assert exc_info.value.code == "INVALID_FROM_ADDRESS" - - def test_build_eip712_message_invalid_to_address(self): - """Lines 176-180: Invalid to_address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="also-not-address", - value=1000, - ) - assert exc_info.value.code == "INVALID_TO_ADDRESS" - - def test_build_eip712_message_self_transfer(self): - """Lines 182-186: Same from/to address raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - addr = "0x" + "a" * 40 - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address=addr, - to_address=addr, - value=1000, - ) - assert exc_info.value.code == "SELF_TRANSFER" - - def test_build_eip712_message_negative_value(self): - """Lines 188-193: Negative value raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=-100, - ) - assert exc_info.value.code == "INVALID_VALUE" - - def test_build_eip712_message_valid_before_too_soon(self): - """Lines 199-206: valid_before too soon raises SigningError.""" - import time - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - valid_before=int(time.time()) + 100, # Too soon - ) - assert exc_info.value.code == "VALID_BEFORE_TOO_SOON" - - def test_build_eip712_message_invalid_nonce_prefix(self): - """Lines 213-217: Nonce without 0x prefix raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="deadbeef" * 8, - ) - assert exc_info.value.code == "INVALID_NONCE_FORMAT" - - def test_build_eip712_message_invalid_nonce_length(self): - """Lines 220-224: Nonce wrong length raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="0x" + "ab" * 10, # 20 bytes, not 32 - ) - assert exc_info.value.code == "INVALID_NONCE_LENGTH" - - def test_build_eip712_message_invalid_nonce_hex(self): - """Lines 227-233: Nonce with invalid hex raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import build_eip712_message, SigningError - - with pytest.raises(SigningError) as exc_info: - build_eip712_message( - from_address="0x" + "a" * 40, - to_address="0x" + "b" * 40, - value=1000, - nonce="0x" + "g" * 64, # 'g' is invalid hex - ) - assert exc_info.value.code == "INVALID_NONCE_HEX" - - def test_eip3009_signer_invalid_key_length(self): - """Lines 317-320: Private key wrong length raises InvalidPrivateKeyError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import InvalidPrivateKeyError - - with pytest.raises(InvalidPrivateKeyError): - EIP3009Signer("0x" + "ab" * 31) # 62 chars - - def test_eip3009_signer_invalid_key_hex(self): - """Lines 322-326: Private key invalid hex raises InvalidPrivateKeyError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import InvalidPrivateKeyError - - with pytest.raises(InvalidPrivateKeyError): - EIP3009Signer("0x" + "g" * 64) # 'g' invalid hex - - def test_eip3009_signer_wrong_scheme(self): - """Lines 402-406: Wrong scheme raises UnsupportedSchemeError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedSchemeError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="wrong-scheme", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="NotGateway", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedSchemeError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_missing_verifying_contract(self): - """Lines 408-415: Missing verifying_contract raises MissingVerifyingContractError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import MissingVerifyingContractError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=None, - ), - ) - - with pytest.raises(MissingVerifyingContractError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_amount_exceeds_requirement(self): - """Lines 421-427: amount_atomic > required raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import SigningError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(SigningError) as exc_info: - signer.sign_transfer_with_authorization(kind, amount_atomic=2000) - assert exc_info.value.code == "AMOUNT_EXCEEDS_REQUIREMENT" - - def test_eip3009_signer_invalid_network_format(self): - """Lines 432-435: Non-eip155 network raises UnsupportedNetworkError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedNetworkError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="cosmos:stargaze", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedNetworkError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_missing_verifying_contract(self): - """Lines 408-415: Missing verifying_contract raises MissingVerifyingContractError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import MissingVerifyingContractError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=None, - ), - ) - - with pytest.raises(MissingVerifyingContractError): - signer.sign_transfer_with_authorization(kind) - - def test_eip3009_signer_amount_exceeds_requirement(self): - """Lines 421-427: amount_atomic > required raises SigningError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import SigningError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="eip155:5042002", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(SigningError) as exc_info: - signer.sign_transfer_with_authorization(kind, amount_atomic=2000) - assert exc_info.value.code == "AMOUNT_EXCEEDS_REQUIREMENT" - - def test_eip3009_signer_invalid_network_format(self): - """Lines 432-435: Non-eip155 network raises UnsupportedNetworkError.""" - from omniclaw.protocols.nanopayments.signing import EIP3009Signer - from omniclaw.protocols.nanopayments.exceptions import UnsupportedNetworkError - from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsKind, - PaymentRequirementsExtra, - ) - - signer = EIP3009Signer("0x" + "1" * 64) - kind = PaymentRequirementsKind( - scheme="exact", - network="cosmos:stargaze", - asset="0x" + "d" * 40, - amount="1000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract="0x" + "c" * 40, - ), - ) - - with pytest.raises(UnsupportedNetworkError): - signer.sign_transfer_with_authorization(kind) - - def test_generate_eoa_keypair_returns_valid(self): - """Line 635: generate_eoa_keypair() returns valid (key, address).""" - from omniclaw.protocols.nanopayments.signing import generate_eoa_keypair - - private_key, address = generate_eoa_keypair() - assert private_key.startswith("0x") - assert len(private_key) == 66 - assert address.startswith("0x") - assert len(address) == 42 - - def test_parse_caip2_chain_id_invalid_format(self): - """Lines 571-572: parse_caip2_chain_id raises ValueError for invalid format.""" - from omniclaw.protocols.nanopayments.signing import parse_caip2_chain_id - - with pytest.raises(ValueError) as exc_info: - parse_caip2_chain_id("cosmos:stargaze") - assert "Invalid CAIP-2 format" in str(exc_info.value) - - def test_parse_caip2_chain_id_invalid_chain_id(self): - """Lines 574-577: parse_caip2_chain_id raises ValueError for invalid chain ID.""" - from omniclaw.protocols.nanopayments.signing import parse_caip2_chain_id - - with pytest.raises(ValueError) as exc_info: - parse_caip2_chain_id("eip155:not-a-number") - assert "Invalid chain ID" in str(exc_info.value) diff --git a/tests/test_nanopayments_signing.py b/tests/test_nanopayments_signing.py index 547d8d2..1b526d6 100644 --- a/tests/test_nanopayments_signing.py +++ b/tests/test_nanopayments_signing.py @@ -22,6 +22,7 @@ from omniclaw.protocols.nanopayments.exceptions import ( InvalidPrivateKeyError, SigningError, + UnsupportedNetworkError, UnsupportedSchemeError, ) from omniclaw.protocols.nanopayments.signing import ( @@ -42,7 +43,6 @@ PaymentRequirementsKind, ) - # ============================================================================= # TEST FIXTURES # ============================================================================= @@ -919,7 +919,7 @@ def test_sign_unsupported_network(self): ), ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(UnsupportedNetworkError) as exc_info: signer.sign_transfer_with_authorization(requirements=req, amount_atomic=1000) assert "network" in str(exc_info.value).lower() @@ -943,7 +943,7 @@ def test_sign_network_empty_chain_id(self): ), ) - with pytest.raises(Exception): + with pytest.raises(UnsupportedNetworkError): signer.sign_transfer_with_authorization(requirements=req, amount_atomic=1000) def test_verify_signature_async_recovery_exception(self): @@ -1029,11 +1029,12 @@ def test_raw_key_property(self): def test_sign_transfer_message_sign_exception(self): """Lines 469-470: sign_message raises exception raises SigningError.""" + from unittest.mock import patch + from omniclaw.protocols.nanopayments.types import ( PaymentRequirementsExtra, PaymentRequirementsKind, ) - from unittest.mock import patch signer = EIP3009Signer( private_key="0x250716a653d2155d15bfb1e1ded08b6764937ca6ab3cdd7e2f0510c975fb5652" diff --git a/tests/test_nanopayments_types.py b/tests/test_nanopayments_types.py index 877d15d..b8bfd51 100644 --- a/tests/test_nanopayments_types.py +++ b/tests/test_nanopayments_types.py @@ -4,8 +4,6 @@ Phase 1: Foundation """ -import pytest - from omniclaw.protocols.nanopayments import ( DepositResult, EIP3009Authorization, diff --git a/tests/test_nanopayments_vault.py b/tests/test_nanopayments_vault.py deleted file mode 100644 index 390c195..0000000 --- a/tests/test_nanopayments_vault.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -Tests for NanoKeyVault (Phase 4: SDK-level key management). - -Tests verify: -- Keys are encrypted before storage -- Raw key never exposed -- Default key management -- Signing with correct alias routing -""" - -import pytest - -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - DuplicateKeyAliasError, - KeyNotFoundError, - NoDefaultKeyError, -) -from omniclaw.protocols.nanopayments.keys import NanoKeyStore -from omniclaw.protocols.nanopayments.signing import EIP3009Signer, generate_eoa_keypair -from omniclaw.protocols.nanopayments.types import ( - PaymentRequirementsExtra, - PaymentRequirementsKind, -) -from omniclaw.protocols.nanopayments.vault import NanoKeyVault - - -# ============================================================================= -# MOCK STORAGE BACKEND -# ============================================================================= - - -class MockStorageBackend: - """In-memory mock for StorageBackend.""" - - def __init__(self) -> None: - self._data: dict[str, dict[str, dict]] = {} - - async def save( - self, - collection: str, - key: str, - data: dict, - ) -> None: - if collection not in self._data: - self._data[collection] = {} - self._data[collection][key] = data - - async def get( - self, - collection: str, - key: str, - ) -> dict | None: - return self._data.get(collection, {}).get(key) - - async def delete( - self, - collection: str, - key: str, - ) -> None: - if collection in self._data and key in self._data[collection]: - del self._data[collection][key] - - async def list_keys(self, collection: str) -> list[str]: - return list(self._data.get(collection, {}).keys()) - - -# ============================================================================= -# TEST FIXTURES -# ============================================================================= - - -@pytest.fixture -def store(): - """A NanoKeyStore for testing.""" - return NanoKeyStore(entity_secret="test-entity-secret-for-vault!") - - -@pytest.fixture -def storage(): - """A mock storage backend.""" - return MockStorageBackend() - - -@pytest.fixture -def vault(store, storage): - """A NanoKeyVault with mocked dependencies.""" - return NanoKeyVault( - entity_secret="test-entity-secret-for-vault!", - storage_backend=storage, - circle_api_key="test-api-key", - nanopayments_environment="testnet", - ) - - -@pytest.fixture -def buyer_keypair(): - return generate_eoa_keypair() - - -@pytest.fixture -def gateway_keypair(): - """Real gateway contract keypair for requirements.""" - return generate_eoa_keypair() - - -@pytest.fixture -def valid_requirements(vault, buyer_keypair, gateway_keypair): - return PaymentRequirementsKind( - scheme="exact", - network=vault.default_network, - asset="0xAbc1234567890aBcD1234567890aBcD12345678", # Fake USDC address - amount="1000000", - max_timeout_seconds=345600, - pay_to="0x" + "b" * 40, # Fake seller (doesn't need to be real) - extra=PaymentRequirementsExtra( - name="GatewayWalletBatched", - version="1", - verifying_contract=gateway_keypair[1], # Real checksummed address - ), - ) - - -# ============================================================================= -# ADD KEY TESTS -# ============================================================================= - - -class TestAddKey: - @pytest.mark.asyncio - async def test_add_key_stores_encrypted_blob(self, vault, storage, buyer_keypair): - private_key, address = buyer_keypair - stored_address = await vault.add_key("alice", private_key) - - assert stored_address == address - record = await storage.get("nano_keys", "alice") - assert record is not None - assert "encrypted_key" in record - assert record["address"] == address - # Raw key should NOT be stored - assert "private_key" not in record - assert private_key not in str(record) - - @pytest.mark.asyncio - async def test_add_key_without_0x_prefix(self, vault, storage): - """Should work with or without 0x prefix.""" - private_key, address = generate_eoa_keypair() - stored_address = await vault.add_key("bob", private_key) - assert stored_address == address - - @pytest.mark.asyncio - async def test_add_key_rejects_duplicate_alias(self, vault, buyer_keypair): - private_key, _ = buyer_keypair - await vault.add_key("alice", private_key) - with pytest.raises(DuplicateKeyAliasError) as exc_info: - await vault.add_key("alice", private_key) - assert exc_info.value.alias == "alice" - - -# ============================================================================= -# GENERATE KEY TESTS -# ============================================================================= - - -class TestGenerateKey: - @pytest.mark.asyncio - async def test_generate_key_stores_encrypted_blob(self, vault, storage): - address = await vault.generate_key("charlie") - - assert address.startswith("0x") - assert len(address) == 42 - record = await storage.get("nano_keys", "charlie") - assert record is not None - assert "encrypted_key" in record - assert record["address"] == address - - @pytest.mark.asyncio - async def test_generate_key_rejects_duplicate_alias(self, vault): - await vault.generate_key("dave") - with pytest.raises(DuplicateKeyAliasError): - await vault.generate_key("dave") - - @pytest.mark.asyncio - async def test_generated_key_can_sign(self, vault, valid_requirements): - address = await vault.generate_key("eve") - payload = await vault.sign( - requirements=valid_requirements, - amount_atomic=1000000, - alias="eve", - ) - assert payload.payload.authorization.from_address.lower() == address.lower() - assert payload.payload.authorization.value == "1000000" - - -# ============================================================================= -# DEFAULT KEY TESTS -# ============================================================================= - - -class TestDefaultKey: - @pytest.mark.asyncio - async def test_set_default_key(self, vault, buyer_keypair): - private_key, _ = buyer_keypair - await vault.add_key("alice", private_key) - await vault.set_default_key("alice") - - # Default alias is set correctly - assert vault._default_key_alias == "alice" - # Can get address without specifying alias - address = await vault.get_address(alias=None) - assert address.startswith("0x") - - @pytest.mark.asyncio - async def test_set_default_key_unknown_alias(self, vault): - with pytest.raises(KeyNotFoundError): - await vault.set_default_key("nonexistent") - - @pytest.mark.asyncio - async def test_sign_without_alias_uses_default(self, vault, buyer_keypair, valid_requirements): - private_key, _ = buyer_keypair - await vault.add_key("frank", private_key) - await vault.set_default_key("frank") - # Sign without specifying alias - payload = await vault.sign( - requirements=valid_requirements, - amount_atomic=1000000, - alias=None, - ) - assert payload is not None - - -# ============================================================================= -# GET ADDRESS TESTS -# ============================================================================= - - -class TestGetAddress: - @pytest.mark.asyncio - async def test_get_address_returns_stored_address(self, vault, buyer_keypair): - private_key, address = buyer_keypair - await vault.add_key("grace", private_key) - retrieved = await vault.get_address("grace") - assert retrieved == address - - @pytest.mark.asyncio - async def test_get_address_unknown_alias_raises(self, vault): - with pytest.raises(KeyNotFoundError) as exc_info: - await vault.get_address("nonexistent") - assert exc_info.value.alias == "nonexistent" - - @pytest.mark.asyncio - async def test_get_address_no_default_raises(self, vault): - with pytest.raises(NoDefaultKeyError): - await vault.get_address(alias=None) - - -# ============================================================================= -# HAS KEY TESTS -# ============================================================================= - - -class TestHasKey: - @pytest.mark.asyncio - async def test_has_key_returns_true_for_existing(self, vault, buyer_keypair): - private_key, _ = buyer_keypair - await vault.add_key("heidi", private_key) - assert await vault.has_key("heidi") is True - - @pytest.mark.asyncio - async def test_has_key_returns_false_for_nonexistent(self, vault): - assert await vault.has_key("nonexistent") is False - - @pytest.mark.asyncio - async def test_has_key_with_default(self, vault, buyer_keypair): - private_key, _ = buyer_keypair - await vault.add_key("ivan", private_key) - await vault.set_default_key("ivan") - assert await vault.has_key(alias=None) is True - - -# ============================================================================= -# SIGN TESTS (CORE SECURITY TEST) -# ============================================================================= - - -class TestSign: - @pytest.mark.asyncio - async def test_sign_returns_payment_payload(self, vault, buyer_keypair, valid_requirements): - private_key, _ = buyer_keypair - await vault.add_key("judith", private_key) - payload = await vault.sign( - requirements=valid_requirements, - amount_atomic=1000000, - alias="judith", - ) - assert payload.x402_version == 2 - assert payload.scheme == "exact" - assert payload.network == valid_requirements.network - assert payload.payload.signature.startswith("0x") - - @pytest.mark.asyncio - async def test_sign_uses_requirements_amount_when_not_specified( - self, vault, buyer_keypair, valid_requirements - ): - """If amount_atomic is None, should use requirements.amount.""" - private_key, _ = buyer_keypair - await vault.add_key("klaus", private_key) - payload = await vault.sign( - requirements=valid_requirements, - alias="klaus", - ) - assert payload.payload.authorization.value == "1000000" - - @pytest.mark.asyncio - async def test_sign_unknown_alias_raises(self, vault, valid_requirements): - with pytest.raises(KeyNotFoundError): - await vault.sign(requirements=valid_requirements, alias="nonexistent") - - @pytest.mark.asyncio - async def test_sign_no_default_raises(self, vault, valid_requirements): - with pytest.raises(NoDefaultKeyError): - await vault.sign(requirements=valid_requirements, alias=None) - - @pytest.mark.asyncio - async def test_sign_signature_is_valid(self, vault, buyer_keypair, valid_requirements): - """Verify the signature can be recovered with eth_account.""" - private_key, _ = buyer_keypair - await vault.add_key("laura", private_key) - payload = await vault.sign( - requirements=valid_requirements, - amount_atomic=500000, - alias="laura", - ) - # Verify locally - from omniclaw.protocols.nanopayments.signing import ( - build_eip712_domain, - build_eip712_structured_data, - ) - from eth_account.messages import encode_typed_data - from eth_account import Account - - chain_id = int(valid_requirements.network.split(":")[1]) - domain = build_eip712_domain( - chain_id=chain_id, - verifying_contract=valid_requirements.extra.verifying_contract, - ) - auth = payload.payload.authorization - message_dict = { - "from": auth.from_address, - "to": auth.to, - "value": int(auth.value), - "validAfter": int(auth.valid_after), - "validBefore": int(auth.valid_before), - "nonce": auth.nonce, - } - structured_data = build_eip712_structured_data(domain, message_dict) - signable = encode_typed_data(full_message=structured_data) - recovered = Account.recover_message(signable, signature=payload.payload.signature) - assert recovered.lower() == payload.payload.authorization.from_address.lower() - - @pytest.mark.asyncio - async def test_sign_network_mismatch_raises(self, vault, buyer_keypair, valid_requirements): - private_key, _ = buyer_keypair - await vault.add_key("mismatch", private_key, network="eip155:1") - with pytest.raises(ValueError, match="Network mismatch"): - await vault.sign( - requirements=valid_requirements, - amount_atomic=1000000, - alias="mismatch", - ) - - -# ============================================================================= -# RAW KEY SECURITY TESTS -# ============================================================================= - - -class TestRawKeySecurity: - @pytest.mark.asyncio - async def test_raw_key_never_in_storage(self, vault, storage, buyer_keypair): - """Encrypted blob in storage must NOT contain raw private key.""" - private_key, _ = buyer_keypair - await vault.add_key("mallory", private_key) - record = await storage.get("nano_keys", "mallory") - # Raw key hex characters must NOT appear in storage - raw_hex = private_key.lstrip("0x") - assert raw_hex not in str(record) - - @pytest.mark.asyncio - async def test_vault_sign_never_returns_raw_key(self, vault, buyer_keypair, valid_requirements): - """vault.sign() must return PaymentPayload, never a raw key.""" - private_key, _ = buyer_keypair - await vault.add_key("nancy", private_key) - payload = await vault.sign( - requirements=valid_requirements, - alias="nancy", - ) - # Must not contain raw key - raw_hex = private_key.lstrip("0x") - assert raw_hex not in payload.to_dict().__repr__() - # Must be a PaymentPayload - from omniclaw.protocols.nanopayments.types import PaymentPayload - - assert isinstance(payload, PaymentPayload) - - -# ============================================================================= -# KEY ROTATION TESTS -# ============================================================================= - - -class TestRotateKey: - @pytest.mark.asyncio - async def test_rotate_key_returns_new_address(self, vault, buyer_keypair): - """Rotated key must have a different address.""" - private_key, _ = buyer_keypair - await vault.add_key("olivia", private_key) - original_address = await vault.get_address("olivia") - new_address = await vault.rotate_key("olivia") - assert new_address != original_address - assert new_address.startswith("0x") - - @pytest.mark.asyncio - async def test_rotate_key_unknown_alias_raises(self, vault): - with pytest.raises(KeyNotFoundError): - await vault.rotate_key("nonexistent") - - @pytest.mark.asyncio - async def test_new_key_can_sign_after_rotation(self, vault, buyer_keypair, valid_requirements): - private_key, _ = buyer_keypair - await vault.add_key("peter", private_key) - new_address = await vault.rotate_key("peter") - # Sign with new key - payload = await vault.sign( - requirements=valid_requirements, - amount_atomic=500000, - alias="peter", - ) - assert payload.payload.authorization.from_address.lower() == new_address.lower() diff --git a/tests/test_nanopayments_vault_coverage.py b/tests/test_nanopayments_vault_coverage.py deleted file mode 100644 index ead4b54..0000000 --- a/tests/test_nanopayments_vault_coverage.py +++ /dev/null @@ -1,509 +0,0 @@ -""" -Additional tests for NanoKeyVault to cover uncovered lines. - -Covers: -- default_network property (lines 96-99) -- environment property (lines 101-104) -- get_network() (lines 226-248) -- update_key_network() (lines 250-268) -- list_keys() (lines 297-310) -- get_balance() (lines 429-445) -- get_raw_key() (lines 447-474) -- create_wallet_manager() (lines 480-543) -""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - InvalidPrivateKeyError, - KeyNotFoundError, - NoDefaultKeyError, -) -from omniclaw.protocols.nanopayments.keys import NanoKeyStore -from omniclaw.protocols.nanopayments.vault import NanoKeyVault - - -# ============================================================================= -# MOCK STORAGE BACKEND -# ============================================================================= - - -class MockStorageBackend: - """In-memory mock for StorageBackend.""" - - def __init__(self) -> None: - self._data: dict[str, dict[str, dict]] = {} - - async def save( - self, - collection: str, - key: str, - data: dict, - ) -> None: - if collection not in self._data: - self._data[collection] = {} - self._data[collection][key] = data - - async def get( - self, - collection: str, - key: str, - ) -> dict | None: - return self._data.get(collection, {}).get(key) - - async def delete( - self, - collection: str, - key: str, - ) -> None: - if collection in self._data and key in self._data[collection]: - del self._data[collection][key] - - async def query( - self, - collection: str, - limit: int = 100, - ) -> list[dict]: - """Query returns list of records for list_keys().""" - return list(self._data.get(collection, {}).values()) - - async def list_keys(self, collection: str) -> list[str]: - return list(self._data.get(collection, {}).keys()) - - -# ============================================================================= -# HARDCODED VALID KEYS (avoid session scope issues with generate_eoa_keypair) -# ============================================================================= - - -PRIVATE_KEY = "0x250716a653d2155d15bfb1e1ded08b6764937ca6ab3cdd7e2f0510c975fb5652" -ADDRESS = "0xb9Ee214552fF51AB41955b3DAfD7A340b5459629" - - -# ============================================================================= -# TEST FIXTURES -# ============================================================================= - - -@pytest.fixture -def store(): - """A NanoKeyStore for testing.""" - return NanoKeyStore(entity_secret="test-entity-secret-for-vault!") - - -@pytest.fixture -def storage(): - """A mock storage backend.""" - return MockStorageBackend() - - -@pytest.fixture -def vault(store, storage): - """A NanoKeyVault with mocked dependencies.""" - return NanoKeyVault( - entity_secret="test-entity-secret-for-vault!", - storage_backend=storage, - circle_api_key="test-api-key", - nanopayments_environment="testnet", - ) - - -@pytest.fixture -def vault_mainnet(store, storage): - """A NanoKeyVault for mainnet environment.""" - return NanoKeyVault( - entity_secret="test-entity-secret-for-vault!", - storage_backend=storage, - circle_api_key="test-api-key", - nanopayments_environment="mainnet", - ) - - -@pytest.fixture -def vault_with_explicit_network(store, storage): - """A NanoKeyVault with explicit default network.""" - return NanoKeyVault( - entity_secret="test-entity-secret-for-vault!", - storage_backend=storage, - circle_api_key="test-api-key", - nanopayments_environment="testnet", - default_network="eip155:421614", # Arbitrum testnet - ) - - -# ============================================================================= -# DEFAULT_NETWORK PROPERTY TESTS -# ============================================================================= - - -class TestDefaultNetworkProperty: - def test_default_network_returns_set_value(self, vault_with_explicit_network): - """Lines 96-99: default_network property returns _default_network.""" - assert vault_with_explicit_network.default_network == "eip155:421614" - - def test_default_network_from_environment_testnet(self, vault): - """Default network is determined from environment.""" - assert vault.default_network == "eip155:5042002" # Arc testnet - - def test_default_network_from_environment_mainnet(self, vault_mainnet): - """Mainnet environment gives Ethereum mainnet.""" - assert vault_mainnet.default_network == "eip155:1" - - -# ============================================================================= -# ENVIRONMENT PROPERTY TESTS -# ============================================================================= - - -class TestEnvironmentProperty: - def test_environment_returns_set_value(self, vault): - """Lines 101-104: environment property returns _environment.""" - assert vault.environment == "testnet" - - def test_environment_mainnet(self, vault_mainnet): - assert vault_mainnet.environment == "mainnet" - - -# ============================================================================= -# GET_NETWORK TESTS -# ============================================================================= - - -class TestGetNetwork: - @pytest.mark.asyncio - async def test_get_network_returns_stored_network(self, vault, storage): - """Lines 226-248: get_network returns stored network.""" - await storage.save( - "nano_keys", - "test-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": "eip155:421614", - }, - ) - network = await vault.get_network("test-key") - assert network == "eip155:421614" - - @pytest.mark.asyncio - async def test_get_network_falls_back_to_default(self, vault, storage): - """If record has no network, falls back to default.""" - await storage.save( - "nano_keys", - "test-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": None, - }, - ) - network = await vault.get_network("test-key") - assert network == vault.default_network - - @pytest.mark.asyncio - async def test_get_network_with_alias_none_uses_default(self, vault, storage): - """Lines 239-241: alias=None uses default key.""" - await storage.save( - "nano_keys", - "default-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": "eip155:421614", - }, - ) - vault._default_key_alias = "default-key" - network = await vault.get_network(alias=None) - assert network == "eip155:421614" - - @pytest.mark.asyncio - async def test_get_network_no_default_raises(self, vault): - """Lines 240-241: No default key raises NoDefaultKeyError.""" - vault._default_key_alias = None - with pytest.raises(NoDefaultKeyError): - await vault.get_network(alias=None) - - @pytest.mark.asyncio - async def test_get_network_key_not_found(self, vault, storage): - """Lines 243-245: Key doesn't exist raises KeyNotFoundError.""" - with pytest.raises(KeyNotFoundError) as exc_info: - await vault.get_network("nonexistent") - assert exc_info.value.alias == "nonexistent" - - -# ============================================================================= -# UPDATE_KEY_NETWORK TESTS -# ============================================================================= - - -class TestUpdateKeyNetwork: - @pytest.mark.asyncio - async def test_update_key_network_success(self, vault, storage): - """Lines 250-268: update_key_network updates storage.""" - await storage.save( - "nano_keys", - "test-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": "eip155:421614", - }, - ) - await vault.update_key_network("test-key", "eip155:1") - - record = await storage.get("nano_keys", "test-key") - assert record["network"] == "eip155:1" - - @pytest.mark.asyncio - async def test_update_key_network_not_found(self, vault): - """Lines 261-263: Key doesn't exist raises KeyNotFoundError.""" - with pytest.raises(KeyNotFoundError) as exc_info: - await vault.update_key_network("nonexistent", "eip155:1") - assert exc_info.value.alias == "nonexistent" - - -# ============================================================================= -# LIST_KEYS TESTS -# ============================================================================= - - -class TestListKeys: - @pytest.mark.asyncio - async def test_list_keys_returns_aliases(self, vault, storage): - """Lines 297-310: list_keys queries storage and returns aliases.""" - # Storage query returns records with "key" field - await storage.save("nano_keys", "key1", {"key": "alias1", "address": ADDRESS}) - await storage.save("nano_keys", "key2", {"key": "alias2", "address": ADDRESS}) - - aliases = await vault.list_keys() - assert "alias1" in aliases - assert "alias2" in aliases - - @pytest.mark.asyncio - async def test_list_keys_returns_alias_from_record(self, vault, storage): - """Uses record.get('key') or record.get('alias').""" - await storage.save("nano_keys", "entry1", {"alias": "my-alias", "address": ADDRESS}) - - aliases = await vault.list_keys() - assert "my-alias" in aliases - - @pytest.mark.asyncio - async def test_list_keys_empty_returns_empty_list(self, vault, storage): - """No keys returns empty list.""" - aliases = await vault.list_keys() - assert aliases == [] - - -# ============================================================================= -# GET_BALANCE TESTS -# ============================================================================= - - -class TestGetBalance: - @pytest.mark.asyncio - async def test_get_balance_returns_balance(self, vault, storage): - """Lines 429-445: get_balance calls get_address, get_network, client.check_balance.""" - await storage.save( - "nano_keys", - "test-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": "eip155:5042002", - }, - ) - - # Mock client.check_balance - mock_balance = MagicMock() - mock_balance.total = 5_000_000 - mock_balance.available = 5_000_000 - mock_balance.formatted_total = "5.000000 USDC" - mock_balance.formatted_available = "5.000000 USDC" - mock_balance.available_decimal = "5.000000" - - vault._client.check_balance = AsyncMock(return_value=mock_balance) - - balance = await vault.get_balance("test-key") - - assert balance.total == 5_000_000 - assert balance.available == 5_000_000 - - @pytest.mark.asyncio - async def test_get_balance_uses_default_key(self, vault, storage): - """Uses default key when alias is None.""" - await storage.save( - "nano_keys", - "default-key", - { - "encrypted_key": "encrypted", - "address": ADDRESS, - "network": "eip155:5042002", - }, - ) - vault._default_key_alias = "default-key" - - mock_balance = MagicMock() - mock_balance.total = 1_000_000 - mock_balance.available = 1_000_000 - mock_balance.formatted_total = "1.000000 USDC" - mock_balance.formatted_available = "1.000000 USDC" - mock_balance.available_decimal = "1.000000" - - vault._client.check_balance = AsyncMock(return_value=mock_balance) - - balance = await vault.get_balance(alias=None) - assert balance.total == 1_000_000 - - -# ============================================================================= -# GET_RAW_KEY TESTS -# ============================================================================= - - -class TestGetRawKey: - @pytest.mark.asyncio - async def test_get_raw_key_decrypts_and_returns(self, vault, storage): - """Lines 447-474: get_raw_key decrypts and returns raw key.""" - # Add a key using the vault - stored_address = await vault.add_key("test-key", PRIVATE_KEY) - assert stored_address == ADDRESS - - # Get the raw key - raw_key = await vault.get_raw_key("test-key") - assert raw_key == PRIVATE_KEY - - @pytest.mark.asyncio - async def test_get_raw_key_no_default_raises(self, vault): - """Lines 465-467: No default key raises NoDefaultKeyError.""" - vault._default_key_alias = None - with pytest.raises(NoDefaultKeyError): - await vault.get_raw_key(alias=None) - - @pytest.mark.asyncio - async def test_get_raw_key_not_found_raises(self, vault, storage): - """Lines 469-471: Key not found raises KeyNotFoundError.""" - with pytest.raises(KeyNotFoundError) as exc_info: - await vault.get_raw_key("nonexistent") - assert exc_info.value.alias == "nonexistent" - - -# ============================================================================= -# CREATE_WALLET_MANAGER TESTS -# ============================================================================= - - -class TestCreateWalletManager: - @pytest.mark.asyncio - async def test_create_wallet_manager_requires_rpc_url(self, vault, storage): - """Lines 527-531: Raises ValueError when rpc_url is None and no env vars.""" - # Add a real key so get_raw_key can decrypt it - await vault.add_key("test-key", PRIVATE_KEY) - - # No RPC URL and no env vars set - with pytest.raises(ValueError) as exc_info: - await vault.create_wallet_manager("test-key", rpc_url=None) - - assert "rpc_url is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_create_wallet_manager_with_rpc_url(self, vault, storage): - """Lines 535-543: With explicit rpc_url, creates manager.""" - # Add a real key so get_raw_key can decrypt it - await vault.add_key("test-key", PRIVATE_KEY) - - with patch("omniclaw.protocols.nanopayments.wallet.GatewayWalletManager") as MockManager: - MockManager.return_value = MagicMock() - - manager = await vault.create_wallet_manager( - alias="test-key", - rpc_url="https://rpc.example.com", - ) - - MockManager.assert_called_once() - call_kwargs = MockManager.call_args.kwargs - assert call_kwargs["rpc_url"] == "https://rpc.example.com" - assert call_kwargs["network"] == vault.default_network - - @pytest.mark.asyncio - async def test_create_wallet_manager_uses_env_rpc(self, vault, storage): - """Lines 520-525: Uses RPC_URL from environment.""" - # Add a real key so get_raw_key can decrypt it - await vault.add_key("test-key", PRIVATE_KEY) - - with patch.dict("os.environ", {"RPC_URL": "https://env-rpc.example.com"}): - with patch( - "omniclaw.protocols.nanopayments.wallet.GatewayWalletManager" - ) as MockManager: - MockManager.return_value = MagicMock() - - manager = await vault.create_wallet_manager( - alias="test-key", - rpc_url=None, # Not provided - ) - - call_kwargs = MockManager.call_args.kwargs - assert call_kwargs["rpc_url"] == "https://env-rpc.example.com" - - @pytest.mark.asyncio - async def test_create_wallet_manager_uses_network_specific_env(self, vault, storage): - """Lines 520-522: Uses network-specific RPC_URL_EIP155_X env var.""" - # Add a real key so get_raw_key can decrypt it - await vault.add_key("test-key", PRIVATE_KEY) - - # The default network for testnet is eip155:5042002 - with patch.dict( - "os.environ", {"RPC_URL_EIP155_5042002": "https://network-rpc.example.com"} - ): - with patch( - "omniclaw.protocols.nanopayments.wallet.GatewayWalletManager" - ) as MockManager: - MockManager.return_value = MagicMock() - - manager = await vault.create_wallet_manager( - alias="test-key", - rpc_url=None, - ) - - call_kwargs = MockManager.call_args.kwargs - assert call_kwargs["rpc_url"] == "https://network-rpc.example.com" - - @pytest.mark.asyncio - async def test_create_wallet_manager_with_explicit_params(self, vault, storage): - """Passes through gateway_address, usdc_address, cctp_gateway_address.""" - # Add a real key so get_raw_key can decrypt it - await vault.add_key("test-key", PRIVATE_KEY) - - with patch("omniclaw.protocols.nanopayments.wallet.GatewayWalletManager") as MockManager: - MockManager.return_value = MagicMock() - - manager = await vault.create_wallet_manager( - alias="test-key", - rpc_url="https://rpc.example.com", - gateway_address="0xGateway123", - usdc_address="0xUSDC456", - cctp_gateway_address="0xCCTP789", - ) - - call_kwargs = MockManager.call_args.kwargs - assert call_kwargs["gateway_address"] == "0xGateway123" - assert call_kwargs["usdc_address"] == "0xUSDC456" - assert call_kwargs["cctp_gateway_address"] == "0xCCTP789" - - -# ============================================================================= -# ADD KEY ERROR HANDLING (line 140-143) -# ============================================================================= - - -class TestAddKeyErrorHandling: - """Test add_key error paths (lines 140-143).""" - - @pytest.mark.asyncio - async def test_add_key_invalid_hex_raises(self, vault): - """Line 140-143: Invalid private key hex raises InvalidPrivateKeyError.""" - with pytest.raises(InvalidPrivateKeyError): - await vault.add_key( - "bad-key", "0xZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ" - ) diff --git a/tests/test_nanopayments_wallet.py b/tests/test_nanopayments_wallet.py deleted file mode 100644 index dd87f95..0000000 --- a/tests/test_nanopayments_wallet.py +++ /dev/null @@ -1,935 +0,0 @@ -""" -Tests for GatewayWalletManager (Phase 5: on-chain operations). - -Tests verify: -- Deposit: approval (if needed) + deposit transaction -- Withdraw: withdrawal transaction -- Balance queries via NanopaymentClient -- Error handling for failed transactions - -All web3 calls are mocked. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from omniclaw.protocols.nanopayments import GatewayWalletManager -from omniclaw.protocols.nanopayments.client import NanopaymentClient -from omniclaw.protocols.nanopayments.exceptions import ( - DepositError, - ERC20ApprovalError, - WithdrawError, -) -from omniclaw.protocols.nanopayments.signing import generate_eoa_keypair - - -# ============================================================================= -# TEST HELPERS -# ============================================================================= - - -def _mock_web3(address: str, chain_id: int = 5042002): - """Create a mock web3 instance.""" - mock_w3 = MagicMock() - mock_w3.eth.get_transaction_count.return_value = 42 - mock_w3.eth.send_raw_transaction.return_value = b"\x01" * 32 - mock_w3.eth.wait_for_transaction_receipt.return_value = { - "status": 1, - "transactionHash": b"\x02" * 32, - } - # Gas reserve check mocks - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH in wei - mock_w3.eth.gas_price = MagicMock(return_value=30_000_000_000) # 30 gwei - mock_w3.from_wei = lambda v, unit: v / 1e18 if unit == "ether" else v - mock_account = MagicMock() - mock_account.sign_transaction.return_value = MagicMock(raw_transaction=b"\x03" * 32) - mock_w3.eth.account = mock_account - return mock_w3 - - -def _make_client_mock( - gateway_addr: str = "0x" + "c" * 40, - usdc_addr: str = "0x" + "d" * 40, -) -> MagicMock: - """Mock NanopaymentClient.""" - from omniclaw.protocols.nanopayments.types import GatewayBalance, SettleResponse - - mock = MagicMock(spec=NanopaymentClient) - mock.get_verifying_contract = AsyncMock(return_value=gateway_addr) - mock.get_usdc_address = AsyncMock(return_value=usdc_addr) - mock.check_balance = AsyncMock( - return_value=GatewayBalance( - total=5000000, - available=4500000, - formatted_total="5.000000 USDC", - formatted_available="4.500000 USDC", - ) - ) - mock.settle = AsyncMock( - return_value=SettleResponse( - success=True, - transaction="batch_tx_123", - payer="0x" + "1" * 40, - error_reason=None, - ) - ) - return mock - - -# ============================================================================= -# INIT TESTS -# ============================================================================= - - -class TestGatewayWalletManagerInit: - def test_derives_address_from_key(self): - private_key, expected_address = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = _mock_web3(expected_address) - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - assert manager.address.lower() == expected_address.lower() - - def test_stores_network(self): - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = _mock_web3("") - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:1", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - assert manager._network == "eip155:1" - - def test_uses_provided_gateway_address(self): - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = _mock_web3("") - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - gateway_address="0xPreSetGateway000000000000000001", - ) - - assert manager._gateway_address == "0xPreSetGateway000000000000000001" - - -# ============================================================================= -# DEPOSIT TESTS -# ============================================================================= - - -class TestDeposit: - @pytest.mark.asyncio - async def test_deposit_approval_not_needed(self): - """If allowance is sufficient, skip approval.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - mock_w3.eth.contract = MagicMock() - - # Mock contract: allowance returns large value - mock_contract = MagicMock() - mock_contract.functions.allowance.return_value.call.return_value = 100_000_000 - mock_contract.encode_abi.return_value = "0x" - mock_w3.eth.contract.return_value = mock_contract - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - result = await manager.deposit("10.00") - - assert result.approval_tx_hash is None - assert result.deposit_tx_hash is not None - assert result.amount == 10_000_000 - assert "10.00 USDC" in result.formatted_amount - - @pytest.mark.asyncio - async def test_deposit_approval_needed(self): - """If allowance is insufficient, approve first.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - mock_w3.eth.contract = MagicMock() - - mock_contract = MagicMock() - # Allowance is 0 (insufficient) - mock_contract.functions.allowance.return_value.call.return_value = 0 - mock_contract.encode_abi.return_value = "0xabcd" - mock_w3.eth.contract.return_value = mock_contract - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - result = await manager.deposit("5.00") - - assert result.approval_tx_hash is not None - assert result.deposit_tx_hash is not None - assert result.amount == 5_000_000 - - @pytest.mark.asyncio - async def test_deposit_transaction_failure(self): - """Transaction failure raises DepositError.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - mock_w3.eth.contract = MagicMock() - mock_w3.eth.wait_for_transaction_receipt.return_value = {"status": 0} - - mock_contract = MagicMock() - mock_contract.functions.allowance.return_value.call.return_value = 100_000_000 - mock_contract.encode_abi.return_value = "0x" - mock_w3.eth.contract.return_value = mock_contract - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - with pytest.raises(DepositError): - await manager.deposit("10.00") - - @pytest.mark.asyncio - async def test_deposit_catches_other_exceptions(self): - """Non-transaction exceptions are wrapped as DepositError.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - mock_w3.eth.contract.side_effect = RuntimeError("RPC error") - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - with pytest.raises(DepositError) as exc_info: - await manager.deposit("10.00") - assert "RPC error" in str(exc_info.value) - - -# ============================================================================= -# WITHDRAW TESTS -# ============================================================================= - - -class TestWithdraw: - @pytest.mark.asyncio - async def test_withdraw_same_chain(self): - """Same-chain withdrawal delegates to Gateway settle flow.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - mock_w3.eth.contract = MagicMock() - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - result = await manager.withdraw("2.50", recipient="0x" + "b" * 40) - assert result.amount == 2_500_000 - assert result.destination_chain == "eip155:5042002" - assert result.mint_tx_hash == "batch_tx_123" - - @pytest.mark.asyncio - async def test_withdraw_cross_chain(self): - """Cross-chain withdrawal delegates to Gateway settle flow.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - mock_w3 = _mock_web3("") - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - result = await manager.withdraw( - "1.00", - destination_chain="eip155:1", - recipient="0x" + "c" * 40, - ) - assert result.amount == 1_000_000 - assert result.destination_chain == "eip155:1" - assert result.recipient == "0x" + "c" * 40 - - @pytest.mark.asyncio - async def test_withdraw_same_chain_returns_result(self): - """Same-chain withdraw returns a structured result.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - result = await manager.withdraw( - "1.00", - destination_chain="eip155:5042002", # Same chain - recipient="0x" + "a" * 40, - ) - assert result.amount == 1_000_000 - assert result.destination_chain == "eip155:5042002" - assert result.recipient == "0x" + "a" * 40 - - -# ============================================================================= -# BALANCE TESTS -# ============================================================================= - - -class TestGetBalance: - @pytest.mark.asyncio - async def test_get_balance_delegates_to_client(self): - """get_balance should delegate to NanopaymentClient.check_balance.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - MockWeb3.return_value = _mock_web3("") - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - balance = await manager.get_balance() - - client.check_balance.assert_called_once_with( - address=manager.address, - network="eip155:5042002", - ) - assert balance.total == 5_000_000 - assert balance.available == 4_500_000 - assert balance.total_decimal == "5.000000" - assert balance.available_decimal == "4.500000" - - -# ============================================================================= -# GAS RESERVE TESTS -# ============================================================================= - - -class TestGasReserve: - """Tests for gas reserve checking and management.""" - - @pytest.mark.asyncio - async def test_get_gas_balance_wei(self): - """get_gas_balance_wei returns ETH balance in wei.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = _mock_web3("") - mock_w3.eth.get_balance.return_value = 5_000_000_000_000_000_000 # 5 ETH - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - balance = manager.get_gas_balance_wei() - - assert balance == 5_000_000_000_000_000_000 - - @pytest.mark.asyncio - async def test_check_gas_reserve_sufficient(self): - """check_gas_reserve returns True when ETH balance is sufficient.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = _mock_web3("") - # 1 ETH = enough for ~5 deposits at 0.2M gas * 30 gwei = 0.006 ETH per deposit - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH - mock_w3.eth.gas_price = 30_000_000_000 # 30 gwei (direct value) - mock_w3.from_wei = lambda v, unit: v / 1e18 if unit == "ether" else v - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - has_sufficient, message = manager.check_gas_reserve() - - assert has_sufficient is True - assert "ETH balance:" in message - - @pytest.mark.asyncio - async def test_check_gas_reserve_insufficient(self): - """check_gas_reserve returns False when ETH balance is too low.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - # Very low ETH balance - below required reserve - # gas_cost = 30e9 * 200000 * 1.2 = 7.2e15 wei = 0.0072 ETH - # required = 2 * 0.0072 = 0.0144 ETH - # balance = 0.001 ETH = 1e15 wei < 1.44e16 wei (required) - mock_w3.eth.get_balance.return_value = 1_000_000_000_000_000 # 0.001 ETH - mock_w3.eth.gas_price = 30_000_000_000 # 30 gwei (attribute) - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - - # Use a real conversion function - def from_wei(value, unit): - if unit == "ether": - return value / 10**18 - return value - - mock_w3.from_wei = from_wei - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - has_sufficient, message = manager.check_gas_reserve() - - assert has_sufficient is False - assert "ETH balance:" in message - - @pytest.mark.asyncio - async def test_deposit_skips_gas_check_when_check_gas_false(self): - """deposit() with check_gas=False skips gas reserve check.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = _mock_web3("") - MockWeb3.return_value = mock_w3 - mock_w3.eth.contract = MagicMock() - - mock_contract = MagicMock() - mock_contract.functions.allowance.return_value.call.return_value = 100_000_000 - mock_contract.encode_abi.return_value = "0x" - mock_w3.eth.contract.return_value = mock_contract - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - # Should NOT raise even without gas balance mock - result = await manager.deposit("10.00", check_gas=False) - - assert result.amount == 10_000_000 - - @pytest.mark.asyncio - async def test_ensure_gas_reserve_raises_on_insufficient(self): - """ensure_gas_reserve raises InsufficientGasError when gas is low.""" - from omniclaw.protocols.nanopayments.exceptions import InsufficientGasError - - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - # Very low ETH balance - mock_w3.eth.get_balance.return_value = 1_000_000_000_000_000 # 0.001 ETH - mock_w3.eth.gas_price = 30_000_000_000 # 30 gwei (attribute) - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - - def from_wei(value, unit): - if unit == "ether": - return value / 10**18 - return value - - mock_w3.from_wei = from_wei - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - with pytest.raises(InsufficientGasError): - manager.ensure_gas_reserve() - - @pytest.mark.asyncio - async def test_deposit_skip_if_insufficient_gas(self): - """deposit() with skip_if_insufficient_gas=True returns empty result.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - # Very low ETH balance - mock_w3.eth.get_balance.return_value = 1_000_000_000_000_000 # 0.001 ETH - mock_w3.eth.gas_price = 30_000_000_000 # 30 gwei (attribute) - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.send_raw_transaction.return_value = b"\x01" * 32 - mock_w3.eth.wait_for_transaction_receipt.return_value = { - "status": 1, - "transactionHash": b"\x02" * 32, - } - mock_w3.eth.account = MagicMock() - mock_w3.eth.account.sign_transaction.return_value = MagicMock( - raw_transaction=b"\x03" * 32 - ) - - def from_wei(value, unit): - if unit == "ether": - return value / 10**18 - return value - - mock_w3.from_wei = from_wei - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - result = await manager.deposit( - "10.00", - skip_if_insufficient_gas=True, - ) - - assert result.approval_tx_hash is None - assert result.deposit_tx_hash is None - - -# ============================================================================= -# TRUSTLESS WITHDRAWAL TESTS -# ============================================================================= - - -class TestTrustlessWithdrawal: - """Tests for emergency trustless withdrawal (on-chain, 7-day delay).""" - - @pytest.mark.asyncio - async def test_initiate_trustless_withdrawal(self): - """initiate_trustless_withdrawal() initiates on-chain withdrawal.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_transaction_count.return_value = 10 - mock_w3.eth.send_raw_transaction.return_value = b"\x01" * 32 - mock_w3.eth.wait_for_transaction_receipt.return_value = { - "status": 1, - "transactionHash": b"\x02" * 32, - } - mock_w3.eth.account = MagicMock() - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - # Mock gateway contract - mock_gateway = MagicMock() - mock_gateway.functions.availableBalance.return_value.call.return_value = 5_000_000 - mock_gateway.functions.withdrawalDelay.return_value.call.return_value = 6300 - mock_gateway.functions.initiateWithdrawal.return_value = MagicMock() - mock_gateway.functions.withdraw.return_value = MagicMock() - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - tx_hash = await manager.initiate_trustless_withdrawal("5.00") - assert tx_hash is not None - assert len(tx_hash) > 0 - - @pytest.mark.asyncio - async def test_initiate_trustless_withdrawal_insufficient_balance(self): - """Insufficient balance raises WithdrawError.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.availableBalance.return_value.call.return_value = ( - 100_000 # Only 0.1 USDC - ) - mock_gateway.functions.withdrawalDelay.return_value.call.return_value = 6300 - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - with pytest.raises(WithdrawError): - await manager.initiate_trustless_withdrawal("100.00") - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_ready(self): - """complete_trustless_withdrawal() succeeds when delay passed.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_transaction_count.return_value = 10 - mock_w3.eth.send_raw_transaction.return_value = b"\x03" * 32 - mock_w3.eth.wait_for_transaction_receipt.return_value = { - "status": 1, - "transactionHash": b"\x04" * 32, - } - mock_w3.eth.account = MagicMock() - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.block_number = 70000 - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.withdrawalBlock.return_value.call.return_value = 69000 - mock_gateway.functions.withdraw.return_value = MagicMock() - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - tx_hash = await manager.complete_trustless_withdrawal() - assert tx_hash is not None - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_not_ready(self): - """complete_trustless_withdrawal() raises when delay not passed.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.block_number = 69500 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.withdrawalBlock.return_value.call.return_value = 69000 - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - with pytest.raises(WithdrawError): - await manager.complete_trustless_withdrawal() - - @pytest.mark.asyncio - async def test_complete_trustless_withdrawal_no_initiation(self): - """complete_trustless_withdrawal() raises when no withdrawal initiated.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.block_number = 70000 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.withdrawalBlock.return_value.call.return_value = ( - 0 # No withdrawal - ) - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - with pytest.raises(WithdrawError): - await manager.complete_trustless_withdrawal() - - -# ============================================================================= -# TRANSFER AND BALANCE TESTS (Additional Coverage) -# ============================================================================= - - -class TestTransferMethods: - """Tests for API-based transfer methods.""" - - @pytest.mark.asyncio - async def test_transfer_to_address_same_chain(self): - """transfer_to_address() executes Gateway settlement transfer.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = _mock_web3("") - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - result = await manager.transfer_to_address( - "10.00", - recipient_address="0x" + "a" * 40, - ) - assert result.amount == 10_000_000 - assert result.destination_chain == "eip155:5042002" - - @pytest.mark.asyncio - async def test_transfer_crosschain(self): - """transfer_crosschain() executes Gateway settlement transfer.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = _mock_web3("") - MockWeb3.return_value = mock_w3 - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - result = await manager.transfer_crosschain( - "5.00", - destination_chain="eip155:8453", # Base - recipient_address="0x" + "b" * 40, - ) - assert result.amount == 5_000_000 - assert result.destination_chain == "eip155:8453" - - @pytest.mark.asyncio - async def test_get_onchain_balance(self): - """get_onchain_balance() returns USDC balance in wallet.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_usdc = MagicMock() - mock_usdc.functions.balanceOf.return_value.call.return_value = 1_000_000 - mock_w3.eth.contract.return_value = mock_usdc - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - balance = await manager.get_onchain_balance() - assert balance == 1_000_000 - - @pytest.mark.asyncio - async def test_get_gateway_available_balance(self): - """get_gateway_available_balance() queries contract.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.availableBalance.return_value.call.return_value = 2_000_000 - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - balance = await manager.get_gateway_available_balance() - assert balance == 2_000_000 - - def test_get_gas_balance_eth(self): - """get_gas_balance_eth() returns ETH balance as decimal string.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - balance = manager.get_gas_balance_eth() - assert "1" in balance - - def test_estimate_gas_cost_eth(self): - """estimate_gas_cost_eth() returns ETH cost as decimal string.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - cost = manager.estimate_gas_cost_eth() - assert cost is not None - # Should be > 0 - cost_float = float(cost) - assert cost_float > 0 - - def test_has_sufficient_gas_for_deposit(self): - """has_sufficient_gas_for_deposit() returns bool.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 # 1 ETH - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - assert manager.has_sufficient_gas_for_deposit() is True - - @pytest.mark.asyncio - async def test_get_withdrawal_delay(self): - """get_withdrawal_delay() returns delay in blocks.""" - private_key, _ = generate_eoa_keypair() - client = _make_client_mock() - - with patch("web3.Web3") as MockWeb3: - mock_w3 = MagicMock() - MockWeb3.return_value = mock_w3 - mock_w3.eth.get_balance.return_value = 10**18 - mock_w3.eth.gas_price.return_value = 30_000_000_000 - mock_w3.eth.get_transaction_count.return_value = 0 - mock_w3.eth.account = MagicMock() - mock_w3.from_wei = lambda v, unit: v / 10**18 if unit == "ether" else v - - mock_gateway = MagicMock() - mock_gateway.functions.withdrawalDelay.return_value.call.return_value = 6300 - mock_w3.eth.contract.return_value = mock_gateway - - manager = GatewayWalletManager( - private_key=private_key, - network="eip155:5042002", - rpc_url="https://rpc.example.com", - nanopayment_client=client, - ) - - delay = await manager.get_withdrawal_delay() - assert delay == 6300 diff --git a/tests/test_payment_failures.py b/tests/test_payment_failures.py index 20ef343..b54c45d 100644 --- a/tests/test_payment_failures.py +++ b/tests/test_payment_failures.py @@ -12,8 +12,8 @@ from omniclaw.core.types import ( Network, PaymentResult, - PaymentStrategy, PaymentStatus, + PaymentStrategy, ) from omniclaw.payment.batch import BatchProcessor from omniclaw.payment.router import PaymentRouter @@ -41,7 +41,7 @@ def client_mocked(): client._wallet_service.get_usdc_balance.return_value = balance client._wallet_service.get_usdc_balance.return_value = balance client._wallet_service.get_usdc_balance_amount.return_value = Decimal("1000000.00") - + # Configure get_wallet mock to return an object with a valid blockchain string mock_wallet = MagicMock() mock_wallet.blockchain = "ARC-TESTNET" diff --git a/tests/test_payment_intents.py b/tests/test_payment_intents.py index 5fe1224..bc01d76 100644 --- a/tests/test_payment_intents.py +++ b/tests/test_payment_intents.py @@ -7,7 +7,7 @@ import pytest from omniclaw.client import OmniClaw -from omniclaw.core.exceptions import InsufficientBalanceError, PaymentError, ValidationError +from omniclaw.core.exceptions import InsufficientBalanceError, ValidationError from omniclaw.core.types import Network, PaymentIntentStatus, PaymentMethod, PaymentResult diff --git a/tests/test_payment_router.py b/tests/test_payment_router.py index 363b78a..522d6fb 100644 --- a/tests/test_payment_router.py +++ b/tests/test_payment_router.py @@ -35,6 +35,7 @@ def mock_config() -> Config: def mock_wallet_service() -> MagicMock: """Create mock wallet service.""" from unittest.mock import AsyncMock + service = MagicMock() service.transfer = AsyncMock() service.execute_contract = AsyncMock() diff --git a/tests/test_real_sdk_code.py b/tests/test_real_sdk_code.py index 382cebb..bea66fa 100644 --- a/tests/test_real_sdk_code.py +++ b/tests/test_real_sdk_code.py @@ -13,14 +13,10 @@ pytest tests/test_real_sdk_code.py -v -s """ -import asyncio import json -import pytest from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx +import pytest # ============================================================================= # TEST REAL PAYMENT ROUTER @@ -290,7 +286,7 @@ def test_ledger_update_status(self): entry["status"] = "completed" entry["tx_hash"] = "0xabc123" - print(f" Before: pending") + print(" Before: pending") print(f" After: {entry['status']}") assert entry["status"] == "completed" @@ -392,9 +388,9 @@ async def release(self, wallet_id): result3 = await lock.acquire("w1", Decimal("75")) print(f" After release: {'βœ“ SUCCESS' if result3 else 'βœ— FAILED'}") - assert result1 == True - assert result2 == False - assert result3 == True + assert result1 + assert not result2 + assert result3 # ============================================================================= @@ -471,7 +467,7 @@ def test_detect_gateway_batched_support(self): print(f" Accepts: {[a['scheme'] for a in accepts]}") print(f" Supports Circle: {supports_circle}") - assert supports_circle == True + assert supports_circle def test_network_matching(self): """Test network matching logic.""" @@ -519,7 +515,7 @@ def test_prefers_nanopayment_when_available(self): print(f" Wallet has gateway: {wallet_has_gateway}") print(f" β†’ Use nanopayment: {use_nanopayment}") - assert use_nanopayment == True + assert use_nanopayment def test_fallback_to_basic_when_no_gateway(self): """Test fallback when wallet has no gateway balance.""" @@ -539,7 +535,7 @@ def test_fallback_to_basic_when_no_gateway(self): print(f" β†’ Use nanopayment: {use_nanopayment}") print(f" β†’ Fall back to basic x402: {not use_nanopayment}") - assert use_nanopayment == False + assert not use_nanopayment # ============================================================================= diff --git a/tests/test_reservation_integrity.py b/tests/test_reservation_integrity.py index c81522e..de7e0bf 100644 --- a/tests/test_reservation_integrity.py +++ b/tests/test_reservation_integrity.py @@ -22,4 +22,3 @@ async def test_get_reserved_total_fails_closed_on_corrupted_amount() -> None: with pytest.raises(ValueError, match="Corrupted reservation amount"): await service.get_reserved_total("wallet-1") - diff --git a/tests/test_sdk_events.py b/tests/test_sdk_events.py index aa3aa1f..9992228 100644 --- a/tests/test_sdk_events.py +++ b/tests/test_sdk_events.py @@ -9,29 +9,30 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -from omniclaw.events import set_emitter @pytest.fixture(autouse=True) def mock_emitter(): - from unittest.mock import patch from types import SimpleNamespace + with patch("omniclaw.events.ProxyEventEmitter.emit_background") as mock_method: yield SimpleNamespace(emit_background=mock_method) + # ─── Guard Event Tests ───────────────────────────────────────────────── + class TestBudgetGuardEvents: """Verify BudgetGuard emits budget-related events.""" async def test_check_pass_emits_guard_evaluated(self, mock_emitter): - from omniclaw.guards.budget import BudgetGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.budget import BudgetGuard storage = AsyncMock() storage.get.return_value = None @@ -43,22 +44,23 @@ async def test_check_pass_emits_guard_evaluated(self, mock_emitter): result = await guard.check(context) assert result.allowed is True - calls = [c for c in mock_emitter.emit_background.call_args_list - if c[0][0] == "payment.guard_evaluated"] + calls = [ + c + for c in mock_emitter.emit_background.call_args_list + if c[0][0] == "payment.guard_evaluated" + ] assert len(calls) >= 1 assert calls[-1][1]["payload"]["result"] == "PASS" async def test_check_exceed_emits_budget_exceeded(self, mock_emitter): - from omniclaw.guards.budget import BudgetGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.budget import BudgetGuard storage = AsyncMock() # Mock get to return high spend data storage.get.return_value = { "total": "990", - "history": [ - {"ts": datetime.now().isoformat(), "amount": "990"} - ] + "history": [{"ts": datetime.now().isoformat(), "amount": "990"}], } guard = BudgetGuard(daily_limit=Decimal("1000"), storage=storage) @@ -76,16 +78,14 @@ async def test_check_exceed_emits_budget_exceeded(self, mock_emitter): async def test_check_approaching_emits_warning(self, mock_emitter): """Budget > 80% but still allowed should emit approaching warning.""" - from omniclaw.guards.budget import BudgetGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.budget import BudgetGuard storage = AsyncMock() # 85% consumed (850 of 1000) storage.get.return_value = { "total": "850", - "history": [ - {"ts": datetime.now().isoformat(), "amount": "850"} - ] + "history": [{"ts": datetime.now().isoformat(), "amount": "850"}], } guard = BudgetGuard(daily_limit=Decimal("1000"), storage=storage) @@ -105,8 +105,8 @@ class TestRateLimitGuardEvents: """Verify RateLimitGuard emits rate limit events.""" async def test_check_pass_emits_evaluated(self, mock_emitter): - from omniclaw.guards.rate_limit import RateLimitGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.rate_limit import RateLimitGuard storage = AsyncMock() storage.get.return_value = None @@ -124,8 +124,8 @@ async def test_check_pass_emits_evaluated(self, mock_emitter): assert "payment.guard_evaluated" in event_types async def test_check_exceeded_emits_hit(self, mock_emitter): - from omniclaw.guards.rate_limit import RateLimitGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.rate_limit import RateLimitGuard storage = AsyncMock() # Count at the limit @@ -149,8 +149,8 @@ class TestRecipientGuardEvents: """Verify RecipientGuard emits recipient events.""" async def test_whitelist_blocked_emits_recipient_blocked(self, mock_emitter): - from omniclaw.guards.recipient import RecipientGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.recipient import RecipientGuard guard = RecipientGuard(mode="whitelist", addresses=["0xAllowed1", "0xAllowed2"]) @@ -164,8 +164,8 @@ async def test_whitelist_blocked_emits_recipient_blocked(self, mock_emitter): assert "guard.recipient_blocked" in event_types async def test_whitelist_pass_emits_evaluated(self, mock_emitter): - from omniclaw.guards.recipient import RecipientGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.recipient import RecipientGuard guard = RecipientGuard(mode="whitelist", addresses=["0xAllowed1"]) @@ -183,8 +183,8 @@ class TestSingleTxGuardEvents: """Verify SingleTxGuard emits guard evaluated events.""" async def test_check_pass_emits_evaluated(self, mock_emitter): - from omniclaw.guards.single_tx import SingleTxGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.single_tx import SingleTxGuard guard = SingleTxGuard(max_amount=Decimal("100")) @@ -198,8 +198,8 @@ async def test_check_pass_emits_evaluated(self, mock_emitter): assert "payment.guard_evaluated" in event_types async def test_check_exceed_emits_fail(self, mock_emitter): - from omniclaw.guards.single_tx import SingleTxGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.single_tx import SingleTxGuard guard = SingleTxGuard(max_amount=Decimal("100")) @@ -209,8 +209,11 @@ async def test_check_exceed_emits_fail(self, mock_emitter): result = await guard.check(context) assert result.allowed is False - calls = [c for c in mock_emitter.emit_background.call_args_list - if c[0][0] == "payment.guard_evaluated"] + calls = [ + c + for c in mock_emitter.emit_background.call_args_list + if c[0][0] == "payment.guard_evaluated" + ] assert calls[-1][1]["payload"]["result"] == "FAIL" @@ -218,8 +221,8 @@ class TestConfirmGuardEvents: """Verify ConfirmGuard emits confirm events.""" async def test_no_confirmation_needed_emits_pass(self, mock_emitter): - from omniclaw.guards.confirm import ConfirmGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.confirm import ConfirmGuard guard = ConfirmGuard(threshold=Decimal("100")) @@ -233,8 +236,8 @@ async def test_no_confirmation_needed_emits_pass(self, mock_emitter): assert "payment.guard_evaluated" in event_types async def test_confirmation_needed_emits_required(self, mock_emitter): - from omniclaw.guards.confirm import ConfirmGuard from omniclaw.guards.base import PaymentContext + from omniclaw.guards.confirm import ConfirmGuard guard = ConfirmGuard(threshold=Decimal("100")) @@ -261,16 +264,15 @@ async def test_create_emits_intent_created(self, mock_emitter): storage = AsyncMock() service = PaymentIntentService(storage) - intent = await service.create( - wallet_id="w-1", recipient="0xabc", amount=Decimal("50") - ) + await service.create(wallet_id="w-1", recipient="0xabc", amount=Decimal("50")) event_types = [c[0][0] for c in mock_emitter.emit_background.call_args_list] assert "intent.created" in event_types # Check wallet_id is passed correctly - create_call = [c for c in mock_emitter.emit_background.call_args_list - if c[0][0] == "intent.created"][0] + create_call = [ + c for c in mock_emitter.emit_background.call_args_list if c[0][0] == "intent.created" + ][0] assert create_call[0][1] == "w-1" async def test_cancel_emits_intent_canceled(self, mock_emitter): @@ -280,9 +282,7 @@ async def test_cancel_emits_intent_canceled(self, mock_emitter): service = PaymentIntentService(storage) # Create an intent first - intent = await service.create( - wallet_id="w-1", recipient="0xabc", amount=Decimal("50") - ) + intent = await service.create(wallet_id="w-1", recipient="0xabc", amount=Decimal("50")) # Mock _load to return the save data as a proper dict # The cancel() method calls get() which calls _load() @@ -312,8 +312,9 @@ async def test_trip_emits_circuit_opened(self, mock_emitter): event_types = [c[0][0] for c in mock_emitter.emit_background.call_args_list] assert "circuit.opened" in event_types - trip_call = [c for c in mock_emitter.emit_background.call_args_list - if c[0][0] == "circuit.opened"][0] + trip_call = [ + c for c in mock_emitter.emit_background.call_args_list if c[0][0] == "circuit.opened" + ][0] assert trip_call[1]["severity"] == "critical" async def test_close_emits_circuit_closed(self, mock_emitter): @@ -361,6 +362,9 @@ async def test_acquire_timeout_emits_lock_timeout(self, mock_emitter): event_types = [c[0][0] for c in mock_emitter.emit_background.call_args_list] assert "system.lock_timeout" in event_types - timeout_call = [c for c in mock_emitter.emit_background.call_args_list - if c[0][0] == "system.lock_timeout"][0] + timeout_call = [ + c + for c in mock_emitter.emit_background.call_args_list + if c[0][0] == "system.lock_timeout" + ][0] assert timeout_call[1]["severity"] == "error" diff --git a/tests/test_sdk_integration.py b/tests/test_sdk_integration.py index e4c5dae..c99eaab 100644 --- a/tests/test_sdk_integration.py +++ b/tests/test_sdk_integration.py @@ -26,14 +26,12 @@ pytest tests/test_sdk_integration.py -v -s """ -import asyncio import json -import pytest from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import httpx - +import pytest # ============================================================================= # USER STORIES / DEV STORIES @@ -191,9 +189,11 @@ def create_mock_wallet_set(wallet_set_id: str = "ws-1") -> dict: def create_402_response( - schemes: list[str] = ["exact"], network: str = "eip155:84532", amount: str = "1000" + schemes: list[str] = None, network: str = "eip155:84532", amount: str = "1000" ) -> httpx.Response: """Create a mock 402 response.""" + if schemes is None: + schemes = ["exact"] accepts = [] usdc_contract = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" seller_address = "0x742d35Cc6634C0532925a3b844Bc9e7595f1E123" @@ -264,7 +264,7 @@ def create_200_response(data: dict = None) -> httpx.Response: # ============================================================================= -class TestStory1_WalletCreation: +class TestStory1WalletCreation: """ STORY 1: Agent Wallet Creation @@ -291,39 +291,13 @@ def test_wallet_creation_basic(self): print(f" Blockchain: {wallet['blockchain']}") print(f" Address: {wallet['address'][:20]}...") - def test_eoa_key_generated(self): - """Test that EOA signing key is automatically generated.""" - print("\n" + "-" * 40) - print("Test: EOA Key Auto-Generation") - - # When wallet is created, EOA key should be generated - # The key is stored in vault with alias "wallet:{wallet_id}" - - wallet_id = "agent-xyz789" - key_alias = f"wallet:{wallet_id}" - - # Simulate key generation - key_data = { - "alias": key_alias, - "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1E123", - "network": "eip155:84532", - } - - assert key_data["alias"] == f"wallet:{wallet_id}" - assert key_data["address"].startswith("0x") - assert len(key_data["address"]) == 42 - - print(f"βœ“ EOA key generated for wallet") - print(f" Alias: {key_data['alias']}") - print(f" Address: {key_data['address'][:20]}...") - # ============================================================================= # STORY 2: Get Payment Address # ============================================================================= -class TestStory2_PaymentAddress: +class TestStory2PaymentAddress: """ STORY 2: Get Payment Address @@ -346,10 +320,10 @@ def test_get_payment_address(self): assert payment_address.startswith("0x") assert len(payment_address) == 42 - print(f"βœ“ Payment address retrieved") + print("βœ“ Payment address retrieved") print(f" Wallet: {wallet_id}") print(f" Address: {payment_address}") - print(f" Fund this address with USDC to make x402 payments") + print(" Fund this address with USDC to make x402 payments") def test_same_address_for_both_payment_types(self): """Test that same address works for basic x402 and nanopayment.""" @@ -362,10 +336,10 @@ def test_same_address_for_both_payment_types(self): # 1. Basic x402 (USDC stays in EOA) # 2. Circle nanopayment (USDC deposited to Gateway) - print(f"βœ“ Same address used for both payment types") + print("βœ“ Same address used for both payment types") print(f" Address: {address}") - print(f" - Basic x402: USDC stays in EOA") - print(f" - Nanopayment: Deposit to Gateway first") + print(" - Basic x402: USDC stays in EOA") + print(" - Nanopayment: Deposit to Gateway first") # ============================================================================= @@ -373,7 +347,7 @@ def test_same_address_for_both_payment_types(self): # ============================================================================= -class TestStory3_PayToAddress: +class TestStory3PayToAddress: """ STORY 3: Pay to Address @@ -400,11 +374,11 @@ def test_payment_to_address_success(self): "status": "completed", } - assert result["success"] == True + assert result["success"] assert result["transaction_id"] is not None assert Decimal(result["amount"]) == amount - print(f"βœ“ Payment successful") + print("βœ“ Payment successful") print(f" From: {wallet_id}") print(f" To: {recipient[:20]}...") print(f" Amount: ${amount}") @@ -427,7 +401,7 @@ def test_payment_tracked_in_ledger(self): assert ledger_entry["id"] is not None assert ledger_entry["status"] == "completed" - print(f"βœ“ Transaction tracked in ledger") + print("βœ“ Transaction tracked in ledger") print(f" Entry ID: {ledger_entry['id']}") print(f" Status: {ledger_entry['status']}") @@ -437,7 +411,7 @@ def test_payment_tracked_in_ledger(self): # ============================================================================= -class TestStory4_PayViaX402: +class TestStory4PayViaX402: """ STORY 4: Pay via x402 URL @@ -469,7 +443,7 @@ def test_x402_smart_routing(self): else: method = "Basic x402 (on-chain)" - print(f"βœ“ Smart routing works") + print("βœ“ Smart routing works") print(f" URL: {url}") print(f" Seller accepts: {schemes}") print(f" β†’ Using: {method}") @@ -488,9 +462,9 @@ def test_x402_with_circle_support(self): supports_circle = any(a["scheme"] == "GatewayWalletBatched" for a in accepts) - assert supports_circle == True + assert supports_circle - print(f"βœ“ Circle nanopayment detected") + print("βœ“ Circle nanopayment detected") print(f" Accepts: {[a['scheme'] for a in accepts]}") def test_x402_free_resource(self): @@ -503,7 +477,7 @@ def test_x402_free_resource(self): # Not a 402 means free resource if mock_200.status_code != 402: result = mock_200.json() - print(f"βœ“ Free resource returned") + print("βœ“ Free resource returned") print(f" Data: {result}") else: pytest.fail("Should be 200, not 402") @@ -514,7 +488,7 @@ def test_x402_free_resource(self): # ============================================================================= -class TestStory5_BudgetGuard: +class TestStory5BudgetGuard: """ STORY 5: Add Budget Guard @@ -542,7 +516,7 @@ def test_add_daily_budget(self): assert guard["limit"] == "100.00" - print(f"βœ“ Daily budget guard added") + print("βœ“ Daily budget guard added") print(f" Wallet: {wallet_id}") print(f" Limit: ${daily_limit}/day") @@ -557,12 +531,12 @@ def test_payment_exceeds_budget_blocked(self): # Check if would exceed would_exceed = payment_amount > daily_limit - assert would_exceed == True + assert would_exceed - print(f"βœ“ Payment blocked (exceeds budget)") + print("βœ“ Payment blocked (exceeds budget)") print(f" Budget: ${daily_limit}") print(f" Payment: ${payment_amount}") - print(f" Result: BLOCKED") + print(" Result: BLOCKED") def test_payment_within_budget_allowed(self): """Test that payment within budget is allowed.""" @@ -574,12 +548,12 @@ def test_payment_within_budget_allowed(self): would_exceed = payment_amount > daily_limit - assert would_exceed == False + assert not would_exceed - print(f"βœ“ Payment allowed (within budget)") + print("βœ“ Payment allowed (within budget)") print(f" Budget: ${daily_limit}") print(f" Payment: ${payment_amount}") - print(f" Result: ALLOWED") + print(" Result: ALLOWED") # ============================================================================= @@ -587,7 +561,7 @@ def test_payment_within_budget_allowed(self): # ============================================================================= -class TestStory6_RecipientWhitelist: +class TestStory6RecipientWhitelist: """ STORY 6: Add Recipient Whitelist @@ -618,8 +592,8 @@ def test_add_whitelist(self): assert guard["mode"] == "whitelist" assert len(guard["addresses"]) == 2 - print(f"βœ“ Whitelist guard added") - print(f" Mode: whitelist") + print("βœ“ Whitelist guard added") + print(" Mode: whitelist") print(f" Allowed: {len(whitelist)} addresses") def test_whitelisted_address_allowed(self): @@ -634,9 +608,9 @@ def test_whitelisted_address_allowed(self): is_allowed = recipient in whitelist - assert is_allowed == True + assert is_allowed - print(f"βœ“ Payment to whitelisted address allowed") + print("βœ“ Payment to whitelisted address allowed") print(f" Recipient: {recipient[:20]}...") def test_non_whitelisted_address_blocked(self): @@ -651,9 +625,9 @@ def test_non_whitelisted_address_blocked(self): is_allowed = recipient in whitelist - assert is_allowed == False + assert not is_allowed - print(f"βœ“ Payment to non-whitelisted address blocked") + print("βœ“ Payment to non-whitelisted address blocked") print(f" Recipient: {recipient[:20]}...") @@ -662,7 +636,7 @@ def test_non_whitelisted_address_blocked(self): # ============================================================================= -class TestStory7_TransactionHistory: +class TestStory7TransactionHistory: """ STORY 7: Transaction History @@ -698,7 +672,7 @@ def test_list_transactions(self): assert len(transactions) == 2 - print(f"βœ“ Transaction history retrieved") + print("βœ“ Transaction history retrieved") print(f" Total: {len(transactions)} transactions") for tx in transactions: print(f" - {tx['id']}: ${tx['amount']} β†’ {tx['recipient'][:10]}...") @@ -713,7 +687,7 @@ def test_transaction_statuses(self): for status in statuses: print(f" - {status}") - print(f"βœ“ All statuses supported") + print("βœ“ All statuses supported") # ============================================================================= @@ -721,7 +695,7 @@ def test_transaction_statuses(self): # ============================================================================= -class TestStory8_PaymentIntents: +class TestStory8PaymentIntents: """ STORY 8: Payment Intents (2-Phase Commit) @@ -747,10 +721,10 @@ def test_create_intent(self): assert intent["status"] == "reserved" - print(f"βœ“ Payment intent created") + print("βœ“ Payment intent created") print(f" ID: {intent['id']}") print(f" Amount: ${amount}") - print(f" Status: reserved") + print(" Status: reserved") def test_execute_intent(self): """Test executing a payment intent.""" @@ -766,9 +740,9 @@ def test_execute_intent(self): "transaction_id": "tx_xyz789", } - assert result["success"] == True + assert result["success"] - print(f"βœ“ Intent executed") + print("βœ“ Intent executed") print(f" Intent: {intent_id}") print(f" TX: {result['transaction_id']}") @@ -787,8 +761,8 @@ def test_release_intent(self): assert result["status"] == "released" - print(f"βœ“ Intent released") - print(f" Funds returned to wallet") + print("βœ“ Intent released") + print(" Funds returned to wallet") # ============================================================================= @@ -796,7 +770,7 @@ def test_release_intent(self): # ============================================================================= -class TestStory9_InsufficientBalance: +class TestStory9InsufficientBalance: """ STORY 9: Insufficient Balance @@ -815,9 +789,9 @@ def test_insufficient_balance_error(self): has_sufficient = wallet_balance >= payment_amount - assert has_sufficient == False + assert not has_sufficient - print(f"βœ“ Insufficient balance detected") + print("βœ“ Insufficient balance detected") print(f" Wallet balance: ${wallet_balance}") print(f" Payment amount: ${payment_amount}") print(f" Shortfall: ${payment_amount - wallet_balance}") @@ -837,7 +811,7 @@ def test_error_includes_details(self): assert "current_balance" in error assert "required_amount" in error - print(f"βœ“ Error includes all details") + print("βœ“ Error includes all details") print(f" {error['message']}") @@ -846,7 +820,7 @@ def test_error_includes_details(self): # ============================================================================= -class TestStory10_GatewayOperations: +class TestStory10GatewayOperations: """ STORY 10: Circle Gateway Operations @@ -860,7 +834,6 @@ def test_deposit_to_gateway(self): print("STORY 10: Circle Gateway Operations") print("=" * 60) - wallet_id = "agent-123" amount = "100.00" result = { @@ -872,7 +845,7 @@ def test_deposit_to_gateway(self): assert result["approval_tx_hash"] is not None assert result["deposit_tx_hash"] is not None - print(f"βœ“ Deposit to Gateway successful") + print("βœ“ Deposit to Gateway successful") print(f" Amount: ${amount}") print(f" Approval TX: {result['approval_tx_hash'][:20]}...") print(f" Deposit TX: {result['deposit_tx_hash'][:20]}...") @@ -892,7 +865,7 @@ def test_withdraw_from_gateway(self): assert result["status"] == "success" - print(f"βœ“ Withdraw from Gateway successful") + print("βœ“ Withdraw from Gateway successful") print(f" Amount: ${amount}") print(f" TX: {result['mint_tx_hash'][:20]}...") @@ -907,7 +880,7 @@ def test_get_gateway_balance(self): "pending": "25.00", } - print(f"βœ“ Gateway balance retrieved") + print("βœ“ Gateway balance retrieved") print(f" Total: ${balance['total']}") print(f" Available: ${balance['available']}") print(f" Pending: ${balance['pending']}") diff --git a/tests/test_sdk_integration_extended.py b/tests/test_sdk_integration_extended.py index 8c5166c..e5cae36 100644 --- a/tests/test_sdk_integration_extended.py +++ b/tests/test_sdk_integration_extended.py @@ -15,13 +15,9 @@ """ import asyncio -import json -import pytest from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock - -import httpx +import pytest # ============================================================================= # REAL SDK IMPORTS (with error handling) @@ -31,20 +27,7 @@ def import_sdk_modules(): """Import SDK modules, handling missing dependencies.""" try: - from omniclaw.core.types import ( - Network, - PaymentMethod, - PaymentStatus, - PaymentResult, - WalletInfo, - WalletSetInfo, - ) - from omniclaw.core.exceptions import ( - InsufficientBalanceError, - PaymentError, - ValidationError, - ) - + import omniclaw # noqa: F401 return True except ImportError as e: print(f"Warning: Could not import all SDK modules: {e}") @@ -307,9 +290,9 @@ async def execute(self, *args, **kwargs): try: result = await adapter.execute() print(f"βœ“ {adapter.name}: SUCCESS") - assert result["success"] == True + assert result["success"] break - except Exception as e: + except Exception: print(f"βœ— {adapter.name}: FAILED, trying next...") continue @@ -336,7 +319,7 @@ async def execute(self, *args, **kwargs): # First adapter works - no fallback needed result = await adapters[0].execute() print(f"βœ“ {adapters[0].name}: SUCCESS (no fallback)") - assert result["success"] == True + assert result["success"] @pytest.mark.asyncio async def test_all_adapters_fail(self): @@ -390,7 +373,7 @@ async def test_reserve_funds(self): print(f" Available: ${available}") assert available == Decimal("75.00") - print(f"βœ“ Funds reserved successfully") + print("βœ“ Funds reserved successfully") @pytest.mark.asyncio async def test_reserve_insufficient_funds(self): @@ -408,7 +391,7 @@ async def test_reserve_insufficient_funds(self): print(f"βœ— Cannot reserve ${reservation_request}") print(f" Only have ${wallet_balance}") - assert can_reserve == False + assert not can_reserve @pytest.mark.asyncio async def test_release_reservation(self): @@ -430,7 +413,7 @@ async def test_release_reservation(self): print(f" After release: ${final_balance}") assert final_balance == initial_balance - print(f"βœ“ Reservation released") + print("βœ“ Reservation released") @pytest.mark.asyncio async def test_execute_reserved_payment(self): @@ -450,7 +433,7 @@ async def test_execute_reserved_payment(self): print(f" Balance after: ${final_balance}") assert final_balance == Decimal("70.00") - print(f"βœ“ Payment executed") + print("βœ“ Payment executed") class TestConcurrency: @@ -479,8 +462,8 @@ async def make_payment(payment_id, amount): successful = sum(1 for r in results if r["success"]) - print(f" Initial balance: $100.00") - print(f" 5 concurrent payments of $25.00 each") + print(" Initial balance: $100.00") + print(" 5 concurrent payments of $25.00 each") print(f" Successful: {successful}/5") print(f" Final balance: ${wallet_balance}") @@ -517,8 +500,8 @@ async def pay(wallet_id, amount): successful = sum(1 for r in results if r) - print(f" 3 wallets with $50 each") - print(f" Each pays $25 concurrently") + print(" 3 wallets with $50 each") + print(" Each pays $25 concurrently") print(f" Successful: {successful}/3") assert successful == 3 @@ -635,7 +618,7 @@ async def test_ledger_update_status(self): entry["status"] = "completed" entry["tx_hash"] = "0xabc123" - print(f" Before: status=pending") + print(" Before: status=pending") print(f" After: status={entry['status']}, tx={entry['tx_hash'][:10]}...") assert entry["status"] == "completed" @@ -726,7 +709,7 @@ async def test_idempotency(self): key = "unique-key-123" results = [] - for i in range(3): + for _i in range(3): # Simulate duplicate requests results.append({"idempotency_key": key, "tx_id": "tx_abc"}) diff --git a/tests/test_seller_side.py b/tests/test_seller_side.py index c8c9c7f..4abd15e 100644 --- a/tests/test_seller_side.py +++ b/tests/test_seller_side.py @@ -15,12 +15,12 @@ import base64 import json -import pytest from decimal import Decimal -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock -from omniclaw.seller import Seller, create_seller, PaymentScheme +import pytest +from omniclaw.seller import PaymentScheme, Seller, create_seller # ============================================================================= # TEST PRICE PARSING (Decimal precision β€” critical fix) @@ -214,14 +214,13 @@ def test_gateway_batched_skipped_without_contract(self, monkeypatch): schemes = [a["scheme"] for a in accepts] # Without gateway contract, only "exact" should be present assert "exact" in schemes - assert not any((a.get("extra", {}) or {}).get("name") == "GatewayWalletBatched" for a in accepts) + assert not any( + (a.get("extra", {}) or {}).get("name") == "GatewayWalletBatched" for a in accepts + ) def test_gateway_batched_included_with_contract(self, monkeypatch): """GatewayWalletBatched should be included when gateway contract is set.""" - monkeypatch.setenv( - "CIRCLE_GATEWAY_CONTRACT", - "0xABCD1234ABCD1234ABCD1234ABCD1234ABCD1234" - ) + monkeypatch.setenv("CIRCLE_GATEWAY_CONTRACT", "0xABCD1234ABCD1234ABCD1234ABCD1234ABCD1234") seller = Seller( seller_address="0x742d35Cc6634C0532925a3b844Bc9e7595f1E123", name="Test", @@ -231,8 +230,12 @@ def test_gateway_batched_included_with_contract(self, monkeypatch): schemes = [a["scheme"] for a in accepts] assert "exact" in schemes # Verify the correct contract is used - gw_accept = [a for a in accepts if (a.get("extra", {}) or {}).get("name") == "GatewayWalletBatched"][0] - assert gw_accept["extra"]["verifyingContract"] == "0xABCD1234ABCD1234ABCD1234ABCD1234ABCD1234" + gw_accept = [ + a for a in accepts if (a.get("extra", {}) or {}).get("name") == "GatewayWalletBatched" + ][0] + assert ( + gw_accept["extra"]["verifyingContract"] == "0xABCD1234ABCD1234ABCD1234ABCD1234ABCD1234" + ) assert gw_accept["extra"]["version"] == "1" @@ -356,9 +359,7 @@ def test_basic_verify_timeout_check(self): }, } - is_valid, error, record = seller.verify_payment( - payload, accepted, verify_signature=False - ) + is_valid, error, record = seller.verify_payment(payload, accepted, verify_signature=False) assert is_valid is False assert "expired" in error.lower() @@ -388,9 +389,7 @@ def test_basic_verify_wrong_recipient(self): }, } - is_valid, error, record = seller.verify_payment( - payload, accepted, verify_signature=False - ) + is_valid, error, record = seller.verify_payment(payload, accepted, verify_signature=False) assert is_valid is False assert "recipient" in error.lower() @@ -420,9 +419,7 @@ def test_basic_verify_insufficient_amount(self): }, } - is_valid, error, record = seller.verify_payment( - payload, accepted, verify_signature=False - ) + is_valid, error, record = seller.verify_payment(payload, accepted, verify_signature=False) assert is_valid is False assert "insufficient" in error.lower() @@ -452,9 +449,7 @@ def test_basic_verify_valid_payment(self): }, } - is_valid, error, record = seller.verify_payment( - payload, accepted, verify_signature=False - ) + is_valid, error, record = seller.verify_payment(payload, accepted, verify_signature=False) assert is_valid is True assert record is not None assert record.amount == 1000 diff --git a/tests/test_server_integration.py b/tests/test_server_integration.py index fb02a6a..01ad9dc 100644 --- a/tests/test_server_integration.py +++ b/tests/test_server_integration.py @@ -17,20 +17,15 @@ - Optional: Circle API key for nanopayment tests """ -import asyncio import json import os import signal import subprocess import sys import time -import pytest -from decimal import Decimal -from typing import Optional -from unittest.mock import patch import httpx - +import pytest # ============================================================================= # TEST SERVER CONFIGURATION @@ -51,7 +46,7 @@ def is_server_running() -> bool: result = sock.connect_ex((SERVER_HOST, SERVER_PORT)) sock.close() return result == 0 - except: + except Exception: return False @@ -263,7 +258,7 @@ def test_health_endpoint_no_payment(self): try: response = httpx.get(f"{SERVER_URL}{route}", timeout=5.0) print(f" {route}: {response.status_code}") - except Exception as e: + except Exception: print(f" {route}: Not found") @@ -291,7 +286,7 @@ def test_flow_1_request_without_payment(self): header = response.headers.get("payment-required") assert header is not None - print(f" Got 402 with payment-required header βœ“") + print(" Got 402 with payment-required header βœ“") def test_flow_2_parse_402_and_detect_scheme(self): """Step 2: Parse 402 and detect payment scheme.""" @@ -317,7 +312,7 @@ def test_flow_2_parse_402_and_detect_scheme(self): # Our test server only supports "exact" (basic x402) assert "exact" in schemes - print(f" Detected scheme: exact βœ“") + print(" Detected scheme: exact βœ“") def test_flow_3_determine_payment_method(self): """Step 3: Determine payment method based on accepts.""" @@ -345,9 +340,9 @@ def test_flow_3_determine_payment_method(self): # Our test server only supports basic if supports_basic and not supports_circle: - print(f" β†’ Will use: Basic x402 (on-chain settlement)") + print(" β†’ Will use: Basic x402 (on-chain settlement)") elif supports_circle: - print(f" β†’ Will use: Circle nanopayment (gasless)") + print(" β†’ Will use: Circle nanopayment (gasless)") assert supports_basic @@ -369,7 +364,7 @@ def test_flow_4_with_invalid_payment(self): # Should still be 402 (payment invalid) assert response.status_code == 402 - print(f" Invalid payment rejected βœ“") + print(" Invalid payment rejected βœ“") # ============================================================================= @@ -402,10 +397,7 @@ def test_route_to_basic_x402_when_no_circle(self): supports_circle = any(a.get("scheme") == "GatewayWalletBatched" for a in accepts) buyer_has_gateway = False - if supports_circle and buyer_has_gateway: - method = "Circle Nanopayment" - else: - method = "Basic x402" + method = "Circle Nanopayment" if supports_circle and buyer_has_gateway else "Basic x402" print(f" Seller accepts: {[a['scheme'] for a in accepts]}") print(f" Buyer has Circle: {buyer_has_gateway}") @@ -548,7 +540,7 @@ def test_server_timeout(self): try: # Very short timeout - response = httpx.get(f"{SERVER_URL}/weather", timeout=0.001) + httpx.get(f"{SERVER_URL}/weather", timeout=0.001) except httpx.TimeoutException: print(" βœ“ Timeout handled correctly") except Exception as e: @@ -576,7 +568,7 @@ def test_server_unavailable(self): # Try to connect to non-existent server try: - response = httpx.get("http://127.0.0.1:9999/health", timeout=2.0) + httpx.get("http://127.0.0.1:9999/health", timeout=2.0) except httpx.ConnectError: print(" βœ“ Connection error handled correctly") except Exception as e: @@ -608,7 +600,7 @@ def test_response_time(self): times = [] for _ in range(5): start = time.time() - response = httpx.get(f"{SERVER_URL}/weather", timeout=5.0) + httpx.get(f"{SERVER_URL}/weather", timeout=5.0) elapsed = time.time() - start times.append(elapsed) print(f" Response time: {elapsed * 1000:.2f}ms") diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 3f9ba22..bad4043 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -34,22 +34,17 @@ async def test_simulation_result_fields(client): """Test that SimulationResult has necessary fields populated.""" # Mock router and balance client._router.simulate = AsyncMock() - sim_res_mock = AsyncMock() + AsyncMock() # Return a simulated result from omniclaw.core.types import SimulationResult + client._router.simulate.return_value = SimulationResult( - would_succeed=True, - route=PaymentMethod.TRANSFER, - estimated_fee=Decimal("0.05") + would_succeed=True, route=PaymentMethod.TRANSFER, estimated_fee=Decimal("0.05") ) client._wallet_service.get_usdc_balance_amount = lambda wid: Decimal("100.0") - res = await client.simulate( - wallet_id="wallet-1", - recipient="0xabc", - amount=Decimal("10.0") - ) + res = await client.simulate(wallet_id="wallet-1", recipient="0xabc", amount=Decimal("10.0")) assert res.would_succeed is True assert res.recipient_type == PaymentMethod.TRANSFER.value @@ -63,16 +58,12 @@ async def test_simulation_respects_reservations(client): """Test that simulation checks available balance (balance - reserved).""" # Setup: Balance 100, Reserved 80 client._wallet_service.get_usdc_balance_amount = lambda wid: Decimal("100.0") - + # Reserve 80 immediately await client._reservation.reserve("wallet-1", Decimal("80.0"), "intent-1") # Trying to simulate 30 should fail because available is 20 - res = await client.simulate( - wallet_id="wallet-1", - recipient="0xabc", - amount=Decimal("30.0") - ) + res = await client.simulate(wallet_id="wallet-1", recipient="0xabc", amount=Decimal("30.0")) assert res.would_succeed is False assert "Insufficient available balance" in res.reason @@ -86,6 +77,7 @@ async def test_simulation_guards_passed(client): client._wallet_service.get_usdc_balance_amount = lambda wid: Decimal("100.0") client._router.simulate = AsyncMock() from omniclaw.core.types import SimulationResult + client._router.simulate.return_value = SimulationResult( would_succeed=True, route=PaymentMethod.TRANSFER, @@ -96,20 +88,14 @@ async def test_simulation_guards_passed(client): await client.guards.add_guard("wallet-1", guard) # Simulate 10.0 (passes guard) - res = await client.simulate( - wallet_id="wallet-1", - recipient="0xabc", - amount=Decimal("10.0") - ) + res = await client.simulate(wallet_id="wallet-1", recipient="0xabc", amount=Decimal("10.0")) assert res.would_succeed is True assert "test_guard" in res.guards_that_would_pass # Simulate 60.0 (fails guard) res_fail = await client.simulate( - wallet_id="wallet-1", - recipient="0xabc", - amount=Decimal("60.0") + wallet_id="wallet-1", recipient="0xabc", amount=Decimal("60.0") ) assert res_fail.would_succeed is False diff --git a/tests/test_trust_gate.py b/tests/test_trust_gate.py index 9f4363d..0f5a15b 100644 --- a/tests/test_trust_gate.py +++ b/tests/test_trust_gate.py @@ -12,9 +12,8 @@ from decimal import Decimal from unittest.mock import AsyncMock, MagicMock, patch -import pytest - import httpx +import pytest from omniclaw.identity.types import ( AgentIdentity, @@ -29,7 +28,6 @@ from omniclaw.trust.policy import PolicyEngine from omniclaw.trust.scoring import ReputationAggregator - # ───────────────────────────────────────────────────────────────── # Trust Policy Tests # ───────────────────────────────────────────────────────────────── @@ -793,9 +791,10 @@ class TestProviderOptimizations: def test_get_reputation_summary_empty_clients_warning(self): """getSummary with empty clients β†’ None (EIP-8004 security requirement).""" - from omniclaw.trust.provider import ERC8004Provider import asyncio + from omniclaw.trust.provider import ERC8004Provider + provider = ERC8004Provider(rpc_url="https://fake.rpc") loop = asyncio.new_event_loop() try: @@ -880,6 +879,7 @@ class TestDatetimeFix: def test_trust_gate_uses_timezone_aware_datetime(self): """gate.py should use datetime.now(timezone.utc).""" import inspect + from omniclaw.trust import gate source = inspect.getsource(gate) @@ -889,6 +889,7 @@ def test_trust_gate_uses_timezone_aware_datetime(self): def test_scoring_uses_timezone_aware_datetime(self): """scoring.py should use datetime.now(timezone.utc).""" import inspect + from omniclaw.trust import scoring source = inspect.getsource(scoring) diff --git a/tests/test_trust_gate_integration.py b/tests/test_trust_gate_integration.py index c41b2ea..8fea6b0 100644 --- a/tests/test_trust_gate_integration.py +++ b/tests/test_trust_gate_integration.py @@ -11,14 +11,12 @@ """ from decimal import Decimal -from unittest.mock import AsyncMock, patch import pytest from omniclaw.identity.resolver import IdentityResolver from omniclaw.identity.types import ( AgentIdentity, - AgentService, FeedbackSignal, ReputationScore, TrustCheckResult, @@ -26,12 +24,10 @@ TrustVerdict, ) from omniclaw.storage.memory import InMemoryStorage -from omniclaw.trust.cache import TrustCache from omniclaw.trust.gate import TrustGate from omniclaw.trust.policy import PolicyEngine from omniclaw.trust.scoring import ReputationAggregator - # ───────────────────────────────────────────────────────────────── # Realistic Test Data β€” Simulates Real ERC-8004 Agents # ───────────────────────────────────────────────────────────────── @@ -40,7 +36,7 @@ "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", "name": "DataPipelineAgent", "description": "Enterprise-grade data pipeline agent for ETL processing. " - "Handles structured and unstructured data. Pricing: $0.10/MB.", + "Handles structured and unstructured data. Pricing: $0.10/MB.", "image": "https://datapipeline.agent/logo.png", "services": [ { @@ -81,6 +77,7 @@ def _make_realistic_signals( ) -> list[FeedbackSignal]: """Generate realistic feedback signals like real Reputation Registry data.""" import random + random.seed(42) # Deterministic for tests signals = [] @@ -92,27 +89,31 @@ def _make_realistic_signals( tag1 = "starred" if score >= 80 else "successRate" tag2 = "" - signals.append(FeedbackSignal( - agent_id=agent_id, - client_address=client, - feedback_index=i + 1, - value=score, - value_decimals=0, - tag1=tag1, - tag2=tag2, - )) + signals.append( + FeedbackSignal( + agent_id=agent_id, + client_address=client, + feedback_index=i + 1, + value=score, + value_decimals=0, + tag1=tag1, + tag2=tag2, + ) + ) # Add fraud signal if requested if include_fraud: - signals.append(FeedbackSignal( - agent_id=agent_id, - client_address="0xFraudReporter", - feedback_index=count + 1, - value=0, - value_decimals=0, - tag1="fraud", - tag2="scam", - )) + signals.append( + FeedbackSignal( + agent_id=agent_id, + client_address="0xFraudReporter", + feedback_index=count + 1, + value=0, + value_decimals=0, + tag1="fraud", + tag2="scam", + ) + ) # Mark some as revoked for i in range(min(include_revoked, len(signals))): @@ -129,14 +130,16 @@ def _make_realistic_signals( # Add self-review if include_self_review: - signals.append(FeedbackSignal( - agent_id=agent_id, - client_address=agent_owner, - feedback_index=count + 2, - value=100, - value_decimals=0, - tag1="starred", - )) + signals.append( + FeedbackSignal( + agent_id=agent_id, + client_address=agent_owner, + feedback_index=count + 2, + value=100, + value_decimals=0, + tag1="starred", + ) + ) return signals @@ -145,6 +148,7 @@ def _make_realistic_signals( # Scenario 1: Healthy, Established Agent # ───────────────────────────────────────────────────────────────── + class TestScenarioEstablishedAgent: """ Story: A well-known data pipeline agent has been operating for months. @@ -167,7 +171,8 @@ def setup_method(self): def test_wts_computation(self): """15 signals with avg 82 β†’ WTS ~80-84.""" score = self.scorer.compute_wts( - self.signals, agent_owner_address="0xAgentOwner", + self.signals, + agent_owner_address="0xAgentOwner", ) assert 75 <= score.wts <= 90 # Should be around 82 Β± noise assert score.new_agent is False @@ -178,7 +183,11 @@ def test_approved_permissive(self): """Should pass permissive policy easily.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("100"), "0xAgent", TrustPolicy.permissive(), + self.identity, + score, + Decimal("100"), + "0xAgent", + TrustPolicy.permissive(), ) assert result.verdict == TrustVerdict.APPROVED assert result.identity_found is True @@ -187,7 +196,11 @@ def test_approved_standard(self): """Should pass standard policy (WTS > 50, > 3 feedback).""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("100"), "0xAgent", TrustPolicy.standard(), + self.identity, + score, + Decimal("100"), + "0xAgent", + TrustPolicy.standard(), ) assert result.verdict == TrustVerdict.APPROVED @@ -195,7 +208,11 @@ def test_approved_strict(self): """Should pass strict policy (WTS > 70, has kyb attestation).""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("100"), "0xAgent", TrustPolicy.strict(), + self.identity, + score, + Decimal("100"), + "0xAgent", + TrustPolicy.strict(), ) assert result.verdict == TrustVerdict.APPROVED @@ -207,7 +224,11 @@ def test_high_value_held(self): # With strict policy, LOW_WTS may trigger first (min_wts=70) # or HIGH_VALUE_WTS_FAIL (min_wts_hv=85) β€” both are correct blocks result = self.engine.evaluate( - self.identity, score, Decimal("1000"), "0xAgent", TrustPolicy.strict(), + self.identity, + score, + Decimal("1000"), + "0xAgent", + TrustPolicy.strict(), ) assert result.verdict in (TrustVerdict.HELD, TrustVerdict.BLOCKED) assert result.block_reason in ("HIGH_VALUE_WTS_FAIL", "LOW_WTS") @@ -217,6 +238,7 @@ def test_high_value_held(self): # Scenario 2: Brand New Agent (No History) # ───────────────────────────────────────────────────────────────── + class TestScenarioNewAgent: """ Story: A new agent just registered on-chain. Has an identity NFT @@ -246,7 +268,11 @@ def test_permissive_approves(self): """Permissive policy lets new agents through.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("10"), "0xNew", TrustPolicy.permissive(), + self.identity, + score, + Decimal("10"), + "0xNew", + TrustPolicy.permissive(), ) assert result.verdict == TrustVerdict.APPROVED @@ -254,7 +280,11 @@ def test_standard_holds(self): """Standard policy holds new agents for review.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("10"), "0xNew", TrustPolicy.standard(), + self.identity, + score, + Decimal("10"), + "0xNew", + TrustPolicy.standard(), ) assert result.verdict == TrustVerdict.HELD assert result.block_reason == "NEW_AGENT" @@ -263,7 +293,11 @@ def test_strict_holds(self): """Strict policy holds new agents.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("10"), "0xNew", TrustPolicy.strict(), + self.identity, + score, + Decimal("10"), + "0xNew", + TrustPolicy.strict(), ) assert result.verdict == TrustVerdict.HELD # Should hit NEW_AGENT before MISSING_ATTESTATIONS @@ -274,6 +308,7 @@ def test_strict_holds(self): # Scenario 3: Fraudulent Agent # ───────────────────────────────────────────────────────────────── + class TestScenarioFraudulentAgent: """ Story: An agent was initially good (80/100) but then started scamming. @@ -291,7 +326,8 @@ def setup_method(self): active=True, ) self.signals = _make_realistic_signals( - count=8, avg_score=60, + count=8, + avg_score=60, include_fraud=True, include_revoked=3, # 3 original clients revoked after getting scammed ) @@ -307,7 +343,11 @@ def test_blocked_by_standard(self): """Standard policy blocks fraud-tagged agents.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("10"), "0xScam", TrustPolicy.standard(), + self.identity, + score, + Decimal("10"), + "0xScam", + TrustPolicy.standard(), ) assert result.verdict == TrustVerdict.BLOCKED assert result.block_reason == "FRAUD_TAG" @@ -316,7 +356,11 @@ def test_blocked_even_by_permissive(self): """Even permissive policy blocks known fraud.""" score = self.scorer.compute_wts(self.signals) result = self.engine.evaluate( - self.identity, score, Decimal("10"), "0xScam", TrustPolicy.permissive(), + self.identity, + score, + Decimal("10"), + "0xScam", + TrustPolicy.permissive(), ) assert result.verdict == TrustVerdict.BLOCKED assert result.block_reason == "FRAUD_TAG" @@ -326,6 +370,7 @@ def test_blocked_even_by_permissive(self): # Scenario 4: Unknown Address (No ERC-8004 Identity) # ───────────────────────────────────────────────────────────────── + class TestScenarioUnknownAddress: """ Story: A payment to a raw Ethereum address with no ERC-8004 identity. @@ -339,8 +384,10 @@ def setup_method(self): def test_permissive_approves_unknown(self): """Permissive: unknown addresses are fine.""" result = self.engine.evaluate( - identity=None, reputation=None, - amount=Decimal("50"), recipient_address="0xRandom", + identity=None, + reputation=None, + amount=Decimal("50"), + recipient_address="0xRandom", policy=TrustPolicy.permissive(), ) assert result.verdict == TrustVerdict.APPROVED @@ -349,8 +396,10 @@ def test_permissive_approves_unknown(self): def test_standard_blocks_unknown(self): """Standard: identity required β†’ blocked.""" result = self.engine.evaluate( - identity=None, reputation=None, - amount=Decimal("50"), recipient_address="0xRandom", + identity=None, + reputation=None, + amount=Decimal("50"), + recipient_address="0xRandom", policy=TrustPolicy.standard(), ) assert result.verdict == TrustVerdict.BLOCKED @@ -359,8 +408,10 @@ def test_standard_blocks_unknown(self): def test_strict_blocks_unknown(self): """Strict: identity required β†’ blocked.""" result = self.engine.evaluate( - identity=None, reputation=None, - amount=Decimal("50"), recipient_address="0xRandom", + identity=None, + reputation=None, + amount=Decimal("50"), + recipient_address="0xRandom", policy=TrustPolicy.strict(), ) assert result.verdict == TrustVerdict.BLOCKED @@ -370,6 +421,7 @@ def test_strict_blocks_unknown(self): # Scenario 5: Blocklisted Address # ───────────────────────────────────────────────────────────────── + class TestScenarioBlocklisted: """ Story: An address is on the operator's blocklist (OFAC, internal ban, etc). @@ -380,8 +432,11 @@ def setup_method(self): self.scorer = ReputationAggregator() self.engine = PolicyEngine() self.identity = AgentIdentity( - agent_id=1, wallet_address="0xBlockedAgent", - organization="good-corp", name="GoodBot", attestations=["kyb"], + agent_id=1, + wallet_address="0xBlockedAgent", + organization="good-corp", + name="GoodBot", + attestations=["kyb"], ) def test_blocklist_overrides_everything(self): @@ -394,7 +449,11 @@ def test_blocklist_overrides_everything(self): address_blocklist=["0xBlockedAgent"], # But this wins ) result = self.engine.evaluate( - self.identity, score, Decimal("1"), "0xBlockedAgent", policy, + self.identity, + score, + Decimal("1"), + "0xBlockedAgent", + policy, ) assert result.verdict == TrustVerdict.BLOCKED assert result.block_reason == "ADDRESS_BLOCKLISTED" @@ -403,7 +462,11 @@ def test_blocklist_case_insensitive(self): """Blocklist matching is case-insensitive.""" policy = TrustPolicy(address_blocklist=["0xblockedagent"]) result = self.engine.evaluate( - self.identity, None, Decimal("1"), "0xBlockedAgent", policy, + self.identity, + None, + Decimal("1"), + "0xBlockedAgent", + policy, ) assert result.verdict == TrustVerdict.BLOCKED @@ -412,6 +475,7 @@ def test_blocklist_case_insensitive(self): # Scenario 6: Self-Review Attack # ───────────────────────────────────────────────────────────────── + class TestScenarioSelfReviewAttack: """ Story: A malicious agent creates fake feedback from its own address. @@ -427,20 +491,31 @@ def test_self_reviews_dont_inflate_score(self): # 10 fake self-reviews (all 100/100) for i in range(10): - signals.append(FeedbackSignal( - agent_id=1, client_address="0xAttacker", - feedback_index=i + 1, value=100, value_decimals=0, - )) + signals.append( + FeedbackSignal( + agent_id=1, + client_address="0xAttacker", + feedback_index=i + 1, + value=100, + value_decimals=0, + ) + ) # 2 real reviews (40/100), terrible agent for i in range(2): - signals.append(FeedbackSignal( - agent_id=1, client_address=f"0xReal{i}", - feedback_index=11 + i, value=40, value_decimals=0, - )) + signals.append( + FeedbackSignal( + agent_id=1, + client_address=f"0xReal{i}", + feedback_index=11 + i, + value=40, + value_decimals=0, + ) + ) score = self.scorer.compute_wts( - signals, agent_owner_address="0xAttacker", + signals, + agent_owner_address="0xAttacker", ) # Only the 2 real reviews should count @@ -453,6 +528,7 @@ def test_self_reviews_dont_inflate_score(self): # Scenario 7: Verified Submitter Boost # ───────────────────────────────────────────────────────────────── + class TestScenarioVerifiedSubmitters: """ Story: An agent has feedback from both verified (ERC-8004 identity) @@ -466,18 +542,29 @@ def test_verified_feedback_weighted_higher(self): """Verified submitters get 1.5x weight β†’ pulls WTS toward their score.""" signals = [ # Verified submitter says 90/100 (index 1 β€” older, gets 0.2 weight) - FeedbackSignal(agent_id=1, client_address="0xVerified", - feedback_index=1, value=90, value_decimals=0), + FeedbackSignal( + agent_id=1, + client_address="0xVerified", + feedback_index=1, + value=90, + value_decimals=0, + ), # Unverified submitter says 60/100 (index 2 β€” recent, gets 1.0 weight) - FeedbackSignal(agent_id=1, client_address="0xUnverified", - feedback_index=2, value=60, value_decimals=0), + FeedbackSignal( + agent_id=1, + client_address="0xUnverified", + feedback_index=2, + value=60, + value_decimals=0, + ), ] # Without boost: recency-weighted = (90*0.2 + 60*1.0) / (0.2 + 1.0) = 78/1.2 β‰ˆ 65 # With boost: (90*0.2*1.5 + 60*1.0) / (0.2*1.5 + 1.0) = (27+60)/(0.3+1.0) = 87/1.3 β‰ˆ 67 score_without = self.scorer.compute_wts(signals) score_with = self.scorer.compute_wts( - signals, verified_submitters={"0xVerified"}, + signals, + verified_submitters={"0xVerified"}, ) # Verified boost should pull score UP even when signal is older assert score_with.wts >= score_without.wts or score_with.verified_submitter_count == 1 @@ -488,6 +575,7 @@ def test_verified_feedback_weighted_higher(self): # Scenario 8: Negative Feedback Values (ERC-8004 int128) # ───────────────────────────────────────────────────────────────── + class TestScenarioNegativeValues: """ Story: An agent trades crypto. Feedback includes negative values @@ -502,14 +590,32 @@ def test_negative_values_clamped(self): """Negative feedback values should clamp to 0 for WTS.""" signals = [ # Trading loss: -3.2% β†’ clamped to 0 - FeedbackSignal(agent_id=1, client_address="0xA", - feedback_index=1, value=-32, value_decimals=1, tag1="tradingYield"), + FeedbackSignal( + agent_id=1, + client_address="0xA", + feedback_index=1, + value=-32, + value_decimals=1, + tag1="tradingYield", + ), # Good score - FeedbackSignal(agent_id=1, client_address="0xB", - feedback_index=2, value=80, value_decimals=0, tag1="starred"), + FeedbackSignal( + agent_id=1, + client_address="0xB", + feedback_index=2, + value=80, + value_decimals=0, + tag1="starred", + ), # Another good score - FeedbackSignal(agent_id=1, client_address="0xC", - feedback_index=3, value=90, value_decimals=0, tag1="starred"), + FeedbackSignal( + agent_id=1, + client_address="0xC", + feedback_index=3, + value=90, + value_decimals=0, + tag1="starred", + ), ] score = self.scorer.compute_wts(signals) # Negative clamped to 0, but recency decay also applies @@ -524,8 +630,13 @@ def test_negative_values_clamped(self): def test_all_negative_gives_zero(self): """All negative feedback β†’ WTS 0.""" signals = [ - FeedbackSignal(agent_id=1, client_address=f"0x{i}", - feedback_index=i + 1, value=-50, value_decimals=0) + FeedbackSignal( + agent_id=1, + client_address=f"0x{i}", + feedback_index=i + 1, + value=-50, + value_decimals=0, + ) for i in range(5) ] score = self.scorer.compute_wts(signals) @@ -537,6 +648,7 @@ def test_all_negative_gives_zero(self): # Scenario 9: Registration File Parsing # ───────────────────────────────────────────────────────────────── + class TestScenarioRegistrationFileParsing: """ Story: Parsing real-world ERC-8004 registration files from the spec. @@ -568,7 +680,9 @@ def test_minimal_registration(self): "description": "The simplest agent", } identity = AgentIdentity.from_registration_file( - agent_id=1, wallet_address="0xMin", data=data, + agent_id=1, + wallet_address="0xMin", + data=data, ) assert identity.name == "MinimalBot" assert identity.services == [] @@ -598,28 +712,33 @@ def test_malformed_data_uri_returns_none(self): def test_value_decimals_examples_from_spec(self): """Test all the value/decimals examples from the EIP spec.""" # starred: 87 / 0 β†’ 87 - signal = FeedbackSignal(agent_id=1, client_address="0x", - feedback_index=1, value=87, value_decimals=0) + signal = FeedbackSignal( + agent_id=1, client_address="0x", feedback_index=1, value=87, value_decimals=0 + ) assert signal.normalized_score == 87.0 # uptime: 9977 / 2 β†’ 99.77 - signal = FeedbackSignal(agent_id=1, client_address="0x", - feedback_index=1, value=9977, value_decimals=2) + signal = FeedbackSignal( + agent_id=1, client_address="0x", feedback_index=1, value=9977, value_decimals=2 + ) assert signal.normalized_score == 99.77 # reachable: 1 / 0 β†’ 1 (boolean true) - signal = FeedbackSignal(agent_id=1, client_address="0x", - feedback_index=1, value=1, value_decimals=0) + signal = FeedbackSignal( + agent_id=1, client_address="0x", feedback_index=1, value=1, value_decimals=0 + ) assert signal.normalized_score == 1.0 # responseTime: 560 / 0 β†’ 560 (milliseconds, NOT a 0-100 score) - signal = FeedbackSignal(agent_id=1, client_address="0x", - feedback_index=1, value=560, value_decimals=0) + signal = FeedbackSignal( + agent_id=1, client_address="0x", feedback_index=1, value=560, value_decimals=0 + ) assert signal.normalized_score == 560.0 # tradingYield: -32 / 1 β†’ -3.2% - signal = FeedbackSignal(agent_id=1, client_address="0x", - feedback_index=1, value=-32, value_decimals=1) + signal = FeedbackSignal( + agent_id=1, client_address="0x", feedback_index=1, value=-32, value_decimals=1 + ) assert signal.normalized_score == -3.2 @@ -627,6 +746,7 @@ def test_value_decimals_examples_from_spec(self): # Scenario 10: Full Trust Gate Pipeline (End-to-End) # ───────────────────────────────────────────────────────────────── + class TestScenarioFullPipeline: """ Story: Test the complete TrustGate orchestrator end-to-end. @@ -709,7 +829,7 @@ async def test_pipeline_caching(self, storage): assert r1.cache_hit is False # Second call β€” cache hit - r2 = await gate.evaluate( + await gate.evaluate( recipient_address="0xAgent", amount=Decimal("10"), ) @@ -750,6 +870,7 @@ async def test_cleanup(self, storage): # Scenario 11: Edge Cases & Boundary Conditions # ───────────────────────────────────────────────────────────────── + class TestEdgeCases: """Test boundary conditions that could crash production.""" @@ -760,8 +881,13 @@ def setup_method(self): def test_exactly_at_wts_threshold(self): """WTS exactly at minimum β†’ should PASS.""" signals = [ - FeedbackSignal(agent_id=1, client_address=f"0x{i}", - feedback_index=i + 1, value=50, value_decimals=0) + FeedbackSignal( + agent_id=1, + client_address=f"0x{i}", + feedback_index=i + 1, + value=50, + value_decimals=0, + ) for i in range(5) ] score = self.scorer.compute_wts(signals) @@ -770,15 +896,23 @@ def test_exactly_at_wts_threshold(self): policy = TrustPolicy(min_wts=50) result = self.engine.evaluate( AgentIdentity(agent_id=1, wallet_address="0x"), - score, Decimal("1"), "0x", policy, + score, + Decimal("1"), + "0x", + policy, ) assert result.verdict == TrustVerdict.APPROVED def test_exactly_at_high_value_threshold(self): """Amount exactly at high-value threshold β†’ should check.""" signals = [ - FeedbackSignal(agent_id=1, client_address=f"0x{i}", - feedback_index=i + 1, value=72, value_decimals=0) + FeedbackSignal( + agent_id=1, + client_address=f"0x{i}", + feedback_index=i + 1, + value=72, + value_decimals=0, + ) for i in range(5) ] score = self.scorer.compute_wts(signals) @@ -790,7 +924,10 @@ def test_exactly_at_high_value_threshold(self): # Exactly $500 β†’ should trigger high-value check result = self.engine.evaluate( AgentIdentity(agent_id=1, wallet_address="0x"), - score, Decimal("500"), "0x", policy, + score, + Decimal("500"), + "0x", + policy, ) assert result.verdict == TrustVerdict.HELD assert result.block_reason == "HIGH_VALUE_WTS_FAIL" @@ -798,7 +935,11 @@ def test_exactly_at_high_value_threshold(self): def test_zero_amount_payment(self): """$0 payment (e.g., lookup or free tier) β†’ should pass.""" result = self.engine.evaluate( - None, None, Decimal("0"), "0x", TrustPolicy.permissive(), + None, + None, + Decimal("0"), + "0x", + TrustPolicy.permissive(), ) assert result.verdict == TrustVerdict.APPROVED @@ -811,16 +952,24 @@ def test_very_large_amount(self): ) result = self.engine.evaluate( AgentIdentity(agent_id=1, wallet_address="0x"), - score, Decimal("1000000"), "0x", policy, + score, + Decimal("1000000"), + "0x", + policy, ) assert result.verdict == TrustVerdict.HELD def test_all_feedback_revoked(self): """All feedback revoked β†’ like new agent.""" signals = [ - FeedbackSignal(agent_id=1, client_address=f"0x{i}", - feedback_index=i + 1, value=90, value_decimals=0, - is_revoked=True) + FeedbackSignal( + agent_id=1, + client_address=f"0x{i}", + feedback_index=i + 1, + value=90, + value_decimals=0, + is_revoked=True, + ) for i in range(5) ] score = self.scorer.compute_wts(signals) @@ -836,7 +985,11 @@ def test_identity_exists_but_no_reputation(self): # With None reputation (no signals at all) result = self.engine.evaluate( - identity, None, Decimal("10"), "0xNew", policy, + identity, + None, + Decimal("10"), + "0xNew", + policy, ) assert result.verdict == TrustVerdict.HELD assert result.block_reason == "NEW_AGENT" @@ -848,15 +1001,20 @@ def test_empty_blocklist_empty_whitelist(self): org_whitelist=[], ) result = self.engine.evaluate( - None, None, Decimal("10"), "0xAnyone", policy, + None, + None, + Decimal("10"), + "0xAnyone", + policy, ) assert result.verdict == TrustVerdict.APPROVED def test_recency_decay_with_single_signal(self): """Single signal should get full weight (max_index = feedback_index).""" signals = [ - FeedbackSignal(agent_id=1, client_address="0xA", - feedback_index=1, value=75, value_decimals=0), + FeedbackSignal( + agent_id=1, client_address="0xA", feedback_index=1, value=75, value_decimals=0 + ), ] score = self.scorer.compute_wts(signals) assert score.wts == 75 @@ -865,14 +1023,21 @@ def test_recency_decay_with_spread_indices(self): """Older signals should contribute less to WTS.""" signals = [ # Old signal (index 1 / max 100 = 1% β†’ old band β†’ 0.2 weight) - FeedbackSignal(agent_id=1, client_address="0xOld", - feedback_index=1, value=20, value_decimals=0), + FeedbackSignal( + agent_id=1, client_address="0xOld", feedback_index=1, value=20, value_decimals=0 + ), # Recent signal (index 100 / 100 = 100% β†’ recent β†’ 1.0 weight) - FeedbackSignal(agent_id=1, client_address="0xRecent", - feedback_index=100, value=90, value_decimals=0), + FeedbackSignal( + agent_id=1, + client_address="0xRecent", + feedback_index=100, + value=90, + value_decimals=0, + ), # Middle signal (index 50 / 100 = 50% β†’ aging β†’ 0.5 weight) - FeedbackSignal(agent_id=1, client_address="0xMiddle", - feedback_index=50, value=60, value_decimals=0), + FeedbackSignal( + agent_id=1, client_address="0xMiddle", feedback_index=50, value=60, value_decimals=0 + ), ] score = self.scorer.compute_wts(signals) diff --git a/tests/test_two_agent_demo.py b/tests/test_two_agent_demo.py new file mode 100644 index 0000000..07ab53e --- /dev/null +++ b/tests/test_two_agent_demo.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +""" +OmniClaw Two-Agent Integration Test +==================================== +Real end-to-end test: Buyer pays Seller through Circle Nanopayments. + +This test runs TWO completely separate agents, each with their own: +- Control Plane server (separate ports) +- Policy file (separate wallets/tokens) +- CLI config (separate configs via env vars) + +Flow: + 1. Start Seller Control Plane (port 8081) β†’ creates seller wallet + 2. Start Buyer Control Plane (port 8082) β†’ creates buyer wallet + 3. Seller: omniclaw-cli serve β†’ x402 paywall on port 9001 + 4. Buyer: Check gateway balance β†’ deposit β†’ pay seller URL + 5. Verify: Seller received payment via Circle Gateway settle() + +Usage: + python3 tests/test_two_agent_demo.py +""" + +import asyncio +import base64 +import contextlib +import json +import os +import signal +import subprocess +import sys +import time + +import httpx + +# ============================================================================ +# CONFIG +# ============================================================================ + +# Shared Circle credentials (same org, different wallets) +CIRCLE_API_KEY = os.environ.get( + "CIRCLE_API_KEY", + "TEST_API_KEY:1965c7f496f043a3c462a58b205ed3be:9f78727fe0a8309e78ed651a6ab79efe", +) +ENTITY_SECRET = os.environ.get( + "ENTITY_SECRET", + "95894cd2a82d2bd76f4668c5008e74c3057026072a79fc37a67014c08e14501c", +) + +# Agent tokens (must match policy files) +SELLER_TOKEN = "seller-agent-token" +BUYER_TOKEN = "buyer-agent-token" + +# Ports +SELLER_CP_PORT = 8081 # Seller control plane +BUYER_CP_PORT = 8082 # Buyer control plane +SELLER_SERVICE_PORT = 9001 # Seller x402 service + +# Paths +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SELLER_POLICY = os.path.join(BASE_DIR, "examples/agent/seller/policy.json") +BUYER_POLICY = os.path.join(BASE_DIR, "examples/agent/buyer/policy.json") + + +# ============================================================================ +# PRETTY PRINT +# ============================================================================ + +def banner(msg): + print(f"\n{'='*60}") + print(f" {msg}") + print(f"{'='*60}\n") + + +def step(num, msg): + print(f"\n β–Ά Step {num}: {msg}") + + +def ok(msg): + print(f" βœ… {msg}") + + +def fail(msg): + print(f" ❌ {msg}") + + +def info(msg): + print(f" ℹ️ {msg}") + + +# ============================================================================ +# PROCESS MANAGEMENT +# ============================================================================ + +processes = [] + + +def start_control_plane(name, port, policy_path): + """Start an OmniClaw Control Plane server.""" + env = os.environ.copy() + env["CIRCLE_API_KEY"] = CIRCLE_API_KEY + env["ENTITY_SECRET"] = ENTITY_SECRET + env["OMNICLAW_AGENT_POLICY_PATH"] = policy_path + env["OMNICLAW_NETWORK"] = "ETH-SEPOLIA" + env["OMNICLAW_RPC_URL"] = "https://ethereum-sepolia-rpc.publicnode.com" + env["OMNICLAW_STORAGE_BACKEND"] = "memory" # Each agent gets own memory + env["OMNICLAW_LOG_LEVEL"] = "WARNING" # Quiet + + proc = subprocess.Popen( + [ + sys.executable, "-m", "uvicorn", + "omniclaw.agent.server:app", + "--host", "0.0.0.0", + "--port", str(port), + "--log-level", "warning", + ], + env=env, + cwd=BASE_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + processes.append(proc) + info(f"{name} Control Plane starting on port {port} (PID: {proc.pid})") + return proc + + +def cleanup(): + """Kill all background processes.""" + for proc in processes: + try: + os.kill(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + except Exception: + with contextlib.suppress(Exception): + os.kill(proc.pid, signal.SIGKILL) + processes.clear() + + +def wait_for_server(port, name, timeout=60): + """Wait for a server to be ready.""" + start = time.time() + while time.time() - start < timeout: + try: + resp = httpx.get(f"http://localhost:{port}/api/v1/health", timeout=2) + if resp.status_code == 200: + ok(f"{name} is ready on port {port}") + return True + except Exception: + pass + time.sleep(1) + fail(f"{name} failed to start within {timeout}s") + return False + + +# ============================================================================ +# FUNDING (SEPOLIA) +# ============================================================================ + +def fund_buyer_from_metamask(buyer_address: str, amount_usdc: float = 0.5): + """Fund the buyer agent's actual address with Sepolia USDC via provided PK.""" + info(f"Funding Buyer EOA {buyer_address} with {amount_usdc} USDC from Metamask...") + try: + from web3 import Web3 + w3 = Web3(Web3.HTTPProvider("https://ethereum-sepolia-rpc.publicnode.com")) + + # Provided by user for testing + pk = "68315157c4a27ce4650fa6a8de2da92bf4ed0b9b24bf119e798ef37f94700562" + account = w3.eth.account.from_key(pk) + + usdc_address = w3.to_checksum_address("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238") + + # Standard ERC20 ABI for transfer and balance + erc20_abi = [ + {"constant": False, "inputs": [{"name": "_to", "type": "address"}, {"name": "_value", "type": "uint256"}], "name": "transfer", "outputs": [{"name": "", "type": "bool"}], "type": "function"}, + {"constant": True, "inputs": [{"name": "_owner", "type": "address"}], "name": "balanceOf", "outputs": [{"name": "balance", "type": "uint256"}], "type": "function"}, + ] + + usdc_contract = w3.eth.contract(address=usdc_address, abi=erc20_abi) + + # Check buyer current balance + buyer_checksum = w3.to_checksum_address(buyer_address) + current_bal = usdc_contract.functions.balanceOf(buyer_checksum).call() + amount_atomic = int(amount_usdc * 1_000_000) + + # 1. First, send some native Sepolia ETH for gas (0.005 ETH) + eth_bal = w3.eth.get_balance(buyer_checksum) + if eth_bal < w3.to_wei(0.002, 'ether'): + info("Sending 0.005 Sepolia ETH for gas...") + eth_tx = { + 'to': buyer_checksum, + 'value': w3.to_wei(0.005, 'ether'), + 'gas': 21000, + 'maxFeePerGas': w3.to_wei('20', 'gwei'), + 'maxPriorityFeePerGas': w3.to_wei('2', 'gwei'), + 'nonce': w3.eth.get_transaction_count(account.address), + 'chainId': 11155111 + } + signed_eth = w3.eth.account.sign_transaction(eth_tx, private_key=pk) + w3.eth.send_raw_transaction(signed_eth.raw_transaction) + ok("Sent native Sepolia ETH for gas.") + + if current_bal >= amount_atomic: + ok(f"Buyer already has {current_bal / 1_000_000} USDC in EOA. Skipping USDC funding.") + return True + + # 2. Check metamask USDC balance + funder_bal = usdc_contract.functions.balanceOf(account.address).call() + if funder_bal < amount_atomic: + fail(f"Metamask wallet {account.address} only has {funder_bal / 1_000_000} USDC. Cannot fund.") + return False + + nonce = w3.eth.get_transaction_count(account.address) + tx = usdc_contract.functions.transfer(buyer_checksum, amount_atomic).build_transaction({ + 'chainId': 11155111, + 'gas': 100000, + 'maxFeePerGas': w3.to_wei('20', 'gwei'), + 'maxPriorityFeePerGas': w3.to_wei('2', 'gwei'), + 'nonce': nonce, + }) + + signed_tx = w3.eth.account.sign_transaction(tx, private_key=pk) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + ok(f"Sent USDC tx: {tx_hash.hex()}. Waiting for confirmation...") + + w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) + ok("Funding confirmed! Buyer EOA now has USDC and ETH.") + return True + + except Exception as e: + fail(f"Funding failed: {e}") + return False + +# ============================================================================ +# TEST FLOW +# ============================================================================ + +async def run_test(): + banner("OmniClaw Two-Agent Demo Test") + print(" Buyer β†’ deposits to Gateway β†’ pays Seller URL") + print(" Seller β†’ accepts payment β†’ Circle Gateway settles") + print(" Both agents are SEPARATE β€” different wallets, different control planes\n") + + try: + # ================================================================ + # STEP 1: Start both Control Planes + # ================================================================ + step(1, "Starting Control Planes (with in-memory storage)") + + start_control_plane("Seller", SELLER_CP_PORT, SELLER_POLICY) + start_control_plane("Buyer", BUYER_CP_PORT, BUYER_POLICY) + + if not wait_for_server(SELLER_CP_PORT, "Seller CP", timeout=90): + return False + if not wait_for_server(BUYER_CP_PORT, "Buyer CP", timeout=90): + return False + + # Wait a bit for wallet initialization to complete (background task) + info("Waiting 15s for wallet initialization (background async tasks)...") + await asyncio.sleep(15) + + # ================================================================ + # STEP 2: Verify wallets exist + # ================================================================ + step(2, "Verifying agent wallets") + + seller_client = httpx.AsyncClient( + base_url=f"http://localhost:{SELLER_CP_PORT}", + headers={"Authorization": f"Bearer {SELLER_TOKEN}"}, + timeout=30, + ) + buyer_client = httpx.AsyncClient( + base_url=f"http://localhost:{BUYER_CP_PORT}", + headers={"Authorization": f"Bearer {BUYER_TOKEN}"}, + timeout=30, + ) + + # Seller address + for _attempt in range(10): + try: + resp = await seller_client.get("/api/v1/address") + if resp.status_code == 200: + seller_addr = resp.json().get("address") + ok(f"Seller wallet: {seller_addr}") + break + elif resp.status_code == 425: + info(f"Seller wallet initializing... (attempt {_attempt + 1})") + await asyncio.sleep(5) + else: + info(f"Seller address response: {resp.status_code} {resp.text}") + await asyncio.sleep(3) + except Exception as e: + info(f"Retry {_attempt + 1}: {e}") + await asyncio.sleep(3) + else: + fail("Seller wallet not ready after 10 attempts") + return False + + # Buyer address + for attempt in range(10): + try: + resp = await buyer_client.get("/api/v1/address") + if resp.status_code == 200: + buyer_addr = resp.json().get("address") + ok(f"Buyer wallet: {buyer_addr}") + break + elif resp.status_code == 425: + info(f"Buyer wallet initializing... (attempt {attempt + 1})") + await asyncio.sleep(5) + else: + info(f"Buyer address response: {resp.status_code} {resp.text}") + await asyncio.sleep(3) + except Exception as e: + info(f"Retry {attempt + 1}: {e}") + await asyncio.sleep(3) + else: + fail("Buyer wallet not ready after 10 attempts") + return False + + # ================================================================ + # STEP 2.5: Fund Buyer with Sepolia USDC via provided private key + # ================================================================ + step(2.5, "Funding Buyer Agent EOA with provided Metamask key") + if not fund_buyer_from_metamask(buyer_addr, 0.5): + info("Continuing test anyway, but payment might fail due to no funds...") + + # Trigger manual deposit to Gateway + try: + info("Triggering Gateway deposit transaction...") + dep_resp = await buyer_client.post("/api/v1/deposit", params={"amount": "0.5"}, timeout=120) + if dep_resp.status_code == 200: + dep_data = dep_resp.json() + ok(f"Deposit triggered! Hash: {dep_data.get('deposit_tx_hash')}") + else: + info(f"Deposit error: {dep_resp.status_code} {dep_resp.text}") + except Exception as e: + info(f"Deposit trigger failed: {e}") + + # ================================================================ + # STEP 3: Get seller's nano address (for Gateway payments) + # ================================================================ + step(3, "Getting seller nano address (Gateway wallet)") + + resp = await seller_client.get("/api/v1/nano-address") + if resp.status_code == 200: + seller_nano_addr = resp.json().get("address") + ok(f"Seller nano address: {seller_nano_addr}") + else: + info(f"Seller nano-address not available: {resp.status_code} {resp.text}") + seller_nano_addr = seller_addr + info(f"Using regular address: {seller_nano_addr}") + + # ================================================================ + # STEP 4: Check buyer Gateway balance + # ================================================================ + step(4, "Checking buyer Gateway balance") + + resp = await buyer_client.get("/api/v1/nano-address") + if resp.status_code == 200: + buyer_nano_addr = resp.json().get("address") + ok(f"Buyer nano address: {buyer_nano_addr}") + else: + buyer_nano_addr = buyer_addr + info(f"Buyer nano address fallback: {buyer_nano_addr}") + + # Check gateway balance via NanopaymentClient + from omniclaw.protocols.nanopayments.client import NanopaymentClient + nano_client = NanopaymentClient(api_key=CIRCLE_API_KEY) + + info("Circle Gateway requires 15+ minutes of block confirmations on Sepolia for finality!") + info("Checking Gateway Wallet balance once, then proceeding to verify 402 flow...") + await asyncio.sleep(5) + + try: + buyer_gw_balance = await nano_client.check_balance( + address=buyer_nano_addr, + network="eip155:11155111", + ) + if buyer_gw_balance.available >= 10_000: + ok(f"Buyer Gateway balance ready: {buyer_gw_balance.available} atomic") + else: + info("Gateway Balance is current 0, as expected. Deposit is pending finality.") + info(f"Buyer nano Gateway address to monitor: {buyer_nano_addr}") + info("Continuing test to verify 402 flow regardless...") + except Exception as e: + info(f"Error checking balance: {e}. Continuing...") + + # ================================================================ + # STEP 5: Start Seller x402 Service (omniclaw-cli serve) + # ================================================================ + step(5, "Starting Seller x402 service (omniclaw-cli serve)") + + # The serve command needs to talk to the seller's control plane + serve_env = os.environ.copy() + serve_env["OMNICLAW_SERVER_URL"] = f"http://localhost:{SELLER_CP_PORT}" + serve_env["OMNICLAW_TOKEN"] = SELLER_TOKEN + serve_env["CIRCLE_API_KEY"] = CIRCLE_API_KEY + serve_env["OMNICLAW_CONFIG_DIR"] = "/tmp/omniclaw_seller_test" + + serve_proc = subprocess.Popen( + [ + sys.executable, "-m", "omniclaw.cli_agent", + "serve", + "--price", "0.01", + "--endpoint", "/api/data", + "--exec", "echo '{\"result\": \"premium data from Agent A\"}'", + "--port", str(SELLER_SERVICE_PORT), + ], + env=serve_env, + cwd=BASE_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + processes.append(serve_proc) + info(f"Seller service starting on port {SELLER_SERVICE_PORT} (PID: {serve_proc.pid})") + + # Wait for it to be ready + await asyncio.sleep(5) + for _attempt in range(10): + try: + resp = httpx.get( + f"http://localhost:{SELLER_SERVICE_PORT}/api/data", + timeout=5, + ) + if resp.status_code == 402: + ok("Seller service returned 402 (Payment Required)") + break + else: + info(f"Unexpected status: {resp.status_code}") + await asyncio.sleep(2) + except Exception as e: + info(f"Waiting for seller service... ({e})") + await asyncio.sleep(2) + else: + fail("Seller service did not start") + return False + + # ================================================================ + # STEP 6: Verify 402 response has correct x402 v2 structure + # ================================================================ + step(6, "Verifying 402 response (x402 v2 compliance)") + + resp = httpx.get( + f"http://localhost:{SELLER_SERVICE_PORT}/api/data", + timeout=10, + ) + + assert resp.status_code == 402, f"Expected 402, got {resp.status_code}" + + # Check PAYMENT-REQUIRED header + payment_required = resp.headers.get("payment-required") or resp.headers.get("PAYMENT-REQUIRED") + if payment_required: + req_data = json.loads(base64.b64decode(payment_required)) + ok("PAYMENT-REQUIRED header present") + ok(f"x402Version: {req_data.get('x402Version')}") + + accepts = req_data.get("accepts", []) + if accepts: + kind = accepts[0] + ok(f"scheme: {kind.get('scheme')}") + ok(f"network: {kind.get('network')}") + ok(f"asset: {kind.get('asset', 'MISSING')}") + ok(f"amount: {kind.get('amount')} atomic") + ok(f"maxTimeoutSeconds: {kind.get('maxTimeoutSeconds', 'MISSING')}") + ok(f"payTo: {kind.get('payTo')}") + + extra = kind.get("extra", {}) + ok(f"extra.name: {extra.get('name', 'MISSING')}") + ok(f"extra.version: {extra.get('version', 'MISSING')}") + ok(f"extra.verifyingContract: {extra.get('verifyingContract', 'MISSING')}") + + # Validate completeness + required_fields = ["scheme", "network", "asset", "amount", "maxTimeoutSeconds", "payTo", "extra"] + missing = [f for f in required_fields if f not in kind or kind[f] is None] + if missing: + fail(f"Missing required fields: {missing}") + else: + ok("βœ… ALL x402 v2 fields present β€” Circle compliant!") + + if extra.get("name") == "GatewayWalletBatched": + ok("βœ… GatewayWalletBatched scheme β€” gasless nanopayment!") + else: + fail(f"Expected GatewayWalletBatched, got {extra.get('name')}") + else: + fail("No accepts array in 402 response") + else: + # Check response body instead + body = resp.json() + info(f"402 body: {json.dumps(body, indent=2)}") + if "accepts" in body: + ok("Found accepts in response body") + else: + fail("No PAYMENT-REQUIRED header found") + + # ================================================================ + # STEP 7: Buyer attempts payment (omniclaw-cli pay via API) + # ================================================================ + step(7, "Buyer paying Seller (x402 nanopayment flow)") + + info("Sending payment request to Buyer Control Plane...") + info(f"Target: http://localhost:{SELLER_SERVICE_PORT}/api/data") + + try: + pay_resp = await buyer_client.post( + "/api/v1/x402/pay", + json={ + "url": f"http://localhost:{SELLER_SERVICE_PORT}/api/data", + "method": "GET", + }, + timeout=60, + ) + + pay_data = pay_resp.json() + info(f"Payment response status: {pay_resp.status_code}") + info(f"Payment result: {json.dumps(pay_data, indent=2)}") + + if pay_data.get("success"): + ok("πŸŽ‰ PAYMENT SUCCESSFUL!") + ok(f"Amount: {pay_data.get('amount')} USDC") + ok(f"Method: {pay_data.get('method')}") + ok(f"Transaction: {pay_data.get('transaction_id', 'N/A')}") + ok(f"Status: {pay_data.get('status')}") + + # Step 8: Verify settlement + step(8, "Verifying settlement (seller side)") + info("Circle Gateway settles in batches β€” balance credited immediately") + + try: + seller_balance = await nano_client.check_balance( + address=seller_nano_addr, + network="eip155:11155111", + ) + ok(f"Seller Gateway balance: {seller_balance.available} atomic ({seller_balance.available / 1_000_000:.6f} USDC)") + except Exception as e: + info(f"Could not check seller balance: {e}") + + else: + error = pay_data.get("error", "Unknown error") + info(f"Payment did not succeed: {error}") + + if "insufficient" in error.lower() or "balance" in error.lower(): + info("⚠️ This is expected if buyer has no Gateway balance!") + info("The 402 and settlement flows are working correctly.") + info("To complete the demo, deposit USDC to the buyer's Gateway wallet.") + ok("βœ… x402 protocol flow is WORKING (needs Gateway balance to complete)") + else: + fail(f"Unexpected error: {error}") + + except Exception as e: + info(f"Payment request failed: {e}") + import traceback + traceback.print_exc() + + # ================================================================ + # Summary + # ================================================================ + banner("Test Summary") + print(" βœ… Seller Control Plane: Started and healthy") + print(" βœ… Buyer Control Plane: Started and healthy") + print(" βœ… Separate wallets: Each agent has its own wallet") + print(" βœ… Seller serve: Returns x402 v2 compliant 402") + print(" βœ… GatewayWalletBatched: Circle Nanopayment scheme") + print(" βœ… Buyerβ†’Seller: x402 payment flow initiated") + print("") + print(" For a fully complete payment, ensure:") + print(f" 1. Buyer has Gateway balance (fund: {buyer_nano_addr})") + print(" 2. Seller accepts on correct network") + print("") + + return True + + finally: + # Cleanup + info("Cleaning up processes...") + cleanup() + + +# ============================================================================ +# MAIN +# ============================================================================ + +if __name__ == "__main__": + try: + result = asyncio.run(run_test()) + sys.exit(0 if result else 1) + except KeyboardInterrupt: + print("\n\n Interrupted. Cleaning up...") + cleanup() + sys.exit(130) + except Exception as e: + print(f"\n ❌ Fatal error: {e}") + import traceback + traceback.print_exc() + cleanup() + sys.exit(1) diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py index 68254f6..18db013 100644 --- a/tests/test_webhook_verification.py +++ b/tests/test_webhook_verification.py @@ -1,12 +1,16 @@ import base64 +import json import os import tempfile +from datetime import datetime, timedelta, timezone from unittest.mock import patch import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 +from omniclaw.core.events import NotificationType +from omniclaw.core.exceptions import ValidationError from omniclaw.webhooks.parser import DuplicateWebhookError, InvalidSignatureError, WebhookParser @@ -109,10 +113,6 @@ def test_no_verification_key(): headers = {} # No header needed assert parser.verify_signature(payload, headers) is True -import json -from datetime import datetime, timezone, timedelta -from omniclaw.core.events import NotificationType -from omniclaw.core.exceptions import ValidationError def test_handle_valid_payload(parser, key_pair): private_key, _ = key_pair @@ -122,7 +122,7 @@ def test_handle_valid_payload(parser, key_pair): "notificationType": "payment_completed", "notificationId": "evt_123", "createDate": now_iso, - "notification": {"status": "COMPLETE"} + "notification": {"status": "COMPLETE"}, } payload = json.dumps(payload_dict) signature = sign_payload(private_key, payload) @@ -130,26 +130,28 @@ def test_handle_valid_payload(parser, key_pair): "x-circle-signature": signature, "x-circle-timestamp": str(int(datetime.now(timezone.utc).timestamp())), } - + event = parser.handle(payload, headers) assert event.type == NotificationType.PAYMENT_COMPLETED assert event.id == "evt_123" + def test_handle_missing_createdate(parser, key_pair): private_key, _ = key_pair payload_dict = { "notificationType": "payment_completed", "notificationId": "evt_123", # missing createDate - "notification": {"status": "COMPLETE"} + "notification": {"status": "COMPLETE"}, } payload = json.dumps(payload_dict) signature = sign_payload(private_key, payload) headers = {"x-circle-signature": signature} - + with pytest.raises(ValidationError, match="createDate"): parser.handle(payload, headers) + def test_handle_replay_attack(parser, key_pair): private_key, _ = key_pair # Timestamp beyond default max replay age window (12 hours). @@ -158,7 +160,7 @@ def test_handle_replay_attack(parser, key_pair): "notificationType": "payment_completed", "notificationId": "evt_123", "createDate": old_time.isoformat(), - "notification": {"status": "COMPLETE"} + "notification": {"status": "COMPLETE"}, } payload = json.dumps(payload_dict) signature = sign_payload(private_key, payload) @@ -166,7 +168,7 @@ def test_handle_replay_attack(parser, key_pair): "x-circle-signature": signature, "x-circle-timestamp": str(int(old_time.timestamp())), } - + with pytest.raises(InvalidSignatureError, match="replay age window"): parser.handle(payload, headers) diff --git a/tests/test_x402_full_flow.py b/tests/test_x402_full_flow.py index e6518ec..06bb9d5 100644 --- a/tests/test_x402_full_flow.py +++ b/tests/test_x402_full_flow.py @@ -11,15 +11,13 @@ pytest tests/test_x402_full_flow.py -v """ -import asyncio import base64 +import binascii import json -import pytest -from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import httpx - +import pytest # ============================================================================= # Test Scenarios @@ -183,7 +181,7 @@ async def test_scenario_1_basic_x402(self): supports_circle = any(acc.get("scheme") == "GatewayWalletBatched" for acc in accepts) - assert supports_circle == False, "Should NOT detect Circle support" + assert not supports_circle, "Should NOT detect Circle support" print("βœ“ Correctly detected: NO Circle nanopayment support") @pytest.mark.asyncio @@ -205,7 +203,7 @@ async def test_scenario_2_nanopayment_detection(self): supports_circle = any(acc.get("scheme") == "GatewayWalletBatched" for acc in accepts) - assert supports_circle == True, "Should detect Circle support" + assert supports_circle, "Should detect Circle support" print("βœ“ Correctly detected: Circle nanopayment IS supported") @pytest.mark.asyncio @@ -290,7 +288,7 @@ async def test_client_routes_based_on_402(self, mock_get): # This is what the client should do supports_nanopayment = any(acc.get("scheme") == "GatewayWalletBatched" for acc in accepts) - assert supports_nanopayment == True + assert supports_nanopayment print("βœ“ Client correctly routes based on 402 response") print(f" Accepts: {[a['scheme'] for a in accepts]}") @@ -362,7 +360,7 @@ async def test_full_flow_basic_x402(self): # Step 4: Seller returns 402 (no Circle) mock_402 = create_402_response(schemes=["exact"]) - print(f"4. Seller returns: 402 (accepts: exact only)") + print("4. Seller returns: 402 (accepts: exact only)") # Step 5: Client routes to basic x402 accepts = mock_402.headers["payment-required"] @@ -438,7 +436,7 @@ async def test_invalid_402_body(self): print("Error: Invalid 402 Body") # Invalid base64 - with pytest.raises(Exception): + with pytest.raises(binascii.Error): base64.b64decode("not-valid-base64!!!") print("βœ“ Correctly detected invalid base64")