diff --git a/ROADMAP.md b/ROADMAP.md index 316d132..0467019 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ Based on `enterprise-dashboard.md`: ### Developer Integration Surface -- TypeScript SDK (parity with Python) +- TypeScript / Node.js SDK - MCP server (production-ready) - `omniclaw` CLI for setup, diagnostics, and ops - agent skills library and templates diff --git a/npm/omniclaw/.gitignore b/npm/omniclaw/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/npm/omniclaw/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/npm/omniclaw/README.md b/npm/omniclaw/README.md new file mode 100644 index 0000000..855b261 --- /dev/null +++ b/npm/omniclaw/README.md @@ -0,0 +1,248 @@ +# OmniClaw Node.js SDK + +Node.js/TypeScript SDK for OmniClaw payment workflows backed by Circle APIs. It is designed for production Node services and agents: typed APIs, async-first I/O, and optional x402 nanopayments through Circle Gateway. + +Typical flow: + +1. Configure the client (API key, wallet, optional nanopayment settings). +2. Run a local simulation before spending funds. +3. Execute payments or route by recipient and amount. +4. Inspect wallet balance and payment intents. +5. Route nanopayments through Circle Gateway (x402) when URLs or micro-amounts require it. +6. Apply guardrails, trust checks, and ledger tracking where you need operational safety. +7. Run seller-side x402 flows with facilitator support when you are the resource owner. + +## Install + +```bash +npm install omniclaw +``` + +## Environment variables + +Required: + +- `CIRCLE_API_KEY` +- `ENTITY_SECRET` (required when nanopayments are enabled) + +Recommended: + +- `CIRCLE_WALLET_ID` +- `CIRCLE_API_BASE_URL` (default: `https://api.circle.com`) +- `CIRCLE_GATEWAY_API_BASE_URL` (optional; defaults by environment) + +## Quick start + +```ts +import { OmniClaw } from "omniclaw"; + +const client = new OmniClaw(); + +const preview = client.simulatePayment({ + amount: "10.00", + destinationAddress: "0x742d35cc6634c0532925a3b844bc9e7595f5e4a0" +}); + +if (preview.readyToExecute) { + const payment = await client.createPayment({ + amount: "10.00", + destinationAddress: "0x742d35cc6634c0532925a3b844bc9e7595f5e4a0" + }); + + console.log(payment.data?.id, payment.data?.status); +} +``` + +## Nanopayments quick start (x402) + +```ts +import { OmniClaw } from "omniclaw"; + +const client = new OmniClaw({ + nanopaymentsEnabled: true, + nanopaymentsEnvironment: "testnet", + entitySecret: process.env.ENTITY_SECRET +}); + +// 1) create or import key used for EIP-3009 authorization signing +const buyerAddress = client.generateNanoKey("buyer-1"); +client.setDefaultNanoKey("buyer-1"); + +// 2) pay an x402 endpoint (auto-detects GatewayWalletBatched in 402 response) +const result = await client.payX402Url({ + url: "https://your-paid-endpoint.example/premium" +}); + +console.log({ buyerAddress, result }); +``` + +## API + +### `new OmniClaw(config?)` + +Config keys: + +- `circleApiKey` +- `circleWalletId` +- `circleApiBaseUrl` +- `defaultCurrency` (default: `USD`) +- `defaultFeeRatePercent` (default: `0.2`) +- `nanopaymentsEnabled` (default: `true`) +- `nanopaymentsEnvironment` (`testnet` or `mainnet`, default: `testnet`) +- `gatewayApiBaseUrl` (optional override) +- `entitySecret` (required for encrypted nanopayment key storage) +- `nanopaymentKeyStorePath` (optional encrypted keystore JSON file path) +- `strictSettlement` (default `true`) +- `retryAttempts`, `retryBaseDelayMs` +- `circuitBreakerFailureThreshold`, `circuitBreakerRecoveryMs` +- `trustEvaluator`, `requireTrustGate` + +Production behavior: + +- if `OMNICLAW_ENV` is `prod`/`production`/`mainnet`, `strictSettlement` must remain `true` +- when nanopayments are enabled in production, set `ENTITY_SECRET` and `nanopaymentKeyStorePath` + +### `simulatePayment(params)` + +Local-only simulation output: + +- `estimatedFees` +- `netTransfer` +- `transferConfirmationPreview` +- `readyToExecute` + +### `payWithRouting(params)` + +Unified routing by recipient and amount: + +- routes `https://...` recipients through x402 nanopayments +- routes micro direct payments (`amount < 1`) through direct nanopayments +- routes larger direct transfers through Circle `POST /v1/payments` +- always runs simulation first +- supports trust checks and guardrails before execution + +### `createPayment(params)` / `pay(params)` + +Calls: + +- `POST /v1/payments` + +### `getWalletBalance(walletId?)` + +Calls: + +- `GET /v1/wallets/:walletId` + +### `createPaymentIntent(params)` + +Calls: + +- `POST /v1/paymentIntents` + +### `getPaymentIntent(intentId)` + +Calls: + +- `GET /v1/paymentIntents/:intentId` + +### `confirmPaymentIntent(intentId)` + +Calls: + +- `POST /v1/paymentIntents/:intentId/confirm` + +### Nanopayment methods (Circle Gateway x402) + +- `generateNanoKey(alias)` / `addNanoKey(alias, privateKey)` +- `setDefaultNanoKey(alias)` / `listNanoKeys()` / `getNanoAddress(alias?)` +- `getGatewaySupportedNetworks()` +- `getGatewayBalance(alias?, network?)` +- `payX402Url({ url, method?, headers?, body?, keyAlias? })` +- `payDirectNano({ sellerAddress, amountUsdc, network, keyAlias? })` + +### Guardrail methods + +- `addBudgetGuard(walletId, maxBudget)` +- `addRateLimitGuard(walletId, maxCalls, windowMs)` +- `addRecipientGuard(walletId, allowedRecipients)` +- `addSingleTxGuard(walletId, maxAmount)` +- `addConfirmGuard(walletId, threshold)` +- `listGuards(walletId?)` + +### Ledger and intents + +- `listLedgerEntries()` returns in-memory payment ledger entries with status transitions. +- `createPaymentIntent(...)` creates idempotent local intent state and includes metadata when calling Circle. +- `cancelPaymentIntent(intentId)` updates local intent state. + +### Exported nanopayment classes + +- `NanopaymentClient` +- `NanoKeyVault` +- `NanopaymentAdapter` +- `GatewayMiddleware` +- `parsePrice` + +### Seller SDK + +- `Seller`, `createSeller` +- `CircleGatewayFacilitator` +- `createFacilitator` for `circle`, `coinbase`, `ordern`, `rbx`, `thirdweb` +- `SUPPORTED_FACILITATORS` + +Seller usage: + +```ts +import { createFacilitator, createSeller } from "omniclaw"; + +const facilitator = createFacilitator({ + provider: "circle", + apiKey: process.env.CIRCLE_API_KEY!, + environment: "testnet" +}); + +const seller = createSeller( + { + sellerAddress: "0x742d35cc6634c0532925a3b844bc9e7595f5e4a0", + name: "Weather API", + network: "eip155:5042002", + usdcContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + gatewayContract: "0xGatewayContract", + strictGatewayContract: true, + nonceStorePath: ".omniclaw-seller-nonces.json" + }, + facilitator +); + +seller.addEndpoint({ path: "/weather", priceUsd: "0.001", description: "Current weather" }); +const paymentRequired = seller.buildPaymentRequired("/weather"); +``` + +### Webhook verification + +- `WebhookVerifier` verifies Circle webhook signatures and replay windows. +- Supports signature headers: `x-circle-signature` / `circle-signature`. +- Supports timestamp headers: `x-circle-timestamp` / `circle-timestamp`. +- Rejects duplicate `notificationId` values while the verifier instance is alive. +- Optional dedup persistence: `dedupStorePath` in `WebhookVerifierOptions`. + +## Design notes + +- Nanopayments cover key management, x402 URL payments, direct nano transfers, and gateway balance and network discovery. +- Private keys are encrypted with PBKDF2 + AES-256-GCM before persistence. +- x402 flows use CAIP-2 validation, strict-settlement mode, retry with backoff, and a circuit breaker. +- Guardrails, trust-gate hooks, local intents, and ledger tracking support auditable runtime behavior. +- Seller x402 flows include endpoint protection, nonce replay prevention, and facilitator-based settlement. + +## Build and package + +```bash +npm install +npm run release:check +``` + +Optional offline integration smoke test (mocked HTTP, no live Circle calls): + +```bash +npm run smoke:validate +``` diff --git a/npm/omniclaw/build.ps1 b/npm/omniclaw/build.ps1 new file mode 100644 index 0000000..ef8526e --- /dev/null +++ b/npm/omniclaw/build.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $root + +Write-Host "Building OmniClaw npm release artifacts..." + +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + throw "npm is required to run build.ps1" +} + +Write-Host "Cleaning previous build artifacts..." +if (Test-Path "dist") { Remove-Item -Recurse -Force "dist" } +Get-ChildItem -Filter "*.tgz" -ErrorAction SilentlyContinue | Remove-Item -Force + +Write-Host "Installing dependencies..." +npm install + +Write-Host "Running TypeScript checks..." +npm run typecheck + +Write-Host "Building package..." +npm run build + +Write-Host "Creating tarball..." +npm pack + +Write-Host "" +Write-Host "Build complete." +Write-Host "Next:" +Write-Host " 1. Inspect package contents: npm pack --dry-run" +Write-Host " 2. Publish with: npm publish" diff --git a/npm/omniclaw/build.sh b/npm/omniclaw/build.sh new file mode 100644 index 0000000..53b51ff --- /dev/null +++ b/npm/omniclaw/build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" + +echo "Building OmniClaw npm release artifacts..." + +if ! command -v npm >/dev/null 2>&1; then + echo "Error: npm is required to run build.sh" + exit 1 +fi + +echo "Cleaning previous build artifacts..." +rm -rf dist *.tgz + +echo "Installing dependencies..." +npm install + +echo "Running TypeScript checks..." +npm run typecheck + +echo "Building package..." +npm run build + +echo "Creating tarball..." +npm pack + +echo +echo "Build complete." +echo "Artifacts:" +ls -1 *.tgz dist 2>/dev/null || true +echo +echo "Next:" +echo " 1. Inspect package contents: npm pack --dry-run" +echo " 2. Publish with: npm publish" diff --git a/npm/omniclaw/package-lock.json b/npm/omniclaw/package-lock.json new file mode 100644 index 0000000..2d4e231 --- /dev/null +++ b/npm/omniclaw/package-lock.json @@ -0,0 +1,160 @@ +{ + "name": "omniclaw", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omniclaw", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ethers": "^6.15.0" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/npm/omniclaw/package.json b/npm/omniclaw/package.json new file mode 100644 index 0000000..752aa6d --- /dev/null +++ b/npm/omniclaw/package.json @@ -0,0 +1,47 @@ +{ + "name": "omniclaw", + "version": "0.1.0", + "description": "The Payment Infrastructure Layer for Autonomous AI Agents (Node.js SDK)", + "license": "MIT", + "author": "Omnuron AI Team", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "ai", + "payments", + "usdc", + "circle", + "agent", + "x402", + "automation" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", + "typecheck": "tsc --noEmit", + "build": "npm run clean && tsc -p tsconfig.json", + "prepare": "npm run build", + "release:check": "npm run typecheck && npm run build && npm pack", + "smoke:validate": "npm run build && node ./dist/scripts/smokeTest.js" + }, + "dependencies": { + "ethers": "^6.15.0" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "typescript": "^5.9.2" + } +} diff --git a/npm/omniclaw/src/client.ts b/npm/omniclaw/src/client.ts new file mode 100644 index 0000000..a3fc9db --- /dev/null +++ b/npm/omniclaw/src/client.ts @@ -0,0 +1,775 @@ +import { CircleApiError, ConfigurationError } from "./errors.js"; +import { deriveIdempotencyKey } from "./core/idempotency.js"; +import { + BudgetGuard, + ConfirmGuard, + GuardManager, + RateLimitGuard, + RecipientGuard, + SingleTxGuard +} from "./core/guards.js"; +import { Ledger } from "./core/ledger.js"; +import { PaymentIntentService } from "./core/intents.js"; +import { TrustGate } from "./core/trust.js"; +import { + assertEvmAddress, + assertPositiveDecimal, + assertWalletId +} from "./core/validation.js"; +import { NanopaymentAdapter } from "./protocols/nanopayments/adapter.js"; +import { NanopaymentClient } from "./protocols/nanopayments/client.js"; +import { NanoKeyVault } from "./protocols/nanopayments/vault.js"; +import { simulatePaymentLocally } from "./simulate.js"; +import type { + CirclePaymentIntentResponse, + CirclePaymentResponse, + CircleWalletResponse, + CreatePaymentIntentParams, + CreatePaymentParams, + OmniClawConfig, + RoutedPaymentParams, + RoutedPaymentResult, + SimulatePaymentParams, + SimulatePaymentResult +} from "./types.js"; +import type { + NanopaymentResult, + PayX402UrlParams +} from "./protocols/nanopayments/types.js"; + +const DEFAULT_BASE_URL = "https://api.circle.com"; +const DEFAULT_CURRENCY = "USD"; +const DEFAULT_FEE_RATE_PERCENT = 0.2; + +export class OmniClaw { + private readonly apiKey: string; + private readonly defaultWalletId: string; + private readonly baseUrl: string; + private readonly defaultCurrency: string; + private readonly defaultFeeRatePercent: number; + private readonly fetchImpl: typeof fetch; + private readonly nanopaymentsEnabled: boolean; + private readonly nanoClient: NanopaymentClient | null; + private readonly nanoVault: NanoKeyVault | null; + private readonly nanoAdapter: NanopaymentAdapter | null; + private readonly requireTrustGate: boolean; + private readonly guardManager: GuardManager; + private readonly ledger: Ledger; + private readonly intents: PaymentIntentService; + private readonly trustGate: TrustGate; + + constructor(config: OmniClawConfig = {}) { + this.apiKey = config.circleApiKey ?? process.env.CIRCLE_API_KEY ?? ""; + this.defaultWalletId = config.circleWalletId ?? process.env.CIRCLE_WALLET_ID ?? ""; + this.baseUrl = + config.circleApiBaseUrl ?? process.env.CIRCLE_API_BASE_URL ?? DEFAULT_BASE_URL; + this.defaultCurrency = config.defaultCurrency ?? DEFAULT_CURRENCY; + this.defaultFeeRatePercent = config.defaultFeeRatePercent ?? DEFAULT_FEE_RATE_PERCENT; + this.fetchImpl = config.fetchImpl ?? fetch; + this.nanopaymentsEnabled = config.nanopaymentsEnabled ?? true; + this.requireTrustGate = config.requireTrustGate ?? false; + this.guardManager = new GuardManager(); + this.ledger = new Ledger(); + this.intents = new PaymentIntentService(); + this.trustGate = new TrustGate(config.trustEvaluator); + + if (!this.apiKey) { + throw new ConfigurationError("CIRCLE_API_KEY is required"); + } + + if (this.nanopaymentsEnabled) { + const entitySecret = config.entitySecret ?? process.env.ENTITY_SECRET ?? ""; + this.nanoClient = new NanopaymentClient({ + apiKey: this.apiKey, + environment: config.nanopaymentsEnvironment ?? "testnet", + baseUrl: config.gatewayApiBaseUrl, + fetchImpl: this.fetchImpl + }); + this.nanoVault = new NanoKeyVault({ + entitySecret, + keyStorePath: config.nanopaymentKeyStorePath + }); + this.nanoAdapter = new NanopaymentAdapter(this.nanoVault, this.nanoClient, this.fetchImpl, { + strictSettlement: config.strictSettlement ?? true, + retryAttempts: config.retryAttempts, + retryBaseDelayMs: config.retryBaseDelayMs, + circuitBreakerFailureThreshold: config.circuitBreakerFailureThreshold, + circuitBreakerRecoveryMs: config.circuitBreakerRecoveryMs + }); + } else { + this.nanoClient = null; + this.nanoVault = null; + this.nanoAdapter = null; + } + + this.enforceProductionStartupRequirements(config); + + if (this.requireTrustGate && !this.trustGate.isConfigured()) { + throw new ConfigurationError( + "Trust gate is required but no trustEvaluator was provided in OmniClawConfig" + ); + } + } + + private enforceProductionStartupRequirements(config: OmniClawConfig): void { + const env = String(process.env.OMNICLAW_ENV ?? "").toLowerCase(); + if (!["prod", "production", "mainnet"].includes(env)) { + return; + } + + if ((config.strictSettlement ?? true) !== true) { + throw new ConfigurationError("strictSettlement must remain true in production-like environments"); + } + if (this.nanopaymentsEnabled) { + const entitySecret = config.entitySecret ?? process.env.ENTITY_SECRET ?? ""; + if (!entitySecret) { + throw new ConfigurationError("ENTITY_SECRET is required when nanopayments are enabled"); + } + if (!config.nanopaymentKeyStorePath) { + throw new ConfigurationError( + "nanopaymentKeyStorePath is required in production-like environments" + ); + } + } + } + + async createPayment(params: CreatePaymentParams): Promise { + const sourceWalletId = params.sourceWalletId ?? this.defaultWalletId; + if (!sourceWalletId) { + throw new ConfigurationError("sourceWalletId is required (or set CIRCLE_WALLET_ID)"); + } + assertWalletId(sourceWalletId, "sourceWalletId"); + assertPositiveDecimal(params.amount, "amount"); + assertEvmAddress(params.destinationAddress, "destinationAddress"); + const idempotencyKey = + params.idempotencyKey ?? + deriveIdempotencyKey([ + sourceWalletId, + params.destinationAddress, + params.amount, + params.currency ?? this.defaultCurrency + ]); + + const body = { + amount: { + amount: params.amount, + currency: params.currency ?? this.defaultCurrency + }, + source: { + type: "wallet", + id: sourceWalletId + }, + destination: { + type: "blockchain", + address: params.destinationAddress + } + }; + + return this.request("/v1/payments", { + method: "POST", + body: JSON.stringify(body), + headers: { "Idempotency-Key": idempotencyKey } + }); + } + + async getWalletBalance(walletId?: string): Promise { + const targetWalletId = walletId ?? this.defaultWalletId; + if (!targetWalletId) { + throw new ConfigurationError("walletId is required (or set CIRCLE_WALLET_ID)"); + } + assertWalletId(targetWalletId, "walletId"); + return this.request(`/v1/wallets/${targetWalletId}`, { + method: "GET" + }); + } + + simulatePayment(params: SimulatePaymentParams): SimulatePaymentResult { + if (!params.destinationAddress) { + throw new ConfigurationError("destinationAddress is required"); + } + assertPositiveDecimal(params.amount, "amount"); + assertEvmAddress(params.destinationAddress, "destinationAddress"); + return simulatePaymentLocally( + params, + this.defaultWalletId, + this.defaultCurrency, + this.defaultFeeRatePercent + ); + } + + async pay(params: CreatePaymentParams): Promise { + const sourceWalletId = params.sourceWalletId ?? this.defaultWalletId; + if (!sourceWalletId) { + throw new ConfigurationError("sourceWalletId is required (or set CIRCLE_WALLET_ID)"); + } + await this.guardManager.evaluate( + sourceWalletId, + { + walletId: sourceWalletId, + recipient: params.destinationAddress, + amount: params.amount, + currency: params.currency ?? this.defaultCurrency, + purpose: params.purpose, + confirm: params.confirm + }, + params.skipGuards ?? false + ); + if (params.checkTrust ?? false) { + const trust = await this.trustGate.check(params.destinationAddress); + if (trust.verdict === "block") { + throw new ConfigurationError(`Trust gate blocked payment: ${trust.reason ?? "blocked"}`); + } + if (trust.verdict === "hold") { + throw new ConfigurationError(`Trust gate held payment: ${trust.reason ?? "requires review"}`); + } + } + return this.createPayment(params); + } + + async createPaymentIntent( + params: CreatePaymentIntentParams + ): Promise { + assertPositiveDecimal(params.amount, "amount"); + const walletId = params.sourceWalletId ?? this.defaultWalletId; + if (!walletId) { + throw new ConfigurationError("sourceWalletId is required (or set CIRCLE_WALLET_ID)"); + } + const recipient = params.recipient ?? "payment-intent"; + const idempotencyKey = + params.idempotencyKey ?? deriveIdempotencyKey([walletId, recipient, params.amount, "intent"]); + let requiresReview = false; + if (params.checkTrust ?? false) { + const trust = await this.trustGate.check(recipient); + if (trust.verdict === "block") { + throw new ConfigurationError(`Trust gate blocked intent: ${trust.reason ?? "blocked"}`); + } + requiresReview = trust.verdict === "hold"; + } + const localIntent = this.intents.createIntent({ + walletId, + recipient, + amount: params.amount, + currency: params.currency ?? this.defaultCurrency, + idempotencyKey, + requiresReview + }); + + const body = { + amount: { + amount: params.amount, + currency: params.currency ?? this.defaultCurrency + }, + settlementCurrency: params.settlementCurrency ?? this.defaultCurrency, + paymentMethods: params.paymentMethods ?? ["card", "wire", "ach", "crypto"], + metadata: { + localIntentId: localIntent.id, + idempotencyKey + } + }; + + return this.request("/v1/paymentIntents", { + method: "POST", + body: JSON.stringify(body) + }); + } + + async getPaymentIntent(intentId: string): Promise { + const remote = await this.request(`/v1/paymentIntents/${intentId}`, { + method: "GET" + }); + const local = this.intents.getIntent(intentId); + if (!local) { + return remote; + } + return { + ...remote, + data: { + ...(remote.data ?? {}), + localStatus: local.status + } + }; + } + + async confirmPaymentIntent(intentId: string): Promise { + const updated = this.intents.updateStatus(intentId, "confirmed"); + if (!updated) { + return this.request( + `/v1/paymentIntents/${intentId}/confirm`, + { method: "POST", body: "{}" } + ); + } + return this.request( + `/v1/paymentIntents/${intentId}/confirm`, + { method: "POST", body: "{}" } + ); + } + + async cancelPaymentIntent(intentId: string): Promise { + const updated = this.intents.updateStatus(intentId, "cancelled"); + if (!updated) { + throw new ConfigurationError(`Unknown intentId: ${intentId}`); + } + } + + // Snake_case helpers for teams migrating from other OmniClaw client bindings + async get_balance(wallet_id: string): Promise { + const balance = await this.getWalletBalance(wallet_id); + const amount = balance.data?.balances?.[0]?.amount ?? "0"; + return Number.parseFloat(amount); + } + + async create_wallet_set(name?: string): Promise> { + return this.request>("/v1/walletSets", { + method: "POST", + body: JSON.stringify({ name: name ?? `wallet-set-${Date.now()}` }) + }); + } + + async create_wallet(params: { + wallet_set_id?: string; + blockchain?: string; + account_type?: string; + } = {}): Promise> { + const walletSetId = params.wallet_set_id; + if (!walletSetId) { + throw new ConfigurationError("wallet_set_id is required"); + } + return this.request>("/v1/wallets", { + method: "POST", + body: JSON.stringify({ + walletSetId, + blockchains: [params.blockchain ?? "ETH-SEPOLIA"], + accountType: params.account_type ?? "SCA" + }) + }); + } + + async create_agent_wallet(name: string): Promise> { + const walletSet = await this.create_wallet_set(name); + const walletSetId = String( + (walletSet.data as Record | undefined)?.walletSetId ?? + (walletSet.data as Record | undefined)?.id ?? + "" + ); + if (!walletSetId) { + throw new ConfigurationError("Unable to resolve wallet set id from Circle response"); + } + const wallet = await this.create_wallet({ wallet_set_id: walletSetId }); + return { wallet_set: walletSet, wallet }; + } + + async list_wallet_sets(): Promise> { + return this.request>("/v1/walletSets", { method: "GET" }); + } + + async list_wallets(wallet_set_id?: string): Promise> { + const endpoint = wallet_set_id + ? `/v1/wallets?walletSetId=${encodeURIComponent(wallet_set_id)}` + : "/v1/wallets"; + return this.request>(endpoint, { method: "GET" }); + } + + async get_wallet(wallet_id: string): Promise { + return this.getWalletBalance(wallet_id); + } + + async get_wallet_set(wallet_set_id: string): Promise> { + return this.request>(`/v1/walletSets/${wallet_set_id}`, { + method: "GET" + }); + } + + async get_payment_address(wallet_id: string): Promise { + const wallet = await this.get_wallet(wallet_id); + const address = (wallet.data as Record | undefined)?.address; + return typeof address === "string" ? address : ""; + } + + async add_budget_guard_for_set(wallet_set_id: string, max_budget: number): Promise { + const wallets = await this.list_wallets(wallet_set_id); + const rows = (wallets.data as Array> | undefined) ?? []; + rows.forEach((entry) => { + const id = entry.walletId ?? entry.id; + if (typeof id === "string") { + this.addBudgetGuard(id, max_budget); + } + }); + } + + async add_confirm_guard_for_set(wallet_set_id: string, threshold: number): Promise { + const wallets = await this.list_wallets(wallet_set_id); + const rows = (wallets.data as Array> | undefined) ?? []; + rows.forEach((entry) => { + const id = entry.walletId ?? entry.id; + if (typeof id === "string") { + this.addConfirmGuard(id, threshold); + } + }); + } + + async add_rate_limit_guard_for_set( + wallet_set_id: string, + max_calls: number, + window_ms: number + ): Promise { + const wallets = await this.list_wallets(wallet_set_id); + const rows = (wallets.data as Array> | undefined) ?? []; + rows.forEach((entry) => { + const id = entry.walletId ?? entry.id; + if (typeof id === "string") { + this.addRateLimitGuard(id, max_calls, window_ms); + } + }); + } + + async add_recipient_guard_for_set(wallet_set_id: string, recipients: string[]): Promise { + const wallets = await this.list_wallets(wallet_set_id); + const rows = (wallets.data as Array> | undefined) ?? []; + rows.forEach((entry) => { + const id = entry.walletId ?? entry.id; + if (typeof id === "string") { + this.addRecipientGuard(id, recipients); + } + }); + } + + async batch_pay( + requests: Array<{ + wallet_id?: string; + recipient: string; + amount: string; + currency?: string; + purpose?: string; + }> + ): Promise> { + const results: Array = []; + for (const request of requests) { + const result = await this.payWithRouting({ + walletId: request.wallet_id, + recipient: request.recipient, + amount: request.amount, + currency: request.currency, + purpose: request.purpose + }); + results.push(result); + } + return results; + } + + async sync_transaction(entry_id: string) { + return this.ledger.get(entry_id); + } + + async list_pending_settlements() { + return this.ledger.list().filter((entry) => entry.status === "pending"); + } + + async finalize_pending_settlement(entry_id: string, success = true) { + return this.ledger.updateStatus(entry_id, success ? "confirmed" : "failed"); + } + + async reconcile_pending_settlements() { + const pending = await this.list_pending_settlements(); + return pending.map((entry) => this.ledger.updateStatus(entry.id, "failed")); + } + + async list_guards_for_set(wallet_set_id: string): Promise { + const wallets = await this.list_wallets(wallet_set_id); + const rows = (wallets.data as Array> | undefined) ?? []; + const all: string[] = []; + rows.forEach((entry) => { + const id = entry.walletId ?? entry.id; + if (typeof id === "string") { + all.push(...this.listGuards(id)); + } + }); + return [...new Set(all)]; + } + + listLedgerEntries() { + return this.ledger.list(); + } + + listGuards(walletId?: string): string[] { + const targetWalletId = walletId ?? this.defaultWalletId; + if (!targetWalletId) { + return []; + } + return this.guardManager.listGuards(targetWalletId); + } + + addBudgetGuard(walletId: string, maxBudget: number): void { + assertWalletId(walletId, "walletId"); + this.guardManager.addGuard(walletId, new BudgetGuard(maxBudget)); + } + + async add_budget_guard(wallet_id: string, max_budget: number): Promise { + this.addBudgetGuard(wallet_id, max_budget); + } + + addRateLimitGuard(walletId: string, maxCalls: number, windowMs: number): void { + assertWalletId(walletId, "walletId"); + this.guardManager.addGuard(walletId, new RateLimitGuard(maxCalls, windowMs)); + } + + async add_rate_limit_guard( + wallet_id: string, + max_calls: number, + window_ms: number + ): Promise { + this.addRateLimitGuard(wallet_id, max_calls, window_ms); + } + + addRecipientGuard(walletId: string, allowedRecipients: string[]): void { + assertWalletId(walletId, "walletId"); + this.guardManager.addGuard(walletId, new RecipientGuard(allowedRecipients)); + } + + async add_recipient_guard(wallet_id: string, recipients: string[]): Promise { + this.addRecipientGuard(wallet_id, recipients); + } + + addSingleTxGuard(walletId: string, maxAmount: number): void { + assertWalletId(walletId, "walletId"); + this.guardManager.addGuard(walletId, new SingleTxGuard(maxAmount)); + } + + async add_single_tx_guard(wallet_id: string, max_amount: number): Promise { + this.addSingleTxGuard(wallet_id, max_amount); + } + + addConfirmGuard(walletId: string, threshold: number): void { + assertWalletId(walletId, "walletId"); + this.guardManager.addGuard(walletId, new ConfirmGuard(threshold)); + } + + async add_confirm_guard(wallet_id: string, threshold: number): Promise { + this.addConfirmGuard(wallet_id, threshold); + } + + async payWithRouting(params: RoutedPaymentParams): Promise { + const walletId = params.walletId ?? this.defaultWalletId; + if (!walletId) { + throw new ConfigurationError("walletId is required (or set CIRCLE_WALLET_ID)"); + } + assertWalletId(walletId, "walletId"); + assertPositiveDecimal(params.amount, "amount"); + const currency = params.currency ?? this.defaultCurrency; + + const simulation = simulatePaymentLocally( + { + amount: params.amount, + currency, + sourceWalletId: walletId, + destinationAddress: params.recipient + }, + walletId, + currency, + this.defaultFeeRatePercent + ); + if (!simulation.readyToExecute) { + throw new ConfigurationError("Simulation failed: payment not ready to execute"); + } + + await this.guardManager.evaluate( + walletId, + { + walletId, + recipient: params.recipient, + amount: params.amount, + currency, + purpose: params.purpose, + confirm: params.confirm + }, + params.skipGuards ?? false + ); + + let trustResult; + if (params.checkTrust ?? false) { + trustResult = await this.trustGate.check(params.recipient); + if (trustResult.verdict === "block") { + throw new ConfigurationError(`Trust gate blocked payment: ${trustResult.reason ?? "blocked"}`); + } + if (trustResult.verdict === "hold") { + throw new ConfigurationError( + `Trust gate held payment for review: ${trustResult.reason ?? "requires review"}` + ); + } + } + + const ledgerEntry = this.ledger.create({ + id: deriveIdempotencyKey([walletId, params.recipient, params.amount, Date.now()]), + walletId, + recipient: params.recipient, + amount: params.amount, + currency, + status: "pending", + metadata: { purpose: params.purpose } + }); + + try { + if (params.recipient.startsWith("https://")) { + if (!this.nanoAdapter) { + throw new ConfigurationError("Nanopayments are disabled; cannot pay x402 URL recipient"); + } + const result = await this.nanoAdapter.payX402Url({ + url: params.recipient, + keyAlias: params.nanoKeyAlias + }); + this.ledger.updateStatus(ledgerEntry.id, result.success ? "confirmed" : "failed"); + return { + route: "x402_nanopayment", + success: result.success, + trust: trustResult, + simulation, + ledgerEntryId: ledgerEntry.id, + result + }; + } + + assertEvmAddress(params.recipient, "recipient"); + const amountFloat = Number.parseFloat(params.amount); + if (amountFloat < 1 && this.nanoAdapter) { + const result = await this.nanoAdapter.payDirect({ + sellerAddress: params.recipient, + amountUsdc: params.amount, + network: params.network ?? "eip155:5042002", + keyAlias: params.nanoKeyAlias + }); + this.ledger.updateStatus(ledgerEntry.id, result.success ? "confirmed" : "failed"); + return { + route: "direct_nanopayment", + success: result.success, + trust: trustResult, + simulation, + ledgerEntryId: ledgerEntry.id, + result + }; + } + + const circleResult = await this.createPayment({ + amount: params.amount, + currency, + sourceWalletId: walletId, + destinationAddress: params.recipient + }); + this.ledger.updateStatus(ledgerEntry.id, "confirmed"); + return { + route: "circle_transfer", + success: true, + trust: trustResult, + simulation, + ledgerEntryId: ledgerEntry.id, + result: circleResult + }; + } catch (error) { + this.ledger.updateStatus(ledgerEntry.id, "failed"); + throw error; + } + } + + addNanoKey(alias: string, privateKey: string): string { + if (!this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoVault.addKey(alias, privateKey); + } + + generateNanoKey(alias: string): string { + if (!this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoVault.generateKey(alias); + } + + setDefaultNanoKey(alias: string): void { + if (!this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + this.nanoVault.setDefaultKey(alias); + } + + listNanoKeys(): string[] { + if (!this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoVault.listKeys(); + } + + getNanoAddress(alias?: string): string { + if (!this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoVault.getAddress(alias); + } + + async getGatewaySupportedNetworks() { + if (!this.nanoClient) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoClient.getSupported(); + } + + async getGatewayBalance(alias?: string, network = "eip155:5042002") { + if (!this.nanoClient || !this.nanoVault) { + throw new ConfigurationError("Nanopayments are disabled"); + } + const address = this.nanoVault.getAddress(alias); + return this.nanoClient.getBalance(address, network); + } + + async payX402Url(params: PayX402UrlParams): Promise { + if (!this.nanoAdapter) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoAdapter.payX402Url(params); + } + + async payDirectNano(params: { + sellerAddress: string; + amountUsdc: string; + network: string; + keyAlias?: string; + }): Promise { + if (!this.nanoAdapter) { + throw new ConfigurationError("Nanopayments are disabled"); + } + return this.nanoAdapter.payDirect(params); + } + + private async request( + endpoint: string, + init: { method: string; body?: string; headers?: Record } + ): Promise { + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + ...(init.headers ?? {}) + }; + + const response = await this.fetchImpl(`${this.baseUrl}${endpoint}`, { + method: init.method, + headers, + body: init.body + }); + + const text = await response.text(); + const payload = text ? safeJsonParse(text) : {}; + if (!response.ok) { + throw new CircleApiError( + `Circle API request failed (${response.status}) for ${endpoint}`, + response.status, + payload + ); + } + return payload as T; + } +} + +function safeJsonParse(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return { raw: value }; + } +} diff --git a/npm/omniclaw/src/compat.ts b/npm/omniclaw/src/compat.ts new file mode 100644 index 0000000..e256dc5 --- /dev/null +++ b/npm/omniclaw/src/compat.ts @@ -0,0 +1,200 @@ +import { ConfigurationError, OmniClawError } from "./errors.js"; +import { randomBytes } from "node:crypto"; +import { parsePrice } from "./protocols/nanopayments/middleware.js"; + +export enum Network { + ARC_TESTNET = "eip155:5042002", + BASE_SEPOLIA = "eip155:84532", + BASE_MAINNET = "eip155:8453", + ETHEREUM = "eip155:1" +} + +export enum FeeLevel { + LOW = "low", + MEDIUM = "medium", + HIGH = "high" +} + +export enum PaymentMethod { + TRANSFER = "transfer", + X402 = "x402", + NANO = "nano" +} + +export enum PaymentStatus { + PENDING = "pending", + SUCCESS = "success", + FAILED = "failed" +} + +export interface WalletInfo { + id: string; + address?: string; + blockchain?: string; +} + +export interface WalletSetInfo { + id: string; + name?: string; +} + +export interface Balance { + amount: string; + currency: string; +} + +export interface TokenInfo { + symbol: string; + decimals: number; + contractAddress?: string; +} + +export interface PaymentRequest { + wallet_id: string; + recipient: string; + amount: string; + currency?: string; + purpose?: string; +} + +export interface PaymentResult { + success: boolean; + transaction_id?: string; + status?: string; +} + +export interface SimulationResult { + would_succeed: boolean; + estimated_fees: string; + net_transfer: string; +} + +export interface TransactionInfo { + id: string; + status: string; +} + +export interface PaymentIntent { + id: string; + status: string; + amount: string; + currency: string; +} + +export type PaymentIntentStatus = "pending" | "requires_review" | "confirmed" | "cancelled" | "failed"; + +export interface TrustPolicy { + minScore?: number; + actionOnUnknown?: "allow" | "hold" | "block"; +} + +export interface AgentIdentity { + id: string; + walletAddress?: string; +} + +export interface ReputationScore { + score: number; + source?: string; +} + +// Exception aliases matching common OmniClaw error names across clients. +export class WalletError extends OmniClawError {} +export class PaymentError extends OmniClawError {} +export class GuardError extends OmniClawError {} +export class ProtocolError extends OmniClawError {} +export class InsufficientBalanceError extends OmniClawError {} +export class NetworkError extends OmniClawError {} +export class X402Error extends OmniClawError {} +export class CrosschainError extends OmniClawError {} +export class IdempotencyError extends OmniClawError {} +export class TransactionTimeoutError extends OmniClawError {} +export class ValidationError extends OmniClawError {} + +export interface DoctorStatus { + ok: boolean; + checks: Record; + notes: string[]; +} + +export function quickSetup(circleApiKey: string, entitySecret?: string): { + CIRCLE_API_KEY: string; + ENTITY_SECRET?: string; +} { + if (!circleApiKey) { + throw new ConfigurationError("circleApiKey is required"); + } + return { CIRCLE_API_KEY: circleApiKey, ENTITY_SECRET: entitySecret }; +} + +export const quick_setup = quickSetup; + +export function ensureSetup(): boolean { + return Boolean(process.env.CIRCLE_API_KEY); +} + +export const ensure_setup = ensureSetup; + +export function verifySetup(): DoctorStatus { + const checks = { + CIRCLE_API_KEY: Boolean(process.env.CIRCLE_API_KEY), + ENTITY_SECRET: Boolean(process.env.ENTITY_SECRET) + }; + return { + ok: Object.values(checks).every(Boolean), + checks, + notes: [] + }; +} + +export const verify_setup = verifySetup; + +export function doctor(): DoctorStatus { + return verifySetup(); +} + +export function printDoctorStatus(status = doctor()): void { + // Intentionally console-based for CLI-style diagnostics. + // eslint-disable-next-line no-console + console.log(status); +} + +export const print_doctor_status = printDoctorStatus; + +export function printSetupStatus(status = verifySetup()): void { + // eslint-disable-next-line no-console + console.log(status); +} + +export const print_setup_status = printSetupStatus; + +export function getConfigDir(): string { + return process.env.OMNICLAW_CONFIG_DIR ?? ".omniclaw"; +} + +export const get_config_dir = getConfigDir; + +export function generateEntitySecret(): string { + return cryptoRandomString(64); +} + +export const generate_entity_secret = generateEntitySecret; + +export function findRecoveryFile(): string | null { + return null; +} + +export const find_recovery_file = findRecoveryFile; + +export function storeManagedCredentials(): void { + // no-op compatibility helper +} + +export const store_managed_credentials = storeManagedCredentials; + +export const parse_price = parsePrice; + +function cryptoRandomString(len: number): string { + const neededBytes = Math.ceil(len / 2); + return randomBytes(neededBytes).toString("hex").slice(0, len); +} diff --git a/npm/omniclaw/src/core/guards.ts b/npm/omniclaw/src/core/guards.ts new file mode 100644 index 0000000..b98fcb2 --- /dev/null +++ b/npm/omniclaw/src/core/guards.ts @@ -0,0 +1,130 @@ +import { ConfigurationError } from "../errors.js"; + +export interface PaymentContext { + walletId: string; + recipient: string; + amount: string; + currency: string; + purpose?: string; + confirm?: boolean; +} + +export interface GuardResult { + allowed: boolean; + reason?: string; +} + +export interface Guard { + readonly name: string; + evaluate(context: PaymentContext): Promise | GuardResult; +} + +export class BudgetGuard implements Guard { + readonly name = "budget"; + private spent = 0; + + constructor(private readonly maxBudget: number) {} + + evaluate(context: PaymentContext): GuardResult { + const amount = Number.parseFloat(context.amount); + if (this.spent + amount > this.maxBudget) { + return { allowed: false, reason: `budget exceeded (${this.maxBudget})` }; + } + this.spent += amount; + return { allowed: true }; + } +} + +export class SingleTxGuard implements Guard { + readonly name = "single_tx"; + + constructor(private readonly maxAmount: number) {} + + evaluate(context: PaymentContext): GuardResult { + const amount = Number.parseFloat(context.amount); + return amount <= this.maxAmount + ? { allowed: true } + : { allowed: false, reason: `single transaction limit exceeded (${this.maxAmount})` }; + } +} + +export class RateLimitGuard implements Guard { + readonly name = "rate_limit"; + private calls: number[] = []; + + constructor( + private readonly maxCalls: number, + private readonly windowMs: number + ) {} + + evaluate(): GuardResult { + const now = Date.now(); + this.calls = this.calls.filter((ts) => now - ts <= this.windowMs); + if (this.calls.length >= this.maxCalls) { + return { allowed: false, reason: "rate limit exceeded" }; + } + this.calls.push(now); + return { allowed: true }; + } +} + +export class RecipientGuard implements Guard { + readonly name = "recipient"; + private readonly whitelist = new Set(); + + constructor(recipients: string[]) { + recipients.forEach((recipient) => this.whitelist.add(recipient.toLowerCase())); + } + + evaluate(context: PaymentContext): GuardResult { + if (this.whitelist.size === 0) { + return { allowed: true }; + } + return this.whitelist.has(context.recipient.toLowerCase()) + ? { allowed: true } + : { allowed: false, reason: "recipient is not whitelisted" }; + } +} + +export class ConfirmGuard implements Guard { + readonly name = "confirm"; + + constructor(private readonly threshold: number) {} + + evaluate(context: PaymentContext): GuardResult { + const amount = Number.parseFloat(context.amount); + if (amount < this.threshold) { + return { allowed: true }; + } + return context.confirm + ? { allowed: true } + : { allowed: false, reason: `confirmation required for amount >= ${this.threshold}` }; + } +} + +export class GuardManager { + private readonly guardsByWallet = new Map(); + + addGuard(walletId: string, guard: Guard): void { + const existing = this.guardsByWallet.get(walletId) ?? []; + existing.push(guard); + this.guardsByWallet.set(walletId, existing); + } + + listGuards(walletId: string): string[] { + return (this.guardsByWallet.get(walletId) ?? []).map((guard) => guard.name); + } + + async evaluate(walletId: string, context: PaymentContext, skipGuards = false): Promise { + if (skipGuards) { + return; + } + const guards = this.guardsByWallet.get(walletId) ?? []; + for (const guard of guards) { + const result = await guard.evaluate(context); + if (!result.allowed) { + throw new ConfigurationError(result.reason ?? `Guard ${guard.name} blocked payment`); + } + } + } +} diff --git a/npm/omniclaw/src/core/idempotency.ts b/npm/omniclaw/src/core/idempotency.ts new file mode 100644 index 0000000..c50bf7e --- /dev/null +++ b/npm/omniclaw/src/core/idempotency.ts @@ -0,0 +1,8 @@ +import { createHash } from "node:crypto"; + +export function deriveIdempotencyKey(parts: Array): string { + const normalized = parts + .map((part) => String(part ?? "").trim().toLowerCase()) + .join("|"); + return createHash("sha256").update(normalized).digest("hex"); +} diff --git a/npm/omniclaw/src/core/intents.ts b/npm/omniclaw/src/core/intents.ts new file mode 100644 index 0000000..3bb3a29 --- /dev/null +++ b/npm/omniclaw/src/core/intents.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; + +export type PaymentIntentStatus = + | "pending" + | "requires_review" + | "confirmed" + | "cancelled" + | "failed"; + +export interface PaymentIntentRecord { + id: string; + walletId: string; + recipient: string; + amount: string; + currency: string; + status: PaymentIntentStatus; + idempotencyKey: string; + createdAt: string; +} + +export class PaymentIntentService { + private readonly intents = new Map(); + private readonly byIdempotency = new Map(); + + createIntent(input: { + walletId: string; + recipient: string; + amount: string; + currency: string; + idempotencyKey: string; + requiresReview?: boolean; + }): PaymentIntentRecord { + const existingId = this.byIdempotency.get(input.idempotencyKey); + if (existingId) { + const existing = this.intents.get(existingId); + if (existing) { + return existing; + } + } + + const intent: PaymentIntentRecord = { + id: randomUUID(), + walletId: input.walletId, + recipient: input.recipient, + amount: input.amount, + currency: input.currency, + status: input.requiresReview ? "requires_review" : "pending", + idempotencyKey: input.idempotencyKey, + createdAt: new Date().toISOString() + }; + this.intents.set(intent.id, intent); + this.byIdempotency.set(intent.idempotencyKey, intent.id); + return intent; + } + + getIntent(intentId: string): PaymentIntentRecord | null { + return this.intents.get(intentId) ?? null; + } + + updateStatus(intentId: string, status: PaymentIntentStatus): PaymentIntentRecord | null { + const intent = this.intents.get(intentId); + if (!intent) { + return null; + } + const next = { ...intent, status }; + this.intents.set(intentId, next); + return next; + } +} diff --git a/npm/omniclaw/src/core/ledger.ts b/npm/omniclaw/src/core/ledger.ts new file mode 100644 index 0000000..1581b62 --- /dev/null +++ b/npm/omniclaw/src/core/ledger.ts @@ -0,0 +1,40 @@ +export type LedgerEntryStatus = "pending" | "confirmed" | "failed" | "cancelled"; + +export interface LedgerEntry { + id: string; + walletId: string; + recipient: string; + amount: string; + currency: string; + status: LedgerEntryStatus; + createdAt: string; + metadata?: Record; +} + +export class Ledger { + private readonly entries = new Map(); + + create(entry: Omit): LedgerEntry { + const full: LedgerEntry = { ...entry, createdAt: new Date().toISOString() }; + this.entries.set(full.id, full); + return full; + } + + updateStatus(id: string, status: LedgerEntryStatus): LedgerEntry | null { + const current = this.entries.get(id); + if (!current) { + return null; + } + const updated: LedgerEntry = { ...current, status }; + this.entries.set(id, updated); + return updated; + } + + get(id: string): LedgerEntry | null { + return this.entries.get(id) ?? null; + } + + list(): LedgerEntry[] { + return [...this.entries.values()]; + } +} diff --git a/npm/omniclaw/src/core/trust.ts b/npm/omniclaw/src/core/trust.ts new file mode 100644 index 0000000..f5721d6 --- /dev/null +++ b/npm/omniclaw/src/core/trust.ts @@ -0,0 +1,24 @@ +export type TrustVerdict = "allow" | "hold" | "block"; + +export interface TrustCheckResult { + verdict: TrustVerdict; + reason?: string; + score?: number; +} + +export type TrustEvaluator = (recipient: string) => Promise; + +export class TrustGate { + constructor(private readonly evaluator?: TrustEvaluator) {} + + async check(recipient: string): Promise { + if (!this.evaluator) { + return { verdict: "allow", reason: "trust evaluator not configured" }; + } + return this.evaluator(recipient); + } + + isConfigured(): boolean { + return Boolean(this.evaluator); + } +} diff --git a/npm/omniclaw/src/core/validation.ts b/npm/omniclaw/src/core/validation.ts new file mode 100644 index 0000000..3764c4b --- /dev/null +++ b/npm/omniclaw/src/core/validation.ts @@ -0,0 +1,35 @@ +import { ConfigurationError } from "../errors.js"; + +const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; +const WALLET_ID_REGEX = /^[a-zA-Z0-9-]{3,128}$/; + +export function assertPositiveDecimal(value: string, fieldName: string): void { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new ConfigurationError(`${fieldName} must be a positive decimal string`); + } +} + +export function assertEvmAddress(value: string, fieldName: string): void { + if (!EVM_ADDRESS_REGEX.test(value)) { + throw new ConfigurationError(`${fieldName} must be a valid EVM address`); + } +} + +export function assertWalletId(value: string, fieldName: string): void { + if (!WALLET_ID_REGEX.test(value)) { + throw new ConfigurationError(`${fieldName} is invalid`); + } +} + +export function assertHttpsUrl(value: string, fieldName: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new ConfigurationError(`${fieldName} must be a valid URL`); + } + if (parsed.protocol !== "https:") { + throw new ConfigurationError(`${fieldName} must use https`); + } +} diff --git a/npm/omniclaw/src/errors.ts b/npm/omniclaw/src/errors.ts new file mode 100644 index 0000000..2586328 --- /dev/null +++ b/npm/omniclaw/src/errors.ts @@ -0,0 +1,25 @@ +export class OmniClawError extends Error { + constructor(message: string) { + super(message); + this.name = "OmniClawError"; + } +} + +export class ConfigurationError extends OmniClawError { + constructor(message: string) { + super(message); + this.name = "ConfigurationError"; + } +} + +export class CircleApiError extends OmniClawError { + readonly status: number; + readonly payload: unknown; + + constructor(message: string, status: number, payload: unknown) { + super(message); + this.name = "CircleApiError"; + this.status = status; + this.payload = payload; + } +} diff --git a/npm/omniclaw/src/index.ts b/npm/omniclaw/src/index.ts new file mode 100644 index 0000000..02dfd60 --- /dev/null +++ b/npm/omniclaw/src/index.ts @@ -0,0 +1,86 @@ +export { OmniClaw } from "./client.js"; +export { CircleApiError, ConfigurationError, OmniClawError } from "./errors.js"; +export { + BudgetGuard, + ConfirmGuard, + GuardManager, + RateLimitGuard, + RecipientGuard, + SingleTxGuard +} from "./core/guards.js"; +export { Ledger } from "./core/ledger.js"; +export { PaymentIntentService } from "./core/intents.js"; +export { TrustGate } from "./core/trust.js"; +export { + CrosschainError, + FeeLevel, + IdempotencyError, + InsufficientBalanceError, + Network, + NetworkError, + PaymentError, + PaymentMethod, + PaymentStatus as CorePaymentStatus, + ProtocolError, + TransactionTimeoutError, + ValidationError, + WalletError, + X402Error, + doctor, + ensureSetup, + ensure_setup, + findRecoveryFile, + find_recovery_file, + generateEntitySecret, + generate_entity_secret, + getConfigDir, + get_config_dir, + parse_price, + printDoctorStatus, + printSetupStatus, + print_doctor_status, + print_setup_status, + quickSetup, + quick_setup, + storeManagedCredentials, + store_managed_credentials, + verifySetup, + verify_setup +} from "./compat.js"; +export type { + AgentIdentity, + Balance, + DoctorStatus, + PaymentIntent, + PaymentIntentStatus as CorePaymentIntentStatus, + PaymentRequest, + PaymentResult, + ReputationScore, + SimulationResult, + TokenInfo, + TransactionInfo, + TrustPolicy, + WalletInfo, + WalletSetInfo +} from "./compat.js"; +export * from "./protocols/nanopayments/index.js"; +export * from "./seller/index.js"; +export { WebhookVerifier } from "./webhooks.js"; +export type { VerifiedWebhook, WebhookVerifierOptions } from "./webhooks.js"; +export { simulatePaymentLocally } from "./simulate.js"; +export type { + CirclePaymentIntentResponse, + CirclePaymentResponse, + CircleWalletResponse, + CreatePaymentIntentParams, + CreatePaymentParams, + OmniClawConfig, + RoutedPaymentParams, + RoutedPaymentResult, + SimulatePaymentParams, + SimulatePaymentResult +} from "./types.js"; +export type { Guard, GuardResult, PaymentContext } from "./core/guards.js"; +export type { LedgerEntry, LedgerEntryStatus } from "./core/ledger.js"; +export type { PaymentIntentRecord, PaymentIntentStatus } from "./core/intents.js"; +export type { TrustCheckResult, TrustEvaluator, TrustVerdict } from "./core/trust.js"; diff --git a/npm/omniclaw/src/protocols/nanopayments/adapter.ts b/npm/omniclaw/src/protocols/nanopayments/adapter.ts new file mode 100644 index 0000000..8de2ff2 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/adapter.ts @@ -0,0 +1,272 @@ +import { Buffer } from "node:buffer"; + +import { assertEvmAddress, assertHttpsUrl, assertPositiveDecimal } from "../../core/validation.js"; +import { CIRCLE_BATCHING_NAME } from "./constants.js"; +import { + CircuitOpenError, + InvalidPaymentRequirementsError, + UnsupportedSchemeError +} from "./errors.js"; +import type { + NanopaymentAdapterOptions, + NanopaymentResult, + PaymentRequirements, + PaymentRequirementsKind, + PayX402UrlParams +} from "./types.js"; +import { NanopaymentClient } from "./client.js"; +import { NanoKeyVault } from "./vault.js"; + +class NanopaymentCircuitBreaker { + private failures = 0; + private openedAt = 0; + + constructor( + private readonly failureThreshold: number, + private readonly recoveryMs: number + ) {} + + ensureAvailable(): void { + if (this.failures < this.failureThreshold) { + return; + } + const elapsed = Date.now() - this.openedAt; + if (elapsed < this.recoveryMs) { + throw new CircuitOpenError(); + } + this.failures = 0; + this.openedAt = 0; + } + + recordSuccess(): void { + this.failures = 0; + this.openedAt = 0; + } + + recordFailure(): void { + this.failures += 1; + if (this.failures >= this.failureThreshold && this.openedAt === 0) { + this.openedAt = Date.now(); + } + } +} + +export class NanopaymentAdapter { + private readonly strictSettlement: boolean; + private readonly retryAttempts: number; + private readonly retryBaseDelayMs: number; + private readonly circuitBreaker: NanopaymentCircuitBreaker; + + constructor( + private readonly vault: NanoKeyVault, + private readonly client: NanopaymentClient, + private readonly fetchImpl: typeof fetch = fetch, + options: NanopaymentAdapterOptions = {} + ) { + this.strictSettlement = options.strictSettlement ?? true; + this.retryAttempts = options.retryAttempts ?? 3; + this.retryBaseDelayMs = options.retryBaseDelayMs ?? 250; + this.circuitBreaker = new NanopaymentCircuitBreaker( + options.circuitBreakerFailureThreshold ?? 5, + options.circuitBreakerRecoveryMs ?? 60_000 + ); + } + + async payX402Url(params: PayX402UrlParams): Promise { + assertHttpsUrl(params.url, "url"); + this.circuitBreaker.ensureAvailable(); + + const initialResp = await this.fetchImpl(params.url, { + method: (params.method ?? "GET").toUpperCase(), + headers: params.headers, + body: params.body + }); + + if (initialResp.status !== 402) { + return { + success: initialResp.ok, + isNanopayment: false, + payer: "", + seller: "", + transaction: "", + amountAtomic: "0", + amountUsdc: "0", + network: "", + responseStatus: initialResp.status, + responseData: await initialResp.text() + }; + } + + const paymentRequiredHeader = + initialResp.headers.get("payment-required") ?? initialResp.headers.get("PAYMENT-REQUIRED"); + if (!paymentRequiredHeader) { + throw new UnsupportedSchemeError("402 response missing PAYMENT-REQUIRED header"); + } + + const requirements = parsePaymentRequirements(paymentRequiredHeader); + const kind = requirements.accepts.find((entry) => entry.extra?.name === CIRCLE_BATCHING_NAME); + if (!kind) { + throw new UnsupportedSchemeError(); + } + + const payload = await this.vault.sign(kind, params.keyAlias); + const paymentSignature = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + + const retryResp = await this.fetchImpl(params.url, { + method: (params.method ?? "GET").toUpperCase(), + headers: { + ...(params.headers ?? {}), + "PAYMENT-SIGNATURE": paymentSignature + }, + body: params.body + }); + + let transaction = ""; + let settlementSuccess = false; + let settlementError: unknown; + try { + const settleResp = await this.settleWithRetry(payload, { + x402Version: requirements.x402Version, + accepts: [kind] + }); + transaction = settleResp.transaction ?? ""; + settlementSuccess = settleResp.success; + this.circuitBreaker.recordSuccess(); + } catch (error) { + settlementError = error; + settlementSuccess = false; + this.circuitBreaker.recordFailure(); + } + + const amountAtomic = kind.amount; + const amountUsdc = (Number.parseInt(amountAtomic, 10) / 1_000_000).toString(); + const contentDelivered = retryResp.ok; + const success = this.strictSettlement + ? contentDelivered && settlementSuccess + : contentDelivered || settlementSuccess; + if (this.strictSettlement && contentDelivered && !settlementSuccess && settlementError) { + throw settlementError; + } + + return { + success, + isNanopayment: true, + payer: this.vault.getAddress(params.keyAlias), + seller: kind.payTo, + transaction, + amountAtomic, + amountUsdc, + network: kind.network, + responseStatus: retryResp.status, + responseData: await retryResp.text() + }; + } + + async payDirect(params: { + sellerAddress: string; + amountUsdc: string; + network: string; + keyAlias?: string; + }): Promise { + this.circuitBreaker.ensureAvailable(); + assertEvmAddress(params.sellerAddress, "sellerAddress"); + assertPositiveDecimal(params.amountUsdc, "amountUsdc"); + assertCaip2Network(params.network); + + const supported = await this.client.getSupported(); + const match = supported.find((entry) => entry.network === params.network); + if (!match?.extra?.verifyingContract || !match.extra.usdcAddress) { + throw new UnsupportedSchemeError(`No supported Gateway contract for network ${params.network}`); + } + + const amountAtomic = Math.round(Number.parseFloat(params.amountUsdc) * 1_000_000).toString(); + const kind: PaymentRequirementsKind = { + scheme: "exact", + network: params.network, + asset: match.extra.usdcAddress, + amount: amountAtomic, + maxTimeoutSeconds: 345600, + payTo: params.sellerAddress, + extra: { + name: CIRCLE_BATCHING_NAME, + version: "1", + verifyingContract: match.extra.verifyingContract + } + }; + + const payload = await this.vault.sign(kind, params.keyAlias); + const settleResp = await this.settleWithRetry(payload, { x402Version: 2, accepts: [kind] }); + this.circuitBreaker.recordSuccess(); + + return { + success: settleResp.success, + isNanopayment: true, + payer: this.vault.getAddress(params.keyAlias), + seller: params.sellerAddress, + transaction: settleResp.transaction ?? "", + amountAtomic, + amountUsdc: params.amountUsdc, + network: params.network + }; + } + + private async settleWithRetry( + payload: Parameters[0], + requirements: Parameters[1] + ) { + let lastError: unknown = null; + for (let attempt = 0; attempt <= this.retryAttempts; attempt += 1) { + try { + return await this.client.settle(payload, requirements); + } catch (error) { + lastError = error; + if (attempt >= this.retryAttempts) { + break; + } + const delay = this.retryBaseDelayMs * 2 ** attempt; + await sleep(delay); + } + } + throw lastError; + } +} + +function parsePaymentRequirements(base64Header: string): PaymentRequirements { + try { + const raw = Buffer.from(base64Header, "base64").toString("utf8"); + const parsed = JSON.parse(raw) as PaymentRequirements; + if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) { + throw new InvalidPaymentRequirementsError("No accepted payment kinds in 402 requirements"); + } + for (const kind of parsed.accepts) { + validatePaymentRequirementKind(kind); + } + return parsed; + } catch (error) { + if (error instanceof InvalidPaymentRequirementsError) { + throw error; + } + throw new InvalidPaymentRequirementsError("Invalid PAYMENT-REQUIRED header payload"); + } +} + +function validatePaymentRequirementKind(kind: PaymentRequirementsKind): void { + if (!kind.scheme || !kind.network || !kind.amount || !kind.payTo) { + throw new InvalidPaymentRequirementsError("Payment requirement kind is missing required fields"); + } + if (kind.extra?.name === CIRCLE_BATCHING_NAME && !kind.extra.verifyingContract) { + throw new InvalidPaymentRequirementsError( + "GatewayWalletBatched requirement must include verifyingContract" + ); + } +} + +function assertCaip2Network(network: string): void { + if (!/^eip155:\d+$/.test(network)) { + throw new InvalidPaymentRequirementsError("network must be in CAIP-2 format eip155:"); + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/npm/omniclaw/src/protocols/nanopayments/client.ts b/npm/omniclaw/src/protocols/nanopayments/client.ts new file mode 100644 index 0000000..efc6c56 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/client.ts @@ -0,0 +1,118 @@ +import { + CAIP2_TO_CIRCLE_DOMAIN, + GATEWAY_API_MAINNET, + GATEWAY_API_TESTNET, + GATEWAY_BALANCES_PATH, + GATEWAY_X402_SETTLE_PATH, + GATEWAY_X402_SUPPORTED_PATH, + GATEWAY_X402_VERIFY_PATH +} from "./constants.js"; +import { GatewayApiError } from "./errors.js"; +import type { + GatewayBalance, + PaymentPayload, + PaymentRequirements, + SettleResponse, + SupportedKind +} from "./types.js"; + +export interface NanopaymentClientOptions { + apiKey: string; + environment?: "testnet" | "mainnet"; + baseUrl?: string; + fetchImpl?: typeof fetch; +} + +export class NanopaymentClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: NanopaymentClientOptions) { + this.apiKey = options.apiKey; + this.baseUrl = + options.baseUrl ?? + (options.environment === "mainnet" ? GATEWAY_API_MAINNET : GATEWAY_API_TESTNET); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async getSupported(forceRefresh = false): Promise { + const suffix = forceRefresh ? `?t=${Date.now()}` : ""; + const payload = await this.request<{ kinds?: SupportedKind[] }>( + `${GATEWAY_X402_SUPPORTED_PATH}${suffix}`, + { method: "GET" } + ); + return payload.kinds ?? []; + } + + async getBalance(depositor: string, network: string): Promise { + const domain = CAIP2_TO_CIRCLE_DOMAIN[network] ?? 26; + const payload = await this.request<{ balances?: GatewayBalance[] }>(GATEWAY_BALANCES_PATH, { + method: "POST", + body: JSON.stringify({ + token: "USDC", + sources: [{ domain, depositor }] + }) + }); + return payload.balances ?? []; + } + + async settle(payload: PaymentPayload, requirements: PaymentRequirements): Promise { + return this.request(GATEWAY_X402_SETTLE_PATH, { + method: "POST", + body: JSON.stringify({ + x402Version: payload.x402Version, + accepted: requirements, + payload: payload.payload, + scheme: payload.scheme, + network: payload.network + }) + }); + } + + async verify(payload: PaymentPayload, requirements: PaymentRequirements): Promise { + return this.request(GATEWAY_X402_VERIFY_PATH, { + method: "POST", + body: JSON.stringify({ + x402Version: payload.x402Version, + accepted: requirements, + payload: payload.payload, + scheme: payload.scheme, + network: payload.network + }) + }); + } + + private async request( + endpoint: string, + init: { method: string; body?: string } + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${endpoint}`, { + method: init.method, + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json" + }, + body: init.body + }); + + const text = await response.text(); + const parsed = text ? safeJsonParse(text) : {}; + if (!response.ok) { + throw new GatewayApiError( + `Gateway request failed (${response.status}) for ${endpoint}`, + response.status, + parsed + ); + } + return parsed as T; + } +} + +function safeJsonParse(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return { raw: value }; + } +} diff --git a/npm/omniclaw/src/protocols/nanopayments/constants.ts b/npm/omniclaw/src/protocols/nanopayments/constants.ts new file mode 100644 index 0000000..79e3699 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/constants.ts @@ -0,0 +1,38 @@ +export const GATEWAY_API_TESTNET = "https://gateway-api-testnet.circle.com"; +export const GATEWAY_API_MAINNET = "https://gateway-api.circle.com"; + +export const GATEWAY_X402_SUPPORTED_PATH = "/v1/x402/supported"; +export const GATEWAY_X402_SETTLE_PATH = "/v1/x402/settle"; +export const GATEWAY_X402_VERIFY_PATH = "/v1/x402/verify"; +export const GATEWAY_BALANCES_PATH = "/v1/balances"; + +export const CIRCLE_BATCHING_NAME = "GatewayWalletBatched"; +export const CIRCLE_BATCHING_VERSION = "1"; +export const CIRCLE_BATCHING_SCHEME = "exact"; +export const X402_VERSION = 2; +export const DEFAULT_VALID_BEFORE_SECONDS = 345600; +export const MIN_VALID_BEFORE_SECONDS = 259200; +export const DEFAULT_MICRO_PAYMENT_THRESHOLD_USDC = "1.00"; + +export const CIRCLE_DOMAIN_TO_CAIP2: Record = { + 0: "eip155:1", + 1: "eip155:43114", + 2: "eip155:10", + 3: "eip155:42161", + 6: "eip155:8453", + 7: "eip155:137", + 26: "eip155:5042002" +}; + +export const CAIP2_TO_CIRCLE_DOMAIN: Record = Object.entries( + CIRCLE_DOMAIN_TO_CAIP2 +).reduce>((acc, [domain, caip2]) => { + acc[caip2] = Number(domain); + return acc; +}, {}); + +export function parseCaip2ChainId(network: string): number { + if (!network.includes(":")) return 0; + const chainId = Number.parseInt(network.split(":")[1] ?? "", 10); + return Number.isFinite(chainId) ? chainId : 0; +} diff --git a/npm/omniclaw/src/protocols/nanopayments/crypto.ts b/npm/omniclaw/src/protocols/nanopayments/crypto.ts new file mode 100644 index 0000000..b5af590 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/crypto.ts @@ -0,0 +1,43 @@ +import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto"; + +const PBKDF2_ITERATIONS = 480_000; +const KEY_LENGTH = 32; +const SALT_LENGTH = 16; +const NONCE_LENGTH = 12; +const ALGO = "aes-256-gcm"; + +export interface EncryptedPrivateKey { + salt: string; + nonce: string; + ciphertext: string; + tag: string; +} + +export function encryptPrivateKey(privateKey: string, secret: string): EncryptedPrivateKey { + const salt = randomBytes(SALT_LENGTH); + const key = pbkdf2Sync(secret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); + const nonce = randomBytes(NONCE_LENGTH); + const cipher = createCipheriv(ALGO, key, nonce); + const ciphertext = Buffer.concat([cipher.update(privateKey, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + salt: salt.toString("base64"), + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64") + }; +} + +export function decryptPrivateKey(payload: EncryptedPrivateKey, secret: string): string { + const salt = Buffer.from(payload.salt, "base64"); + const key = pbkdf2Sync(secret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); + const nonce = Buffer.from(payload.nonce, "base64"); + const decipher = createDecipheriv(ALGO, key, nonce); + decipher.setAuthTag(Buffer.from(payload.tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(payload.ciphertext, "base64")), + decipher.final() + ]); + return plaintext.toString("utf8"); +} diff --git a/npm/omniclaw/src/protocols/nanopayments/errors.ts b/npm/omniclaw/src/protocols/nanopayments/errors.ts new file mode 100644 index 0000000..c43e1fc --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/errors.ts @@ -0,0 +1,53 @@ +export class NanopaymentError extends Error { + constructor(message: string) { + super(message); + this.name = "NanopaymentError"; + } +} + +export class UnsupportedSchemeError extends NanopaymentError { + constructor(message = "GatewayWalletBatched scheme not found in payment requirements") { + super(message); + this.name = "UnsupportedSchemeError"; + } +} + +export class GatewayApiError extends NanopaymentError { + readonly status: number; + readonly payload: unknown; + + constructor(message: string, status: number, payload: unknown) { + super(message); + this.name = "GatewayApiError"; + this.status = status; + this.payload = payload; + } +} + +export class KeyNotFoundError extends NanopaymentError { + constructor(alias: string) { + super(`No nanopayment key found for alias: ${alias}`); + this.name = "KeyNotFoundError"; + } +} + +export class NoDefaultKeyError extends NanopaymentError { + constructor() { + super("No default nanopayment key is set"); + this.name = "NoDefaultKeyError"; + } +} + +export class CircuitOpenError extends NanopaymentError { + constructor() { + super("Nanopayment circuit is open; retry later"); + this.name = "CircuitOpenError"; + } +} + +export class InvalidPaymentRequirementsError extends NanopaymentError { + constructor(message: string) { + super(message); + this.name = "InvalidPaymentRequirementsError"; + } +} diff --git a/npm/omniclaw/src/protocols/nanopayments/index.ts b/npm/omniclaw/src/protocols/nanopayments/index.ts new file mode 100644 index 0000000..7f38ec3 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/index.ts @@ -0,0 +1,36 @@ +export { + CAIP2_TO_CIRCLE_DOMAIN, + CIRCLE_BATCHING_NAME, + CIRCLE_BATCHING_SCHEME, + CIRCLE_BATCHING_VERSION, + CIRCLE_DOMAIN_TO_CAIP2, + DEFAULT_MICRO_PAYMENT_THRESHOLD_USDC, + MIN_VALID_BEFORE_SECONDS, + GATEWAY_API_MAINNET, + GATEWAY_API_TESTNET, + X402_VERSION +} from "./constants.js"; +export { NanopaymentClient } from "./client.js"; +export { NanoKeyVault } from "./vault.js"; +export { NanopaymentAdapter } from "./adapter.js"; +export { GatewayMiddleware, parsePrice } from "./middleware.js"; +export { + GatewayApiError, + CircuitOpenError, + InvalidPaymentRequirementsError, + KeyNotFoundError, + NanopaymentError, + NoDefaultKeyError, + UnsupportedSchemeError +} from "./errors.js"; +export type { + GatewayBalance, + NanopaymentResult, + NanopaymentAdapterOptions, + PaymentPayload, + PaymentRequirements, + PaymentRequirementsKind, + PayX402UrlParams, + SettleResponse, + SupportedKind +} from "./types.js"; diff --git a/npm/omniclaw/src/protocols/nanopayments/middleware.ts b/npm/omniclaw/src/protocols/nanopayments/middleware.ts new file mode 100644 index 0000000..550c863 --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/middleware.ts @@ -0,0 +1,39 @@ +import { CIRCLE_BATCHING_NAME, X402_VERSION } from "./constants.js"; +import type { PaymentRequirements, SupportedKind } from "./types.js"; + +export function parsePrice(price: string): string { + const normalized = price.trim().startsWith("$") ? price.trim().slice(1) : price.trim(); + if (normalized.includes(".")) { + const decimal = Number.parseFloat(normalized); + return Math.round(decimal * 1_000_000).toString(); + } + const asInt = Number.parseInt(normalized, 10); + if (!Number.isFinite(asInt) || asInt <= 0) { + throw new Error(`Invalid price: ${price}`); + } + return asInt >= 1_000_000 ? String(asInt) : String(asInt * 1_000_000); +} + +export class GatewayMiddleware { + constructor(private readonly sellerAddress: string, private readonly supported: SupportedKind[]) {} + + build402Response(price: string): PaymentRequirements { + const amount = parsePrice(price); + const accepts = this.supported + .filter((kind) => kind.extra?.verifyingContract && kind.extra?.usdcAddress) + .map((kind) => ({ + scheme: "exact", + network: kind.network, + asset: kind.extra?.usdcAddress ?? "", + amount, + maxTimeoutSeconds: 345600, + payTo: this.sellerAddress, + extra: { + name: CIRCLE_BATCHING_NAME, + version: "1", + verifyingContract: kind.extra?.verifyingContract ?? "" + } + })); + return { x402Version: X402_VERSION, accepts }; + } +} diff --git a/npm/omniclaw/src/protocols/nanopayments/types.ts b/npm/omniclaw/src/protocols/nanopayments/types.ts new file mode 100644 index 0000000..3abdf4f --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/types.ts @@ -0,0 +1,95 @@ +export interface PaymentRequirementsExtra { + name: string; + version: string; + verifyingContract: string; +} + +export interface PaymentRequirementsKind { + scheme: string; + network: string; + asset: string; + amount: string; + maxTimeoutSeconds: number; + payTo: string; + extra: PaymentRequirementsExtra; +} + +export interface PaymentRequirements { + x402Version: number; + accepts: PaymentRequirementsKind[]; +} + +export interface EIP3009Authorization { + from: string; + to: string; + value: string; + validAfter: string; + validBefore: string; + nonce: string; +} + +export interface PaymentPayloadInner { + authorization: EIP3009Authorization; + signature: string; +} + +export interface PaymentPayload { + x402Version: number; + scheme: string; + network: string; + payload: PaymentPayloadInner; +} + +export interface SupportedKind { + x402Version: number; + scheme: string; + network: string; + extra?: { + verifyingContract?: string; + usdcAddress?: string; + [key: string]: unknown; + }; +} + +export interface SettleResponse { + success: boolean; + transaction?: string; + payer?: string; + network?: string; + [key: string]: unknown; +} + +export interface GatewayBalance { + amount: string; + token: string; + network?: string; +} + +export interface NanopaymentResult { + success: boolean; + isNanopayment: boolean; + payer: string; + seller: string; + transaction: string; + amountAtomic: string; + amountUsdc: string; + network: string; + responseStatus?: number; + responseData?: string; +} + +export interface PayX402UrlParams { + url: string; + method?: string; + headers?: Record; + body?: string; + keyAlias?: string; +} + +export interface NanopaymentAdapterOptions { + strictSettlement?: boolean; + retryAttempts?: number; + retryBaseDelayMs?: number; + circuitBreakerFailureThreshold?: number; + circuitBreakerRecoveryMs?: number; +} diff --git a/npm/omniclaw/src/protocols/nanopayments/vault.ts b/npm/omniclaw/src/protocols/nanopayments/vault.ts new file mode 100644 index 0000000..57939fe --- /dev/null +++ b/npm/omniclaw/src/protocols/nanopayments/vault.ts @@ -0,0 +1,190 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { Wallet } from "ethers"; + +import { + CIRCLE_BATCHING_NAME, + CIRCLE_BATCHING_SCHEME, + CIRCLE_BATCHING_VERSION, + DEFAULT_VALID_BEFORE_SECONDS, + X402_VERSION, + parseCaip2ChainId, + MIN_VALID_BEFORE_SECONDS +} from "./constants.js"; +import { + InvalidPaymentRequirementsError, + KeyNotFoundError, + NoDefaultKeyError +} from "./errors.js"; +import { decryptPrivateKey, encryptPrivateKey } from "./crypto.js"; +import type { PaymentPayload, PaymentRequirementsKind } from "./types.js"; + +interface StoredKey { + encryptedPrivateKey: ReturnType; + address: string; +} + +interface PersistedState { + defaultAlias: string | null; + keys: Record; +} + +export interface NanoKeyVaultOptions { + entitySecret: string; + keyStorePath?: string; +} + +export class NanoKeyVault { + private readonly keyStore = new Map(); + private readonly entitySecret: string; + private readonly keyStorePath?: string; + private defaultAlias: string | null = null; + + constructor(options: NanoKeyVaultOptions) { + if (!options.entitySecret || options.entitySecret.length < 16) { + throw new InvalidPaymentRequirementsError( + "entitySecret is required and must be at least 16 characters for nanopayment key encryption" + ); + } + this.entitySecret = options.entitySecret; + this.keyStorePath = options.keyStorePath; + this.loadFromDiskIfAvailable(); + } + + addKey(alias: string, privateKey: string): string { + const wallet = new Wallet(privateKey); + this.keyStore.set(alias, { + encryptedPrivateKey: encryptPrivateKey(wallet.privateKey, this.entitySecret), + address: wallet.address + }); + if (!this.defaultAlias) this.defaultAlias = alias; + this.persistToDisk(); + return wallet.address; + } + + generateKey(alias: string): string { + const wallet = Wallet.createRandom(); + this.keyStore.set(alias, { + encryptedPrivateKey: encryptPrivateKey(wallet.privateKey, this.entitySecret), + address: wallet.address + }); + if (!this.defaultAlias) this.defaultAlias = alias; + this.persistToDisk(); + return wallet.address; + } + + setDefaultKey(alias: string): void { + if (!this.keyStore.has(alias)) { + throw new KeyNotFoundError(alias); + } + this.defaultAlias = alias; + this.persistToDisk(); + } + + listKeys(): string[] { + return [...this.keyStore.keys()]; + } + + getAddress(alias?: string): string { + const resolvedAlias = alias ?? this.defaultAlias; + if (!resolvedAlias) throw new NoDefaultKeyError(); + const key = this.keyStore.get(resolvedAlias); + if (!key) throw new KeyNotFoundError(resolvedAlias); + return key.address; + } + + async sign(kind: PaymentRequirementsKind, alias?: string): Promise { + const resolvedAlias = alias ?? this.defaultAlias; + if (!resolvedAlias) throw new NoDefaultKeyError(); + const key = this.keyStore.get(resolvedAlias); + if (!key) throw new KeyNotFoundError(resolvedAlias); + + validateRequirements(kind); + const wallet = new Wallet(decryptPrivateKey(key.encryptedPrivateKey, this.entitySecret)); + const chainId = parseCaip2ChainId(kind.network); + const now = Math.floor(Date.now() / 1000); + const validBefore = now + DEFAULT_VALID_BEFORE_SECONDS; + const authorization = { + from: wallet.address, + to: kind.payTo, + value: kind.amount, + validAfter: "0", + validBefore: String(validBefore), + nonce: `0x${randomBytes(32).toString("hex")}` + }; + + const domain = { + name: kind.extra?.name || CIRCLE_BATCHING_NAME, + version: kind.extra?.version || CIRCLE_BATCHING_VERSION, + chainId, + verifyingContract: kind.extra?.verifyingContract + }; + const types = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" } + ] + }; + + const signature = await wallet.signTypedData(domain, types, authorization); + return { + x402Version: X402_VERSION, + scheme: kind.scheme || CIRCLE_BATCHING_SCHEME, + network: kind.network, + payload: { authorization, signature } + }; + } + + private persistToDisk(): void { + if (!this.keyStorePath) { + return; + } + const payload: PersistedState = { + defaultAlias: this.defaultAlias, + keys: Object.fromEntries(this.keyStore.entries()) + }; + writeFileSync(this.keyStorePath, JSON.stringify(payload, null, 2), { encoding: "utf8" }); + } + + private loadFromDiskIfAvailable(): void { + if (!this.keyStorePath) { + return; + } + try { + const raw = readFileSync(this.keyStorePath, { encoding: "utf8" }); + const parsed = JSON.parse(raw) as PersistedState; + this.defaultAlias = parsed.defaultAlias ?? null; + for (const [alias, value] of Object.entries(parsed.keys ?? {})) { + this.keyStore.set(alias, value); + } + } catch { + // First-run or corrupted keystore: continue with empty in-memory store. + } + } +} + +function validateRequirements(kind: PaymentRequirementsKind): void { + if (kind.extra?.name !== CIRCLE_BATCHING_NAME) { + throw new InvalidPaymentRequirementsError("Expected GatewayWalletBatched payment scheme"); + } + if (!kind.extra?.verifyingContract) { + throw new InvalidPaymentRequirementsError("Missing verifyingContract in payment requirements"); + } + if (parseCaip2ChainId(kind.network) <= 0) { + throw new InvalidPaymentRequirementsError("Network must be CAIP-2 eip155:"); + } + const amountAtomic = Number.parseInt(kind.amount, 10); + if (!Number.isFinite(amountAtomic) || amountAtomic <= 0) { + throw new InvalidPaymentRequirementsError("Amount must be a positive atomic integer"); + } + const validitySeconds = DEFAULT_VALID_BEFORE_SECONDS; + if (validitySeconds < MIN_VALID_BEFORE_SECONDS) { + throw new InvalidPaymentRequirementsError( + "validBefore window is below minimum required threshold" + ); + } +} diff --git a/npm/omniclaw/src/scripts/smokeTest.ts b/npm/omniclaw/src/scripts/smokeTest.ts new file mode 100644 index 0000000..879e29d --- /dev/null +++ b/npm/omniclaw/src/scripts/smokeTest.ts @@ -0,0 +1,306 @@ +import { generateKeyPairSync, sign as signPayload } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Wallet } from "ethers"; + +import { + OmniClaw, + WebhookVerifier, + createSeller, + quick_setup +} from "../index.js"; + +type FetchFn = typeof fetch; +const SELLER_ADDR = "0x742d35cc6634c0532925a3b844bc9e7595f5e4a0"; + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(message); + } +} + +function buildPaymentRequiredHeader(): string { + const requirements = { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:5042002", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + amount: "1000", + payTo: SELLER_ADDR, + maxTimeoutSeconds: 345600, + extra: { + name: "GatewayWalletBatched", + version: "1", + verifyingContract: "0x1111111111111111111111111111111111111111" + } + } + ] + }; + return Buffer.from(JSON.stringify(requirements), "utf8").toString("base64"); +} + +function createMockFetch(): FetchFn { + const paymentRequiredHeader = buildPaymentRequiredHeader(); + + return (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + + if (url.includes("/v1/payments") && method === "POST") { + return jsonResponse({ + data: { id: "pay_123", status: "pending", amount: { amount: "2.00", currency: "USD" } } + }); + } + if (url.includes("/v1/wallets/") && method === "GET") { + return jsonResponse({ + data: { walletId: "wallet-123", balances: [{ amount: "12.500000", currency: "USD" }] } + }); + } + if (url.endsWith("/v1/walletSets") && method === "POST") { + return jsonResponse({ data: { id: "ws_1", walletSetId: "ws_1" } }); + } + if (url.includes("/v1/wallets") && method === "POST") { + return jsonResponse({ data: { id: "w_1", walletId: "w_1", address: "0xabc" } }); + } + if (url.includes("/v1/walletSets") && method === "GET") { + return jsonResponse({ data: [{ id: "ws_1" }] }); + } + if (url.match(/\/v1\/wallets(\?|$)/) && method === "GET") { + return jsonResponse({ data: [{ id: "w_1", walletId: "w_1", address: "0xabc" }] }); + } + if (url.endsWith("/v1/paymentIntents") && method === "POST") { + return jsonResponse({ + data: { id: "intent_1", status: "pending", amount: { amount: "1.00", currency: "USD" } } + }); + } + if (url.includes("/v1/paymentIntents/") && method === "GET") { + return jsonResponse({ + data: { id: "intent_1", status: "pending", amount: { amount: "1.00", currency: "USD" } } + }); + } + if (url.includes("/v1/paymentIntents/") && method === "POST") { + return jsonResponse({ + data: { id: "intent_1", status: "confirmed", amount: { amount: "1.00", currency: "USD" } } + }); + } + if (url.endsWith("/v1/x402/supported") && method === "GET") { + return jsonResponse({ + kinds: [ + { + x402Version: 2, + scheme: "exact", + network: "eip155:5042002", + extra: { + verifyingContract: "0x1111111111111111111111111111111111111111", + usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + } + } + ] + }); + } + if (url.endsWith("/v1/balances") && method === "POST") { + return jsonResponse({ + balances: [{ amount: "10.000000", token: "USDC", network: "eip155:5042002" }] + }); + } + if (url.endsWith("/v1/x402/settle") && method === "POST") { + return jsonResponse({ + success: true, + transaction: "settle_tx_1", + network: "eip155:5042002" + }); + } + if (url === "https://your-paid-endpoint.example/premium") { + if (!headers.get("PAYMENT-SIGNATURE")) { + return new Response(JSON.stringify({ error: "payment required" }), { + status: 402, + headers: { + "content-type": "application/json", + "payment-required": paymentRequiredHeader + } + }); + } + return new Response(JSON.stringify({ ok: true, data: "paid content" }), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + + return new Response(JSON.stringify({ error: `unhandled mock: ${method} ${url}` }), { + status: 500, + headers: { "content-type": "application/json" } + }); + }) as FetchFn; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function run(): Promise { + const mockFetch = createMockFetch(); + const tempRoot = mkdtempSync(join(tmpdir(), "omniclaw-smoke-")); + const keyStorePath = join(tempRoot, "nano-keys.json"); + const nonceStorePath = join(tempRoot, "seller-nonces.json"); + + try { + const env = quick_setup("test_circle_key", "test_entity_secret_1234567890"); + assert(Boolean(env.CIRCLE_API_KEY), "quick_setup should return api key"); + + const client = new OmniClaw({ + circleApiKey: "test_circle_key", + circleWalletId: "wallet-123", + entitySecret: "test_entity_secret_1234567890", + nanopaymentKeyStorePath: keyStorePath, + fetchImpl: mockFetch, + trustEvaluator: async (recipient) => + recipient.includes("blocked") + ? { verdict: "block", reason: "blocked recipient" } + : { verdict: "allow", score: 0.9 } + }); + + const nanoAddress = client.generateNanoKey("buyer-1"); + client.setDefaultNanoKey("buyer-1"); + assert(nanoAddress.startsWith("0x"), "generated nano address must be an EVM address"); + + const sim = client.simulatePayment({ + amount: "2.00", + destinationAddress: SELLER_ADDR + }); + assert(sim.readyToExecute, "simulation should succeed"); + + client.addRecipientGuard("wallet-123", [SELLER_ADDR]); + const routed = await client.payWithRouting({ + walletId: "wallet-123", + recipient: SELLER_ADDR, + amount: "2.00", + checkTrust: true + }); + assert(routed.success, "direct routed payment should succeed"); + assert(routed.route === "circle_transfer", "route should be circle_transfer"); + + const x402 = await client.payX402Url({ + url: "https://your-paid-endpoint.example/premium" + }); + assert(x402.success, "x402 nanopayment should settle"); + assert(x402.isNanopayment, "x402 result should be nanopayment"); + + const paymentIntent = await client.createPaymentIntent({ + amount: "1.00", + recipient: SELLER_ADDR, + sourceWalletId: "wallet-123", + idempotencyKey: "intent-test-key" + }); + assert(Boolean(paymentIntent.data?.id), "payment intent should be created"); + + const balance = await client.getWalletBalance("wallet-123"); + assert(balance.data?.balances?.[0]?.amount === "12.500000", "wallet balance should be mocked"); + + // Webhook verification test (Ed25519) + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString(); + const payload = JSON.stringify({ + notificationType: "payments", + notificationId: "notif-1", + createDate: new Date().toISOString() + }); + const signature = signPayload(null, Buffer.from(payload, "utf8"), privateKey).toString("base64"); + const verifier = new WebhookVerifier({ + verificationKey: publicKeyPem, + dedupStorePath: join(tempRoot, "webhook-dedup.json") + }); + const verified = verifier.verify(payload, { + "x-circle-signature": signature, + "x-circle-timestamp": String(Math.floor(Date.now() / 1000)) + }); + assert(verified.notificationId === "notif-1", "webhook should verify"); + + // Seller flow with local settlement and nonce replay protection. + const seller = createSeller({ + sellerAddress: SELLER_ADDR, + name: "Weather API", + network: "eip155:5042002", + usdcContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + gatewayContract: "0x1111111111111111111111111111111111111111", + strictGatewayContract: true, + nonceStorePath + }); + seller.addEndpoint({ + path: "/weather", + priceUsd: "0.001", + schemes: ["GatewayWalletBatched"] + }); + const paymentRequired = seller.buildPaymentRequired("/weather"); + + const buyerWallet = Wallet.createRandom(); + const accepted = (paymentRequired.body.accepts as Array>)[0]; + const auth = { + from: buyerWallet.address, + to: String(accepted.payTo), + value: String(accepted.amount), + validAfter: "0", + validBefore: String(Math.floor(Date.now() / 1000) + 345600), + nonce: `0x${Buffer.from("n".repeat(32)).toString("hex").slice(0, 64)}` + }; + const sig = await buyerWallet.signTypedData( + { + name: String((accepted.extra as Record).name), + version: String((accepted.extra as Record).version), + chainId: 5042002, + verifyingContract: String((accepted.extra as Record).verifyingContract) + }, + { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" } + ] + }, + auth + ); + const signatureHeader = Buffer.from( + JSON.stringify({ + x402Version: 2, + scheme: "exact", + network: "eip155:5042002", + payload: { authorization: auth, signature: sig } + }), + "utf8" + ).toString("base64"); + + const settled = await seller.settlePayment({ + paymentSignatureHeader: signatureHeader, + paymentRequiredBody: paymentRequired.body, + endpointPath: "/weather" + }); + assert(settled.success, "seller settlement should succeed"); + + const replayAttempt = await seller.verifyPayment({ + paymentSignatureHeader: signatureHeader, + paymentRequiredBody: paymentRequired.body + }); + assert(!replayAttempt.isValid, "seller should reject nonce replay"); + + // eslint-disable-next-line no-console + console.log("Integration smoke test passed."); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +run().catch((error) => { + // eslint-disable-next-line no-console + console.error("Integration smoke test failed:", error); + process.exitCode = 1; +}); diff --git a/npm/omniclaw/src/seller/facilitator.ts b/npm/omniclaw/src/seller/facilitator.ts new file mode 100644 index 0000000..8fb8470 --- /dev/null +++ b/npm/omniclaw/src/seller/facilitator.ts @@ -0,0 +1,106 @@ +import type { SettleResult, VerifyResult } from "./types.js"; + +export interface BaseFacilitator { + readonly name: string; + readonly environment: "testnet" | "mainnet"; + verify(paymentPayload: unknown, paymentRequirements: unknown): Promise; + settle(paymentPayload: unknown, paymentRequirements: unknown): Promise; + getSupportedNetworks(): Promise>>; +} + +export class CircleGatewayFacilitator implements BaseFacilitator { + readonly name = "circle"; + readonly environment: "testnet" | "mainnet"; + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly headers: Record; + + constructor(options: { + circleApiKey: string; + environment?: "testnet" | "mainnet"; + baseUrl?: string; + fetchImpl?: typeof fetch; + }) { + this.environment = options.environment ?? "testnet"; + this.baseUrl = + options.baseUrl ?? + (this.environment === "mainnet" + ? "https://gateway-api.circle.com" + : "https://gateway-api-testnet.circle.com"); + this.fetchImpl = options.fetchImpl ?? fetch; + this.headers = { + Authorization: `Bearer ${options.circleApiKey}`, + "Content-Type": "application/json" + }; + } + + async verify(paymentPayload: unknown, paymentRequirements: unknown): Promise { + const payload = await this.request<{ isValid?: boolean; payer?: string; invalidReason?: string }>( + "/v1/x402/verify", + { + paymentPayload, + paymentRequirements + } + ); + return { + isValid: payload.isValid ?? false, + payer: payload.payer, + invalidReason: payload.invalidReason + }; + } + + async settle(paymentPayload: unknown, paymentRequirements: unknown): Promise { + const payload = await this.request<{ + success?: boolean; + transaction?: string; + network?: string; + payer?: string; + errorReason?: string; + }>("/v1/x402/settle", { + paymentPayload, + paymentRequirements + }); + return { + success: payload.success ?? false, + transaction: payload.transaction, + network: payload.network, + payer: payload.payer, + errorReason: payload.errorReason + }; + } + + async getSupportedNetworks(): Promise>> { + const payload = await this.request<{ kinds?: Array> }>( + "/v1/x402/supported", + undefined, + "GET" + ); + return payload.kinds ?? []; + } + + private async request( + endpoint: string, + body?: unknown, + method: "GET" | "POST" = "POST" + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${endpoint}`, { + method, + headers: this.headers, + body: body ? JSON.stringify(body) : undefined + }); + const text = await response.text(); + const parsed = text ? safeJsonParse(text) : {}; + if (!response.ok) { + throw new Error(`Facilitator ${this.name} request failed (${response.status}): ${endpoint}`); + } + return parsed as T; + } +} + +function safeJsonParse(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return { raw: value }; + } +} diff --git a/npm/omniclaw/src/seller/facilitatorGeneric.ts b/npm/omniclaw/src/seller/facilitatorGeneric.ts new file mode 100644 index 0000000..56dafc7 --- /dev/null +++ b/npm/omniclaw/src/seller/facilitatorGeneric.ts @@ -0,0 +1,143 @@ +import { CircleGatewayFacilitator, type BaseFacilitator } from "./facilitator.js"; +import type { SettleResult, VerifyResult } from "./types.js"; + +class GenericHttpFacilitator implements BaseFacilitator { + readonly name: string; + readonly environment: "testnet" | "mainnet"; + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: { + name: string; + environment?: "testnet" | "mainnet"; + baseUrl: string; + apiKey: string; + fetchImpl?: typeof fetch; + }) { + this.name = options.name; + this.environment = options.environment ?? "testnet"; + this.baseUrl = options.baseUrl; + this.apiKey = options.apiKey; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async verify(paymentPayload: unknown, paymentRequirements: unknown): Promise { + const payload = await this.request("/v2/x402/verify", { paymentPayload, paymentRequirements }); + return { + isValid: Boolean(payload.isValid), + payer: asOptionalString(payload.payer), + invalidReason: asOptionalString(payload.invalidReason) + }; + } + + async settle(paymentPayload: unknown, paymentRequirements: unknown): Promise { + const payload = await this.request("/v2/x402/settle", { paymentPayload, paymentRequirements }); + return { + success: Boolean(payload.success), + transaction: asOptionalString(payload.transaction), + network: asOptionalString(payload.network), + payer: asOptionalString(payload.payer), + errorReason: asOptionalString(payload.errorReason) + }; + } + + async getSupportedNetworks(): Promise>> { + const response = await this.fetchImpl(`${this.baseUrl}/v2/x402/supported`, { + method: "GET", + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: "application/json" + } + }); + const text = await response.text(); + const parsed = text ? safeJsonParse(text) : {}; + if (!response.ok) { + throw new Error(`Failed to fetch supported networks for facilitator ${this.name}`); + } + if (Array.isArray(parsed)) { + return parsed as Array>; + } + if (isRecord(parsed) && Array.isArray(parsed.kinds)) { + return parsed.kinds as Array>; + } + if (isRecord(parsed) && Array.isArray(parsed.networks)) { + return parsed.networks as Array>; + } + return []; + } + + private async request(endpoint: string, body: unknown): Promise> { + const response = await this.fetchImpl(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }); + const text = await response.text(); + const parsed = text ? safeJsonParse(text) : {}; + if (!response.ok) { + return { success: false, errorReason: `HTTP ${response.status}` }; + } + return parsed as Record; + } +} + +const DEFAULT_BASE_URLS: Record = { + coinbase: "https://api.cdp.coinbase.com/platform", + ordern: "https://gateway.ordern.ai", + rbx: "https://x402.rbx.com", + thirdweb: "https://x402.thirdweb.com" +}; + +export const SUPPORTED_FACILITATORS = [ + "circle", + "coinbase", + "ordern", + "rbx", + "thirdweb" +] as const; + +export type FacilitatorName = (typeof SUPPORTED_FACILITATORS)[number]; + +export function createFacilitator(options: { + provider: FacilitatorName; + apiKey: string; + environment?: "testnet" | "mainnet"; + baseUrl?: string; + fetchImpl?: typeof fetch; +}): BaseFacilitator { + if (options.provider === "circle") { + return new CircleGatewayFacilitator({ + circleApiKey: options.apiKey, + environment: options.environment, + baseUrl: options.baseUrl, + fetchImpl: options.fetchImpl + }); + } + return new GenericHttpFacilitator({ + name: options.provider, + apiKey: options.apiKey, + environment: options.environment, + baseUrl: options.baseUrl ?? DEFAULT_BASE_URLS[options.provider], + fetchImpl: options.fetchImpl + }); +} + +function safeJsonParse(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return { raw: value }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/npm/omniclaw/src/seller/index.ts b/npm/omniclaw/src/seller/index.ts new file mode 100644 index 0000000..bebbf4e --- /dev/null +++ b/npm/omniclaw/src/seller/index.ts @@ -0,0 +1,17 @@ +export { CircleGatewayFacilitator } from "./facilitator.js"; +export type { BaseFacilitator } from "./facilitator.js"; +export { + SUPPORTED_FACILITATORS, + createFacilitator, + type FacilitatorName +} from "./facilitatorGeneric.js"; +export { Seller, createSeller } from "./seller.js"; +export type { + EndpointConfig, + PaymentRecord, + PaymentScheme, + PaymentStatus, + SellerConfig, + SettleResult, + VerifyResult +} from "./types.js"; diff --git a/npm/omniclaw/src/seller/seller.ts b/npm/omniclaw/src/seller/seller.ts new file mode 100644 index 0000000..06708c6 --- /dev/null +++ b/npm/omniclaw/src/seller/seller.ts @@ -0,0 +1,265 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { verifyTypedData } from "ethers"; + +import { assertEvmAddress, assertPositiveDecimal } from "../core/validation.js"; +import { CIRCLE_BATCHING_NAME } from "../protocols/nanopayments/constants.js"; +import type { BaseFacilitator } from "./facilitator.js"; +import type { + EndpointConfig, + PaymentRecord, + PaymentScheme, + SellerConfig, + SettleResult, + VerifyResult +} from "./types.js"; + +interface ParsedPaymentSignature { + x402Version: number; + scheme: string; + network: string; + payload: { + authorization: { + from: string; + to: string; + value: string; + validAfter: string; + validBefore: string; + nonce: string; + }; + signature: string; + }; +} + +export class Seller { + private readonly config: SellerConfig; + private readonly endpoints = new Map(); + private readonly payments = new Map(); + private readonly usedNonces = new Set(); + private readonly facilitator?: BaseFacilitator; + + constructor(config: SellerConfig, facilitator?: BaseFacilitator) { + assertEvmAddress(config.sellerAddress, "sellerAddress"); + this.config = { ...config }; + this.facilitator = facilitator; + this.loadNonceStore(); + } + + addEndpoint(endpoint: EndpointConfig): void { + assertPositiveDecimal(endpoint.priceUsd, "priceUsd"); + this.endpoints.set(endpoint.path, { + ...endpoint, + schemes: endpoint.schemes ?? ["exact", "GatewayWalletBatched"] + }); + } + + buildPaymentRequired(path: string): { status: number; body: Record } { + const endpoint = this.endpoints.get(path); + if (!endpoint) { + throw new Error(`Endpoint not configured: ${path}`); + } + const amountAtomic = toAtomic(endpoint.priceUsd); + const accepts = (endpoint.schemes ?? ["exact", "GatewayWalletBatched"]).map((scheme) => { + if (scheme === "GatewayWalletBatched") { + if (this.config.strictGatewayContract && !this.config.gatewayContract) { + throw new Error( + "GatewayWalletBatched enabled but gatewayContract missing in strict mode" + ); + } + } + return { + scheme: "exact", + network: this.config.network, + asset: this.config.usdcContract, + amount: amountAtomic, + payTo: this.config.sellerAddress, + maxTimeoutSeconds: 345600, + extra: + scheme === "GatewayWalletBatched" + ? { + name: CIRCLE_BATCHING_NAME, + version: "1", + verifyingContract: this.config.gatewayContract ?? "" + } + : { name: "USDC", version: "2" } + }; + }); + return { status: 402, body: { x402Version: 2, accepts } }; + } + + async verifyPayment(input: { + paymentSignatureHeader: string; + paymentRequiredBody: Record; + }): Promise { + const signaturePayload = parsePaymentSignature(input.paymentSignatureHeader); + const accepted = findAcceptedKind(input.paymentRequiredBody, signaturePayload); + if (!accepted) { + return { isValid: false, invalidReason: "no matching accepted payment scheme" }; + } + if (signaturePayload.payload.authorization.to.toLowerCase() !== this.config.sellerAddress.toLowerCase()) { + return { isValid: false, invalidReason: "payTo does not match seller address" }; + } + if (signaturePayload.payload.authorization.value !== String(accepted.amount)) { + return { isValid: false, invalidReason: "amount mismatch" }; + } + + const nonceKey = nonceFingerprint(signaturePayload.payload.authorization); + if (this.usedNonces.has(nonceKey)) { + return { isValid: false, invalidReason: "nonce already used" }; + } + + const recovered = verifyTypedData( + { + name: accepted.extra?.name || "USDC", + version: accepted.extra?.version || "2", + chainId: parseChainId(signaturePayload.network), + verifyingContract: accepted.extra?.verifyingContract || this.config.usdcContract + }, + { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" } + ] + }, + signaturePayload.payload.authorization, + signaturePayload.payload.signature + ); + + if ( + recovered.toLowerCase() !== signaturePayload.payload.authorization.from.toLowerCase() + ) { + return { isValid: false, invalidReason: "signature recovery mismatch" }; + } + + this.usedNonces.add(nonceKey); + this.persistNonceStore(); + return { isValid: true, payer: recovered }; + } + + async settlePayment(input: { + paymentSignatureHeader: string; + paymentRequiredBody: Record; + endpointPath: string; + }): Promise { + const parsed = parsePaymentSignature(input.paymentSignatureHeader); + const verification = await this.verifyPayment({ + paymentSignatureHeader: input.paymentSignatureHeader, + paymentRequiredBody: input.paymentRequiredBody + }); + if (!verification.isValid) { + return { success: false, errorReason: verification.invalidReason }; + } + + let settlement: SettleResult = { + success: true, + transaction: randomUUID(), + network: parsed.network, + payer: verification.payer + }; + if (this.facilitator) { + settlement = await this.facilitator.settle(parsed, input.paymentRequiredBody); + } + + const accepted = findAcceptedKind(input.paymentRequiredBody, parsed); + const amountAtomic = String(accepted?.amount ?? "0"); + const record: PaymentRecord = { + id: settlement.transaction ?? randomUUID(), + endpointPath: input.endpointPath, + scheme: accepted?.extra?.name === CIRCLE_BATCHING_NAME ? "GatewayWalletBatched" : "exact", + buyerAddress: verification.payer ?? parsed.payload.authorization.from, + sellerAddress: this.config.sellerAddress, + amountAtomic, + amountUsd: fromAtomic(amountAtomic), + status: settlement.success ? "settled" : "failed", + createdAt: new Date().toISOString(), + transaction: settlement.transaction + }; + this.payments.set(record.id, record); + return settlement; + } + + listPayments(): PaymentRecord[] { + return [...this.payments.values()]; + } + + private loadNonceStore(): void { + if (!this.config.nonceStorePath) return; + try { + const raw = readFileSync(this.config.nonceStorePath, { encoding: "utf8" }); + const parsed = JSON.parse(raw) as string[]; + parsed.forEach((value) => this.usedNonces.add(value)); + } catch { + // no-op on first run + } + } + + private persistNonceStore(): void { + if (!this.config.nonceStorePath) return; + writeFileSync(this.config.nonceStorePath, JSON.stringify([...this.usedNonces]), { + encoding: "utf8" + }); + } +} + +export function createSeller(config: SellerConfig, facilitator?: BaseFacilitator): Seller { + return new Seller(config, facilitator); +} + +function parsePaymentSignature(header: string): ParsedPaymentSignature { + const raw = Buffer.from(header, "base64").toString("utf8"); + return JSON.parse(raw) as ParsedPaymentSignature; +} + +function parseChainId(network: string): number { + const parts = network.split(":"); + return Number.parseInt(parts[1] ?? "0", 10); +} + +function toAtomic(priceUsd: string): string { + const amount = Number.parseFloat(priceUsd.replace("$", "")); + return Math.round(amount * 1_000_000).toString(); +} + +function fromAtomic(amountAtomic: string): string { + return (Number.parseInt(amountAtomic, 10) / 1_000_000).toString(); +} + +function nonceFingerprint(auth: ParsedPaymentSignature["payload"]["authorization"]): string { + return createHash("sha256") + .update(`${auth.from}|${auth.to}|${auth.value}|${auth.nonce}|${auth.validBefore}`) + .digest("hex"); +} + +function findAcceptedKind( + paymentRequiredBody: Record, + parsed: ParsedPaymentSignature +): + | { + scheme: string; + network: string; + amount: string; + payTo: string; + extra?: { name?: string; version?: string; verifyingContract?: string }; + } + | undefined { + const accepts = Array.isArray(paymentRequiredBody.accepts) ? paymentRequiredBody.accepts : []; + return accepts.find((accept) => { + if (typeof accept !== "object" || accept === null) { + return false; + } + const record = accept as Record; + return record.network === parsed.network && record.scheme === parsed.scheme; + }) as + | { + scheme: string; + network: string; + amount: string; + payTo: string; + extra?: { name?: string; version?: string; verifyingContract?: string }; + } + | undefined; +} diff --git a/npm/omniclaw/src/seller/types.ts b/npm/omniclaw/src/seller/types.ts new file mode 100644 index 0000000..dfa67a9 --- /dev/null +++ b/npm/omniclaw/src/seller/types.ts @@ -0,0 +1,48 @@ +export type PaymentScheme = "exact" | "GatewayWalletBatched"; +export type PaymentStatus = "pending" | "verified" | "settled" | "failed"; + +export interface EndpointConfig { + path: string; + priceUsd: string; + description?: string; + schemes?: PaymentScheme[]; +} + +export interface SellerConfig { + sellerAddress: string; + name: string; + network: string; + usdcContract: string; + gatewayContract?: string; + webhookUrl?: string; + webhookSecret?: string; + strictGatewayContract?: boolean; + nonceStorePath?: string; +} + +export interface VerifyResult { + isValid: boolean; + payer?: string; + invalidReason?: string; +} + +export interface SettleResult { + success: boolean; + transaction?: string; + network?: string; + payer?: string; + errorReason?: string; +} + +export interface PaymentRecord { + id: string; + endpointPath: string; + scheme: PaymentScheme; + buyerAddress: string; + sellerAddress: string; + amountAtomic: string; + amountUsd: string; + status: PaymentStatus; + createdAt: string; + transaction?: string; +} diff --git a/npm/omniclaw/src/simulate.ts b/npm/omniclaw/src/simulate.ts new file mode 100644 index 0000000..72dc360 --- /dev/null +++ b/npm/omniclaw/src/simulate.ts @@ -0,0 +1,41 @@ +import type { SimulatePaymentParams, SimulatePaymentResult } from "./types.js"; + +import { ConfigurationError } from "./errors.js"; + +function toMoneyNumber(value: string): number { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new ConfigurationError("amount must be a positive decimal string"); + } + return parsed; +} + +function toFixed(value: number): string { + return value.toFixed(6); +} + +export function simulatePaymentLocally( + params: SimulatePaymentParams, + sourceWalletId: string, + defaultCurrency: string, + defaultFeeRatePercent: number +): SimulatePaymentResult { + const amountNumber = toMoneyNumber(params.amount); + const feeRatePercent = params.feeRatePercent ?? defaultFeeRatePercent; + const currency = params.currency ?? defaultCurrency; + const fees = amountNumber * (feeRatePercent / 100); + const netTransfer = amountNumber - fees; + const finalSourceWalletId = params.sourceWalletId ?? sourceWalletId; + + return { + estimatedFees: toFixed(fees), + netTransfer: toFixed(Math.max(0, netTransfer)), + transferConfirmationPreview: { + amount: params.amount, + currency, + sourceWalletId: finalSourceWalletId, + destinationAddress: params.destinationAddress + }, + readyToExecute: netTransfer > 0 + }; +} diff --git a/npm/omniclaw/src/types.ts b/npm/omniclaw/src/types.ts new file mode 100644 index 0000000..d26fa20 --- /dev/null +++ b/npm/omniclaw/src/types.ts @@ -0,0 +1,127 @@ +import type { TrustCheckResult, TrustEvaluator } from "./core/trust.js"; +import type { NanopaymentResult } from "./protocols/nanopayments/types.js"; + +export interface OmniClawConfig { + circleApiKey?: string; + circleWalletId?: string; + circleApiBaseUrl?: string; + defaultCurrency?: string; + defaultFeeRatePercent?: number; + nanopaymentsEnabled?: boolean; + nanopaymentsEnvironment?: "testnet" | "mainnet"; + gatewayApiBaseUrl?: string; + entitySecret?: string; + nanopaymentKeyStorePath?: string; + strictSettlement?: boolean; + retryAttempts?: number; + retryBaseDelayMs?: number; + circuitBreakerFailureThreshold?: number; + circuitBreakerRecoveryMs?: number; + requireTrustGate?: boolean; + trustEvaluator?: TrustEvaluator; + fetchImpl?: typeof fetch; +} + +export interface Amount { + amount: string; + currency: string; +} + +export interface CreatePaymentParams { + amount: string; + currency?: string; + sourceWalletId?: string; + destinationAddress: string; + idempotencyKey?: string; + purpose?: string; + skipGuards?: boolean; + checkTrust?: boolean; + confirm?: boolean; +} + +export interface CirclePaymentResponse { + data?: { + id?: string; + status?: string; + createDate?: string; + updateDate?: string; + amount?: Amount; + source?: { type?: string; id?: string }; + destination?: { type?: string; address?: string }; + }; + [key: string]: unknown; +} + +export interface CircleWalletResponse { + data?: { + walletId?: string; + id?: string; + balances?: Array<{ amount?: string; currency?: string }>; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface SimulatePaymentParams { + amount: string; + currency?: string; + sourceWalletId?: string; + destinationAddress: string; + feeRatePercent?: number; +} + +export interface SimulatePaymentResult { + estimatedFees: string; + netTransfer: string; + transferConfirmationPreview: { + amount: string; + currency: string; + sourceWalletId: string; + destinationAddress: string; + }; + readyToExecute: boolean; +} + +export interface CreatePaymentIntentParams { + amount: string; + currency?: string; + settlementCurrency?: string; + paymentMethods?: string[]; + sourceWalletId?: string; + recipient?: string; + checkTrust?: boolean; + confirm?: boolean; + idempotencyKey?: string; +} + +export interface CirclePaymentIntentResponse { + data?: { + id?: string; + status?: string; + amount?: Amount; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface RoutedPaymentParams { + walletId?: string; + recipient: string; + amount: string; + currency?: string; + purpose?: string; + checkTrust?: boolean; + skipGuards?: boolean; + confirm?: boolean; + nanoKeyAlias?: string; + network?: string; +} + +export interface RoutedPaymentResult { + route: "circle_transfer" | "x402_nanopayment" | "direct_nanopayment"; + success: boolean; + trust?: TrustCheckResult; + simulation?: SimulatePaymentResult; + ledgerEntryId: string; + result: CirclePaymentResponse | NanopaymentResult; +} diff --git a/npm/omniclaw/src/webhooks.ts b/npm/omniclaw/src/webhooks.ts new file mode 100644 index 0000000..4ae2f1d --- /dev/null +++ b/npm/omniclaw/src/webhooks.ts @@ -0,0 +1,154 @@ +import { verify as verifySignature, createPublicKey } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; + +import { ConfigurationError } from "./errors.js"; + +export interface WebhookVerifierOptions { + verificationKey: string; + maxReplayAgeSeconds?: number; + maxFutureSkewSeconds?: number; + dedupEnabled?: boolean; + dedupStorePath?: string; +} + +export interface VerifiedWebhook { + notificationId: string; + notificationType: string; + createDate?: string; + payload: Record; +} + +const DEFAULT_MAX_REPLAY_AGE_SECONDS = 43_200; +const DEFAULT_MAX_FUTURE_SKEW_SECONDS = 300; + +export class WebhookVerifier { + private readonly verificationKey: string; + private readonly maxReplayAgeSeconds: number; + private readonly maxFutureSkewSeconds: number; + private readonly dedupEnabled: boolean; + private readonly dedupStorePath?: string; + private readonly seenNotificationIds = new Set(); + + constructor(options: WebhookVerifierOptions) { + if (!options.verificationKey) { + throw new ConfigurationError("verificationKey is required"); + } + this.verificationKey = options.verificationKey; + this.maxReplayAgeSeconds = options.maxReplayAgeSeconds ?? DEFAULT_MAX_REPLAY_AGE_SECONDS; + this.maxFutureSkewSeconds = options.maxFutureSkewSeconds ?? DEFAULT_MAX_FUTURE_SKEW_SECONDS; + this.dedupEnabled = options.dedupEnabled ?? true; + this.dedupStorePath = options.dedupStorePath; + this.loadDedupStore(); + } + + verify(rawBody: string, headers: Record): VerifiedWebhook { + const signatureHeader = headers["x-circle-signature"] ?? headers["circle-signature"]; + if (!signatureHeader) { + throw new ConfigurationError("Missing Circle signature header"); + } + const timestampHeader = headers["x-circle-timestamp"] ?? headers["circle-timestamp"]; + this.verifyTimestamp(timestampHeader); + this.verifySignature(rawBody, signatureHeader); + + const payload = JSON.parse(rawBody) as Record; + const notificationId = stringField(payload, "notificationId"); + const notificationType = stringField(payload, "notificationType"); + if (this.dedupEnabled) { + if (this.seenNotificationIds.has(notificationId)) { + throw new ConfigurationError(`Duplicate webhook notificationId: ${notificationId}`); + } + this.seenNotificationIds.add(notificationId); + this.persistDedupStore(); + } + + return { + notificationId, + notificationType, + createDate: stringOptionalField(payload, "createDate"), + payload + }; + } + + private verifyTimestamp(timestamp?: string): void { + if (!timestamp) { + return; + } + const ts = Number.parseInt(timestamp, 10); + if (!Number.isFinite(ts)) { + throw new ConfigurationError("Invalid webhook timestamp header"); + } + const now = Math.floor(Date.now() / 1000); + if (now - ts > this.maxReplayAgeSeconds) { + throw new ConfigurationError("Webhook timestamp is too old"); + } + if (ts - now > this.maxFutureSkewSeconds) { + throw new ConfigurationError("Webhook timestamp is too far in the future"); + } + } + + private verifySignature(rawBody: string, signatureHeader: string): void { + const sigBuffer = parseSignature(signatureHeader); + const key = createPublicKey(this.verificationKey); + + let valid = false; + try { + valid = verifySignature(null, Buffer.from(rawBody, "utf8"), key, sigBuffer); + } catch { + try { + valid = verifySignature("sha256", Buffer.from(rawBody, "utf8"), key, sigBuffer); + } catch { + valid = false; + } + } + if (!valid) { + throw new ConfigurationError("Invalid webhook signature"); + } + } + + private loadDedupStore(): void { + if (!this.dedupStorePath) { + return; + } + try { + const raw = readFileSync(this.dedupStorePath, { encoding: "utf8" }); + const parsed = JSON.parse(raw) as string[]; + for (const id of parsed) { + if (typeof id === "string" && id.length > 0) { + this.seenNotificationIds.add(id); + } + } + } catch { + // no-op on first run + } + } + + private persistDedupStore(): void { + if (!this.dedupStorePath) { + return; + } + writeFileSync(this.dedupStorePath, JSON.stringify([...this.seenNotificationIds]), { + encoding: "utf8" + }); + } +} + +function parseSignature(value: string): Buffer { + const trimmed = value.trim(); + if (/^[0-9a-fA-F]+$/.test(trimmed)) { + return Buffer.from(trimmed, "hex"); + } + return Buffer.from(trimmed, "base64"); +} + +function stringField(payload: Record, key: string): string { + const value = payload[key]; + if (typeof value !== "string" || value.length === 0) { + throw new ConfigurationError(`Missing required webhook field: ${key}`); + } + return value; +} + +function stringOptionalField(payload: Record, key: string): string | undefined { + const value = payload[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/npm/omniclaw/tsconfig.json b/npm/omniclaw/tsconfig.json new file mode 100644 index 0000000..9af45ec --- /dev/null +++ b/npm/omniclaw/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "node" + ], + "lib": [ + "ES2022", + "DOM" + ], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/pyproject.toml b/pyproject.toml index cbde67c..b65b1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,6 @@ addopts = "-v --tb=short -p no:pytest_ethereum" [tool.ruff] line-length = 100 target-version = "py310" -src = ["src"] [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] diff --git a/src/omniclaw/cli.py b/src/omniclaw/cli.py index d36a08b..d25ff22 100644 --- a/src/omniclaw/cli.py +++ b/src/omniclaw/cli.py @@ -7,8 +7,8 @@ warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") import argparse -import os from collections.abc import Sequence +import os from omniclaw.onboarding import print_doctor_status