diff --git a/.agents/skills/omniclaw-cli/SKILL.md b/.agents/skills/omniclaw-cli/SKILL.md index 79975db..b79d65f 100644 --- a/.agents/skills/omniclaw-cli/SKILL.md +++ b/.agents/skills/omniclaw-cli/SKILL.md @@ -12,7 +12,7 @@ requires: - env: OMNICLAW_SERVER_URL description: > OmniClaw Financial Policy Engine base URL. Required unless the CLI was - already configured locally before the agent turn. + already persisted in local CLI config before the agent turn. - env: OMNICLAW_TOKEN description: > Scoped agent token. Never print, log, or transmit it. If missing, stop @@ -69,8 +69,9 @@ The runtime should normally provide either: - `OMNICLAW_TOKEN` - optionally `OMNICLAW_OWNER_TOKEN` if this run is allowed to approve confirmations -2. preconfigured CLI state +2. persisted CLI config - `omniclaw-cli configure` was already run before the turn +- the CLI reads saved config values for server URL, token, wallet alias, and optional owner token If neither is true, stop and ask the owner for: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5918ab1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.venv +.mypy_cache +.pytest_cache +.ruff_cache +.coverage +logs +tmp +.runtime +output/doc +docs/internal +dist +build +*.egg-info +__pycache__ +*.pyc +.pytype +video/ diff --git a/.gitignore b/.gitignore index 7f51374..3831e67 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,14 @@ htmlcov/ # Logs *.log +logs/ +tmp/ +.runtime/ +output/doc/ + +# Internal docs / GTM materials +docs/internal/ # OS Thumbs.db +video/ diff --git a/docker-compose.agent.yml b/docker-compose.agent.yml deleted file mode 100644 index 6bb3ad6..0000000 --- a/docker-compose.agent.yml +++ /dev/null @@ -1,10 +0,0 @@ -# Canonical root-level stacks now live here: -# -# Buyer / payment agent: -# docker compose -f docker-compose.payment-agent.yml up -d --build -# -# Seller agent: -# docker compose -f docker-compose.seller-agent.yml up -d --build -# -# This file is kept as a compatibility stub so existing references do not -# silently point to stale configuration. diff --git a/docs/FOUNDATION_DEMO.md b/docs/FOUNDATION_DEMO.md deleted file mode 100644 index aa9bedc..0000000 --- a/docs/FOUNDATION_DEMO.md +++ /dev/null @@ -1,70 +0,0 @@ -# Foundation Demo - -Official dual-sided OmniClaw demo flow: - -1. Buyer policy blocks a malicious URL instantly. -2. Seller exposes a paid API behind `omniclaw-cli serve`. -3. Buyer checks budget, triggers approval, and pays with `omniclaw-cli pay`. -4. Script waits for Circle Gateway batch settlement to update buyer/seller Gateway contract balances. - -## Default Run - -```bash -scripts/demo_foundation_showcase.sh -``` - -Defaults: - -- network: `ETH-SEPOLIA` -- buyer Financial Policy Engine: `http://localhost:9190` -- seller Financial Policy Engine: `http://localhost:9191` -- seller paid endpoint: `http://localhost:9291/api/data` - -## Base Mode - -```bash -scripts/demo_foundation_showcase.sh --base -``` - -Equivalent explicit form: - -```bash -scripts/demo_foundation_showcase.sh --network BASE-SEPOLIA -``` - -If the current wallets are not funded on Base Sepolia, the script now stops early with a clear funding hint instead of failing late in the flow. - -## Ethereum Explicit - -```bash -scripts/demo_foundation_showcase.sh --eth -``` - -## Recording Layout - -One-command tmux layout: - -```bash -scripts/demo_foundation_tmux.sh -``` - -It opens: - -- top-left: buyer terminal using `omniclaw-cli pay` -- top-right: seller terminal using `omniclaw-cli serve` -- bottom-left: live Circle Gateway settlement monitor -- bottom-right: stage log with the demo narrative - -There is also a hidden `infra` tmux window running the two OmniClaw Financial Policy Engine processes. - -Base version: - -```bash -scripts/demo_foundation_tmux.sh --base -``` - -## Notes - -- The payment step remains `omniclaw-cli pay`. -- The budget simulation step uses the Financial Policy Engine API because `omniclaw-cli simulate` is still intermittently unstable in this environment. -- Logs are written under `logs/foundation_demo_*` or `logs/foundation_demo_tmux_*`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..cf3809c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# OmniClaw Docs + +This directory is split into two parts: + +- public product and developer docs in `docs/` +- internal GTM, grant, outreach, and demo materials in `docs/internal/` + +## Public Docs + +### Getting Started + +- `docs/agent-getting-started.md` +- `docs/agent-skills.md` +- `docs/cli-reference.md` + +### Product And Architecture + +- `docs/FEATURES.md` +- `docs/compliance-architecture.md` +- `docs/architecture_overview.svg` +- `docs/OmniClaw_Whitepaper_v1.0.pdf` + +### Technical Reference + +- `docs/API_REFERENCE.md` +- `docs/POLICY_REFERENCE.md` +- `docs/SDK_USAGE_GUIDE.md` +- `docs/CCTP_USAGE.md` +- `docs/PRODUCTION_HARDENING.md` +- `docs/erc_804_spec.md` + +## Internal Docs + +Use `docs/internal/README.md` for: + +- grant strategy +- sponsor and investor messaging +- public post drafts +- demo narration and story +- archived older grant docs diff --git a/docs/TWO_SIDED_DEMO.md b/docs/TWO_SIDED_DEMO.md deleted file mode 100644 index 19e41d4..0000000 --- a/docs/TWO_SIDED_DEMO.md +++ /dev/null @@ -1,78 +0,0 @@ -# Two-Sided OmniClaw CLI Demo - -This demo shows both sides of the economy: -- **Seller** exposes a paid x402 URL. -- **Buyer** pays that URL through OmniClaw + Circle nanopayments. - -It uses: -- `examples/agent/seller/policy.json` -- `examples/agent/buyer/policy.json` -- `scripts/demo_two_sided.sh` - -Note: the script copies policy files into per-run `logs/demo_/...runtime.json` -so your checked-in policy files are not modified during the demo. - -## 1. Preflight - -```bash -./scripts/demo_two_sided.sh --check-only -``` - -This validates required env keys from `.env` without printing secrets. - -## 2. ETH Sepolia Demo (recommended first) - -If your default ports are already used: - -```bash -./scripts/demo_two_sided.sh \ - --seller-cp-port 8181 \ - --buyer-cp-port 8182 \ - --seller-gate-port 9101 -``` - -Optional full flow attempt (pay from buyer to seller URL): - -```bash -./scripts/demo_two_sided.sh \ - --seller-cp-port 8181 \ - --buyer-cp-port 8182 \ - --seller-gate-port 9101 \ - --run-payment -``` - -Keep services running for live walkthrough: - -```bash -./scripts/demo_two_sided.sh \ - --seller-cp-port 8181 \ - --buyer-cp-port 8182 \ - --seller-gate-port 9101 \ - --hold -``` - -## 3. Base Sepolia Variant - -```bash -./scripts/demo_two_sided.sh \ - --network BASE-SEPOLIA \ - --rpc-url https://base-sepolia-rpc.publicnode.com \ - --seller-cp-port 8181 \ - --buyer-cp-port 8182 \ - --seller-gate-port 9101 -``` - -## 4. Logs - -Each run writes logs under: - -```text -logs/demo_/ -``` - -Files include: -- `seller-control-plane.log` - seller Financial Policy Engine log -- `buyer-control-plane.log` - buyer Financial Policy Engine log -- `seller-gateway.log` -- `seller-cli.log` -- `buyer-cli.log` diff --git a/docs/agent-skills.md b/docs/agent-skills.md index c0a1b11..ca0d588 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -55,7 +55,7 @@ A typical OmniClaw agent runtime needs: - `OMNICLAW_TOKEN`: scoped agent token - optionally `OMNICLAW_OWNER_TOKEN`: only if the run is allowed to approve confirmations -For local convenience, you can persist those values in CLI config: +For local convenience, you can persist those values in CLI config. `configure` writes saved CLI config; it does not export shell environment variables: ```bash omniclaw-cli configure \ @@ -80,6 +80,8 @@ Show saved config: omniclaw-cli configure --show ``` +When the CLI has been configured already, later commands such as `balance`, `can-pay`, `pay`, and `serve` can reuse that saved config without re-exporting `OMNICLAW_SERVER_URL` and `OMNICLAW_TOKEN` in the shell. + ## Runtime Architecture Typical execution path: diff --git a/examples/local-economy/README.md b/examples/local-economy/README.md new file mode 100644 index 0000000..74dbf86 --- /dev/null +++ b/examples/local-economy/README.md @@ -0,0 +1,28 @@ +# Local Economy Example + +This is the canonical local buyer/seller OmniClaw example. + +It contains: +- buyer stack: `docker-compose.payment-agent.yml` +- seller stack: `docker-compose.seller-agent.yml` +- buyer policy: `payment-agent.policy.json` +- seller policy: `seller-agent.policy.json` + +Roles: +- buyer uses `omniclaw-cli pay` +- seller uses `omniclaw-cli serve` + +Default ports: +- buyer Financial Policy Engine: `9090` +- seller Financial Policy Engine: `9091` +- seller paid endpoint example: `8000` + +Start buyer: +```bash +docker compose -p omniclaw-buyer -f examples/local-economy/docker-compose.payment-agent.yml up -d --build --remove-orphans +``` + +Start seller: +```bash +docker compose -p omniclaw-seller -f examples/local-economy/docker-compose.seller-agent.yml up -d --build --remove-orphans +``` diff --git a/docker-compose.payment-agent.yml b/examples/local-economy/docker-compose.payment-agent.yml similarity index 78% rename from docker-compose.payment-agent.yml rename to examples/local-economy/docker-compose.payment-agent.yml index eb780ab..9232ceb 100644 --- a/docker-compose.payment-agent.yml +++ b/examples/local-economy/docker-compose.payment-agent.yml @@ -11,16 +11,18 @@ services: retries: 10 payment-agent: + image: omniclaw-agent:local build: - context: . + context: ../.. dockerfile: Dockerfile.agent command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 9090 environment: OMNICLAW_REDIS_URL: redis://payment-agent-redis:6379/0 - OMNICLAW_AGENT_POLICY_PATH: /config/payment-agent.policy.json + OMNICLAW_AGENT_POLICY_PATH: /config/runtime-policy.json OMNICLAW_AGENT_TOKEN: payment-agent-token OMNICLAW_LOG_LEVEL: INFO OMNICLAW_POLICY_RELOAD_INTERVAL: 0 + OMNICLAW_OWNER_TOKEN: payment-owner-token OMNICLAW_NANOPAYMENTS_ENABLED: "true" OMNICLAW_NETWORK: ${OMNICLAW_NETWORK} OMNICLAW_RPC_URL: ${OMNICLAW_RPC_URL} @@ -29,9 +31,11 @@ services: CIRCLE_API_KEY: ${BUYER_CIRCLE_API_KEY:-${CIRCLE_API_KEY}} ENTITY_SECRET: ${BUYER_ENTITY_SECRET:-${ENTITY_SECRET}} volumes: - - ./payment-agent.policy.json:/config/payment-agent.policy.json + - ${PAYMENT_AGENT_POLICY_FILE:-./payment-agent.policy.json}:/config/runtime-policy.json ports: - "9090:9090" + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: payment-agent-redis: condition: service_healthy diff --git a/docker-compose.seller-agent.yml b/examples/local-economy/docker-compose.seller-agent.yml similarity index 84% rename from docker-compose.seller-agent.yml rename to examples/local-economy/docker-compose.seller-agent.yml index d67e071..5cd7a0d 100644 --- a/docker-compose.seller-agent.yml +++ b/examples/local-economy/docker-compose.seller-agent.yml @@ -11,13 +11,14 @@ services: retries: 10 seller-agent: + image: omniclaw-agent:local build: - context: . + context: ../.. dockerfile: Dockerfile.agent command: uv run uvicorn omniclaw.agent.server:app --host 0.0.0.0 --port 9091 environment: OMNICLAW_REDIS_URL: redis://seller-agent-redis:6379/0 - OMNICLAW_AGENT_POLICY_PATH: /config/seller-agent.policy.json + OMNICLAW_AGENT_POLICY_PATH: /config/runtime-policy.json OMNICLAW_AGENT_TOKEN: seller-agent-token OMNICLAW_LOG_LEVEL: INFO OMNICLAW_POLICY_RELOAD_INTERVAL: 0 @@ -29,7 +30,7 @@ services: CIRCLE_API_KEY: ${SELLER_CIRCLE_API_KEY:-${CIRCLE_API_KEY}} ENTITY_SECRET: ${SELLER_ENTITY_SECRET:-${ENTITY_SECRET}} volumes: - - ./seller-agent.policy.json:/config/seller-agent.policy.json + - ${SELLER_AGENT_POLICY_FILE:-./seller-agent.policy.json}:/config/runtime-policy.json ports: - "9091:9091" depends_on: diff --git a/payment-agent.policy.json b/examples/local-economy/payment-agent.policy.json similarity index 73% rename from payment-agent.policy.json rename to examples/local-economy/payment-agent.policy.json index 8ddae5e..cd8f1a9 100644 --- a/payment-agent.policy.json +++ b/examples/local-economy/payment-agent.policy.json @@ -10,8 +10,6 @@ "wallets": { "payment-agent": { "name": "Payment Agent", - "wallet_id": "2c9bf657-c70e-5d33-873e-08f9b3fcb9c9", - "address": "0x1c3c4c77088494fbc3f9ce1e87bfd7b4cfec0e18", "limits": { "daily_max": "25.00", "hourly_max": "10.00", @@ -25,15 +23,17 @@ "recipients": { "mode": "whitelist", "addresses": [ - "0x5a4e248fa08c37b15ea0efdfdf336e92317d5243" + "0x5a4e248fa08c37b15ea0efdfdf336e92317d5243", + "0x95cE2edF56cc05b2634267d55D8AfB7f8630c143" ], "domains": [ "api.stripe.com", "localhost", - "127.0.0.1" + "127.0.0.1", + "host.docker.internal" ] }, - "confirm_threshold": "0.005" + "confirm_threshold": null } } -} \ No newline at end of file +} diff --git a/seller-agent.policy.json b/examples/local-economy/seller-agent.policy.json similarity index 85% rename from seller-agent.policy.json rename to examples/local-economy/seller-agent.policy.json index abc3b01..b3e0888 100644 --- a/seller-agent.policy.json +++ b/examples/local-economy/seller-agent.policy.json @@ -25,7 +25,8 @@ "addresses": [], "domains": [] }, - "confirm_threshold": null + "confirm_threshold": null, + "address": "0x6a5fb3f11216ad67f80f3ac4c5896aafcc2afc3a" } } } diff --git a/scripts/start_local_economy.sh b/scripts/start_local_economy.sh new file mode 100755 index 0000000..368305a --- /dev/null +++ b/scripts/start_local_economy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" +if [[ -f .env ]]; then + set -a + source .env + set +a +fi +STATE_DIR="${STATE_DIR:-$ROOT/.runtime}" +mkdir -p "$STATE_DIR" +cp examples/local-economy/payment-agent.policy.json "$STATE_DIR/payment-agent.policy.runtime.json" +cp examples/local-economy/seller-agent.policy.json "$STATE_DIR/seller-agent.policy.runtime.json" +HOST_IP=$(hostname -I | awk '{print $1}') +python3 - </dev/null 2>&1; then + echo "Building $IMAGE_TAG ..." + DOCKER_BUILDKIT=0 docker build -t "$IMAGE_TAG" -f Dockerfile.agent . +fi + +docker compose -p "${BUYER_PROJECT_NAME:-omniclaw-buyer}" -f examples/local-economy/docker-compose.payment-agent.yml up -d --no-build --remove-orphans +sleep 2 +docker compose -p "${SELLER_PROJECT_NAME:-omniclaw-seller}" -f examples/local-economy/docker-compose.seller-agent.yml up -d --no-build --remove-orphans +HOST_IP=$(hostname -I | awk '{print $1}') +printf 'Buyer server: http://localhost:9090\n' +printf 'Buyer token: payment-agent-token\n' +printf 'Buyer wallet: payment-agent\n' +printf 'Seller server: http://localhost:9091\n' +printf 'Seller token: seller-agent-token\n' +printf 'Seller wallet: seller-agent\n' +printf 'Seller paid URL for buyer: http://172.17.0.1:8000/ping\n' +printf 'Seller paid URL for other local/LAN clients: http://%s:8000/ping\n' "$HOST_IP" +printf 'Runtime buyer policy: %s\n' "$PAYMENT_AGENT_POLICY_FILE" +printf 'Runtime seller policy: %s\n' "$SELLER_AGENT_POLICY_FILE" diff --git a/src/omniclaw/agent/models.py b/src/omniclaw/agent/models.py index 55222c0..c0d2c0b 100644 --- a/src/omniclaw/agent/models.py +++ b/src/omniclaw/agent/models.py @@ -189,3 +189,10 @@ class X402VerifyRequest(BaseModel): amount: str = Field(..., description="Amount paid") sender: str = Field(..., description="Sender address") resource: str = Field(..., description="Resource URL") + + +class X402RequirementsRequest(BaseModel): + """X402 requirements request for seller-side paid endpoints.""" + + amount: str = Field(..., description="Price in USD or atomic units") + resource: str = Field(..., description="Protected resource URL") diff --git a/src/omniclaw/agent/routes.py b/src/omniclaw/agent/routes.py index a874e22..648f212 100644 --- a/src/omniclaw/agent/routes.py +++ b/src/omniclaw/agent/routes.py @@ -26,11 +26,13 @@ TransactionInfo, WalletInfo, X402PayRequest, + X402RequirementsRequest, X402VerifyRequest, ) from omniclaw.agent.policy import PolicyManager, WalletManager from omniclaw.core.logging import get_logger from omniclaw.guards.confirmations import ConfirmationStore +from omniclaw.ledger import LedgerEntry, LedgerEntryStatus, LedgerEntryType if TYPE_CHECKING: from omniclaw import OmniClaw @@ -40,6 +42,13 @@ router = APIRouter(prefix="/api/v1", tags=["agent"]) +def _fmt_amount(value: object) -> str: + try: + return f"{Decimal(str(value)).quantize(Decimal('0.01'))}" + except Exception: + return str(value) + + async def get_policy_manager(request: Request) -> PolicyManager: return request.app.state.policy_mgr @@ -169,8 +178,8 @@ async def get_balance( return BalanceResponse( wallet_id=agent.wallet_id, - available=available, - total=total, + available=_fmt_amount(available), + total=_fmt_amount(total) if total is not None else None, reserved=reserved, ) @@ -194,15 +203,46 @@ async def get_detailed_balance( gateway_balance = ( await client.get_gateway_balance(agent.wallet_id) if client._nano_adapter else None ) + gateway_onchain_balance = ( + await client.get_gateway_onchain_balance(agent.wallet_id) if client._nano_adapter else None + ) + payment_address = ( + await client.get_payment_address(agent.wallet_id) if client._nano_client else None + ) + payment_gateway_balance = None + if payment_address: + try: + payment_gateway_balance = await client.get_gateway_balance_for_address(payment_address) + except Exception: + payment_gateway_balance = None return { "wallet_id": agent.wallet_id, "eoa_address": eoa_address, - "gateway_balance": gateway_balance.available_decimal if gateway_balance else "0", + "gateway_balance": _fmt_amount(gateway_balance.available_decimal) + if gateway_balance + else "0.00", "gateway_balance_atomic": gateway_balance.available if gateway_balance else 0, "gateway_total_atomic": gateway_balance.total if gateway_balance else 0, + "gateway_onchain_balance": _fmt_amount(gateway_onchain_balance.available_decimal) + if gateway_onchain_balance + else "0.00", + "gateway_onchain_balance_atomic": gateway_onchain_balance.available + if gateway_onchain_balance + else 0, "circle_wallet_address": circle_address, - "circle_wallet_balance": str(circle_balance) if circle_balance is not None else "0", + "circle_wallet_balance": _fmt_amount(circle_balance) + if circle_balance is not None + else "0.00", + "payment_address": payment_address, + "payment_gateway_balance": ( + _fmt_amount(payment_gateway_balance.available_decimal) + if payment_gateway_balance + else None + ), + "payment_gateway_balance_atomic": ( + payment_gateway_balance.available if payment_gateway_balance else None + ), } @@ -276,6 +316,8 @@ async def withdraw_from_gateway( ) try: + from decimal import Decimal + if recipient is None: wallet_cfg = policy_mgr.get_wallet_config(agent.wallet_id) recipient = wallet_cfg.get("address") @@ -285,23 +327,58 @@ async def withdraw_from_gateway( 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", - } + requested_amount = Decimal(str(amount)) + try: + 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": _fmt_amount(result.formatted_amount.split()[0]) + " USDC", + "burn_tx_hash": burn_tx_hash, + "mint_tx_hash": mint_tx_hash, + "status": status, + "message": "Withdrawal initiated", + } + except Exception as exc: + available = await client.get_gateway_balance(agent.wallet_id) + if ( + destination_chain is None + and requested_amount > Decimal("0.10") + and Decimal(str(available.available_decimal)) >= requested_amount + and "insufficient_balance" in str(exc).lower() + ): + remaining = requested_amount + mint_tx_hashes = [] + chunk_size = Decimal("0.10") + while remaining > Decimal("0"): + chunk = min(chunk_size, remaining) + chunk_result = await client.withdraw_from_gateway( + wallet_id=agent.wallet_id, + amount_usdc=str(chunk), + destination_chain=destination_chain, + recipient=recipient, + ) + mint_tx_hash = getattr(chunk_result, "mint_tx_hash", None) + if mint_tx_hash: + mint_tx_hashes.append(mint_tx_hash) + remaining -= chunk + return { + "success": True, + "amount_withdrawn": _fmt_amount(requested_amount) + " USDC", + "burn_tx_hash": None, + "mint_tx_hash": mint_tx_hashes[-1] if mint_tx_hashes else None, + "mint_tx_hashes": mint_tx_hashes, + "status": "COMPLETED", + "message": f"Withdrawal initiated in {len(mint_tx_hashes)} chunks of up to {chunk_size} USDC", + } + raise exc except Exception as e: import traceback @@ -566,7 +643,7 @@ async def pay( success=result.success, transaction_id=result.transaction_id, blockchain_tx=result.blockchain_tx, - amount=str(result.amount), + amount=_fmt_amount(result.amount), recipient=result.recipient, status=result.status.value if result.status and hasattr(result.status, "value") @@ -582,7 +659,7 @@ async def pay( logger.error(f"Payment failed: {e}") return PayResponse( success=False, - amount=request.amount, + amount=_fmt_amount(request.amount), recipient=request.recipient, status="FAILED", method="TRANSFER", @@ -636,49 +713,21 @@ async def list_transactions( client: OmniClaw = Depends(get_omniclaw_client), ): try: - transactions = await client.list_transactions(wallet_id=agent.wallet_id) - transactions = transactions[:limit] - + entries = await client._ledger.query(wallet_id=agent.wallet_id, limit=limit) return ListTransactionsResponse( transactions=[ TransactionInfo( - id=tx.id, - wallet_id=tx.wallet_id, - 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 getattr(tx, "created_at", None) - else ( - tx.create_date.isoformat() if getattr(tx, "create_date", None) else None - ) - ), + id=entry.id, + wallet_id=entry.wallet_id, + recipient=entry.recipient, + amount=_fmt_amount(entry.amount), + status=entry.status.value, + tx_hash=entry.tx_hash, + created_at=entry.timestamp.isoformat() if entry.timestamp else None, ) - for tx in transactions + for entry in entries ], - total=len(transactions), + total=len(entries), ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @@ -775,7 +824,7 @@ async def confirm_intent( success=result.success, transaction_id=result.transaction_id, blockchain_tx=result.blockchain_tx, - amount=str(result.amount), + amount=_fmt_amount(result.amount), recipient=result.recipient, status=result.status.value if result.status and hasattr(result.status, "value") @@ -933,7 +982,7 @@ async def x402_pay( success=result.success, transaction_id=result.transaction_id, blockchain_tx=result.blockchain_tx, - amount=str(result.amount), + amount=_fmt_amount(result.amount), recipient=result.recipient, status=result.status.value if result.status and hasattr(result.status, "value") @@ -948,7 +997,7 @@ async def x402_pay( logger.error(f"x402 payment failed: {e}") return PayResponse( success=False, - amount="0", + amount="0.00", recipient=request.url, status="FAILED", method="nanopayment", @@ -973,37 +1022,87 @@ async def x402_verify( sig_data = json.loads(base64.b64decode(request.signature)) + from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware from omniclaw.protocols.nanopayments.types import PaymentPayload, PaymentRequirements payload = PaymentPayload.from_dict(sig_data) + amount_text = request.amount if request.amount.startswith("$") else f"${request.amount}" - 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) + seller_address = await client.get_payment_address(agent.wallet_id) + if not seller_address: + return {"valid": False, "error": "Seller payment address not found"} - requirements = PaymentRequirements( - scheme=payload.scheme, - network=payload.network, - max_amount_required=str(amount_micro), - resource=request.resource, - description="x402 payment", - recipient=wallet_addr, + middleware = GatewayMiddleware( + seller_address=seller_address, + nanopayment_client=client._nano_client, ) + requirements_body = await middleware._build_402_response(amount_text) + requirements = PaymentRequirements.from_dict(requirements_body) result = await client._nano_client.settle(payload, requirements) if result.success: + await client._ledger.record( + LedgerEntry( + wallet_id=agent.wallet_id, + recipient=result.payer or "", + amount=Decimal(str(request.amount)), + entry_type=LedgerEntryType.PAYMENT, + status=LedgerEntryStatus.COMPLETED, + tx_hash=result.transaction, + method="nanopayment_receive", + purpose=f"x402 settlement for {request.resource}", + metadata={ + "direction": "incoming", + "resource": request.resource, + "payer": result.payer, + "transaction_id": result.transaction, + }, + ) + ) return { "valid": True, "sender": result.payer, "amount": request.amount, "transaction": result.transaction, } - else: - return {"valid": False, "error": result.error_reason or "Settlement failed"} + 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)} + + +@router.post("/x402/requirements") +async def x402_requirements( + request: X402RequirementsRequest, + agent: AuthenticatedAgent = Depends(get_current_agent), + client: OmniClaw = Depends(get_omniclaw_client), +): + """Build x402 payment requirements for a seller-side paid endpoint.""" + try: + if not client._nano_client: + raise HTTPException(status_code=500, detail="Nanopayment client not initialized") + + from omniclaw.protocols.nanopayments.middleware import GatewayMiddleware + + seller_address = await client.get_payment_address(agent.wallet_id) + if not seller_address: + raise HTTPException(status_code=404, detail="Seller payment address not found") + + middleware = GatewayMiddleware( + seller_address=seller_address, + nanopayment_client=client._nano_client, + ) + body = await middleware._build_402_response(request.amount) + header_value = middleware._encode_requirements(body) + return { + "status_code": 402, + "detail": body, + "headers": {"PAYMENT-REQUIRED": header_value}, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"x402 requirements failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/src/omniclaw/cli.py b/src/omniclaw/cli.py index 5f1984b..1509b5e 100644 --- a/src/omniclaw/cli.py +++ b/src/omniclaw/cli.py @@ -169,7 +169,10 @@ def handle_setup(args: argparse.Namespace) -> int: create_env_file(api_key, entity_secret, env_path=env_path, network=args.network, overwrite=True) print(f"✨ Successfully configured {env_path}!") print("To start the server locally, run: omniclaw server") - print("To start via Docker, run: docker compose -f docker-compose.agent.yml up -d") + print( + "To start via Docker, run: " + "docker compose -f examples/local-economy/docker-compose.payment-agent.yml up -d" + ) return 0 diff --git a/src/omniclaw/cli/commands/serve.py b/src/omniclaw/cli/commands/serve.py index b26bcd6..22b2201 100644 --- a/src/omniclaw/cli/commands/serve.py +++ b/src/omniclaw/cli/commands/serve.py @@ -1,8 +1,9 @@ from __future__ import annotations +import base64 +import json import os import subprocess -from typing import Any import typer @@ -43,80 +44,67 @@ def serve( typer.echo("Error: FastAPI not installed. Run: pip install fastapi", err=True) raise typer.Exit(1) - circle_api_key = os.environ.get("CIRCLE_API_KEY", "").strip() - if not circle_api_key: - typer.echo( - "Error: CIRCLE_API_KEY is required for omniclaw-cli serve. " - "Start serve in a shell/container that has the seller Circle credentials.", - 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 string in USD format 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 Financial Policy Engine - 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 Financial Policy Engine") - - # Initialize Circle nanopayment client - 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) + sig_header = headers.get("payment-signature") or headers.get("PAYMENT-SIGNATURE") + + if not sig_header: + requirements_resp = ctrl_client.post( + "/api/v1/x402/requirements", + json={ + "amount": price_usd, + "resource": str(request.url), + }, + ) + requirements_resp.raise_for_status() + requirements = requirements_resp.json() + return JSONResponse( + status_code=requirements.get("status_code", 402), + content=requirements.get("detail", {}), + headers=requirements.get("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) + verify_resp = ctrl_client.post( + "/api/v1/x402/verify", + json={ + "signature": sig_header, + "amount": str(price), + "sender": headers.get("x-forwarded-for", ""), + "resource": str(request.url), + }, + ) + verify_resp.raise_for_status() + verify_data = verify_resp.json() + if not verify_data.get("valid"): + requirements_resp = ctrl_client.post( + "/api/v1/x402/requirements", + json={ + "amount": price_usd, + "resource": str(request.url), + }, + ) + requirements_resp.raise_for_status() + requirements = requirements_resp.json() + return JSONResponse( + status_code=402, + content=requirements.get("detail", {"error": verify_data.get("error")}), + headers=requirements.get("headers", {}), + ) # Payment settled successfully — execute the command try: env = os.environ.copy() - env["OMNICLAW_PAYER_ADDRESS"] = payment_info.payer or "unknown" + env["OMNICLAW_PAYER_ADDRESS"] = verify_data.get("sender") or "unknown" env["OMNICLAW_AMOUNT_USD"] = str(price) - env["OMNICLAW_TX_HASH"] = payment_info.transaction or "" + env["OMNICLAW_TX_HASH"] = verify_data.get("transaction") or "" result = subprocess.run( exec_cmd, shell=True, capture_output=True, text=True, env=env @@ -128,20 +116,18 @@ async def payment_gate(request: Request): 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 + response.headers["PAYMENT-RESPONSE"] = base64.b64encode( + json.dumps( + { + "success": True, + "transaction": verify_data.get("transaction", ""), + "network": "", + "payer": verify_data.get("sender", ""), + } + ).encode() + ).decode() 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, diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index 690de1e..480fc30 100644 --- a/src/omniclaw/client.py +++ b/src/omniclaw/client.py @@ -649,22 +649,48 @@ async def withdraw_from_gateway( recipient=recipient, ) + async def get_gateway_balance_for_address( + self, + address: str, + ) -> GatewayBalance: + """Get Gateway balance for an arbitrary address.""" + if not self._nano_client: + raise NanopaymentNotInitializedError() + + network = self._nanopayment_network() + balance = await self._nano_client.check_balance(address=address, network=network) + return GatewayBalance( + total=balance.total, + available=balance.available, + formatted_total=f"{balance.total / 1e6} USDC", + formatted_available=f"{balance.available / 1e6} USDC", + ) + async def get_gateway_balance( self, wallet_id: str, ) -> GatewayBalance: - """ - Get the Gateway wallet balance for a wallet. - - Args: - wallet_id: The wallet ID to check gateway balance for. + """Get the spendable Gateway balance for the current signer.""" + if not self._nano_adapter or not self._nano_client: + raise NanopaymentNotInitializedError() - Returns: - GatewayBalance with total, available, and formatted amounts. + network = self._nanopayment_network() + balance = await self._nano_client.check_balance( + address=self._nano_adapter.address, + network=network, + ) + return GatewayBalance( + total=balance.total, + available=balance.available, + formatted_total=f"{balance.total / 1e6} USDC", + formatted_available=f"{balance.available / 1e6} USDC", + ) - Raises: - NanopaymentNotInitializedError: If nanopayments are disabled. - """ + async def get_gateway_onchain_balance( + self, + wallet_id: str, + ) -> GatewayBalance: + """Get the raw on-chain Gateway contract balance for the current signer.""" if not self._nano_adapter or not self._nano_client: raise NanopaymentNotInitializedError() @@ -678,11 +704,8 @@ async def get_gateway_balance( 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 - + total = available return GatewayBalance( total=total, available=available, @@ -920,16 +943,16 @@ async def get_wallet(self, wallet_id: str) -> WalletInfo: async def get_payment_address(self, wallet_id: str) -> str: """ - Get the payment address for a wallet. + Get the address that should receive x402/nanopayment proceeds. - This is the address that should be funded with USDC to enable payments. - - Args: - wallet_id: The wallet ID to get the payment address for. - - Returns: - The Ethereum address (0x...) that can receive USDC. + When nanopayments are enabled we must use the locally controlled EOA, + because Gateway deposits/withdrawals and on-chain balance queries are tied + to the signer address. Falling back to the Circle wallet address causes + seller revenue to accrue to a different address than the one the server can + withdraw from. """ + if self._nano_adapter: + return self._nano_adapter.address wallet = await self.get_wallet(wallet_id) return wallet.address @@ -1247,26 +1270,15 @@ async def pay( if route_uses_gateway and self._nano_adapter: try: - # 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, - ) - # Use on-chain query (bypasses Circle API) - available = await manager.get_gateway_available_balance() - balance_source = f"Gateway: {available}" + gateway_balance = await self.get_gateway_balance(wallet_id) + available = Decimal(str(gateway_balance.available_decimal)) + balance_source = f"Gateway available: {available}" except Exception as e: # For nanopayment routes, don't fall back to circle balance # Instead, log error and use 0 (will fail with clearer message) self._logger.warning(f"Gateway balance check failed: {e}") - available = 0 - balance_source = "Gateway: (check failed)" + available = Decimal("0") + balance_source = "Gateway available: (check failed)" else: available = circle_balance - reserved_total balance_source = f"Circle: {available}" @@ -1360,6 +1372,12 @@ async def pay( result.blockchain_tx, metadata_updates={"transaction_id": result.transaction_id}, ) + if result.amount != amount_decimal: + await self._storage.update( + self._ledger.COLLECTION, + ledger_entry.id, + {"amount": str(result.amount)}, + ) if guards_chain: try: await guards_chain.commit(reservation_tokens) diff --git a/src/omniclaw/ledger/ledger.py b/src/omniclaw/ledger/ledger.py index cf64694..5219b0e 100644 --- a/src/omniclaw/ledger/ledger.py +++ b/src/omniclaw/ledger/ledger.py @@ -241,10 +241,9 @@ async def query( if status: filters["status"] = status.value - # Fetch without limit if we have date filters, otherwise fetch limit - fetch_limit = None if (from_date or to_date) else limit - - raw_results = await self._storage.query(self.COLLECTION, filters=filters, limit=fetch_limit) + # Storage backends do not guarantee ordering. Fetch all matching rows, + # sort by timestamp locally, then apply the requested limit. + raw_results = await self._storage.query(self.COLLECTION, filters=filters, limit=None) entries = [LedgerEntry.from_dict(d) for d in raw_results]