diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 95e51059d..c6628e829 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -31,7 +31,7 @@ What has monetary value, irreversibility, or trust significance in this system: | Agent reputation records | On-chain, external ERC-8004 ReputationRegistry `0x8004BAa1…9b63` (Base mainnet) | Stars written by clients of the agents via `giveFeedback` (the `/assistent` rating widget; agentId 97599 always, 97600 when web tools ran). Third-party contract, permissionless by design (spec v1.1 removed pre-authorization): anyone can rate from any wallet, so the records are an **advisory** discovery signal — like the analytics counters, never an input to a security or financial decision (§5, reputation layer) | | Agent wallet private key | scw_js secrets | Can trigger `requestImageUpdate()` and receive mintPrice per call. Same address as the x402 `payTo` (`NFT_WALLET`), bound as the `agentWallet` of the three ERC-8004 agents | | Receiver-authorizer private key | scw_js secrets | Signs batch-settlement channel-config and refund authorizations (EIP-712) for the `/assistent` sellers (LLM, search, fetch) via `receiverAuthorizerSigner`. Never sends transactions, never needs funding | -| Facilitator wallet private key | x402_facilitator secrets | Receives settlement fees (USDC; EURC and EURe on Base) | +| Facilitator wallet private key | x402_facilitator secrets; post-cutover also a `bunny_js` script secret (write-once) — see [`x402_facilitator/bunny-port-implementation-plan.md`](../x402_facilitator/bunny-port-implementation-plan.md) | Receives settlement fees (USDC; EURC and EURe on Base). The edge move puts the settle/fee-relay hot key on bunny.net infrastructure — a deliberate custody change; the key never grants custody of escrowed funds, only the fee pull and transaction relay | | | Scaleway secrets (SCW_SECRET_KEY) | Serverless secrets | Access to S3 image bucket, transactional email, and analytics counters | | BFL / IONOS API keys | Serverless secrets | Image generation quota; no on-chain access | | bunny.net account (incl. account-wide API key) | bunny.net dashboard; CLI profile at `~/.config/bunnynet.json` (mode 600) | Controls all three Edge Script twins (searchapi 97472, llm 97948, mcp 97949) — can read each script's plain env values, overwrite secrets, replace code, delete scripts. Broader than the per-script deploy keys below | diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml index a416eb899..ad241b615 100644 --- a/.github/workflows/deploy-bunny-js.yml +++ b/.github/workflows/deploy-bunny-js.yml @@ -95,3 +95,15 @@ jobs: script_id: ${{ secrets.BUNNYMCP_SCRIPT_ID }} deploy_key: ${{ secrets.BUNNYMCP_DEPLOY_KEY }} file: "bunny_js/dist/mcp.js" + + - name: Deploy facilitator script to Bunny Edge Scripting + uses: BunnyWay/actions/deploy-script@931a74b9c45ea9f9f01435fbbf1649830e67d3b3 # 2026-10-12 main; a mutable ref here is code-exec with the deploy keys — bump deliberately + with: + # The facilitator twin (payment core ported from x402_facilitator/). Publishing does + # NOT make it live: facilitator.fretchen.eu keeps serving the Scaleway function + # until the DNS flip (x402_facilitator/bunny-port-implementation-plan.md). Same + # rule as the other steps: set both secrets in GitHub before merging, or this step + # fails (and does not block the twins' deploys above). + script_id: ${{ secrets.BUNNYFACILITATOR_SCRIPT_ID }} + deploy_key: ${{ secrets.BUNNYFACILITATOR_DEPLOY_KEY }} + file: "bunny_js/dist/facilitator.js" diff --git a/bunny_js/AGENTS.md b/bunny_js/AGENTS.md index 4c87270dc..a56b700f6 100644 --- a/bunny_js/AGENTS.md +++ b/bunny_js/AGENTS.md @@ -1,6 +1,8 @@ # bunny_js — agent rules -Bunny Edge Scripts: the x402 llm/search/mcp **sellers** (plus the scw twins' origin domains). +Bunny Edge Scripts: the x402 llm/search/mcp **sellers** plus the ported **facilitator** +(the payment core's verbatim twin from `x402_facilitator/`, waiting on its cutover — +see [`x402_facilitator/bunny-port-implementation-plan.md`](../x402_facilitator/bunny-port-implementation-plan.md)). See [`README.md`](./README.md) for the architecture, commands, environment/secrets, known limits and deploy paths, and [`SECURITY.md`](./SECURITY.md) for the security posture. diff --git a/bunny_js/README.md b/bunny_js/README.md index 89ad7c51d..f8c164775 100644 --- a/bunny_js/README.md +++ b/bunny_js/README.md @@ -10,6 +10,13 @@ domains, and the Scaleway twins they replaced are removed from `scw_js`'s `npm run deploy` (run it before merging to make the docs' "retired" claims literal). Only `scw_js`'s `llmx402cron` remains, claiming the channels these twins charge. +The fourth script is the **facilitator** (`facilitator.ts`, `service="facilitator"`) — the x402 +payment facilitator (`/verify`, `/settle`, `/supported`, `/openapi.json`) ported verbatim from +`x402_facilitator/` (payment core + its test suite) with Scaleway's envelope exchanged for +native `Request`/`Response` routing in `src/facilitator_routes.ts`. Its cutover plan is +[`x402_facilitator/bunny-port-implementation-plan.md`](../x402_facilitator/bunny-port-implementation-plan.md): +until its DNS flip the Scaleway facilitator remains the live one at `facilitator.fretchen.eu`. + ## Architecture - `src/routes.ts` — the **only new code**: the routing layer, natively against web-standard @@ -29,17 +36,32 @@ Only `scw_js`'s `llmx402cron` remains, claiming the channels these twins charge. envelope helpers are not ported — there is no Scaleway envelope here. - `src/runtime-shims.ts` — pins the Node `Buffer`/`process` globals the ported code assumes, imported FIRST by the entry so they exist before any ported module's top-level code runs. +- `src/facilitator/` — the facilitator's own corner of the tree (`src/` is otherwise the + twins'): the payment core ported **verbatim** from `x402_facilitator/` (that package keeps + its own copy alive for its Scaleway function until the teardown PR; only `walletreportcron` + stays on Scaleway long-term, because the edge has no cron) plus one new file — the routing. + `src/facilitator/facilitator_routes.ts` mirrors the Scaleway handler's routing/CORS/envelope + semantics line by line against web-standard `Request`/`Response`; everything else is + untouched payment behavior, including its test suite (`test/facilitator/` — the ported + core tests, `facilitator_routes.test.ts` for the native routing, and + `facilitator_alert_coverage.test.ts`, which pins the ported log phrases to + `observability/alerts/bunny.yaml`'s facilitator branch; the twins need no such test). + The entry is `src/facilitator.ts`, flat like the other three. ## Commands - `npm run build` — tsdown bundle to a **single** `dist/searchapi.js` (bunny deploys one file). -- `npm run dev:searchapi` / `dev:llm` / `dev:mcp` — local serve of one twin via Deno on +- `npm run dev:searchapi` / `dev:llm` / `dev:mcp` / `dev:facilitator` — local serve of one + twin via Deno on 127.0.0.1 (from source, no build needed; `--sloppy-imports` covers the shared packages' extensionless tsc output, and `--env-file=.env` is baked in — a missing `.env` only warns). The paid path needs `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY` in `.env`; extra args go after `--` (`npm run dev:mcp -- --port 8081`-style), because npm eats unknown flags like `--env-file` itself. -- `npm test` — vitest (165 tests: 108 ported + the translated routes suite (incl. cache-control)). +- `npm test` — vitest (521 tests: the twins' ported suite + routes, the facilitator's ported + payment suite (`test/x402_*.test.ts`) + native routes + its alert-phrase guard; + `npm run test:integration` runs the live-RPC cases under `test/integration/`, inherited + from the facilitator package). - `npm run check` — typecheck + lint + format + tests. - `npm run deploy` — build, then `bunny scripts deploy dist/searchapi.js` (needs the bunny CLI, installed and logged in). @@ -73,6 +95,17 @@ it is inert without another credential, plain — and plain stays inspectable wh | `SCW_COCKPIT_PUSH_TOKEN` | secret (`--secret`) | Cockpit push-only token — authenticates the Loki log flush to the observability data source (see the observability README/implementation plan) | | `COCKPIT_LOKI_URL` | plain | push URL of the custom Cockpit logs data source — public and inert without the token | +**Facilitator script — its own env set** (`bunny scripts env set` against its script id; the +values mirror `x402_facilitator/serverless.yml`): + +| Variable | bunny kind | Use | +| --------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | +| `FACILITATOR_FEE_AMOUNT` | plain | fee in the token's smallest unit (`10000` = 0.01 USDC/EURC) | +| `BATCH_SETTLEMENT_TEST_WALLETS` | plain | testnet-only allowance-check bypass list (public, testnet-fenced in code) | +| `FACILITATOR_WALLET_PRIVATE_KEY` | secret (`--secret`) | the facilitator hot wallet — signs settles and pulls fees; write-once on bunny's side | +| `RPC_URL_EIP155_*` | secret (`--secret`) | the four network RPC URLs — real values embed API keys; unset falls back to rate-limited public endpoints | +| `SCW_COCKPIT_PUSH_TOKEN` / `COCKPIT_LOKI_URL` | as above | the logger pair, same as the twins | + ## Known limits / decisions - **Single-file bundle, 1 MB limit.** tsdown emits one `dist/searchapi.js` with `codeSplitting: @@ -93,7 +126,12 @@ false`; Node builtins stay external and MUST be imported through the `node:` pre sync) + 1 upstream + facilitator calls. Well within the cap, but `getFacilitatorFeeConfig` style fetch storms would not be. - **No cron at the edge** — `llmx402cron` stays on Scaleway and claims the channels this twin - charges, unchanged, because the channel storage is the same bucket. + charges, unchanged, because the channel storage is the same bucket. For the same reason the + facilitator's `walletreportcron` (weekly wallet-report email) stays on Scaleway too — the + edge has no scheduler, and the report additionally rides Scaleway Transactional Email. +- **Facilitator bundle ≈ 573 KB minified** (`dist/facilitator.js`, single file, deps inlined — + viem + the @x402 SDK are the bulk), well under the 1 MB script limit. Its imports are + `node:buffer`/`node:process` only, same as the twins. ## Trial matrix (Task 6 — historical; superseded by the production switch) @@ -123,6 +161,11 @@ these twins' channels) and `growthapi` — the old twins' entries are gone from The original port plan (scaffold, bundle gate, SigV4 spike, verbatim stack + tests, native routing + tests) is complete. +The facilitator twin is ported and green but **not yet live**: `facilitator.fretchen.eu` still +serves the Scaleway function until the port plan's operational cutover (trial-hostname probes +→ DNS flip), after which `x402_facilitator/` trims down to `walletreportcron` in the teardown +PR. + ## Deploy paths (both can coexist) Bunny publishes whichever deployment happened **last**, so a manual CLI deploy and the @@ -143,6 +186,8 @@ next push to `main` overwrites it back to the committed state. - `BUNNYJS_SCRIPT_ID` / `BUNNYJS_DEPLOY_KEY` — the searchapi script - `BUNNYLLM_SCRIPT_ID` / `BUNNYLLM_DEPLOY_KEY` — the llm script - `BUNNYMCP_SCRIPT_ID` / `BUNNYMCP_DEPLOY_KEY` — the mcp script + - `BUNNYFACILITATOR_SCRIPT_ID` / `BUNNYFACILITATOR_DEPLOY_KEY` — the facilitator script + (same pattern; serves `facilitator.fretchen.eu` after its DNS flip — see the port plan) (Per-script deploy keys are deliberately chosen over the account-wide API key a CLI-in-CI variant would need — narrower blast radius. A step whose secrets are missing diff --git a/bunny_js/deno.lock b/bunny_js/deno.lock index 218dc4652..59f5e4e7f 100644 --- a/bunny_js/deno.lock +++ b/bunny_js/deno.lock @@ -13,12 +13,13 @@ "npm:@types/node@^26.1.0", "npm:@vitest/coverage-v8@^4.1.11", "npm:@vitest/eslint-plugin@^1.6.27", - "npm:@x402/core@^2.23.0", - "npm:@x402/evm@^2.23.0", + "npm:@x402/core@^2.29.0", + "npm:@x402/evm@^2.29.0", "npm:@x402/extensions@2.28", "npm:eslint@10", "npm:prettier@^3.6.2", "npm:tsdown@~0.23.1", + "npm:tsx@^4.23.15", "npm:typescript-eslint@^8.58.2", "npm:typescript@^5.9.3", "npm:viem@2.57.4", diff --git a/bunny_js/notebooks/README.md b/bunny_js/notebooks/README.md new file mode 100644 index 000000000..e7aaea678 --- /dev/null +++ b/bunny_js/notebooks/README.md @@ -0,0 +1,14 @@ +# bunny_js notebooks + +Front-end-free test notebooks for the Edge Scripts in this package. All use the **Deno +kernel**; each notebook resolves its npm dependencies via `npm:` specifiers and this +folder's own `deno.json`/`node_modules` (scoped here — see `deno.json`'s comment), so they +run without touching the parent `bunny_js` npm project — subfolders inherit that scope too. + +- `facilitator/` — the facilitator test notebooks, one per payment scheme; the target host is + the `FACILITATOR_URL` env var. See [`facilitator/README.md`](./facilitator/README.md). +- `llm_x402_buyer.ipynb` / `llm_mcp_buyer.ipynb` / `search_x402_buyer.ipynb` — the buyer's + side of the deployed twins (`wrapFetchWithPayment` against the `*-agent.fretchen.eu` + endpoints). While trialing, point a notebook's `SERVER` at the trial hostnames the same + way — the documented pattern there is the origin of the facilitator folder's + `FACILITATOR_URL` knob. diff --git a/bunny_js/notebooks/facilitator/README.md b/bunny_js/notebooks/facilitator/README.md new file mode 100644 index 000000000..6b8ed72e2 --- /dev/null +++ b/bunny_js/notebooks/facilitator/README.md @@ -0,0 +1,33 @@ +# Facilitator test notebooks + +The facilitator's four scheme notebooks — replicated from +[`x402_facilitator/notebooks/`](../../../x402_facilitator/notebooks/README.md) (the +authoritative per-scheme flow descriptions live there) — exercising the bunny twin +end-to-end over HTTP. Together they are the exact Task 5 probe matrix of the port plan +([`bunny-port-implementation-plan.md`](../../../x402_facilitator/bunny-port-implementation-plan.md)): + +| Notebook | Kernel | Tests what (short form) | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | +| `x402_exact.ipynb` | Deno/TS | `exact`: /supported → approve fee allowance → /verify → /settle → confirm the fee was pulled | +| `x402_exact_permit2.ipynb` | Deno/TS | `exact` via Permit2, paying EURe (no EIP-3009) on Base (`DRY_RUN=1` skips the on-chain writes) | +| `x402_batch_settlement.ipynb` | Deno/TS | `batch-settlement`: escrowed deposit → off-chain vouchers → one claim (runs on Base Sepolia) | +| `x402_batch_settlement_permit2.ipynb` | Deno/TS | `batch-settlement` with EURe: Permit2 deposit → two vouchers → one claim (`DRY_RUN=1` supported) | + +## The target — `FACILITATOR_URL` + +Env-driven in these copies (`Deno.env.get("FACILITATOR_URL") ?? "http://localhost:8080"`), +unlike the originals' hardcoded cell: + +```bash +# unset → http://localhost:8080, the port `npm run dev:facilitator` serves on (verified) +# against the deployed trial script: +FACILITATOR_URL=https://fretchen-facilitator-rtsp5.bunny.run jupyter … +``` + +Prerequisites when targeting the deployed script (see the package README's env table and +the port plan's Task 5): `FACILITATOR_WALLET_PRIVATE_KEY` (the `facilitatorFees` recipient +the notebooks approve against), `BATCH_SETTLEMENT_TEST_WALLETS` (testnet fee-gate bypass for +the notebook wallets), ideally `RPC_URL_EIP155_84532`. + +Dependencies stay self-contained: `npm:` specifiers resolve through the parent +`notebooks/deno.json` scope (Deno config applies to this subfolder too). diff --git a/bunny_js/notebooks/facilitator/x402_batch_settlement.ipynb b/bunny_js/notebooks/facilitator/x402_batch_settlement.ipynb new file mode 100644 index 000000000..90e6fea90 --- /dev/null +++ b/bunny_js/notebooks/facilitator/x402_batch_settlement.ipynb @@ -0,0 +1,928 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5ebe745b", + "metadata": {}, + "source": [ + "# x402 `batch-settlement` scheme — facilitator test\n", + "\n", + "Tests the **`batch-settlement`** scheme end-to-end against a running facilitator: open a\n", + "channel with an escrowed deposit, accumulate several requests as off-chain vouchers, then\n", + "claim them all in a single on-chain transaction.\n", + "\n", + "**Where this fits among the sibling notebooks:**\n", + "- **This notebook** — `batch-settlement` scheme.\n", + "- [`x402_exact.ipynb`](./x402_exact.ipynb) — same idea for the `exact` scheme (one\n", + " on-chain transfer per request, no channel).\n", + "- [`genimg_x402_buyer.ipynb`](./genimg_x402_buyer.ipynb) — the buyer's perspective\n", + " instead: a real production integration paid via `@x402/fetch`, no direct facilitator calls.\n", + "\n", + "## Which side is this notebook playing?\n", + "\n", + "Both — batch-settlement needs both halves to demonstrate anything, so the sections are\n", + "labelled by role rather than split across files:\n", + "\n", + "| Sections | Role | In production this is… |\n", + "|---|---|---|\n", + "| 2–4, 8 (deposit, vouchers) | **Buyer** — signs the deposit and each cumulative voucher | `website/` (browser wallet) |\n", + "| 8b–9 (approve, claim) | **Seller** — approves the fee allowance, authorizes and submits the claim | `scw_js/` + its claim/settle cron |\n", + "| 1, 5, 7 (`/supported`, `/verify`, `/settle`) | Calls **into the facilitator** | `x402_facilitator/` — this package |\n", + "\n", + "The facilitator is neither buyer nor seller: it verifies signatures and submits the\n", + "deposit/claim/settle transactions on-chain, and never holds funds.\n", + "\n", + "## Prerequisites\n", + "\n", + "- Deno Jupyter kernel (same as the sibling `.ipynb` notebooks — see this directory's README).\n", + "- The package's single **`bunny_js/.env`** (two levels up — there is no per-notebook\n", + " `.env`) with: `TEST_WALLET_PRIVATE_KEY` (buyer; funded on **Base Sepolia** — ETH for gas\n", + " plus testnet USDC via https://faucet.circle.com/), `NFT_WALLET_PUBLIC_KEY` (seller /\n", + " recipient), and `FACILITATOR_WALLET_PUBLIC_KEY`. `NFT_WALLET_PRIVATE_KEY` is also needed\n", + " for section 8b (the seller approves the facilitator's fee allowance) — optional if you\n", + " only run through section 8.\n", + "- A facilitator running locally: from the parent dir `npm install && npm run build && npm run dev`\n", + " → `http://localhost:8080`.\n", + "\n", + "**Why Base Sepolia:** the canonical `BATCH_SETTLEMENT_ADDRESS` (`0x4020…0003`) is deployed on\n", + "Base Sepolia, Optimism mainnet and Base mainnet — but **not** Optimism Sepolia. So testnet\n", + "runs use Base Sepolia.\n", + "\n", + "> ⚠️ The cells that hit `/settle` perform **real on-chain** actions (escrowing USDC, claiming\n", + "> it). On mainnet they are additionally gated behind `CONFIRM_MAINNET_WRITES`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e5b90ed0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚀 x402 batch-settlement — facilitator test\n", + " Payer (buyer) : 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Recipient : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " receiverAuthorizer: 0x93eafeeD33503966A4938Ebe35FE474377973B0D\n" + ] + } + ], + "source": [ + "// Setup: imports + config\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount, generatePrivateKey } from \"npm:viem@2/accounts\";\n", + "import { createPublicClient, http, formatUnits } from \"npm:viem@2\";\n", + "import { baseSepolia, base, optimism } from \"npm:viem@2/chains\";\n", + "\n", + "// All env lives in the package's single .env, two levels up: bunny_js/.env\n", + "// (TEST_WALLET_PRIVATE_KEY, NFT_WALLET_PUBLIC_KEY, FACILITATOR_WALLET_*).\n", + "// `examplePath: null` disables dotenv's default assertion that every key in a\n", + "// ./.env.example must also be present — we're not using a per-notebook .env.\n", + "const env = await load({ envPath: \"../../.env\", examplePath: null, export: true });\n", + "\n", + "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", + "if (!PRIVATE_KEY) {\n", + " throw new Error(\"TEST_WALLET_PRIVATE_KEY missing from bunny_js/.env — \" +\n", + " \"add a key for a wallet funded on the selected network.\");\n", + "}\n", + "const PAY_TO_ADDRESS = env.NFT_WALLET_PUBLIC_KEY ?? \"0x209693Bc6afc0C5328bA36FaF03C514EF312287C\";\n", + "const FACILITATOR_ADDRESS = env.FACILITATOR_WALLET_PUBLIC_KEY; // from ../../.env — used for the gas check\n", + "const account = privateKeyToAccount(`0x${PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "\n", + "// Needed for section 8b: the merchant (recipient) approves the facilitator's fee token\n", + "// allowance for claim/settle (Phase 3). Optional — sections 1-8 don't need it.\n", + "const RECIPIENT_PRIVATE_KEY = env.NFT_WALLET_PRIVATE_KEY;\n", + "const recipientAccount = RECIPIENT_PRIVATE_KEY\n", + " ? privateKeyToAccount(`0x${RECIPIENT_PRIVATE_KEY.replace(/^0x/, \"\")}`)\n", + " : null;\n", + "\n", + "// The receiver self-manages an authorizer key (signs claims/refunds). In production\n", + "// the seller holds this and advertises its address in the 402's extra.receiverAuthorizer.\n", + "// Here we generate a throwaway one for the run.\n", + "const receiverAuthorizer = privateKeyToAccount(generatePrivateKey());\n", + "\n", + "console.log(\"🚀 x402 batch-settlement — facilitator test\");\n", + "console.log(` Payer (buyer) : ${account.address}`);\n", + "console.log(` Recipient : ${PAY_TO_ADDRESS}`);\n", + "console.log(` receiverAuthorizer: ${receiverAuthorizer.address}`);\n", + "if (recipientAccount && recipientAccount.address.toLowerCase() !== PAY_TO_ADDRESS.toLowerCase()) {\n", + " console.warn(\"⚠️ NFT_WALLET_PRIVATE_KEY does not match PAY_TO_ADDRESS — section 8b's approve() would sign for the wrong address.\");\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "0f59ed57", + "metadata": {}, + "source": [ + "## Network selection\n", + "\n", + "Modeled on `x402_exact.ipynb`, but **Optimism Sepolia is disabled** — the canonical\n", + "`BATCH_SETTLEMENT_ADDRESS` (`0x4020…0003`) is **not** deployed there. Valid networks (all have the contract):\n", + "Base Sepolia (default), Base Mainnet, Optimism Mainnet.\n", + "\n", + "| Chain | Testnet | Mainnet |\n", + "|---|---|---|\n", + "| Base | `eip155:84532` ✅ | `eip155:8453` |\n", + "| Optimism | ~~`eip155:11155420`~~ (no contract) | `eip155:10` |\n", + "\n", + "**Token:** set `TOKEN` to `\"EURC\"` (default) or `\"USDC\"`. EURC exists on Base only.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "df636fad", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🧪 Base Sepolia (Testnet)\n", + " eip155:84532 • EURC (EURC) @ 0x808456652fdb597867f38412077A9182bf77359F\n", + " Facilitator: http://localhost:8080\n" + ] + } + ], + "source": [ + "// ── Network selection ──────────────────────────────────────────────\n", + "const USE_MAINNET = false; // ⚠️ true = REAL MONEY. Keep false unless you mean it.\n", + "\n", + "// Second, deliberate opt-in for the two cells that actually move money on mainnet\n", + "// (settle + claim). USE_MAINNET alone only selects the network; this one arms the\n", + "// writes. Left false, those cells skip with a notice instead of spending anything.\n", + "const CONFIRM_MAINNET_WRITES = false;\n", + "const USE_BASE = true; // Base by default. (Optimism + testnet is unavailable.)\n", + "\n", + "// Guard: Optimism Sepolia has no batch-settlement contract.\n", + "if (!USE_BASE && !USE_MAINNET) {\n", + " throw new Error(\"Optimism Sepolia is unsupported for batch-settlement (no canonical contract). \" +\n", + " \"Use Base Sepolia (USE_BASE=true) or a mainnet (USE_MAINNET=true).\");\n", + "}\n", + "\n", + "const NETWORK_CONFIG = {\n", + " \"base-testnet\": {\n", + " chain: baseSepolia, chainId: 84532, caip2Network: \"eip155:84532\" as const,\n", + " networkName: \"Base Sepolia (Testnet)\", usdcName: \"USDC\",\n", + " usdcAddress: \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\" as `0x${string}`,\n", + " rpcUrl: \"https://sepolia.base.org\", explorer: \"https://sepolia.basescan.org\",\n", + " faucet: \"https://faucet.circle.com/\",\n", + " },\n", + " \"base-mainnet\": {\n", + " chain: base, chainId: 8453, caip2Network: \"eip155:8453\" as const,\n", + " networkName: \"Base Mainnet\", usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", + " rpcUrl: \"https://mainnet.base.org\", explorer: \"https://basescan.org\",\n", + " faucet: \"Bridge: https://bridge.base.org\",\n", + " },\n", + " \"optimism-mainnet\": {\n", + " chain: optimism, chainId: 10, caip2Network: \"eip155:10\" as const,\n", + " networkName: \"Optimism Mainnet\", usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\" as `0x${string}`,\n", + " rpcUrl: \"https://mainnet.optimism.io\", explorer: \"https://optimistic.etherscan.io\",\n", + " faucet: \"Bridge: https://app.optimism.io/bridge\",\n", + " },\n", + "};\n", + "const configKey = USE_MAINNET ? (USE_BASE ? \"base-mainnet\" : \"optimism-mainnet\") : \"base-testnet\";\n", + "const config = NETWORK_CONFIG[configKey];\n", + "\n", + "// ── Token selection ────────────────────────────────────────────────\n", + "// EURC exists on Base only (Circle has no Optimism deployment). A EURC channel is a separate\n", + "// channel from a USDC one — the token is part of the channel id — so it needs its own deposit.\n", + "const TOKEN: \"USDC\" | \"EURC\" = \"EURC\";\n", + "// Address and EIP-712 domain name read on-chain (name() = \"EURC\", version \"2\").\n", + "const EURC_BY_NETWORK: Record = {\n", + " \"eip155:84532\": { address: \"0x808456652fdb597867f38412077A9182bf77359F\", name: \"EURC\" }, // Base Sepolia\n", + " \"eip155:8453\": { address: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\", name: \"EURC\" }, // Base\n", + "};\n", + "const eurc = EURC_BY_NETWORK[config.caip2Network];\n", + "if (TOKEN === \"EURC\" && !eurc) {\n", + " throw new Error(`EURC is not deployed on ${config.networkName} — set USE_BASE = true, or TOKEN = \"USDC\".`);\n", + "}\n", + "// The token the payer escrows, and so the one the facilitator charges its claim fee in.\n", + "const tokenAddress = TOKEN === \"EURC\" ? eurc!.address : config.usdcAddress;\n", + "const tokenName = TOKEN === \"EURC\" ? eurc!.name : config.usdcName;\n", + "\n", + "// Max per-request price (6 decimals — USDC and EURC alike). Channel deposit ≈ depositMultiplier(5) × this.\n", + "const MAX_PRICE = \"4000\"; // $0.004 → deposit ≈ $0.02\n", + "\n", + "const FACILITATOR_URL = Deno.env.get(\"FACILITATOR_URL\") ?? \"http://localhost:8080\"; // or \"https://facilitator.fretchen.eu\"\n", + "// const FACILITATOR_URL = \"https://facilitator.fretchen.eu\"\n", + "const VERIFY_URL = `${FACILITATOR_URL}/verify`;\n", + "const SETTLE_URL = `${FACILITATOR_URL}/settle`;\n", + "const SUPPORTED_URL = `${FACILITATOR_URL}/supported`;\n", + "\n", + "console.log(USE_MAINNET ? `🚨 REAL MONEY on ${config.networkName}` : `🧪 ${config.networkName}`);\n", + "console.log(` ${config.caip2Network} • ${TOKEN} (${tokenName}) @ ${tokenAddress}`);\n", + "console.log(` Facilitator: ${FACILITATOR_URL}`);" + ] + }, + { + "cell_type": "markdown", + "id": "3a6d592e", + "metadata": {}, + "source": [ + "## 1. `/supported` — assert the facilitator advertises `batch-settlement`\n", + "\n", + "This is the core Phase A check: after registering `BatchSettlementEvmScheme` in `facilitator_instance.ts`,\n", + "the facilitator's `/supported` should list the `batch-settlement` scheme for each network.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2dbdd220", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "schemes advertised: exact=4, batch-settlement=3\n", + "batch-settlement kinds: [\n", + " {\n", + " \"x402Version\": 2,\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:10\"\n", + " },\n", + " {\n", + " \"x402Version\": 2,\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:8453\"\n", + " },\n", + " {\n", + " \"x402Version\": 2,\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:84532\"\n", + " }\n", + "]\n", + "✅ batch-settlement advertised for eip155:84532\n" + ] + } + ], + "source": [ + "const supported = await (await fetch(SUPPORTED_URL)).json();\n", + "const kinds = supported.kinds ?? [];\n", + "const batchKinds = kinds.filter((k: any) => k.scheme === \"batch-settlement\");\n", + "const exactKinds = kinds.filter((k: any) => k.scheme === \"exact\");\n", + "\n", + "console.log(`schemes advertised: exact=${exactKinds.length}, batch-settlement=${batchKinds.length}`);\n", + "console.log(\"batch-settlement kinds:\", JSON.stringify(batchKinds, null, 2));\n", + "\n", + "if (batchKinds.some((k: any) => k.network === config.caip2Network)) {\n", + " console.log(`✅ batch-settlement advertised for ${config.caip2Network}`);\n", + "} else {\n", + " console.log(`❌ batch-settlement NOT advertised for ${config.caip2Network} — check facilitator registration`);\n", + "}\n", + "// With no receiverAuthorizer configured on the facilitator (self-managed receiver),\n", + "// `extra.receiverAuthorizer` should be absent here — the seller supplies its own." + ] + }, + { + "cell_type": "markdown", + "id": "d250a5e1", + "metadata": {}, + "source": [ + "## 2. Buyer setup\n", + "\n", + "Constructs the **buyer**-side scheme — SDK class `BatchSettlementEvmScheme`, imported from\n", + "`.../batch-settlement/client` (the SDK's \"client\" = the paying wallet). In production this is\n", + "`website/`, signing with the connected browser wallet; here it's the test key from cell 1.\n", + "\n", + "Two things differ from the `exact` scheme:\n", + "- There is **no `registerBatchSettlementEvmScheme` helper** — construct the scheme and register\n", + " it on the `x402Client` manually.\n", + "- The scheme needs **channel storage**. A browser buyer would back this with `localStorage` so\n", + " channel state survives a reload; in-memory is fine here since the notebook runs start to finish." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "87f8ca73", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ buyer registered for eip155:84532 (payer: 0x553179556FC2A39e535D65b921e01fA995E79101)\n" + ] + } + ], + "source": [ + "import { x402Client } from \"npm:@x402/fetch@^2.17.0\";\n", + "// SDK naming: \"client\" here means the buyer/payer role (→ website/ in production),\n", + "// NOT scw_js (which plays the \"server\"/seller role — see the terminology table above).\n", + "import { BatchSettlementEvmScheme, InMemoryClientChannelStorage }\n", + " from \"npm:@x402/evm@^2.17.0/batch-settlement/client\";\n", + "\n", + "// Reused later (section 8) to sign additional off-chain vouchers on this same channel.\n", + "const buyerSigner = { address: account.address, signTypedData: (a: any) => account.signTypedData(a) };\n", + "const buyerStorage = new InMemoryClientChannelStorage();\n", + "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage });\n", + "\n", + "const buyerClient = new x402Client();\n", + "buyerClient.register(config.caip2Network, buyerScheme);\n", + "// The SDK's default spend controls only accept tokens in its own registry (USDC), so\n", + "// allowlist the token explicitly — EURC would be rejected otherwise.\n", + "buyerClient.setSpendControls({ allowedAssets: [{ network: config.caip2Network, asset: tokenAddress }] });\n", + "console.log(\"✅ buyer registered for\", config.caip2Network, \"(payer:\", account.address + \")\");" + ] + }, + { + "cell_type": "markdown", + "id": "abe3f70f", + "metadata": {}, + "source": [ + "## 3. Build batch-settlement payment requirements\n", + "\n", + "In production these come from the **server's 402 response** (the scw_js resource server injects these via the\n", + "server scheme's `enhancePaymentRequirements`). **Confirmed required in `extra`:** `receiverAuthorizer`\n", + "(non-zero — the client throws `Payment requirements must include a non-zero extra.receiverAuthorizer` without it)\n", + "and `withdrawDelay`, plus the usual EIP-712 `name`/`version`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0a372849", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"4000\",\n", + " \"asset\": \"0x808456652fdb597867f38412077A9182bf77359F\",\n", + " \"payTo\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", + " \"maxTimeoutSeconds\": 3600,\n", + " \"extra\": {\n", + " \"name\": \"EURC\",\n", + " \"version\": \"2\",\n", + " \"receiverAuthorizer\": \"0x93eafeeD33503966A4938Ebe35FE474377973B0D\",\n", + " \"withdrawDelay\": 86400\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "const paymentRequirements = {\n", + " scheme: \"batch-settlement\",\n", + " network: config.caip2Network,\n", + " amount: MAX_PRICE,\n", + " asset: tokenAddress,\n", + " payTo: PAY_TO_ADDRESS,\n", + " maxTimeoutSeconds: 3600,\n", + " extra: {\n", + " name: tokenName,\n", + " version: \"2\",\n", + " receiverAuthorizer: receiverAuthorizer.address, // server's self-managed authorizer in prod\n", + " withdrawDelay: 86400,\n", + " },\n", + "};\n", + "\n", + "const paymentRequired = {\n", + " x402Version: 2,\n", + " accepts: [paymentRequirements],\n", + " resource: { url: \"https://example.com/llm\", description: \"batch-settlement test\", mimeType: \"application/json\" },\n", + " extensions: {},\n", + "};\n", + "console.log(JSON.stringify(paymentRequirements, null, 2));" + ] + }, + { + "cell_type": "markdown", + "id": "fb57a7f0", + "metadata": {}, + "source": [ + "## 4. Buyer creates the deposit + first cumulative voucher payload\n", + "\n", + "On the first request the buyer opens a channel: an EIP-3009 deposit into the batch-settlement contract plus\n", + "an initial cumulative voucher. `createPaymentPayload` handles the signing. **Confirmed payload shape:**\n", + "`payload.type == \"deposit\"` with `channelConfig` (payer, payerAuthorizer, receiver, receiverAuthorizer, token,\n", + "withdrawDelay, salt), `voucher` (channelId, maxClaimableAmount, signature), and `deposit`. The deposit is sized\n", + "by the deposit policy (default `depositMultiplier: 5`), so the payer needs ≥ 5×MAX_PRICE USDC to settle.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8ed125fd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ payload created (deposit + voucher):\n", + "{\n", + " \"x402Version\": 2,\n", + " \"payload\": {\n", + " \"type\": \"deposit\",\n", + " \"channelConfig\": {\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"payerAuthorizer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"receiver\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", + " \"receiverAuthorizer\": \"0x93eafeeD33503966A4938Ebe35FE474377973B0D\",\n", + " \"token\": \"0x808456652fdb597867f38412077A9182bf77359F\",\n", + " \"withdrawDelay\": 86400,\n", + " \"salt\": \"0x0000000000000000000000000000000000000000000000000000000000000000\"\n", + " },\n", + " \"voucher\": {\n", + " \"channelId\": \"0x7d85daf9f85f907304bda59018e1f5c7b49d1843a6908be6d71efde938e78969\",\n", + " \"maxClaimableAmount\": \"4000\",\n", + " \"signature\": \"0xe41222b0029b8124354d0f8660ec21f080f24e1f626404aa3af9611bb21552050390eb6b6bc788ec577e1176ae0cf38fd662e61a84ed0f1b9e82988b9a65ef311b\"\n", + " },\n", + " \"deposit\": {\n", + " \"amount\": \"20000\",\n", + " \"authorization\": {\n", + " \"erc3009Authorization\": {\n", + " \"validAfter\": \"0\",\n", + " \"validBefore\": \"1790358643\",\n", + " \"salt\": \"0x882cca4373fe3641f775861540f8da9dfcf05a28f682488716c19623a7b5ecaa\",\n", + " \"signature\": \"0xf1af0d934af9bd1d928abd15af27354b87800849993da23c3a92231fb5ff44877defd163dcfe363e\n" + ] + } + ], + "source": [ + "const paymentPayload = await buyerClient.createPaymentPayload(paymentRequired as any);\n", + "console.log(\"✅ payload created (deposit + voucher):\");\n", + "console.log(JSON.stringify(paymentPayload, null, 2).slice(0, 1200));" + ] + }, + { + "cell_type": "markdown", + "id": "b10b87cc", + "metadata": {}, + "source": [ + "## 5. `/verify` the payload\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "65dc352b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "verify (200): {\n", + " \"isValid\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"extra\": {\n", + " \"channelId\": \"0x7d85daf9f85f907304bda59018e1f5c7b49d1843a6908be6d71efde938e78969\",\n", + " \"balance\": \"0\",\n", + " \"totalClaimed\": \"0\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"0\"\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "const verifyRes = await fetch(VERIFY_URL, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ paymentPayload, paymentRequirements }),\n", + "});\n", + "const verifyResult = await verifyRes.json();\n", + "console.log(`verify (${verifyRes.status}):`, JSON.stringify(verifyResult, null, 2));" + ] + }, + { + "cell_type": "markdown", + "id": "9c0e7919", + "metadata": {}, + "source": [ + "## 6. Pre-settlement balance check (like the demo notebook)\n", + "\n", + "Settlement escrows the deposit on-chain, so the **payer needs USDC** (≈ `depositMultiplier` × MAX_PRICE) and\n", + "the **facilitator wallet needs ETH** for gas. Check before spending.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "66524d3a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💵 Payer EURC: 19.92 (deposit needs ≈ 0.02)\n", + " ✅ enough EURC to open the channel\n", + "⛽ Facilitator ETH: 0.009964430097829149 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" + ] + } + ], + "source": [ + "const publicClient = createPublicClient({ chain: config.chain, transport: http(config.rpcUrl) });\n", + "const erc20 = [{ inputs: [{ name: \"a\", type: \"address\" }], name: \"balanceOf\",\n", + " outputs: [{ name: \"\", type: \"uint256\" }], stateMutability: \"view\", type: \"function\" }] as const;\n", + "\n", + "const payerBalance = await publicClient.readContract({\n", + " address: tokenAddress, abi: erc20, functionName: \"balanceOf\", args: [account.address] });\n", + "const DEPOSIT_MULTIPLIER = 5n; // batch-settlement default\n", + "const needed = BigInt(MAX_PRICE) * DEPOSIT_MULTIPLIER;\n", + "\n", + "console.log(`💵 Payer ${TOKEN}: ${formatUnits(payerBalance, 6)} (deposit needs ≈ ${formatUnits(needed, 6)})`);\n", + "if (payerBalance < needed) {\n", + " console.log(` ⚠️ insufficient ${TOKEN} on ${config.networkName} — ${config.faucet}`);\n", + "} else {\n", + " console.log(` ✅ enough ${TOKEN} to open the channel`);\n", + "}\n", + "\n", + "// The facilitator submits the deposit tx, so it needs ETH for gas (from ../../.env).\n", + "if (FACILITATOR_ADDRESS) {\n", + " const facEth = await publicClient.getBalance({ address: FACILITATOR_ADDRESS as `0x${string}` });\n", + " console.log(`⛽ Facilitator ETH: ${formatUnits(facEth, 18)} (${FACILITATOR_ADDRESS})`);\n", + " if (facEth === 0n) console.log(` ⚠️ facilitator has no ETH for gas on ${config.networkName}`);\n", + "} else {\n", + " console.log(\"⛽ FACILITATOR_WALLET_PUBLIC_KEY not found in ../../.env — skipping gas check\");\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "4d2285ca", + "metadata": {}, + "source": [ + "## 7. `/settle` — submit the deposit on-chain\n", + "\n", + "> ⚠️ Real on-chain tx: escrows USDC into the batch-settlement contract. The facilitator dispatches the deposit\n", + "> internally by payload type — no separate endpoint. `deposit` is **free** — no fee `transferFrom`. (As of\n", + "> Phase 3, batch-settlement's flat fee applies only to `claim`/`settle`, the payload types that actually\n", + "> pay out — see section 8b and section 9.)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5b9de201", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "settle (200): {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xe045c2e581b6325a9504256cf4f044d74d2b5178965262d5f71a7de2e3778e5a\",\n", + " \"network\": \"eip155:84532\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x7d85daf9f85f907304bda59018e1f5c7b49d1843a6908be6d71efde938e78969\",\n", + " \"balance\": \"20000\",\n", + " \"totalClaimed\": \"0\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"0\"\n", + " }\n", + " }\n", + "}\n", + "🔗 https://sepolia.basescan.org/tx/0xe045c2e581b6325a9504256cf4f044d74d2b5178965262d5f71a7de2e3778e5a\n" + ] + } + ], + "source": [ + "const maySettle = !USE_MAINNET || CONFIRM_MAINNET_WRITES;\n", + "if (!maySettle) {\n", + " console.warn(\"⏭️ Skipped — this settles REAL funds on mainnet.\");\n", + " console.warn(\" Set CONFIRM_MAINNET_WRITES = true in the network-selection cell to arm it.\");\n", + "}\n", + "\n", + "if (maySettle) {\n", + " const settleRes = await fetch(SETTLE_URL, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ paymentPayload, paymentRequirements }),\n", + " });\n", + " const settleResult = await settleRes.json();\n", + " console.log(`settle (${settleRes.status}):`, JSON.stringify(settleResult, null, 2));\n", + " if (settleResult.transaction) console.log(`🔗 ${config.explorer}/tx/${settleResult.transaction}`);\n", + " // deposit is fee-free (Phase 3 only fee-gates claim/settle) — settleResult should carry NO facilitatorFees / fee.collected.\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "c6d5bd11", + "metadata": {}, + "source": [ + "## 8. Accumulate several requests in the same channel (off-chain, no on-chain action)\n", + "\n", + "This is the actual batching mechanic: once a channel is open (section 4), each further \"request\" — a chat\n", + "message, an image generation, anything metered per-call — just signs a **new, larger cumulative** voucher for\n", + "the SAME `channelId`. No transaction, no gas, no facilitator round-trip required. `signVoucher` is a standalone\n", + "buyer-side helper (same `.../batch-settlement/client` subpath) for exactly this — it doesn't re-open the channel,\n", + "it just bumps the authorized max.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "7c7c0198", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "request #2 -> cumulative maxClaimable: 8000\n", + "request #3 -> cumulative maxClaimable: 12000\n", + "\n", + "✅ 3 requests accumulated in one channel, zero on-chain transactions so far.\n" + ] + } + ], + "source": [ + "import { signVoucher } from \"npm:@x402/evm@^2.17.0/batch-settlement/client\";\n", + "\n", + "const channelId = paymentPayload.payload.voucher.channelId;\n", + "const channelConfig = paymentPayload.payload.channelConfig;\n", + "\n", + "// \"Request #2\" and \"#3\" on the SAME channel — purely off-chain signing, cumulative amount grows.\n", + "const voucher2 = await signVoucher(buyerSigner, channelId, \"8000\", config.caip2Network);\n", + "console.log(\"request #2 -> cumulative maxClaimable:\", voucher2.maxClaimableAmount);\n", + "\n", + "const voucher3 = await signVoucher(buyerSigner, channelId, \"12000\", config.caip2Network);\n", + "console.log(\"request #3 -> cumulative maxClaimable:\", voucher3.maxClaimableAmount);\n", + "console.log(\"\\n✅ 3 requests accumulated in one channel, zero on-chain transactions so far.\");" + ] + }, + { + "cell_type": "markdown", + "id": "fd18964e", + "metadata": {}, + "source": [ + "## 8b. Merchant approves the fee token for fee collection (Phase 3)\n", + "\n", + "The fee is charged in the channel's token: with `TOKEN = \"EURC\"` the merchant approves **EURC**.\n", + "\n", + "As of Phase 3 (`x402_facilitator/FEE_MODEL_PLAN.md`), `claim`/`settle` — the payload types that\n", + "actually pay out to the receiver — charge the same flat fee `exact` does, gated by the same USDC\n", + "allowance check (`checkMerchantAllowance` in `x402_fee.ts`). The manual recipient whitelist this\n", + "scheme originally ran against is retired. `deposit`/`voucher`/`refund` (sections 1-8 above) stay\n", + "free and ungated — only section 9's claim is affected.\n", + "\n", + "Without an approval, section 9 will fail with `insufficient_fee_allowance` instead of succeeding.\n", + "This mirrors `x402_exact.ipynb`'s Step 3 for the `exact` scheme — same allowance,\n", + "same facilitator address, same recommended amount (1 USDC ≈ 100 settlements at the 0.01 USDC\n", + "default fee, deliberately small since the spender is a hot wallet).\n", + "\n", + "Needs `NFT_WALLET_PRIVATE_KEY` in `../../.env` (loaded in cell 1 as `recipientAccount`) — the private\n", + "key for `PAY_TO_ADDRESS`, since that's the address whose allowance section 9's claim draws on." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "beb324cd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💰 Current allowance: 0.98 EURC\n", + "📝 Submitting approve(0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206, 1 EURC)...\n", + " ✅ Approved. Tx: https://sepolia.basescan.org/tx/0x316fa4abf1d6413c4a710076dfe31429c5678a53eda5be6cd165cea21ad32de1\n" + ] + } + ], + "source": [ + "import { createWalletClient } from \"npm:viem@2\";\n", + "\n", + "const ERC20_ALLOWANCE_ABI = [\n", + " { name: \"allowance\", type: \"function\", stateMutability: \"view\",\n", + " inputs: [{ name: \"owner\", type: \"address\" }, { name: \"spender\", type: \"address\" }],\n", + " outputs: [{ name: \"\", type: \"uint256\" }] },\n", + " { name: \"approve\", type: \"function\", stateMutability: \"nonpayable\",\n", + " inputs: [{ name: \"spender\", type: \"address\" }, { name: \"value\", type: \"uint256\" }],\n", + " outputs: [{ name: \"\", type: \"bool\" }] },\n", + "] as const;\n", + "\n", + "// 1 token ≈ 100 settlements at the facilitator's 0.01 default fee (charged in the channel's token) — same recommendation\n", + "// x402_exact.ipynb uses for the exact scheme (FEE_MODEL_PLAN.md Phase 5).\n", + "const FEE_APPROVAL_AMOUNT = 1_000_000n;\n", + "\n", + "if (!recipientAccount) {\n", + " console.warn(\"⏭️ Skipped — NFT_WALLET_PRIVATE_KEY not set in ../../.env, no key to sign approve().\");\n", + " console.warn(\" Section 9's claim will fail with insufficient_fee_allowance without it.\");\n", + "} else if (!FACILITATOR_ADDRESS) {\n", + " console.warn(\"⏭️ Skipped — FACILITATOR_WALLET_PUBLIC_KEY not set in ../../.env.\");\n", + "} else {\n", + " const currentAllowance = await publicClient.readContract({\n", + " address: tokenAddress, abi: ERC20_ALLOWANCE_ABI, functionName: \"allowance\",\n", + " args: [recipientAccount.address, FACILITATOR_ADDRESS as `0x${string}`],\n", + " });\n", + " console.log(`💰 Current allowance: ${formatUnits(currentAllowance, 6)} ${TOKEN}`);\n", + "\n", + " if (currentAllowance >= FEE_APPROVAL_AMOUNT) {\n", + " console.log(\" ✅ Already sufficient — skipping approve() tx.\");\n", + " } else {\n", + " const recipientWalletClient = createWalletClient({\n", + " account: recipientAccount, chain: config.chain, transport: http(config.rpcUrl),\n", + " });\n", + " console.log(`📝 Submitting approve(${FACILITATOR_ADDRESS}, ${formatUnits(FEE_APPROVAL_AMOUNT, 6)} ${TOKEN})...`);\n", + " const approveTx = await recipientWalletClient.writeContract({\n", + " address: tokenAddress, abi: ERC20_ALLOWANCE_ABI, functionName: \"approve\",\n", + " args: [FACILITATOR_ADDRESS as `0x${string}`, FEE_APPROVAL_AMOUNT],\n", + " });\n", + " await publicClient.waitForTransactionReceipt({ hash: approveTx });\n", + " console.log(` ✅ Approved. Tx: ${config.explorer}/tx/${approveTx}`);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "7a4f3312", + "metadata": {}, + "source": [ + "## 9. Claim the accumulated amount in ONE on-chain transaction\n", + "\n", + "This is the batch-settlement payoff: settle the 3 accumulated off-chain requests with a single\n", + "claim instead of 3 separate on-chain payments. A **claim** is a settlement command signed by the\n", + "`receiverAuthorizer` — in production, whatever runs the seller's claim/settle cron; here, the key\n", + "generated in cell 1.\n", + "\n", + "**Two things to get right when building a claim by hand:**\n", + "\n", + "**1. `totalClaimed` is a target, not a history.** In a `VoucherClaim` it is **the new cumulative\n", + "amount you are claiming *up to***, not \"how much was already claimed\". The contract reads\n", + "`if (vc.totalClaimed <= ch.totalClaimed) return;` — so passing `\"0\"` (or anything at or below the\n", + "current on-chain total) is a **silent no-op**: the transaction succeeds, no revert, and moves zero\n", + "value. Set it to the amount being claimed — here, `voucher3.maxClaimableAmount`.\n", + "\n", + "**2. A claim does not pay out on its own.** `claim` moves the channel's escrow into a per-(receiver,\n", + "token) pending-payout bucket inside the contract — no ERC-20 transfer happens yet. A separate\n", + "`settle(receiver, token)` call sweeps that bucket to the receiver's wallet. So a successful claim\n", + "with an unchanged wallet balance is expected, not a bug. (The SDK's\n", + "`BatchSettlementChannelManager.claimAndSettle()` does both, as two transactions.)\n", + "\n", + "`claimBatchTypes` and `BATCH_SETTLEMENT_DOMAIN` / `BATCH_SETTLEMENT_ADDRESS` — used below to build\n", + "the same EIP-712 domain the SDK signs internally — are public root exports of `@x402/evm`.\n", + "\n", + "**Fee gate (Phase 3):** before the claim executes, the facilitator checks the receiver's USDC\n", + "allowance (the same check `exact` uses) and rejects with `insufficient_fee_allowance` if it's too\n", + "low — that's what section 8b sets up. A successful claim's response carries `fee` and\n", + "`extensions.facilitatorFees`, which the code below prints.\n", + "\n", + "> ⚠️ Real on-chain transaction. To confirm it actually moved value, check the explorer's Logs tab\n", + "> for a `Claimed(channelId, sender, claimAmount, newTotalClaimed)` event — the facilitator's\n", + "> `success: true` alone is not proof, since `claimWithSignature` returns no value and the\n", + "> facilitator does not decode logs. Reading contract state immediately after a transaction can lag\n", + "> a few seconds on the public RPC; re-check after a moment if a read looks stale." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d74abe79", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "claim /settle (200): {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0x745454f3926d14bdb3b031facbd275e07e92d89036fd8478b95fcc2c99f8d961\",\n", + " \"network\": \"eip155:84532\"\n", + "}\n", + "🔗 https://sepolia.basescan.org/tx/0x745454f3926d14bdb3b031facbd275e07e92d89036fd8478b95fcc2c99f8d961\n", + "\n", + "Check the explorer's Logs tab for a Claimed(channelId, sender, claimAmount, newTotalClaimed)\n", + "event — that's the definitive proof this specific claim moved real value, not just success:true.\n" + ] + } + ], + "source": [ + "import { claimBatchTypes, BATCH_SETTLEMENT_DOMAIN, BATCH_SETTLEMENT_ADDRESS } from \"npm:@x402/evm@^2.17.0\";\n", + "import { getAddress } from \"npm:viem@2\";\n", + "\n", + "const mayClaim = !USE_MAINNET || CONFIRM_MAINNET_WRITES;\n", + "if (!mayClaim) {\n", + " console.warn(\"⏭️ Skipped — this claims REAL funds on mainnet.\");\n", + " console.warn(\" Set CONFIRM_MAINNET_WRITES = true in the network-selection cell to arm it.\");\n", + "}\n", + "\n", + "if (mayClaim) {\n", + " // totalClaimed = the NEW cumulative target to claim to (must be > onchain totalClaimed, ≤ maxClaimableAmount).\n", + " // Here we're claiming the full accumulated voucher, so it equals maxClaimableAmount.\n", + " const claimTarget = voucher3.maxClaimableAmount;\n", + "\n", + " // The receiver (server) authorizes claiming this channel's latest voucher. In\n", + " // production the server (scw_js) signs this with its own receiverAuthorizer key;\n", + " // here, the throwaway one generated in cell 1.\n", + " const claimEip712Domain = {\n", + " ...BATCH_SETTLEMENT_DOMAIN, chainId: config.chainId,\n", + " verifyingContract: getAddress(BATCH_SETTLEMENT_ADDRESS),\n", + " };\n", + " const claimAuthorizerSignature = await receiverAuthorizer.signTypedData({\n", + " domain: claimEip712Domain, types: claimBatchTypes, primaryType: \"ClaimBatch\",\n", + " message: { claims: [{\n", + " channelId, maxClaimableAmount: BigInt(voucher3.maxClaimableAmount), totalClaimed: BigInt(claimTarget),\n", + " }] },\n", + " });\n", + "\n", + " const claimPayload = {\n", + " type: \"claim\",\n", + " claims: [{\n", + " voucher: { channel: channelConfig, maxClaimableAmount: voucher3.maxClaimableAmount },\n", + " signature: voucher3.signature, totalClaimed: claimTarget,\n", + " }],\n", + " claimAuthorizerSignature,\n", + " };\n", + " const claimSettleRes = await fetch(SETTLE_URL, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({\n", + " paymentPayload: { x402Version: 2, accepted: paymentRequirements, payload: claimPayload },\n", + " paymentRequirements,\n", + " }),\n", + " });\n", + " const claimResult = await claimSettleRes.json();\n", + " console.log(`claim /settle (${claimSettleRes.status}):`, JSON.stringify(claimResult, null, 2));\n", + " if (claimResult.transaction) {\n", + " console.log(`🔗 ${config.explorer}/tx/${claimResult.transaction}`);\n", + " console.log(\"\\nCheck the explorer's Logs tab for a Claimed(channelId, sender, claimAmount, newTotalClaimed)\");\n", + " console.log(\"event — that's the definitive proof this specific claim moved real value, not just success:true.\");\n", + " }\n", + " // Phase 3: a successful claim now carries a fee receipt, same shape exact uses.\n", + " if (claimResult.extensions?.facilitatorFees) {\n", + " const info = claimResult.extensions.facilitatorFees.info;\n", + " console.log(`\\n💸 Facilitator fee: ${info.facilitatorFeePaid} (${info.model}) — collection: ${info.collection.status}`);\n", + " // The fee is charged in the channel's token, so the receipt must name it.\n", + " const feeInChannelToken = (info.asset ?? \"\").toLowerCase().endsWith(tokenAddress.toLowerCase());\n", + " console.log(` Fee asset: ${info.asset} ${feeInChannelToken ? `✅ (${TOKEN})` : \"❌ not the channel token\"}`);\n", + " }\n", + " if (claimResult.errorReason === \"insufficient_fee_allowance\") {\n", + " console.warn(`\\n⚠️ Run section 8b to approve the facilitator's ${TOKEN} allowance, then retry this cell.`);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "09636c55", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "What a full run through this notebook exercises:\n", + "\n", + "| Section | What it proves |\n", + "|---|---|\n", + "| 1 | The facilitator advertises `batch-settlement` for this network |\n", + "| 3–4 | The buyer can build a valid deposit + first cumulative voucher |\n", + "| 5 | `/verify` accepts the signature, channel config, and EIP-712 domain |\n", + "| 7 | `/settle` escrows the deposit on-chain, returning the new `channelState` |\n", + "| 8 | Further requests are pure off-chain signatures — no transaction, no gas, no facilitator call |\n", + "| 8b–9 | The seller's fee allowance gates the claim, and one claim settles all accumulated vouchers |\n", + "\n", + "**The payoff to notice:** section 8 adds requests to the channel with zero on-chain cost. Under the\n", + "`exact` scheme (see [`x402_exact.ipynb`](./x402_exact.ipynb)) each of those would have been its own\n", + "settlement transaction and its own flat fee. Here they cost one claim and one fee, no matter how\n", + "many vouchers accumulated.\n", + "\n", + "**Not exercised here:** the `settle(receiver, token)` sweep that moves claimed funds from the\n", + "contract's pending bucket to the receiver's wallet (see section 9), and `refund`.\n", + "\n", + "For why the fee model works the way it does — including why `deposit`/`voucher`/`refund` are free\n", + "while `claim`/`settle` are charged — see `x402_facilitator/FEE_MODEL_PLAN.md` Phase 3." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": { + "name": "typescript" + }, + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/notebooks/facilitator/x402_batch_settlement_permit2.ipynb b/bunny_js/notebooks/facilitator/x402_batch_settlement_permit2.ipynb new file mode 100644 index 000000000..e3048ad54 --- /dev/null +++ b/bunny_js/notebooks/facilitator/x402_batch_settlement_permit2.ipynb @@ -0,0 +1,429 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# x402 `batch-settlement` with Permit2 — EURe facilitator test\n", + "\n", + "The EURe counterpart of `x402_batch_settlement.ipynb`, kept short:\n", + "\n", + "1. the buyer opens a channel with an EURe **deposit**, paid via Permit2;\n", + "2. several requests accumulate as off-chain **vouchers**;\n", + "3. the seller **claims** them in one transaction and pays the 0.01 EURe fee.\n", + "\n", + "**The difference from USDC/EURC is the deposit.** EURe has no EIP-3009, so the buyer signs a Permit2\n", + "transfer to the batch-settlement **Permit2 deposit collector** (`0x4020…0005`), whose witness is the\n", + "channel id. An EIP-2612 permit for Permit2 travels with it (`eip2612GasSponsoring`), so the buyer\n", + "needs no ETH and no prior `approve()`. Vouchers and claims are the same as for any token.\n", + "\n", + "**Prerequisites**:\n", + "- EURe on the buyer. On Base Sepolia it comes from the Monerium sandbox; see `README.md` →\n", + " *Test EURe*.\n", + "- A local facilitator built from the Permit2 branch: `npm run build && npm run dev`.\n", + "- The seller needs a little ETH for its one EURe fee `approve()`.\n", + "\n", + "`DRY_RUN=1` skips every on-chain write (deposit, approve, claim). The off-chain parts and\n", + "`/verify` still run." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "818937fa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🧪 Base (eip155:8453)\n", + " Buyer: 0x553179556FC2A39e535D65b921e01fA995E79101 (from TEST_WALLET_PRIVATE_KEY)\n", + " Seller: 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: imports, accounts, network\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount, generatePrivateKey } from \"npm:viem@2/accounts\";\n", + "import { createPublicClient, createWalletClient, http, formatUnits, parseUnits, getAddress } from \"npm:viem@2\";\n", + "import { base, baseSepolia } from \"npm:viem@2/chains\";\n", + "\n", + "const env = await load({ envPath: \"../../.env\", examplePath: null, export: true });\n", + "const toKey = (k: string) => `0x${k.replace(/^0x/, \"\")}` as `0x${string}`;\n", + "// Same buyer selection as x402_exact_permit2.ipynb: the address linked in the Monerium sandbox.\n", + "const BUYER_KEY_VAR = env.EURE_BUYER_PRIVATE_KEY ? \"EURE_BUYER_PRIVATE_KEY\" : \"TEST_WALLET_PRIVATE_KEY\";\n", + "const buyer = privateKeyToAccount(toKey(env[BUYER_KEY_VAR]));\n", + "const seller = privateKeyToAccount(toKey(env.NFT_WALLET_PRIVATE_KEY));\n", + "// The seller's claim-signing key. In production the seller keeps it; here it is throwaway.\n", + "const receiverAuthorizer = privateKeyToAccount(generatePrivateKey());\n", + "\n", + "const USE_MAINNET = false; // ⚠️ true = REAL MONEY\n", + "const CONFIRM_MAINNET_WRITES = false; // second opt-in for the on-chain writes on mainnet\n", + "const DRY_RUN = Deno.env.get(\"DRY_RUN\") === \"1\";\n", + "const mayWrite = !DRY_RUN && (!USE_MAINNET || CONFIRM_MAINNET_WRITES);\n", + "\n", + "const net = USE_MAINNET\n", + " ? { chain: base, caip2: \"eip155:8453\", eure: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\", explorer: \"https://basescan.org/tx/\" }\n", + " : { chain: baseSepolia, caip2: \"eip155:84532\", eure: \"0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\", explorer: \"https://sepolia.basescan.org/tx/\" };\n", + "const EURE = net.eure as `0x${string}`;\n", + "const fmt = (x: bigint | string) => `${formatUnits(BigInt(x), 18)} EURe`; // EURe has 18 decimals\n", + "const rpcUrl = env[`RPC_URL_EIP155_${net.caip2.split(\":\")[1]}`] ?? net.chain.rpcUrls.default.http[0];\n", + "const publicClient = createPublicClient({ chain: net.chain, transport: http(rpcUrl) });\n", + "\n", + "const FACILITATOR_URL = Deno.env.get(\"FACILITATOR_URL\") ?? \"http://localhost:8080\";\n", + "const post = async (path: string, body: unknown) =>\n", + " (await fetch(`${FACILITATOR_URL}${path}`, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(body),\n", + " })).json();\n", + "\n", + "console.log(`🧪 ${net.chain.name} (${net.caip2})${DRY_RUN ? \" — DRY_RUN: no on-chain writes\" : \"\"}`);\n", + "console.log(` Buyer: ${buyer.address} (from ${BUYER_KEY_VAR})`);\n", + "console.log(` Seller: ${seller.address}`);" + ] + }, + { + "cell_type": "markdown", + "id": "478060de", + "metadata": {}, + "source": [ + "## 1. `/supported`\n", + "\n", + "The facilitator must offer `batch-settlement` on this network and list EURe's fee in 18 decimals." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "46cc3446", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ batch-settlement on eip155:8453; claim fee 0.01 EURe; facilitator 0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206\n" + ] + } + ], + "source": [ + "const supported = await (await fetch(`${FACILITATOR_URL}/supported`)).json();\n", + "const hasBatch = supported.kinds.some((k: any) => k.scheme === \"batch-settlement\" && k.network === net.caip2);\n", + "const eureFee = supported.facilitatorFees?.assets?.find((a: any) => a.network === net.caip2 && a.symbol === \"EURe\");\n", + "if (!hasBatch || !eureFee) {\n", + " throw new Error(\"No batch-settlement or no EURe fee asset here — run a local facilitator from the Permit2 branch.\");\n", + "}\n", + "const facilitator = supported.facilitatorFees.recipient as `0x${string}`;\n", + "const FEE = BigInt(eureFee.flatFee);\n", + "console.log(`✅ batch-settlement on ${net.caip2}; claim fee ${fmt(FEE)}; facilitator ${facilitator}`);" + ] + }, + { + "cell_type": "markdown", + "id": "7370edab", + "metadata": {}, + "source": [ + "## 2. Buyer opens a channel: Permit2 deposit + first voucher\n", + "\n", + "`assetTransferMethod: \"permit2\"` makes the SDK client sign the deposit through Permit2. Because\n", + "the 402 declares `eip2612GasSponsoring`, the client also signs the EIP-2612 permit; it reads the\n", + "buyer's Permit2 allowance and EURe `nonces` through `readContract` to decide whether to." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "608ff935", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✍️ deposit 0.02 EURe via Permit2\n", + " spender: 0x4020425FAf3B746C082C2f942b4E5159887B0005 (batch Permit2 deposit collector)\n", + " channelId: 0x667e3f8fa3eb486bf1ee5e3b3ebd65c15ba04e1611cec09f8db1e475af0785c0\n", + " voucher #1 cumulative: 0.004 EURe\n", + "🔏 EIP-2612 permit for Permit2 attached\n" + ] + } + ], + "source": [ + "import { x402Client } from \"npm:@x402/fetch@^2.26.0\";\n", + "import { BatchSettlementEvmScheme, InMemoryClientChannelStorage, signVoucher }\n", + " from \"npm:@x402/evm@^2.26.0/batch-settlement/client\";\n", + "\n", + "const buyerSigner = {\n", + " address: buyer.address,\n", + " signTypedData: (msg: any) => buyer.signTypedData(msg),\n", + " readContract: (args: any) => publicClient.readContract(args),\n", + "};\n", + "const client = new x402Client();\n", + "client.register(net.caip2 as any, new BatchSettlementEvmScheme(buyerSigner as any, { storage: new InMemoryClientChannelStorage() }));\n", + "client.setSpendControls({ allowedAssets: [{ network: net.caip2 as any, asset: EURE }] });\n", + "\n", + "const PRICE = parseUnits(\"0.004\", 18); // per request; the deposit is 5× this\n", + "const paymentRequirements = {\n", + " scheme: \"batch-settlement\",\n", + " network: net.caip2,\n", + " amount: PRICE.toString(),\n", + " asset: EURE,\n", + " payTo: seller.address,\n", + " maxTimeoutSeconds: 3600,\n", + " extra: {\n", + " name: \"Monerium EURe\", version: \"1\", // EURe's EIP-712 domain, for the EIP-2612 permit\n", + " assetTransferMethod: \"permit2\",\n", + " receiverAuthorizer: receiverAuthorizer.address,\n", + " withdrawDelay: 86400,\n", + " },\n", + "};\n", + "const depositPayload: any = await client.createPaymentPayload({\n", + " x402Version: 2,\n", + " accepts: [paymentRequirements],\n", + " resource: { url: \"https://example.com/llm\", description: \"EURe batch test\", mimeType: \"application/json\" },\n", + " extensions: { eip2612GasSponsoring: {} },\n", + "} as any);\n", + "\n", + "const p2 = depositPayload.payload.deposit.authorization.permit2Authorization;\n", + "console.log(`✍️ deposit ${fmt(depositPayload.payload.deposit.amount)} via Permit2`);\n", + "console.log(` spender: ${p2.spender} (batch Permit2 deposit collector)`);\n", + "console.log(` channelId: ${p2.witness.channelId}`);\n", + "console.log(` voucher #1 cumulative: ${fmt(depositPayload.payload.voucher.maxClaimableAmount)}`);\n", + "console.log(depositPayload.extensions?.eip2612GasSponsoring\n", + " ? \"🔏 EIP-2612 permit for Permit2 attached\"\n", + " : \"ℹ️ no permit — the buyer already approved Permit2 enough\");" + ] + }, + { + "cell_type": "markdown", + "id": "b70ea1f9", + "metadata": {}, + "source": [ + "## 3. `/verify` and `/settle` the deposit\n", + "\n", + "The deposit is free (no facilitator fee). With an unfunded buyer, `/verify` fails on the balance." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "02d6f653", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "verify: {\"isValid\":true,\"payer\":\"0x553179556FC2A39e535D65b921e01fA995E79101\",\"extra\":{\"channelId\":\"0x667e3f8fa3eb486bf1ee5e3b3ebd65c15ba04e1611cec09f8db1e475af0785c0\",\"balance\":\"0\",\"totalClaimed\":\"0\",\"withdrawRequestedAt\":0,\"refundNonce\":\"0\"}}\n", + "✅ channel funded: https://basescan.org/tx/0x76965b4bd7c651b20476c9e5725eb9dd2515af3df6d544cb5ff0320a7c4c1fe8\n" + ] + } + ], + "source": [ + "const verify = await post(\"/verify\", { paymentPayload: depositPayload, paymentRequirements });\n", + "console.log(\"verify:\", JSON.stringify(verify));\n", + "\n", + "let depositSettled = false;\n", + "if (!verify.isValid) {\n", + " console.log(\"⏭️ deposit not settled — /verify did not pass\");\n", + "} else if (!mayWrite) {\n", + " console.log(\"⏭️ deposit not settled (DRY_RUN or unconfirmed mainnet)\");\n", + "} else {\n", + " const settled = await post(\"/settle\", { paymentPayload: depositPayload, paymentRequirements });\n", + " depositSettled = settled.success;\n", + " console.log(settled.success ? `✅ channel funded: ${net.explorer}${settled.transaction}` : `❌ ${JSON.stringify(settled)}`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "56c49b31", + "metadata": {}, + "source": [ + "## 4. Two more requests, off-chain\n", + "\n", + "Each request is just a new voucher with a higher cumulative amount. Nothing touches the chain." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "8340de26", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ 3 requests in one channel, cumulative 0.012 EURe — requests 2 and 3 cost no transaction\n" + ] + } + ], + "source": [ + "const channelId = depositPayload.payload.voucher.channelId;\n", + "const channelConfig = depositPayload.payload.channelConfig;\n", + "const voucher3 = await (async () => {\n", + " await signVoucher(buyerSigner as any, channelId, (2n * PRICE).toString(), net.caip2 as any);\n", + " return signVoucher(buyerSigner as any, channelId, (3n * PRICE).toString(), net.caip2 as any);\n", + "})();\n", + "console.log(`✅ 3 requests in one channel, cumulative ${fmt(voucher3.maxClaimableAmount)} — requests 2 and 3 cost no transaction`);" + ] + }, + { + "cell_type": "markdown", + "id": "d3a8c84e", + "metadata": {}, + "source": [ + "## 5. Seller approves EURe for the claim fee\n", + "\n", + "The fee is charged in the channel's token, so a seller paid in EURe approves **EURe**: 1 EURe\n", + "(`10¹⁸`) covers 100 claims. This cell approves only if the allowance is short." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8f38d3b7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔍 seller → facilitator: 0.99 EURe = 99 claims\n" + ] + } + ], + "source": [ + "const ERC20 = [\n", + " { name: \"allowance\", type: \"function\", stateMutability: \"view\", inputs: [{ name: \"o\", type: \"address\" }, { name: \"s\", type: \"address\" }], outputs: [{ name: \"\", type: \"uint256\" }] },\n", + " { name: \"approve\", type: \"function\", stateMutability: \"nonpayable\", inputs: [{ name: \"s\", type: \"address\" }, { name: \"v\", type: \"uint256\" }], outputs: [{ name: \"\", type: \"bool\" }] },\n", + "] as const;\n", + "const allowance = await publicClient.readContract({ address: EURE, abi: ERC20, functionName: \"allowance\", args: [seller.address, facilitator] });\n", + "console.log(`🔍 seller → facilitator: ${fmt(allowance)} = ${allowance / FEE} claims`);\n", + "\n", + "if (allowance < FEE && mayWrite) {\n", + " const wallet = createWalletClient({ account: seller, chain: net.chain, transport: http(rpcUrl) });\n", + " const hash = await wallet.writeContract({ address: EURE, abi: ERC20, functionName: \"approve\", args: [facilitator, BigInt(eureFee.recommended_amount)] });\n", + " await publicClient.waitForTransactionReceipt({ hash });\n", + " console.log(`✅ approved ${fmt(eureFee.recommended_amount)}: ${net.explorer}${hash}`);\n", + "} else if (allowance < FEE) {\n", + " console.log(\"⏭️ approve skipped (DRY_RUN or unconfirmed mainnet)\");\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "be9aa36c", + "metadata": {}, + "source": [ + "## 6. Seller claims all three requests in one transaction\n", + "\n", + "The seller's `receiverAuthorizer` signs the claim. The facilitator submits it, then pulls 0.01 EURe\n", + "from the seller (`10000000000000000` atomic)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "95c8f130", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "claim: {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0x52774514e6459308ac6a7478f32b584131ddb43219a80a2ee14f8983f8125faf\",\n", + " \"network\": \"eip155:8453\",\n", + " \"fee\": {\n", + " \"collected\": true,\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0xa0c8e567d9def5522ca067886ecf292446a0355de3dce4f16a4855b7df0454bc\"\n", + " },\n", + " \"extensions\": {\n", + " \"facilitatorFees\": {\n", + " \"info\": {\n", + " \"version\": \"1\",\n", + " \"facilitatorFeePaid\": \"10000000000000000\",\n", + " \"asset\": \"eip155:8453/erc20:0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\",\n", + " \"model\": \"flat\",\n", + " \"collection\": {\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0xa0c8e567d9def5522ca067886ecf292446a0355de3dce4f16a4855b7df0454bc\"\n", + " }\n", + " }\n", + " }\n", + " }\n", + "}\n", + "✅ claimed 0.012 EURe: https://basescan.org/tx/0x52774514e6459308ac6a7478f32b584131ddb43219a80a2ee14f8983f8125faf\n", + "💸 fee 0.01 EURe ✅, collection collected\n" + ] + } + ], + "source": [ + "import { claimBatchTypes, BATCH_SETTLEMENT_DOMAIN, BATCH_SETTLEMENT_ADDRESS } from \"npm:@x402/evm@^2.26.0\";\n", + "\n", + "if (!depositSettled) {\n", + " console.log(\"⏭️ no funded channel to claim from\");\n", + "} else {\n", + " const claimAuthorizerSignature = await receiverAuthorizer.signTypedData({\n", + " domain: { ...BATCH_SETTLEMENT_DOMAIN, chainId: net.chain.id, verifyingContract: getAddress(BATCH_SETTLEMENT_ADDRESS) },\n", + " types: claimBatchTypes, primaryType: \"ClaimBatch\",\n", + " message: { claims: [{ channelId, maxClaimableAmount: BigInt(voucher3.maxClaimableAmount), totalClaimed: BigInt(voucher3.maxClaimableAmount) }] },\n", + " });\n", + " const claimPayload = {\n", + " type: \"claim\",\n", + " claims: [{\n", + " voucher: { channel: channelConfig, maxClaimableAmount: voucher3.maxClaimableAmount },\n", + " signature: voucher3.signature,\n", + " totalClaimed: voucher3.maxClaimableAmount,\n", + " }],\n", + " claimAuthorizerSignature,\n", + " };\n", + " const claim = await post(\"/settle\", {\n", + " paymentPayload: { x402Version: 2, accepted: paymentRequirements, payload: claimPayload },\n", + " paymentRequirements,\n", + " });\n", + " console.log(\"claim:\", JSON.stringify(claim, null, 2));\n", + " if (claim.success) {\n", + " const info = claim.extensions?.facilitatorFees?.info;\n", + " console.log(`✅ claimed ${fmt(voucher3.maxClaimableAmount)}: ${net.explorer}${claim.transaction}`);\n", + " // On testnets, a seller listed in BATCH_SETTLEMENT_TEST_WALLETS is exempt from the fee:\n", + " // the claim then carries no fee receipt at all.\n", + " const exempt = !USE_MAINNET && (env.BATCH_SETTLEMENT_TEST_WALLETS ?? \"\").toLowerCase().includes(seller.address.toLowerCase());\n", + " if (info) {\n", + " console.log(`💸 fee ${fmt(info.facilitatorFeePaid)} ${BigInt(info.facilitatorFeePaid) === FEE ? \"✅\" : \"❌\"}, collection ${info.collection?.status}`);\n", + " } else if (exempt) {\n", + " console.log(\"ℹ️ no fee: the seller is in BATCH_SETTLEMENT_TEST_WALLETS (testnet exemption).\");\n", + " console.log(\" Remove it from bunny_js/.env and restart the facilitator to test the EURe fee.\");\n", + " } else {\n", + " console.log(\"❌ no fee receipt, and the seller is not exempt\");\n", + " }\n", + " }\n", + "}" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/notebooks/facilitator/x402_exact.ipynb b/bunny_js/notebooks/facilitator/x402_exact.ipynb new file mode 100644 index 000000000..d3d99cb3e --- /dev/null +++ b/bunny_js/notebooks/facilitator/x402_exact.ipynb @@ -0,0 +1,1140 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0dc0d45d", + "metadata": {}, + "source": [ + "# x402 `exact` scheme — seller-side facilitator test\n", + "\n", + "Tests the **`exact`** scheme's full seller-facing flow directly against a running\n", + "facilitator: discover it via `/supported`, approve its USDC fee allowance, then walk\n", + "one payment through `/verify` → `/settle` and confirm the fee was actually collected.\n", + "\n", + "**Where this fits among the sibling notebooks:**\n", + "- **This notebook** — `exact` scheme, seller's perspective (what you'd build if you're\n", + " integrating a server against this facilitator).\n", + "- [`x402_batch_settlement.ipynb`](./x402_batch_settlement.ipynb) — same idea, for the\n", + " `batch-settlement` scheme.\n", + "- [`genimg_x402_buyer.ipynb`](./genimg_x402_buyer.ipynb) — the buyer's perspective\n", + " instead: a real production integration (`scw_js`'s genimg endpoint) paid via\n", + " `@x402/fetch`, with no direct facilitator calls at all.\n", + "\n", + "## Fee flow\n", + "\n", + "1. **Buyer** signs an EIP-3009 authorization: `transferWithAuthorization(buyer → seller, amount)`.\n", + "2. **Facilitator** verifies the signature and checks the seller's USDC allowance for the fee.\n", + "3. **Facilitator** settles: executes the `transferWithAuthorization` on-chain (buyer → seller).\n", + "4. **Facilitator** collects its fee: `transferFrom(seller → facilitator, feeAmount)`, post-settlement.\n", + "\n", + "**Key point:** the **seller** pays the fee, not the buyer. The seller must pre-approve USDC\n", + "spending by the facilitator wallet — Step 3 below does that. The recommended approval is\n", + "deliberately small (1 USDC ≈ 100 settlements), since the facilitator's spender is a hot\n", + "wallet — re-approval is expected, not a one-time setup. Watch `remainingSettlements` in\n", + "the `/verify` response to know when.\n", + "\n", + "## Prerequisites\n", + "\n", + "1. Start the local facilitator: `cd x402_facilitator && npm run dev`\n", + "2. Set env vars in the package's single `bunny_js/.env` (two levels up):\n", + " - `TEST_WALLET_PRIVATE_KEY` — buyer's private key (needs testnet USDC)\n", + " - `NFT_WALLET_PUBLIC_KEY` — seller's address (receives payment)\n", + " - `NFT_WALLET_PRIVATE_KEY` — seller's private key (signs the fee approval)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d9ee2db9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚀 x402 exact scheme — facilitator test\n", + " Buyer: 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Seller: 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " Seller (from key): 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: Imports and Configuration\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", + "import { createPublicClient, createWalletClient, http, formatUnits, getContract } from \"npm:viem@2\";\n", + "import { optimism, optimismSepolia, base, baseSepolia } from \"npm:viem@2/chains\";\n", + "\n", + "// Load environment variables\n", + "// Load from the package's single .env, two levels up (bunny_js/.env).\n", + "// examplePath: null skips the \"every key in ./.env.example must be present\" check.\n", + "const env = await load({ envPath: \"../../.env\", examplePath: null, export: true });\n", + "\n", + "const BUYER_PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", + "const SELLER_ADDRESS = env.NFT_WALLET_PUBLIC_KEY as `0x${string}`;\n", + "const SELLER_PRIVATE_KEY = env.NFT_WALLET_PRIVATE_KEY;\n", + "\n", + "// Create buyer account\n", + "// Idempotent whether or not the .env value already carries the 0x prefix (it does, per\n", + "// .env.example) — a raw template literal here would double it and fail viem's hex parse.\n", + "const buyerAccount = privateKeyToAccount(`0x${BUYER_PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "const sellerAccount = privateKeyToAccount(`0x${SELLER_PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "\n", + "console.log(\"🚀 x402 exact scheme — facilitator test\");\n", + "console.log(` Buyer: ${buyerAccount.address}`);\n", + "console.log(` Seller: ${SELLER_ADDRESS}`);\n", + "console.log(` Seller (from key): ${sellerAccount.address}`);" + ] + }, + { + "cell_type": "markdown", + "id": "ef06c7bf", + "metadata": {}, + "source": [ + "## Network & Payment Configuration\n", + "\n", + "**Supported Networks:**\n", + "| Chain | Testnet | Mainnet |\n", + "|-------|---------|---------|\n", + "| Optimism | `eip155:11155420` | `eip155:10` |\n", + "| Base | `eip155:84532` | `eip155:8453` |\n", + "\n", + "**To switch networks:**\n", + "1. Set `USE_BASE = true` for Base, `false` for Optimism\n", + "2. Set `USE_MAINNET = true` for mainnet (⚠️ REAL MONEY!)\n", + "3. Set `TOKEN` to `\"EURC\"` or `\"USDC\"`. EURC exists on Base only, so it needs `USE_BASE = true`.\n", + " The facilitator charges its fee in the token you pay in, so the seller approves that token.\n", + "\n", + "The buyer pays `PAYMENT_AMOUNT` to the seller. The facilitator fee (0.01 of the paid token) is collected **from the seller** after settlement — the buyer pays nothing extra." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fee1aa4f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "🚨 WARNING: Using REAL MONEY on Base Mainnet!\n", + " Chain: Base\n", + " Chain ID: 8453\n", + " CAIP-2: eip155:8453\n", + " Token: 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\n", + " Token Name: EURC\n", + " Payment: 20000 (0.02 EURC)\n" + ] + } + ], + "source": [ + "// ═══════════════════════════════════════════════════════════════\n", + "// ⚠️ NETWORK SELECTION - Choose your network here\n", + "// ═══════════════════════════════════════════════════════════════\n", + "\n", + "// Step 1: Choose the chain (Optimism or Base)\n", + "const USE_BASE = true; // Set to true for Base, false for Optimism (Optimism has no EURC)\n", + "\n", + "// Step 2: Choose testnet or mainnet\n", + "const USE_MAINNET = true; // Set to true for mainnet with REAL MONEY\n", + "\n", + "// Step 3: Choose the token. EURC exists on Base only (Circle has no Optimism deployment).\n", + "const TOKEN: \"USDC\" | \"EURC\" = \"EURC\";\n", + "\n", + "// Network configuration (all 4 combinations)\n", + "interface NetworkConfig {\n", + " chain: typeof optimism;\n", + " chainId: number;\n", + " caip2Network: string;\n", + " networkName: string;\n", + " usdcAddress: `0x${string}`;\n", + " usdcName: string;\n", + "}\n", + "\n", + "const NETWORK_CONFIGS: Record = {\n", + " \"optimism:testnet\": {\n", + " chain: optimismSepolia,\n", + " chainId: 11155420,\n", + " caip2Network: \"eip155:11155420\",\n", + " networkName: \"Optimism Sepolia (Testnet)\",\n", + " usdcAddress: \"0x5fd84259d66Cd46123540766Be93DFE6D43130D7\",\n", + " usdcName: \"USDC\",\n", + " },\n", + " \"optimism:mainnet\": {\n", + " chain: optimism,\n", + " chainId: 10,\n", + " caip2Network: \"eip155:10\",\n", + " networkName: \"Optimism Mainnet\",\n", + " usdcAddress: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\",\n", + " usdcName: \"USD Coin\",\n", + " },\n", + " \"base:testnet\": {\n", + " chain: baseSepolia,\n", + " chainId: 84532,\n", + " caip2Network: \"eip155:84532\",\n", + " networkName: \"Base Sepolia (Testnet)\",\n", + " usdcAddress: \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\",\n", + " usdcName: \"USDC\",\n", + " },\n", + " \"base:mainnet\": {\n", + " chain: base,\n", + " chainId: 8453,\n", + " caip2Network: \"eip155:8453\",\n", + " networkName: \"Base Mainnet\",\n", + " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", + " usdcName: \"USD Coin\",\n", + " },\n", + "};\n", + "\n", + "// Select config based on toggles\n", + "const configKey = `${USE_BASE ? \"base\" : \"optimism\"}:${USE_MAINNET ? \"mainnet\" : \"testnet\"}`;\n", + "const config = NETWORK_CONFIGS[configKey];\n", + "\n", + "// Convenience aliases used in all downstream cells\n", + "const chainConfig = config.chain;\n", + "\n", + "// EURC per network — address and EIP-712 domain name read on-chain (name() = \"EURC\", version \"2\").\n", + "const EURC_BY_NETWORK: Record = {\n", + " \"eip155:84532\": { address: \"0x808456652fdb597867f38412077A9182bf77359F\", name: \"EURC\" }, // Base Sepolia\n", + " \"eip155:8453\": { address: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\", name: \"EURC\" }, // Base\n", + "};\n", + "const eurc = EURC_BY_NETWORK[config.caip2Network];\n", + "if (TOKEN === \"EURC\" && !eurc) {\n", + " throw new Error(`EURC is not deployed on ${config.networkName} — set USE_BASE = true, or TOKEN = \"USDC\".`);\n", + "}\n", + "// The token the buyer pays in, and so the one the facilitator charges its fee in.\n", + "const tokenAddress = TOKEN === \"EURC\" ? eurc!.address : config.usdcAddress;\n", + "const tokenName = TOKEN === \"EURC\" ? eurc!.name : config.usdcName;\n", + "\n", + "// What the buyer pays (seller receives this minus nothing — fee is separate!)\n", + "const PAYMENT_AMOUNT = \"20000\"; // 0.02 of the token (USDC and EURC both have 6 decimals)\n", + "\n", + "if (USE_MAINNET) {\n", + " console.log(`\\n🚨 WARNING: Using REAL MONEY on ${config.networkName}!`);\n", + "} else {\n", + " console.log(`\\n🧪 Using testnet: ${config.networkName}`);\n", + "}\n", + "console.log(` Chain: ${USE_BASE ? \"Base\" : \"Optimism\"}`);\n", + "console.log(` Chain ID: ${config.chainId}`);\n", + "console.log(` CAIP-2: ${config.caip2Network}`);\n", + "console.log(` Token: ${tokenAddress}`);\n", + "console.log(` Token Name: ${tokenName}`);\n", + "console.log(` Payment: ${PAYMENT_AMOUNT} (${Number(PAYMENT_AMOUNT) / 1e6} ${TOKEN})`);" + ] + }, + { + "cell_type": "markdown", + "id": "5b51c2fe", + "metadata": {}, + "source": [ + "## Step 1: Query /supported\n", + "\n", + "Check what the facilitator supports. The `facilitator_fee` extension shows the fee amount and the facilitator's wallet address (needed for USDC approval)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1c5d0c57", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 GET https://fretchen-facilitator-rtsp5.bunny.run/supported → 200\n", + "\n", + "🌐 Supported Networks:\n", + " - eip155:10 (scheme: exact, x402v2)\n", + " - eip155:10 (scheme: batch-settlement, x402v2)\n", + " - eip155:11155420 (scheme: exact, x402v2)\n", + " - eip155:8453 (scheme: exact, x402v2)\n", + " - eip155:8453 (scheme: batch-settlement, x402v2)\n", + " - eip155:84532 (scheme: exact, x402v2)\n", + " - eip155:84532 (scheme: batch-settlement, x402v2)\n", + "\n", + "💸 Facilitator fee:\n", + " Fee: 0.01 of the settled token (USDC; EURC and EURe on Base) per settlement (exact), or per on-chain claim or settle transaction (batch-settlement) — same flat amount either way. batch-settlement charges whatever realizes a payment: claim, settle, and a refund that carries claims (which settles them in the same transaction). Deposits, vouchers, and claim-less refunds are not charged.\n", + " Amount: 10000 (0.01 EURC, charged in the settled token)\n", + " Facilitator Address: 0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206\n", + " Collection: post_settlement_transferFrom\n", + "\n", + "🔧 Setup Required:\n", + " Recurring approval, one per token you are paid in: call approve() on the USDC contract (any network) and/or the EURC and EURe contracts (Base) for the facilitator's address. EURe has 18 decimals: approve the per-token amount listed in `assets`. A settlement in a token you have not approved is refused. Applies to both schemes: exact recipients and batch-settlement claim/settle recipients draw from the same per-token allowance. The recommended amount is deliberately small: the spender is a hot wallet, so a large standing allowance is a standing risk. Re-approve when remainingSettlements (in the /verify response) runs low; revoke any time with approve(spender, 0).\n", + " Function: approve(address spender, uint256 amount)\n", + " Spender: 0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206\n", + "\n", + "📝 Facilitator Signers: 0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206\n" + ] + } + ], + "source": [ + "// Facilitator endpoint (local dev server)\n", + "const FACILITATOR_URL = Deno.env.get(\"FACILITATOR_URL\") ?? \"http://localhost:8080\";\n", + "\n", + "const SUPPORTED_URL = `${FACILITATOR_URL}/supported`;\n", + "const VERIFY_URL = `${FACILITATOR_URL}/verify`;\n", + "const SETTLE_URL = `${FACILITATOR_URL}/settle`;\n", + "\n", + "// Query /supported\n", + "const supportedResponse = await fetch(SUPPORTED_URL);\n", + "const supported = await supportedResponse.json();\n", + "\n", + "console.log(`📡 GET ${SUPPORTED_URL} → ${supportedResponse.status}`);\n", + "\n", + "// Show supported networks\n", + "console.log(`\\n🌐 Supported Networks:`);\n", + "for (const kind of supported.kinds || []) {\n", + " console.log(` - ${kind.network} (scheme: ${kind.scheme}, x402v${kind.x402Version})`);\n", + "}\n", + "\n", + "// Fee disclosure: `extensions` is only a list of key names; the detail lives in the\n", + "// top-level `facilitatorFees` object (x402 fee disclosure proposal, coinbase/x402#1016).\n", + "const fees = supported.facilitatorFees;\n", + "let facilitatorAddress: string | null = null;\n", + "let feeAmount: string | null = null;\n", + "\n", + "if (fees) {\n", + " facilitatorAddress = fees.recipient;\n", + " feeAmount = fees.flatFee;\n", + " console.log(`\\n💸 Facilitator fee:`);\n", + " console.log(` Fee: ${fees.fee?.description}`);\n", + " console.log(` Amount: ${feeAmount} (${Number(feeAmount) / 1e6} ${TOKEN}, charged in the settled token)`);\n", + " console.log(` Facilitator Address: ${facilitatorAddress}`);\n", + " console.log(` Collection: ${fees.fee?.collection}`);\n", + " console.log(`\\n🔧 Setup Required:`);\n", + " console.log(` ${fees.setup?.description}`);\n", + " console.log(` Function: ${fees.setup?.function}`);\n", + " console.log(` Spender: ${fees.setup?.spender}`);\n", + "} else {\n", + " console.log(`\\n⚠️ No facilitatorFees disclosure found — fees may be disabled`);\n", + "}\n", + "\n", + "// Show signers\n", + "if (supported.signers?.[\"eip155:*\"]) {\n", + " console.log(`\\n📝 Facilitator Signers: ${supported.signers[\"eip155:*\"].join(\", \")}`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "9c637308", + "metadata": {}, + "source": [ + "> **Note:** the printed output above is captured from a past run, before Phase 5.2 reworded the `/supported` disclosure. A fresh run now shows a *recurring*-approval message recommending **1 USDC (100 settlements)**, not the \"one-time\" / \"100 USDC (10,000 settlements)\" text shown here — see `x402_facilitator/FEE_MODEL_PLAN.md` Phase 5." + ] + }, + { + "cell_type": "markdown", + "id": "da1e47cf", + "metadata": {}, + "source": [ + "## Step 2: Check USDC Balances (Before)\n", + "\n", + "We check the USDC balances of all three parties before any transaction:\n", + "- **Buyer** — will pay the full `PAYMENT_AMOUNT`\n", + "- **Seller** — will receive `PAYMENT_AMOUNT`, then the facilitator takes the fee via `transferFrom`\n", + "- **Facilitator** — collects the fee after settlement" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0f5709ba", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💰 EURC Balances Before:\n", + " Buyer (0x553179556FC2A39e535D65b921e01fA995E79101): 0.94 EURC\n", + " Seller (0xAAEBC1441323B8ad6Bdf6793A8428166b510239C): 0.765706 EURC\n", + " Facilitator (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206): 0.28 EURC\n", + "\n", + "✅ Buyer has enough EURC for payment\n" + ] + } + ], + "source": [ + "// Minimal ERC-20 ABI for balance, allowance, and approval\n", + "const ERC20_ABI = [\n", + " {\n", + " name: \"balanceOf\",\n", + " type: \"function\",\n", + " stateMutability: \"view\",\n", + " inputs: [{ name: \"account\", type: \"address\" }],\n", + " outputs: [{ name: \"\", type: \"uint256\" }],\n", + " },\n", + " {\n", + " name: \"allowance\",\n", + " type: \"function\",\n", + " stateMutability: \"view\",\n", + " inputs: [\n", + " { name: \"owner\", type: \"address\" },\n", + " { name: \"spender\", type: \"address\" },\n", + " ],\n", + " outputs: [{ name: \"\", type: \"uint256\" }],\n", + " },\n", + " {\n", + " name: \"approve\",\n", + " type: \"function\",\n", + " stateMutability: \"nonpayable\",\n", + " inputs: [\n", + " { name: \"spender\", type: \"address\" },\n", + " { name: \"value\", type: \"uint256\" },\n", + " ],\n", + " outputs: [{ name: \"\", type: \"bool\" }],\n", + " },\n", + "] as const;\n", + "\n", + "// Create public client for reading on-chain state\n", + "const publicClient = createPublicClient({\n", + " chain: chainConfig,\n", + " transport: http(chainConfig.rpcUrls.default.http[0]),\n", + "});\n", + "\n", + "// Helper to read token balance\n", + "async function getTokenBalance(address: `0x${string}`): Promise {\n", + " return publicClient.readContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"balanceOf\",\n", + " args: [address],\n", + " });\n", + "}\n", + "\n", + "// Check all balances\n", + "const balancesBefore = {\n", + " buyer: await getTokenBalance(buyerAccount.address),\n", + " seller: await getTokenBalance(SELLER_ADDRESS as `0x${string}`),\n", + " facilitator: facilitatorAddress\n", + " ? await getTokenBalance(facilitatorAddress as `0x${string}`)\n", + " : 0n,\n", + "};\n", + "\n", + "console.log(`💰 ${TOKEN} Balances Before:`);\n", + "console.log(` Buyer (${buyerAccount.address}): ${Number(balancesBefore.buyer) / 1e6} ${TOKEN}`);\n", + "console.log(` Seller (${SELLER_ADDRESS}): ${Number(balancesBefore.seller) / 1e6} ${TOKEN}`);\n", + "if (facilitatorAddress) {\n", + " console.log(` Facilitator (${facilitatorAddress}): ${Number(balancesBefore.facilitator) / 1e6} ${TOKEN}`);\n", + "}\n", + "\n", + "// Check if buyer has enough\n", + "const paymentBigInt = BigInt(PAYMENT_AMOUNT);\n", + "if (balancesBefore.buyer < paymentBigInt) {\n", + " console.log(`\\n⚠️ Buyer needs at least ${Number(paymentBigInt) / 1e6} ${TOKEN} but only has ${Number(balancesBefore.buyer) / 1e6}`);\n", + "} else {\n", + " console.log(`\\n✅ Buyer has enough ${TOKEN} for payment`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "cb429d6d", + "metadata": {}, + "source": [ + "## Step 3: Seller Approves USDC for Fee Collection\n", + "\n", + "The fee model requires the **seller** to have an active ERC-20 `approve` for the facilitator's address.\n", + "This is a **recurring approval** — the recommended amount is deliberately small (the facilitator's\n", + "spender is a hot wallet), so re-approve as `remainingSettlements` (from `/verify`) runs low.\n", + "\n", + "**Flow:**\n", + "1. Seller calls `USDC.approve(facilitatorAddress, amount)` — e.g. 1 USDC = 100 settlements at 0.01 USDC fee\n", + "2. The facilitator checks `allowance(seller, facilitator)` during `/verify`\n", + "3. After `/settle`, the facilitator calls `transferFrom(seller, facilitator, fee)` to collect\n", + "\n", + "If the allowance is already sufficient, you can skip the approval transaction." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ab1d4474", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔍 Current Seller Allowance:\n", + " Allowance: 0.72 EURC\n", + " Fee per settlement: 0.01 EURC\n", + " Remaining settlements: 72\n", + "\n", + "✅ Allowance is sufficient for at least 1 settlement\n", + " (skip approval step if you want)\n" + ] + } + ], + "source": [ + "// First check existing allowance\n", + "const sellerAddr = SELLER_ADDRESS as `0x${string}`;\n", + "const facilitatorAddr = facilitatorAddress as `0x${string}`;\n", + "\n", + "const currentAllowance = await publicClient.readContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"allowance\",\n", + " args: [sellerAddr, facilitatorAddr],\n", + "});\n", + "\n", + "const feeAmountBigInt = feeAmount ? BigInt(feeAmount) : 10000n;\n", + "const feePerSettlement = Number(feeAmountBigInt) / 1e6;\n", + "const remainingSettlements = feeAmountBigInt > 0n\n", + " ? Number(currentAllowance / feeAmountBigInt)\n", + " : Infinity;\n", + "\n", + "console.log(`🔍 Current Seller Allowance:`);\n", + "console.log(` Allowance: ${Number(currentAllowance) / 1e6} ${TOKEN}`);\n", + "console.log(` Fee per settlement: ${feePerSettlement} ${TOKEN}`);\n", + "console.log(` Remaining settlements: ${remainingSettlements}`);\n", + "\n", + "if (currentAllowance >= feeAmountBigInt) {\n", + " console.log(`\\n✅ Allowance is sufficient for at least 1 settlement`);\n", + " console.log(` (skip approval step if you want)`);\n", + "} else {\n", + " console.log(`\\n⚠️ Allowance insufficient — approval required!`);\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "876c6ee5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📝 Submitting approve(0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206, 1 EURC)...\n", + " Tx Hash: 0x6651d8af29d81fc7763703592701ed98355ee70b6ec1331e290bf8f1812ad069\n", + " Waiting for confirmation...\n", + " ✅ Approval confirmed in block 54487606\n", + " Gas used: 38337\n", + "\n", + "💰 Updated Allowance:\n", + " Allowance: 1 EURC\n", + " Settlements available: 100\n" + ] + } + ], + "source": [ + "// Approve token spending for the facilitator\n", + "// Amount: 1 token = covers 100 settlements at 0.01 token fee\n", + "const APPROVAL_AMOUNT = 1_000_000n; // 1 token (6 decimals)\n", + "\n", + "// Create seller wallet client for the approval transaction\n", + "const sellerWalletClient = createWalletClient({\n", + " account: sellerAccount,\n", + " chain: chainConfig,\n", + " transport: http(chainConfig.rpcUrls.default.http[0]),\n", + "});\n", + "\n", + "console.log(`📝 Submitting approve(${facilitatorAddr}, ${Number(APPROVAL_AMOUNT) / 1e6} ${TOKEN})...`);\n", + "\n", + "const approveTxHash = await sellerWalletClient.writeContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"approve\",\n", + " args: [facilitatorAddr, APPROVAL_AMOUNT],\n", + "});\n", + "\n", + "console.log(` Tx Hash: ${approveTxHash}`);\n", + "console.log(` Waiting for confirmation...`);\n", + "\n", + "const approveReceipt = await publicClient.waitForTransactionReceipt({\n", + " hash: approveTxHash,\n", + "});\n", + "\n", + "console.log(` ✅ Approval confirmed in block ${approveReceipt.blockNumber}`);\n", + "console.log(` Gas used: ${approveReceipt.gasUsed}`);\n", + "\n", + "// Verify new allowance\n", + "const newAllowance = await publicClient.readContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"allowance\",\n", + " args: [sellerAddr, facilitatorAddr],\n", + "});\n", + "\n", + "const newRemainingSettlements = feeAmountBigInt > 0n\n", + " ? Number(newAllowance / feeAmountBigInt)\n", + " : Infinity;\n", + "\n", + "console.log(`\\n💰 Updated Allowance:`);\n", + "console.log(` Allowance: ${Number(newAllowance) / 1e6} ${TOKEN}`);\n", + "console.log(` Settlements available: ${newRemainingSettlements}`);" + ] + }, + { + "cell_type": "markdown", + "id": "f21c6df8", + "metadata": {}, + "source": [ + "## Step 4: Create x402 Payment Payload\n", + "\n", + "Now we create a standard x402 payment from the buyer to the seller using the `exact` scheme.\n", + "The buyer signs an EIP-3009 `transferWithAuthorization` that moves USDC from buyer → seller.\n", + "\n", + "The facilitator fee is **not** part of this payload — it's collected separately after settlement." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "d0c376a2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ Payment payload created\n", + " Scheme: exact\n", + " Network: eip155:8453 (Base Mainnet)\n", + " From: 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " To (Seller): 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " Amount: 20000 (0.02 EURC)\n", + "\n", + "💡 Payment goes directly to the seller; the facilitator fee is a\n", + " separate transferFrom, collected after settlement.\n" + ] + } + ], + "source": [ + "// Import x402 packages for payment creation\n", + "import { x402Client } from \"npm:@x402/fetch@^2.17.0\";\n", + "import type { PaymentRequirements, PaymentPayload, Network } from \"npm:@x402/core@^2.17.0\";\n", + "import { ExactEvmScheme } from \"npm:@x402/evm@^2.17.0\";\n", + "\n", + "// Create x402 client with the standard ExactEvmScheme\n", + "const evmScheme = new ExactEvmScheme(buyerAccount);\n", + "const client = new x402Client();\n", + "client.register(\"eip155:*\" as Network, evmScheme);\n", + "// The SDK's default spend controls only accept tokens in its own registry (USDC), so\n", + "// allowlist the token explicitly — EURC would be rejected otherwise.\n", + "client.setSpendControls({\n", + " allowedAssets: [{ network: config.caip2Network as `${string}:${string}`, asset: tokenAddress }],\n", + "});\n", + "\n", + "// Build PaymentRequirements — standard x402 exact scheme\n", + "// The seller simply receives the payment amount\n", + "const paymentRequirements: PaymentRequirements = {\n", + " scheme: \"exact\",\n", + " network: config.caip2Network, // From network config\n", + " amount: PAYMENT_AMOUNT, // What the buyer pays\n", + " asset: tokenAddress,\n", + " payTo: SELLER_ADDRESS as `0x${string}`, // Seller's own address\n", + " maxTimeoutSeconds: 3600,\n", + " extra: {\n", + " name: tokenName, // From the TOKEN selection\n", + " version: \"2\"\n", + " }\n", + "};\n", + "\n", + "// Build mock 402 response (normally comes from server)\n", + "const paymentRequired = {\n", + " x402Version: 2,\n", + " accepts: [paymentRequirements],\n", + " resource: {\n", + " url: \"https://example.com/resource\",\n", + " description: \"exact scheme facilitator test\",\n", + " mimeType: \"application/json\"\n", + " },\n", + " extensions: {}\n", + "};\n", + "\n", + "// Create payment payload — buyer signs EIP-3009 transferWithAuthorization\n", + "const paymentPayload: PaymentPayload = await client.createPaymentPayload(paymentRequired as any);\n", + "\n", + "console.log(\"✅ Payment payload created\");\n", + "console.log(` Scheme: exact`);\n", + "console.log(` Network: ${config.caip2Network} (${config.networkName})`);\n", + "console.log(` From: ${paymentPayload.payload?.authorization?.from}`);\n", + "console.log(` To (Seller): ${paymentPayload.payload?.authorization?.to}`);\n", + "console.log(` Amount: ${paymentPayload.payload?.authorization?.value} (${Number(paymentPayload.payload?.authorization?.value || 0) / 1e6} ${TOKEN})`);\n", + "console.log(`\\n💡 Payment goes directly to the seller; the facilitator fee is a`);\n", + "console.log(` separate transferFrom, collected after settlement.`);" + ] + }, + { + "cell_type": "markdown", + "id": "f406e8e6", + "metadata": {}, + "source": [ + "## Step 5: Verify Payment\n", + "\n", + "Send the payment to `/verify`. The facilitator will:\n", + "1. Validate the EIP-3009 signature\n", + "2. Check buyer has sufficient USDC balance\n", + "3. **Check seller's USDC allowance for fee collection** (via `onAfterVerify` hook)\n", + "\n", + "If the seller hasn't approved USDC spending, the verify response will include `invalidReason: \"insufficient_fee_allowance\"` with details about the required approval." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b26524d4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔍 Sending verification request...\n", + "\n", + "📦 Verify Response (Status 200):\n", + "{\n", + " \"isValid\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"remainingSettlements\": 100\n", + "}\n", + "\n", + "✅ Payment is VALID!\n", + " Payer: 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Settlements left on your approval: 100\n" + ] + } + ], + "source": [ + "// Build verify request\n", + "const verifyRequest = {\n", + " paymentPayload: paymentPayload,\n", + " paymentRequirements: paymentRequirements\n", + "};\n", + "\n", + "console.log(\"🔍 Sending verification request...\");\n", + "\n", + "const verifyResponse = await fetch(VERIFY_URL, {\n", + " method: \"POST\",\n", + " headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify(verifyRequest)\n", + "});\n", + "\n", + "const verifyResult = await verifyResponse.json();\n", + "\n", + "console.log(`\\n📦 Verify Response (Status ${verifyResponse.status}):`);\n", + "console.log(JSON.stringify(verifyResult, null, 2));\n", + "\n", + "if (verifyResult.isValid) {\n", + " console.log(`\\n✅ Payment is VALID!`);\n", + " console.log(` Payer: ${verifyResult.payer}`);\n", + " // The facilitator's early warning: how many more settlements your current approval\n", + " // covers. Omitted when there's no fee, or when the allowance couldn't be read.\n", + " if (verifyResult.remainingSettlements !== undefined) {\n", + " console.log(` Settlements left on your approval: ${verifyResult.remainingSettlements}`);\n", + " }\n", + "} else {\n", + " console.log(`\\n❌ Payment is INVALID!`);\n", + " console.log(` Reason: ${verifyResult.invalidReason}`);\n", + " if (verifyResult.invalidReason === \"insufficient_fee_allowance\") {\n", + " // /verify returns only the reason. Everything needed to fix it comes from\n", + " // /supported (Step 1), which this notebook already read into these variables.\n", + " console.log(`\\n💡 Fix: approve more ${TOKEN}, then retry — see Step 3 above.`);\n", + " console.log(` Approve on ${TOKEN} contract: ${tokenAddress}`);\n", + " console.log(` Spender (facilitator): ${facilitatorAddress}`);\n", + " console.log(` Fee per settlement: ${Number(feeAmountBigInt) / 1e6} ${TOKEN}`);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "36da86fa", + "metadata": {}, + "source": [ + "## Step 6: Settle Payment\n", + "\n", + "Execute the settlement:\n", + "1. Facilitator calls `transferWithAuthorization` on USDC (buyer → seller)\n", + "2. **If fee is required:** Facilitator calls `transferFrom(seller, facilitator, feeAmount)` on USDC\n", + "\n", + "The settle response will include a `fee` object showing whether the fee was collected." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bac64ca4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💸 Attempting Settlement...\n", + " Network: eip155:8453\n", + " Buyer pays: 0.02 EURC → Seller\n", + " Fee: 0.01 EURC (collected after settlement)\n", + "\n", + "🚨 WARNING: REAL transaction with REAL MONEY!\n", + "\n", + "📦 Settle Response (Status 200):\n", + "{\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0x3081a7e3ef5f0c8f6d07291011c15d6a727075554e03f0164ee1ea6731ba130a\",\n", + " \"network\": \"eip155:8453\",\n", + " \"fee\": {\n", + " \"collected\": true,\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0x16ce0e4c0f094b363268081d73d193b1071c7e55df86a6b2889fa6a472c7a10c\"\n", + " },\n", + " \"extensions\": {\n", + " \"facilitatorFees\": {\n", + " \"info\": {\n", + " \"version\": \"1\",\n", + " \"facilitatorFeePaid\": \"10000\",\n", + " \"asset\": \"eip155:8453/erc20:0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\",\n", + " \"model\": \"flat\",\n", + " \"collection\": {\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0x16ce0e4c0f094b363268081d73d193b1071c7e55df86a6b2889fa6a472c7a10c\"\n", + " }\n", + " }\n", + " }\n", + " }\n", + "}\n", + "\n", + "🎉 Settlement successful!\n", + " Transaction: 0x3081a7e3ef5f0c8f6d07291011c15d6a727075554e03f0164ee1ea6731ba130a\n", + " Network: eip155:8453\n", + "\n", + "💰 Fee Collection:\n", + " Collected: true\n", + " Fee Tx: 0x16ce0e4c0f094b363268081d73d193b1071c7e55df86a6b2889fa6a472c7a10c\n", + " Fee asset: eip155:8453/erc20:0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 ✅ (EURC)\n" + ] + } + ], + "source": [ + "console.log(`💸 Attempting Settlement...`);\n", + "console.log(` Network: ${paymentRequirements.network}`);\n", + "console.log(` Buyer pays: ${Number(PAYMENT_AMOUNT) / 1e6} ${TOKEN} → Seller`);\n", + "console.log(` Fee: ${Number(feeAmountBigInt) / 1e6} ${TOKEN} (collected after settlement)`);\n", + "\n", + "// A fresh snapshot taken right here, not `balancesBefore`/`currentAllowance` (Steps 2/3) —\n", + "// those are only as fresh as whenever those cells last ran, which need not be \"immediately\n", + "// before this settlement\" if cells are re-run out of order. Re-running just this cell (and\n", + "// cell 19 below) repeatedly still gives Step 7 an accurate before/after comparison.\n", + "const snapshotBeforeSettle = {\n", + " balances: {\n", + " buyer: await getTokenBalance(buyerAccount.address),\n", + " seller: await getTokenBalance(SELLER_ADDRESS as `0x${string}`),\n", + " facilitator: facilitatorAddress\n", + " ? await getTokenBalance(facilitatorAddress as `0x${string}`)\n", + " : 0n,\n", + " },\n", + " allowance: await publicClient.readContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"allowance\",\n", + " args: [sellerAddr, facilitatorAddr],\n", + " }),\n", + "};\n", + "\n", + "if (USE_MAINNET) {\n", + " console.log(`\\n🚨 WARNING: REAL transaction with REAL MONEY!`);\n", + "}\n", + "\n", + "const settleResponse = await fetch(SETTLE_URL, {\n", + " method: \"POST\",\n", + " headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify(verifyRequest)\n", + "});\n", + "\n", + "const settleResult = await settleResponse.json();\n", + "\n", + "console.log(`\\n📦 Settle Response (Status ${settleResponse.status}):`);\n", + "console.log(JSON.stringify(settleResult, null, 2));\n", + "\n", + "if (settleResult.success) {\n", + " console.log(`\\n🎉 Settlement successful!`);\n", + " console.log(` Transaction: ${settleResult.transaction}`);\n", + " if (settleResult.network) {\n", + " console.log(` Network: ${settleResult.network}`);\n", + " }\n", + " // Fee details\n", + " if (settleResult.fee) {\n", + " console.log(`\\n💰 Fee Collection:`);\n", + " console.log(` Collected: ${settleResult.fee.collected}`);\n", + " if (settleResult.fee.txHash) {\n", + " console.log(` Fee Tx: ${settleResult.fee.txHash}`);\n", + " }\n", + " if (settleResult.fee.error) {\n", + " console.log(` ⚠️ Fee Error: ${settleResult.fee.error}`);\n", + " }\n", + " // The receipt names the token the fee was charged in — it must be the paid token.\n", + " const feeAsset = settleResult.extensions?.facilitatorFees?.info?.asset ?? \"\";\n", + " const feeInPaidToken = feeAsset.toLowerCase().endsWith(tokenAddress.toLowerCase());\n", + " console.log(` Fee asset: ${feeAsset} ${feeInPaidToken ? `✅ (${TOKEN})` : \"❌ not the paid token\"}`);\n", + " }\n", + "} else {\n", + " console.log(`\\n❌ Settlement failed`);\n", + " console.log(` Reason: ${settleResult.errorReason || \"unknown\"}`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "8b05084b", + "metadata": {}, + "source": [ + "## Step 7: Post-Settlement Verification\n", + "\n", + "Check balances and allowance after settlement to verify the complete flow:\n", + "- **Buyer** should have paid `PAYMENT_AMOUNT`\n", + "- **Seller** should have received `PAYMENT_AMOUNT - fee`\n", + "- **Facilitator** should have gained `fee`\n", + "- **Allowance** should have decreased by `fee`" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c4ca72d7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💰 EURC Balances AFTER Settlement:\n", + " Buyer: 0.92 EURC\n", + " Seller: 0.775706 EURC\n", + " Facilitator: 0.29 EURC\n", + "\n", + "📊 Balance Changes:\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Buyer spent: 0.02 EURC\n", + " Seller gained: 0.01 EURC\n", + " Facilitator gained: 0.01 EURC\n", + " Allowance decreased: 0.01 EURC\n", + "\n", + "🔍 Allowance Status:\n", + " Before: 1 EURC\n", + " After: 0.99 EURC\n", + " Settlements remaining: 99\n", + "\n", + "✅ Verification:\n", + " ✅ Buyer paid correct amount: 0.02 EURC\n", + " ✅ Seller received correct net: 0.01 EURC (payment - fee)\n", + " ✅ Facilitator collected correct fee: 0.01 EURC\n", + " ✅ Allowance decreased by fee amount: 0.01 EURC\n", + " ✅ Conservation of funds: Seller + Fee = Buyer spent\n" + ] + } + ], + "source": [ + "// Check token balances AFTER settlement\n", + "const balancesAfter = {\n", + " buyer: await getTokenBalance(buyerAccount.address),\n", + " seller: await getTokenBalance(SELLER_ADDRESS as `0x${string}`),\n", + " facilitator: facilitatorAddress\n", + " ? await getTokenBalance(facilitatorAddress as `0x${string}`)\n", + " : 0n,\n", + "};\n", + "\n", + "// Check allowance AFTER settlement\n", + "const allowanceAfter = await publicClient.readContract({\n", + " address: tokenAddress as `0x${string}`,\n", + " abi: ERC20_ABI,\n", + " functionName: \"allowance\",\n", + " args: [sellerAddr, facilitatorAddr],\n", + "});\n", + "\n", + "console.log(`💰 ${TOKEN} Balances AFTER Settlement:`);\n", + "console.log(` Buyer: ${Number(balancesAfter.buyer) / 1e6} ${TOKEN}`);\n", + "console.log(` Seller: ${Number(balancesAfter.seller) / 1e6} ${TOKEN}`);\n", + "if (facilitatorAddress) {\n", + " console.log(` Facilitator: ${Number(balancesAfter.facilitator) / 1e6} ${TOKEN}`);\n", + "}\n", + "\n", + "// Calculate deltas\n", + "const buyerDelta = snapshotBeforeSettle.balances.buyer - balancesAfter.buyer;\n", + "const sellerDelta = balancesAfter.seller - snapshotBeforeSettle.balances.seller;\n", + "const facilitatorDelta = balancesAfter.facilitator - snapshotBeforeSettle.balances.facilitator;\n", + "const allowanceDelta = snapshotBeforeSettle.allowance - allowanceAfter;\n", + "\n", + "console.log(`\\n📊 Balance Changes:`);\n", + "console.log(` Buyer spent: ${Number(buyerDelta) / 1e6} ${TOKEN}`);\n", + "console.log(` Seller gained: ${Number(sellerDelta) / 1e6} ${TOKEN}`);\n", + "console.log(` Facilitator gained: ${Number(facilitatorDelta) / 1e6} ${TOKEN}`);\n", + "console.log(` Allowance decreased: ${Number(allowanceDelta) / 1e6} ${TOKEN}`);\n", + "\n", + "console.log(`\\n🔍 Allowance Status:`);\n", + "console.log(` Before: ${Number(snapshotBeforeSettle.allowance) / 1e6} ${TOKEN}`);\n", + "console.log(` After: ${Number(allowanceAfter) / 1e6} ${TOKEN}`);\n", + "const settlementsRemaining = feeAmountBigInt > 0n ? Number(allowanceAfter / feeAmountBigInt) : Infinity;\n", + "console.log(` Settlements remaining: ${settlementsRemaining}`);\n", + "\n", + "// Verify correctness\n", + "console.log(`\\n✅ Verification:`);\n", + "\n", + "// Buyer should have paid PAYMENT_AMOUNT\n", + "if (buyerDelta === BigInt(PAYMENT_AMOUNT)) {\n", + " console.log(` ✅ Buyer paid correct amount: ${Number(buyerDelta) / 1e6} ${TOKEN}`);\n", + "} else {\n", + " console.log(` ❌ Buyer payment mismatch! Expected: ${Number(PAYMENT_AMOUNT) / 1e6}, Got: ${Number(buyerDelta) / 1e6}`);\n", + "}\n", + "\n", + "// Seller should have gained PAYMENT_AMOUNT - fee\n", + "const expectedSellerGain = BigInt(PAYMENT_AMOUNT) - feeAmountBigInt;\n", + "if (sellerDelta === expectedSellerGain) {\n", + " console.log(` ✅ Seller received correct net: ${Number(sellerDelta) / 1e6} ${TOKEN} (payment - fee)`);\n", + "} else {\n", + " console.log(` ❌ Seller amount mismatch! Expected: ${Number(expectedSellerGain) / 1e6}, Got: ${Number(sellerDelta) / 1e6}`);\n", + "}\n", + "\n", + "// Facilitator should have gained fee\n", + "if (facilitatorDelta === feeAmountBigInt) {\n", + " console.log(` ✅ Facilitator collected correct fee: ${Number(facilitatorDelta) / 1e6} ${TOKEN}`);\n", + "} else {\n", + " console.log(` ❌ Fee mismatch! Expected: ${Number(feeAmountBigInt) / 1e6}, Got: ${Number(facilitatorDelta) / 1e6}`);\n", + "}\n", + "\n", + "// Allowance should have decreased by fee\n", + "if (allowanceDelta === feeAmountBigInt) {\n", + " console.log(` ✅ Allowance decreased by fee amount: ${Number(allowanceDelta) / 1e6} ${TOKEN}`);\n", + "} else {\n", + " console.log(` ❌ Allowance mismatch! Expected decrease: ${Number(feeAmountBigInt) / 1e6}, Got: ${Number(allowanceDelta) / 1e6}`);\n", + "}\n", + "\n", + "// Conservation of funds\n", + "if (sellerDelta + facilitatorDelta === buyerDelta) {\n", + " console.log(` ✅ Conservation of funds: Seller + Fee = Buyer spent`);\n", + "} else {\n", + " console.log(` ❌ Funds don't add up!`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "13ac1fc9", + "metadata": {}, + "source": [ + "## Step 8: Block Explorer\n", + "\n", + "View the transactions on the block explorer." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "c85eea60", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔍 Settlement Transaction:\n", + " https://basescan.org/tx/0x3081a7e3ef5f0c8f6d07291011c15d6a727075554e03f0164ee1ea6731ba130a\n", + "\n", + "💰 Fee Collection Transaction:\n", + " https://basescan.org/tx/0x16ce0e4c0f094b363268081d73d193b1071c7e55df86a6b2889fa6a472c7a10c\n", + "\n", + "📊 Summary:\n", + " Network: eip155:8453 (Base Mainnet)\n", + " EURC: 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\n", + " Buyer → Seller: 0.02 EURC\n", + " Seller → Facilitator: 0.01 EURC (fee)\n", + " Net to Seller: 0.01 EURC\n" + ] + } + ], + "source": [ + "// Block Explorer URLs (Optimism + Base)\n", + "const networkToExplorer: Record = {\n", + " \"eip155:10\": \"https://optimistic.etherscan.io/tx/\",\n", + " \"eip155:11155420\": \"https://sepolia-optimism.etherscan.io/tx/\",\n", + " \"eip155:8453\": \"https://basescan.org/tx/\",\n", + " \"eip155:84532\": \"https://sepolia.basescan.org/tx/\",\n", + "};\n", + "\n", + "const network = paymentRequirements.network;\n", + "const explorerBase = networkToExplorer[network] || \"\";\n", + "\n", + "if (settleResult.success && settleResult.transaction) {\n", + " const settleTxUrl = explorerBase ? `${explorerBase}${settleResult.transaction}` : settleResult.transaction;\n", + " console.log(`🔍 Settlement Transaction:`);\n", + " console.log(` ${settleTxUrl}`);\n", + "\n", + " if (settleResult.fee?.txHash) {\n", + " const feeTxUrl = explorerBase ? `${explorerBase}${settleResult.fee.txHash}` : settleResult.fee.txHash;\n", + " console.log(`\\n💰 Fee Collection Transaction:`);\n", + " console.log(` ${feeTxUrl}`);\n", + " }\n", + " \n", + " console.log(`\\n📊 Summary:`);\n", + " console.log(` Network: ${network} (${config.networkName})`);\n", + " console.log(` ${TOKEN}: ${tokenAddress}`);\n", + " console.log(` Buyer → Seller: ${Number(PAYMENT_AMOUNT) / 1e6} ${TOKEN}`);\n", + " console.log(` Seller → Facilitator: ${Number(feeAmountBigInt) / 1e6} ${TOKEN} (fee)`);\n", + " console.log(` Net to Seller: ${(Number(PAYMENT_AMOUNT) - Number(feeAmountBigInt)) / 1e6} ${TOKEN}`);\n", + "} else {\n", + " console.log(`⚠️ No transaction available — settlement may have failed`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "9849e93b", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This notebook tests the `exact` scheme's fee flow end-to-end:\n", + "\n", + "| Step | What happens |\n", + "|------|-------------|\n", + "| 1. `/supported` | Returns the `facilitatorFees` disclosure — fee amount & facilitator address |\n", + "| 2. Balance check | USDC balances of buyer, seller, facilitator |\n", + "| 3. Seller approval | Recurring `USDC.approve(facilitator, amount)` — small amount, enables fee collection |\n", + "| 4. Payment creation | `exact` scheme — buyer signs EIP-3009 to seller |\n", + "| 5. `/verify` | Validates signature + checks seller's allowance for the fee |\n", + "| 6. `/settle` | Executes payment on-chain, then collects the fee via `transferFrom` |\n", + "| 7. Verification | Checks all balances and the allowance changed correctly |\n", + "\n", + "See `x402_facilitator/FEE_MODEL_PLAN.md` for why the fee model looks like this — in\n", + "particular Phase 5 for why the recommended approval is small and recurring rather than a\n", + "large one-time grant.\n", + "\n", + "**Next:** [`x402_batch_settlement.ipynb`](./x402_batch_settlement.ipynb) walks the same\n", + "kind of test for the `batch-settlement` scheme, whose `claim`/`settle` steps charge this\n", + "same flat fee (Phase 3) but reach it through a different flow (an escrow channel instead\n", + "of a single transferWithAuthorization)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/notebooks/facilitator/x402_exact_permit2.ipynb b/bunny_js/notebooks/facilitator/x402_exact_permit2.ipynb new file mode 100644 index 000000000..525b467ca --- /dev/null +++ b/bunny_js/notebooks/facilitator/x402_exact_permit2.ipynb @@ -0,0 +1,626 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# x402 `exact` scheme with Permit2 — EURe facilitator test\n", + "\n", + "Tests the **Permit2** variant of the `exact` scheme end to end against a running facilitator,\n", + "paying in **EURe** (Monerium) on Base. It is the EURe counterpart of `x402_exact.ipynb`, which\n", + "covers USDC and EURC.\n", + "\n", + "**Why Permit2.** USDC and EURC implement EIP-3009 (`transferWithAuthorization`), so the buyer\n", + "signs one authorization and the facilitator moves the tokens. EURe has no EIP-3009. Instead, the\n", + "buyer signs two things, both off-chain:\n", + "\n", + "1. a **Permit2 witness transfer** (`permit2Authorization`): spender is the x402 exact Permit2\n", + " proxy (`0x4020…0001`), and the witness pins the recipient (`witness.to` = the seller);\n", + "2. an **EIP-2612 permit** letting Permit2 spend the buyer's EURe (the `eip2612GasSponsoring`\n", + " extension). The client adds it only if the buyer's Permit2 allowance is short.\n", + "\n", + "The facilitator submits both in one transaction (`settleWithPermit`), so the **buyer needs no\n", + "ETH**. The seller needs a little ETH for one EURe `approve()`, and the facilitator wallet needs ETH\n", + "for settlement and the fee pull.\n", + "\n", + "**18 decimals.** EURe has 18 decimals, not 6. The 0.01 fee is `10000000000000000` atomic units,\n", + "and the seller's recommended fee approval is `10¹⁸` (1 EURe = 100 fees). The notebook reads both\n", + "from `/supported` → `facilitatorFees.assets`.\n", + "\n", + "**Test EURe on Base Sepolia** comes from the Monerium sandbox\n", + "(token `0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85`):\n", + "1. Sign up at (sandbox KYC takes test data).\n", + "2. Link the **buyer** address on **Base Sepolia**. Linking signs a message, so import that key\n", + " into a wallet such as MetaMask first. The buyer key is `EURE_BUYER_PRIVATE_KEY` if set in\n", + " `bunny_js/.env`, else `TEST_WALLET_PRIVATE_KEY`.\n", + "3. Receive → *Simulate bank transfer* → amount → *Add money*. EURe is minted to the linked address.\n", + "\n", + "Without EURe the notebook still runs to `/verify`, which then reports\n", + "`permit2_insufficient_balance`. That outcome proves the payload, signatures and contracts are all\n", + "accepted, and only the balance is missing.\n", + "\n", + "**Facilitator.** Point the notebook's `FACILITATOR_URL` at the deployed facilitator\n", + "(`https://facilitator.fretchen.eu`), or run a local one from `x402_facilitator/`:\n", + "`npm run build && npm run dev` (port 8080) — the default — when testing facilitator changes.\n", + "\n", + "**Dry run.** Set `DRY_RUN=1` in the environment to skip the two on-chain writes (the seller's\n", + "`approve()` and `/settle`). Everything else, including `/verify`, still runs." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚀 x402 exact (Permit2) — EURe facilitator test\n", + " Buyer: 0x553179556FC2A39e535D65b921e01fA995E79101 (from TEST_WALLET_PRIVATE_KEY)\n", + " Seller: 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: imports and accounts\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", + "import { createPublicClient, createWalletClient, http, formatUnits, formatEther, parseUnits } from \"npm:viem@2\";\n", + "import { base, baseSepolia } from \"npm:viem@2/chains\";\n", + "\n", + "// The package's single .env, two levels up (bunny_js/.env).\n", + "const env = await load({ envPath: \"../../.env\", examplePath: null, export: true });\n", + "\n", + "const toKey = (k: string) => `0x${k.replace(/^0x/, \"\")}` as `0x${string}`;\n", + "// The buyer is the address you linked in the Monerium sandbox. EURE_BUYER_PRIVATE_KEY selects\n", + "// it when that is not the usual test wallet; otherwise TEST_WALLET_PRIVATE_KEY pays.\n", + "const BUYER_KEY_VAR = env.EURE_BUYER_PRIVATE_KEY ? \"EURE_BUYER_PRIVATE_KEY\" : \"TEST_WALLET_PRIVATE_KEY\";\n", + "const buyerAccount = privateKeyToAccount(toKey(env[BUYER_KEY_VAR]));\n", + "const sellerAccount = privateKeyToAccount(toKey(env.NFT_WALLET_PRIVATE_KEY));\n", + "const SELLER_ADDRESS = sellerAccount.address;\n", + "\n", + "// DRY_RUN=1 skips the on-chain writes (seller approve, /settle).\n", + "const DRY_RUN = Deno.env.get(\"DRY_RUN\") === \"1\";\n", + "\n", + "console.log(\"🚀 x402 exact (Permit2) — EURe facilitator test\");\n", + "console.log(` Buyer: ${buyerAccount.address} (from ${BUYER_KEY_VAR})`);\n", + "console.log(` Seller: ${SELLER_ADDRESS}`);\n", + "if (DRY_RUN) console.log(\" 🧪 DRY_RUN: no on-chain writes\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network & payment configuration\n", + "\n", + "EURe exists on Base and Base Sepolia only, so there is no Optimism option. Mainnet moves real\n", + "money and needs a second, deliberate opt-in for the cells that write on-chain." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚨 REAL MONEY on Base (eip155:8453)\n", + " EURe: 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\n", + " Payment: 0.02 EURe\n" + ] + } + ], + "source": [ + "const USE_MAINNET = false; // ⚠️ true = REAL MONEY\n", + "const CONFIRM_MAINNET_WRITES = false; // second opt-in for approve + settle on mainnet\n", + "\n", + "const NETWORKS = {\n", + " testnet: { chain: baseSepolia, caip2: \"eip155:84532\", eure: \"0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\", explorer: \"https://sepolia.basescan.org/tx/\" },\n", + " mainnet: { chain: base, caip2: \"eip155:8453\", eure: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\", explorer: \"https://basescan.org/tx/\" },\n", + "} as const;\n", + "const net = NETWORKS[USE_MAINNET ? \"mainnet\" : \"testnet\"];\n", + "const EURE = net.eure as `0x${string}`;\n", + "\n", + "// EURe's EIP-712 domain and decimals, read on-chain on both networks (2026-10-04).\n", + "const EURE_DOMAIN = { name: \"Monerium EURe\", version: \"1\" };\n", + "const DECIMALS = 18;\n", + "const fmt = (x: bigint) => `${formatUnits(x, DECIMALS)} EURe`;\n", + "\n", + "const PAYMENT_AMOUNT = parseUnits(\"0.02\", DECIMALS);\n", + "const mayWrite = !DRY_RUN && (!USE_MAINNET || CONFIRM_MAINNET_WRITES);\n", + "\n", + "const rpcUrl = env[`RPC_URL_EIP155_${net.caip2.split(\":\")[1]}`] ?? net.chain.rpcUrls.default.http[0];\n", + "const publicClient = createPublicClient({ chain: net.chain, transport: http(rpcUrl) });\n", + "\n", + "console.log(`${USE_MAINNET ? \"🚨 REAL MONEY on\" : \"🧪 Testnet:\"} ${net.chain.name} (${net.caip2})`);\n", + "console.log(` EURe: ${EURE}`);\n", + "console.log(` Payment: ${fmt(PAYMENT_AMOUNT)}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. `/supported` — EURe fee and gas sponsoring\n", + "\n", + "The facilitator must advertise `eip2612GasSponsoring`, and list EURe in `facilitatorFees.assets`\n", + "with its fee in 18 decimals. The top-level `flatFee` is the nominal fee in 6-decimal units and is\n", + "wrong for EURe, so this notebook never uses it." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 extensions: eip2612GasSponsoring, facilitator_fee, facilitatorFees\n", + "✅ eip2612GasSponsoring advertised\n", + "💸 Fee: 0.01 EURe (10000000000000000 atomic)\n", + " Recommended approval: 1 EURe = 100 settlements\n", + " Facilitator: 0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206\n" + ] + } + ], + "source": [ + "const FACILITATOR_URL = Deno.env.get(\"FACILITATOR_URL\") ?? \"http://localhost:8080\";\n", + "const supported = await (await fetch(`${FACILITATOR_URL}/supported`)).json();\n", + "\n", + "const fees = supported.facilitatorFees;\n", + "const eureFee = fees?.assets?.find((a: any) => a.network === net.caip2 && a.symbol === \"EURe\");\n", + "if (!eureFee) {\n", + " throw new Error(\n", + " \"The facilitator lists no EURe fee asset — it is an older build. \" +\n", + " \"Run `npm run build && npm run dev` in x402_facilitator/ on the Permit2 branch.\",\n", + " );\n", + "}\n", + "const facilitatorAddress = fees.recipient as `0x${string}`;\n", + "const FEE = BigInt(eureFee.flatFee);\n", + "const RECOMMENDED_APPROVAL = BigInt(eureFee.recommended_amount);\n", + "\n", + "console.log(`📡 extensions: ${supported.extensions.join(\", \")}`);\n", + "console.log(supported.extensions.includes(\"eip2612GasSponsoring\")\n", + " ? \"✅ eip2612GasSponsoring advertised\"\n", + " : \"⚠️ eip2612GasSponsoring NOT advertised\");\n", + "console.log(`💸 Fee: ${fmt(FEE)} (${FEE} atomic)`);\n", + "console.log(` Recommended approval: ${fmt(RECOMMENDED_APPROVAL)} = ${RECOMMENDED_APPROVAL / FEE} settlements`);\n", + "console.log(` Facilitator: ${facilitatorAddress}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Balances before\n", + "\n", + "Besides the three EURe balances, this reads the buyer's EURe **allowance to Permit2**. For a\n", + "fresh wallet it is 0, which is why the buyer signs an EIP-2612 permit below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💰 EURe before:\n", + " Buyer: 0.5 EURe\n", + " Seller: 0 EURe\n", + " Facilitator: 0 EURe\n", + " Buyer → Permit2 allowance: 0 EURe\n", + "⛽ ETH: seller 0.002698739556519162, facilitator 0.005936660941626891\n" + ] + } + ], + "source": [ + "import { PERMIT2_ADDRESS } from \"npm:@x402/evm@^2.26.0\";\n", + "\n", + "const ERC20_ABI = [\n", + " { name: \"balanceOf\", type: \"function\", stateMutability: \"view\", inputs: [{ name: \"a\", type: \"address\" }], outputs: [{ name: \"\", type: \"uint256\" }] },\n", + " { name: \"allowance\", type: \"function\", stateMutability: \"view\", inputs: [{ name: \"o\", type: \"address\" }, { name: \"s\", type: \"address\" }], outputs: [{ name: \"\", type: \"uint256\" }] },\n", + " { name: \"approve\", type: \"function\", stateMutability: \"nonpayable\", inputs: [{ name: \"s\", type: \"address\" }, { name: \"v\", type: \"uint256\" }], outputs: [{ name: \"\", type: \"bool\" }] },\n", + "] as const;\n", + "\n", + "const eureBalance = (a: `0x${string}`) =>\n", + " publicClient.readContract({ address: EURE, abi: ERC20_ABI, functionName: \"balanceOf\", args: [a] });\n", + "const eureAllowance = (owner: `0x${string}`, spender: `0x${string}`) =>\n", + " publicClient.readContract({ address: EURE, abi: ERC20_ABI, functionName: \"allowance\", args: [owner, spender] });\n", + "\n", + "async function snapshot() {\n", + " return {\n", + " buyer: await eureBalance(buyerAccount.address),\n", + " seller: await eureBalance(SELLER_ADDRESS),\n", + " facilitator: await eureBalance(facilitatorAddress),\n", + " buyerPermit2Allowance: await eureAllowance(buyerAccount.address, PERMIT2_ADDRESS as `0x${string}`),\n", + " };\n", + "}\n", + "\n", + "const before = await snapshot();\n", + "console.log(\"💰 EURe before:\");\n", + "console.log(` Buyer: ${fmt(before.buyer)}`);\n", + "console.log(` Seller: ${fmt(before.seller)}`);\n", + "console.log(` Facilitator: ${fmt(before.facilitator)}`);\n", + "console.log(` Buyer → Permit2 allowance: ${fmt(before.buyerPermit2Allowance)}`);\n", + "console.log(`⛽ ETH: seller ${formatEther(await publicClient.getBalance({ address: SELLER_ADDRESS }))}, ` +\n", + " `facilitator ${formatEther(await publicClient.getBalance({ address: facilitatorAddress }))}`);\n", + "\n", + "if (before.buyer < PAYMENT_AMOUNT) {\n", + " console.log(`\\n⚠️ Buyer has less than ${fmt(PAYMENT_AMOUNT)}. Mint sandbox EURe (see the intro); ` +\n", + " \"until then /verify will report permit2_insufficient_balance.\");\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Seller approves EURe for the fee\n", + "\n", + "The facilitator pulls its fee from the seller after each settlement, in the token that was paid.\n", + "A seller paid in EURe therefore approves **EURe**, and in 18-decimal units: an approval of\n", + "`1000000` (1 USDC) covers no EURe fee at all. This cell approves only if the allowance is short." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔍 Seller → facilitator allowance: 0 EURe = 0 settlements\n", + "📝 approve(0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206, 1 EURe)…\n", + " ✅ success in block 52436470: https://basescan.org/tx/0xb5bc51ee508b4e1ceb8fcea1e606c8ba9838cbf69c64b5edadc660501a38bf55\n", + " Allowance now 0 EURe = 0 settlements\n" + ] + } + ], + "source": [ + "const currentAllowance = await eureAllowance(SELLER_ADDRESS, facilitatorAddress);\n", + "console.log(`🔍 Seller → facilitator allowance: ${fmt(currentAllowance)} = ${currentAllowance / FEE} settlements`);\n", + "\n", + "if (currentAllowance >= FEE) {\n", + " console.log(\"✅ Enough for at least one settlement — no approval needed\");\n", + "} else if (!mayWrite) {\n", + " console.log(\"⏭️ Allowance short, but approve skipped (DRY_RUN or unconfirmed mainnet)\");\n", + "} else {\n", + " const sellerWallet = createWalletClient({ account: sellerAccount, chain: net.chain, transport: http(rpcUrl) });\n", + " console.log(`📝 approve(${facilitatorAddress}, ${fmt(RECOMMENDED_APPROVAL)})…`);\n", + " const hash = await sellerWallet.writeContract({\n", + " address: EURE, abi: ERC20_ABI, functionName: \"approve\", args: [facilitatorAddress, RECOMMENDED_APPROVAL],\n", + " });\n", + " const receipt = await publicClient.waitForTransactionReceipt({ hash });\n", + " console.log(` ✅ ${receipt.status} in block ${receipt.blockNumber}: ${net.explorer}${hash}`);\n", + " const updated = await eureAllowance(SELLER_ADDRESS, facilitatorAddress);\n", + " console.log(` Allowance now ${fmt(updated)} = ${updated / FEE} settlements`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Buyer signs the Permit2 payment\n", + "\n", + "`ExactEvmScheme` (the SDK's buyer-side client) picks Permit2 because the requirements say\n", + "`assetTransferMethod: \"permit2\"`. The mock 402 declares `eip2612GasSponsoring`, and `x402Client`\n", + "hands that to the scheme. The scheme then reads the buyer's Permit2 allowance and EURe `nonces`\n", + "on-chain, and signs an EIP-2612 permit if the allowance is short." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✍️ permit2Authorization:\n", + " from: 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " spender: 0x402085c248EeA27D92E8b30b2C58ed07f9E20001 (x402 exact Permit2 proxy)\n", + " witness.to: 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C (= seller)\n", + " amount: 0.02 EURe\n", + "🔏 EIP-2612 permit for Permit2: 0.02 EURe, nonce 0\n" + ] + } + ], + "source": [ + "import { x402Client } from \"npm:@x402/fetch@^2.26.0\";\n", + "import type { Network } from \"npm:@x402/core@^2.26.0\";\n", + "import { ExactEvmScheme } from \"npm:@x402/evm@^2.26.0/exact/client\";\n", + "\n", + "const buyerSigner = {\n", + " address: buyerAccount.address,\n", + " signTypedData: (msg: any) => buyerAccount.signTypedData(msg),\n", + " readContract: (args: any) => publicClient.readContract(args),\n", + "};\n", + "\n", + "function buyerClient() {\n", + " const client = new x402Client();\n", + " client.register(\"eip155:*\" as Network, new ExactEvmScheme(buyerSigner as any));\n", + " // The SDK's default spend controls only know its own registry's tokens.\n", + " client.setSpendControls({ allowedAssets: [{ network: net.caip2 as `${string}:${string}`, asset: EURE }] });\n", + " return client;\n", + "}\n", + "\n", + "function requirementsFor(payTo: `0x${string}`) {\n", + " return {\n", + " scheme: \"exact\",\n", + " network: net.caip2 as Network,\n", + " amount: PAYMENT_AMOUNT.toString(),\n", + " asset: EURE,\n", + " payTo,\n", + " maxTimeoutSeconds: 300,\n", + " extra: { ...EURE_DOMAIN, assetTransferMethod: \"permit2\" },\n", + " };\n", + "}\n", + "\n", + "function paymentRequiredFor(payTo: `0x${string}`) {\n", + " return {\n", + " x402Version: 2,\n", + " resource: { url: \"https://example.com/eure-test\", description: \"EURe Permit2 test\", mimeType: \"text/plain\" },\n", + " accepts: [requirementsFor(payTo)],\n", + " extensions: { eip2612GasSponsoring: {} },\n", + " };\n", + "}\n", + "\n", + "const paymentRequirements = requirementsFor(SELLER_ADDRESS);\n", + "const paymentPayload: any = await buyerClient().createPaymentPayload(paymentRequiredFor(SELLER_ADDRESS) as any);\n", + "\n", + "const auth = paymentPayload.payload.permit2Authorization;\n", + "console.log(\"✍️ permit2Authorization:\");\n", + "console.log(` from: ${auth.from}`);\n", + "console.log(` spender: ${auth.spender} (x402 exact Permit2 proxy)`);\n", + "console.log(` witness.to: ${auth.witness.to} (= seller)`);\n", + "console.log(` amount: ${fmt(BigInt(auth.permitted.amount))}`);\n", + "const permit = paymentPayload.extensions?.eip2612GasSponsoring?.info;\n", + "console.log(permit\n", + " ? `🔏 EIP-2612 permit for Permit2: ${fmt(BigInt(permit.amount))}, nonce ${permit.nonce}`\n", + " : \"ℹ️ No EIP-2612 permit — the buyer already approved Permit2 enough\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Safety check: a payment to someone else is refused\n", + "\n", + "The buyer signs the same payment but to a different address. `/verify` checks it against the\n", + "**real** requirements (`payTo` = the seller) and must refuse it because the Permit2 witness pins the\n", + "recipient. Nothing goes on-chain." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ refused: invalid_permit2_recipient_mismatch\n" + ] + } + ], + "source": [ + "const OTHER = \"0x000000000000000000000000000000000000dEaD\" as `0x${string}`;\n", + "const misdirected: any = await buyerClient().createPaymentPayload(paymentRequiredFor(OTHER) as any);\n", + "misdirected.accepted = paymentRequirements; // claim the seller's terms, keep the signature to OTHER\n", + "\n", + "const mismatch = await (await fetch(`${FACILITATOR_URL}/verify`, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ paymentPayload: misdirected, paymentRequirements }),\n", + "})).json();\n", + "console.log(mismatch.invalidReason === \"invalid_permit2_recipient_mismatch\"\n", + " ? \"✅ refused: invalid_permit2_recipient_mismatch\"\n", + " : `❌ unexpected: ${JSON.stringify(mismatch)}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. `/verify`\n", + "\n", + "Expected: `isValid: true` and `remainingSettlements` counted in EURe (100 for a 1-EURe approval).\n", + "With an unfunded buyer, expect `permit2_insufficient_balance`: the facilitator simulated\n", + "`settleWithPermit` against the live proxy and only the balance failed." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"isValid\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"remainingSettlements\": 100\n", + "}\n", + "✅ valid — 100 settlements left on the seller's EURe allowance\n" + ] + } + ], + "source": [ + "const verifyResult = await (await fetch(`${FACILITATOR_URL}/verify`, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ paymentPayload, paymentRequirements }),\n", + "})).json();\n", + "console.log(JSON.stringify(verifyResult, null, 2));\n", + "if (verifyResult.isValid) {\n", + " console.log(`✅ valid — ${verifyResult.remainingSettlements} settlements left on the seller's EURe allowance`);\n", + "} else if (verifyResult.invalidReason === \"permit2_insufficient_balance\") {\n", + " console.log(\"ℹ️ Wiring accepted; the buyer just has no EURe yet (mint it in the Monerium sandbox).\");\n", + "} else {\n", + " console.log(`❌ ${verifyResult.invalidReason}`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. `/settle`\n", + "\n", + "> ⚠️ Real on-chain transaction. The facilitator calls `settleWithPermit` on the proxy (permit and\n", + "> transfer in one transaction), then pulls the 0.01 EURe fee from the seller." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0x325ee28be01911b4e89eab640e941714917cae1cc29b30bc00d1a99e22d5def6\",\n", + " \"network\": \"eip155:8453\",\n", + " \"fee\": {\n", + " \"collected\": true,\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0xe5b3b873b0d3ddde5ad54a47ad0f0ee5ce897cf5ee3dd8d73aa68615da9c433a\"\n", + " },\n", + " \"extensions\": {\n", + " \"facilitatorFees\": {\n", + " \"info\": {\n", + " \"version\": \"1\",\n", + " \"facilitatorFeePaid\": \"10000000000000000\",\n", + " \"asset\": \"eip155:8453/erc20:0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\",\n", + " \"model\": \"flat\",\n", + " \"collection\": {\n", + " \"status\": \"collected\",\n", + " \"txHash\": \"0xe5b3b873b0d3ddde5ad54a47ad0f0ee5ce897cf5ee3dd8d73aa68615da9c433a\"\n", + " }\n", + " }\n", + " }\n", + " }\n", + "}\n", + "✅ settled: https://basescan.org/tx/0x325ee28be01911b4e89eab640e941714917cae1cc29b30bc00d1a99e22d5def6\n", + "💸 fee assessed: 0.01 EURe, collection collected\n", + " fee tx: https://basescan.org/tx/0xe5b3b873b0d3ddde5ad54a47ad0f0ee5ce897cf5ee3dd8d73aa68615da9c433a\n" + ] + } + ], + "source": [ + "let settleResult: any = null;\n", + "if (!verifyResult.isValid) {\n", + " console.log(\"⏭️ Skipped — /verify did not pass\");\n", + "} else if (!mayWrite) {\n", + " console.log(\"⏭️ Skipped (DRY_RUN or unconfirmed mainnet)\");\n", + "} else {\n", + " settleResult = await (await fetch(`${FACILITATOR_URL}/settle`, {\n", + " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ paymentPayload, paymentRequirements }),\n", + " })).json();\n", + " console.log(JSON.stringify(settleResult, null, 2));\n", + " const info = settleResult.extensions?.facilitatorFees?.info;\n", + " if (settleResult.success) {\n", + " console.log(`✅ settled: ${net.explorer}${settleResult.transaction}`);\n", + " console.log(`💸 fee assessed: ${fmt(BigInt(info?.facilitatorFeePaid ?? \"0\"))}, collection ${info?.collection?.status}`);\n", + " if (settleResult.fee?.txHash) console.log(` fee tx: ${net.explorer}${settleResult.fee.txHash}`);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Balances after\n", + "\n", + "Expected deltas: buyer −0.02, seller +0.02 − 0.01 fee, facilitator +0.01. The buyer's Permit2\n", + "allowance is spent by the transfer, so it returns to where it started." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📊 EURe deltas:\n", + " Buyer -0.02 EURe expected -0.02 EURe ✅\n", + " Seller 0.01 EURe expected 0.01 EURe ✅\n", + " Facilitator 0.01 EURe expected 0.01 EURe ✅\n", + " Buyer → Permit2 allowance: 0 EURe → 0 EURe\n" + ] + } + ], + "source": [ + "if (settleResult?.success) {\n", + " const after = await snapshot();\n", + " const row = (label: string, delta: bigint, expected: bigint) =>\n", + " console.log(` ${label.padEnd(12)} ${fmt(delta).padStart(16)} expected ${fmt(expected).padStart(16)} ${delta === expected ? \"✅\" : \"❌\"}`);\n", + " console.log(\"📊 EURe deltas:\");\n", + " row(\"Buyer\", after.buyer - before.buyer, -PAYMENT_AMOUNT);\n", + " row(\"Seller\", after.seller - before.seller, PAYMENT_AMOUNT - FEE);\n", + " row(\"Facilitator\", after.facilitator - before.facilitator, FEE);\n", + " console.log(` Buyer → Permit2 allowance: ${fmt(before.buyerPermit2Allowance)} → ${fmt(after.buyerPermit2Allowance)}`);\n", + "} else {\n", + " console.log(\"⏭️ No settlement in this run\");\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Section | What it proves |\n", + "|---|---|\n", + "| 1 | The facilitator advertises `eip2612GasSponsoring` and an 18-decimal EURe fee |\n", + "| 3 | The seller's EURe fee allowance, in EURe units |\n", + "| 4 | The SDK client builds a Permit2 payload with an EIP-2612 permit for a fresh buyer |\n", + "| 5 | A payment signed to another recipient is refused (`invalid_permit2_recipient_mismatch`) |\n", + "| 6 | The live proxy and token accept the payment (or only the balance is missing) |\n", + "| 7–8 | One `settleWithPermit` moves the EURe, and the fee pull takes 0.01 EURe from the seller |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/package-lock.json b/bunny_js/package-lock.json index cdcf40c1c..3734a162b 100644 --- a/bunny_js/package-lock.json +++ b/bunny_js/package-lock.json @@ -12,8 +12,8 @@ "@bunny.net/edgescript-sdk": "^0.11.2", "@fretchen/chain-utils": "file:../shared/chain-utils", "@fretchen/s3-utils": "file:../shared/s3-utils", - "@x402/core": "^2.23.0", - "@x402/evm": "^2.23.0", + "@x402/core": "^2.29.0", + "@x402/evm": "^2.29.0", "@x402/extensions": "~2.28.0", "viem": "2.57.4", "zod": "^4.5.4" @@ -26,6 +26,7 @@ "eslint": "^10.0.0", "prettier": "^3.6.2", "tsdown": "^0.23.1", + "tsx": "^4.23.15", "typescript": "^5.9.3", "typescript-eslint": "^8.58.2", "vitest": "^4.1.11" @@ -166,6 +167,448 @@ "keyv": "^5.6.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -1303,41 +1746,23 @@ } }, "node_modules/@x402/core": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", - "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.29.0.tgz", + "integrity": "sha512-fkS0JqVwHgczteDlwItLRipOlIzm28U9HWLvYnYpnNbfmepoBT/4ux7LjrR0xGD51JHoC8/r+Qo7uCfoAQYeuw==", "license": "Apache-2.0", "dependencies": { - "zod": "^3.24.2" - } - }, - "node_modules/@x402/core/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "zod": "^4.6.6" } }, "node_modules/@x402/evm": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.28.0.tgz", - "integrity": "sha512-BZuoaCKMoPQQuMTa4oibvoyI06WdCpgrzgdicUu8d+5NBMAfWb6LAmSQf1KkeiS8C+25/+2JGB4cHSL0VLGnSA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.29.0.tgz", + "integrity": "sha512-Gk4bJ6WoRR3jeT2+vERSPMSkSr1nRL5kjtVh14uoynRm8y+fGFH8xmvlGzuwKT0liA6OLjdr9xjZtN8i0WYk9g==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.28.0", + "@x402/core": "~2.29.0", "viem": "^2.48.11", - "zod": "^3.24.2" - } - }, - "node_modules/@x402/evm/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "zod": "^4.6.6" } }, "node_modules/@x402/extensions": { @@ -1357,6 +1782,15 @@ "zod": "^3.24.2" } }, + "node_modules/@x402/extensions/node_modules/@x402/core": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, "node_modules/@x402/extensions/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -1806,6 +2240,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3397,6 +3873,25 @@ "node": ">=12.20.0" } }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/bunny_js/package.json b/bunny_js/package.json index 42bcabae0..e482942ab 100644 --- a/bunny_js/package.json +++ b/bunny_js/package.json @@ -4,9 +4,11 @@ "description": "Bunny Edge Script twins of the scw_js x402 sellers. First port: searchapi (web-agent).", "type": "module", "scripts": { - "build": "npm run build:searchapi && npm run build:llm && npm run build:mcp", + "build": "npm run build:searchapi && npm run build:llm && npm run build:mcp && npm run build:facilitator", "test": "NODE_ENV=test vitest run", "test:watch": "NODE_ENV=test vitest", + "test:integration": "NODE_ENV=test vitest run --config vitest.integration.config.js", + "generate:openapi": "tsx scripts/generate-openapi.ts && prettier --write src/openapi.facilitator.json", "test:coverage": "NODE_ENV=test vitest run --coverage", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -18,9 +20,11 @@ "build:searchapi": "tsdown src/searchapi.ts", "build:llm": "tsdown src/llm.ts", "build:mcp": "tsdown src/mcp.ts", + "build:facilitator": "tsdown src/facilitator.ts", "dev:searchapi": "deno run -A --sloppy-imports --env-file=.env src/searchapi.ts", "dev:llm": "deno run -A --sloppy-imports --env-file=.env src/llm.ts", - "dev:mcp": "deno run -A --sloppy-imports --env-file=.env src/mcp.ts" + "dev:mcp": "deno run -A --sloppy-imports --env-file=.env src/mcp.ts", + "dev:facilitator": "deno run -A --sloppy-imports --env-file=.env src/facilitator.ts" }, "keywords": [ "bunny.net", @@ -37,6 +41,7 @@ "eslint": "^10.0.0", "prettier": "^3.6.2", "tsdown": "^0.23.1", + "tsx": "^4.23.15", "typescript": "^5.9.3", "typescript-eslint": "^8.58.2", "vitest": "^4.1.11" @@ -45,8 +50,8 @@ "@bunny.net/edgescript-sdk": "^0.11.2", "@fretchen/chain-utils": "file:../shared/chain-utils", "@fretchen/s3-utils": "file:../shared/s3-utils", - "@x402/core": "^2.23.0", - "@x402/evm": "^2.23.0", + "@x402/core": "^2.29.0", + "@x402/evm": "^2.29.0", "@x402/extensions": "~2.28.0", "viem": "2.57.4", "zod": "^4.5.4" diff --git a/bunny_js/scripts/generate-openapi.ts b/bunny_js/scripts/generate-openapi.ts new file mode 100644 index 000000000..9726167ee --- /dev/null +++ b/bunny_js/scripts/generate-openapi.ts @@ -0,0 +1,182 @@ +/** + * Generates `src/facilitator/openapi.facilitator.json` from the Zod schemas in `src/facilitator/x402_schemas.ts`. + * + * Run via `npm run generate:openapi` (also runs automatically as part of `npm run + * build`/`predeploy`). `test/openapi_generation.test.ts` re-runs this same logic and + * deep-equals it against the committed file — that test is what actually enforces + * "run this after changing a schema," not this script alone. + * + * Only `components.schemas` is generated. `info`, `servers`, `tags`, and `paths` are + * prose — summaries, descriptions, guidance for callers — not data shapes, so they + * gain nothing from codegen and stay hand-written below, verbatim from the previous + * hand-maintained `openapi.json`. + */ + +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { z } from "zod"; +import { + PaymentRequestSchema, + VerifyResponseSchema, + SettleResponseSchema, + SupportedResponseSchema, +} from "../src/facilitator/x402_schemas"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUTPUT_PATH = join(__dirname, "..", "src", "facilitator", "openapi.facilitator.json"); + +/** + * zod's `toJSONSchema` stamps a top-level `$schema` key (correct for a standalone JSON + * Schema document) — meaningless nested under `components.schemas.` in an + * OpenAPI document, which declares its schema dialect once, globally, via + * `"openapi": "3.1.0"`. Strip it. + */ +function toComponentSchema(schema: z.ZodType): Record { + const { $schema: _$schema, ...rest } = z.toJSONSchema(schema) as Record; + return rest; +} + +export function generateOpenApiSpec(): object { + return { + openapi: "3.1.0", + info: { + title: "fretchen x402 Facilitator", + description: + "x402 v2 facilitator: verifies and settles USDC payments on Optimism and Base. This is protocol infrastructure a seller (resource server) integrates against, not a paid resource — a buyer never calls these endpoints directly; the seller relays a buyer's signed payment payload here to verify and settle it. The facilitator never holds funds and is not a custodian.", + version: "1.0.0", + "x-guidance": + "Sellers: on receiving a request with a payment header, POST { paymentPayload, paymentRequirements } to /verify before delivering the resource, then POST the same body to /settle after delivery. GET /supported once to discover which networks, schemes, and fee model this facilitator advertises. There is no request body for /supported.", + contact: { + name: "fretchen", + url: "https://www.fretchen.eu", + email: "fretchen.dev@proton.me", + }, + }, + servers: [{ url: "https://facilitator.fretchen.eu" }], + tags: [{ name: "x402", description: "x402 v2 payment verification and settlement" }], + paths: { + "/verify": { + post: { + operationId: "verify", + summary: "Verify a signed payment off-chain, before delivering the resource", + description: + "Called by a seller after receiving a payment header from a buyer. Checks signature validity, sufficient balance, correct recipient, and expiration. Does not move funds. Call this before delivering the resource; call /settle after.", + tags: ["x402"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/PaymentRequest" }, + }, + }, + }, + responses: { + "200": { + description: + "Verification result. A 200 status does not imply the payment was valid — check isValid.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/VerifyResponse" }, + }, + }, + }, + "400": { + description: + "Request body was not valid JSON, or was missing paymentPayload/paymentRequirements.", + }, + "405": { description: "Method not allowed — use POST." }, + }, + }, + }, + "/settle": { + post: { + operationId: "settle", + summary: "Settle a verified payment on-chain", + description: + "Called by a seller after delivering the resource. Executes the payment on-chain (EIP-3009 transferWithAuthorization for the exact scheme). Same request shape as /verify.", + tags: ["x402"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/PaymentRequest" }, + }, + }, + }, + responses: { + "200": { + description: + "Settlement result. A 200 status does not imply the settlement succeeded — check success.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SettleResponse" }, + }, + }, + }, + "400": { + description: + "Request body was not valid JSON, or was missing paymentPayload/paymentRequirements.", + }, + "405": { description: "Method not allowed — use POST." }, + }, + }, + }, + "/supported": { + get: { + operationId: "supported", + summary: "Discover the networks, schemes, and fee model this facilitator advertises", + description: + "Called once by a seller (or a buyer's client) to learn what this facilitator supports, before ever calling /verify or /settle. No request body.", + tags: ["x402"], + responses: { + "200": { + description: "Supported capabilities.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SupportedResponse" }, + }, + }, + }, + "405": { description: "Method not allowed — use GET." }, + }, + }, + }, + "/openapi.json": { + get: { + operationId: "openapiSpec", + summary: "This document", + tags: ["x402"], + responses: { + "200": { + description: "This OpenAPI document.", + content: { "application/json": { schema: { type: "object" } } }, + }, + }, + }, + }, + }, + components: { + schemas: { + PaymentRequest: toComponentSchema(PaymentRequestSchema), + VerifyResponse: toComponentSchema(VerifyResponseSchema), + SettleResponse: toComponentSchema(SettleResponseSchema), + SupportedResponse: toComponentSchema(SupportedResponseSchema), + }, + }, + }; +} + +function main() { + const spec = generateOpenApiSpec(); + // Written unformatted, then Prettier'd by the npm script — the repo's `format:check` + // covers openapi.json, so a raw JSON.stringify here would make every regeneration + // fail `npm run check`. The golden-file test compares parsed JSON, so formatting + // never affects it either way. + writeFileSync(OUTPUT_PATH, JSON.stringify(spec, null, 2) + "\n"); + console.log(`Generated ${OUTPUT_PATH}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/bunny_js/src/facilitator.ts b/bunny_js/src/facilitator.ts new file mode 100644 index 000000000..12b91c0ab --- /dev/null +++ b/bunny_js/src/facilitator.ts @@ -0,0 +1,16 @@ +/** + * The facilitator's entry — the x402 payment facilitator (/verify, /settle, /supported, + * /openapi.json) at facilitator.fretchen.eu, ported from the Scaleway Function to a Bunny + * Edge Script (x402_facilitator/bunny-port-implementation-plan.md). The routing lives in + * facilitator_routes.ts; the payment core in the ported x402_*.ts files — one twin, one + * bundle, no Scaleway concept anywhere. + * + * The runtime shims must import first: the ported payment core assumes the Node `Buffer` + * and `process` globals, which bunny's Deno isolate does not guarantee. + */ +import "./runtime-shims.js"; +import * as BunnySDK from "@bunny.net/edgescript-sdk"; +import { withLogFlush } from "./logger.js"; +import { handleFacilitatorRequest } from "./facilitator/facilitator_routes.js"; + +BunnySDK.net.http.serve(withLogFlush("facilitator", handleFacilitatorRequest)); diff --git a/bunny_js/src/facilitator/chain_utils.ts b/bunny_js/src/facilitator/chain_utils.ts new file mode 100644 index 000000000..8ec7c34ad --- /dev/null +++ b/bunny_js/src/facilitator/chain_utils.ts @@ -0,0 +1,102 @@ +/** + * Chain Utilities for x402 Facilitator + * Shared functions for network/chain handling + * + * Uses @fretchen/chain-utils for chain/address data. + */ + +import type { Chain } from "viem"; +import { + getViemChain, + tryGetEIP3009SplitterAddress, + getUSDCAddress, + getUSDCName, + getSettlementTokens, + type Network, + type SettlementTokenInfo, +} from "@fretchen/chain-utils"; + +export interface ChainConfig { + chain: Chain; + SPLITTER_ADDRESS: string | null; + USDC_ADDRESS: string; + USDC_NAME: string; +} + +// Per-network RPC endpoint resolution lives in the shared package so `scw_js` (which +// has the same bare-`http()` exposure) uses one implementation and one env-var +// convention. Re-exported here so existing local imports keep working. +export { getRpcUrl } from "@fretchen/chain-utils"; + +/** + * Get chain configuration including contract addresses + * @param network - Network ID (e.g. "eip155:10" or "eip155:11155420") + * @returns Chain config with contract addresses + * @throws If network is not supported + */ +export function getChainConfig(network: string): ChainConfig { + return { + chain: getViemChain(network), + SPLITTER_ADDRESS: tryGetEIP3009SplitterAddress(network), + USDC_ADDRESS: getUSDCAddress(network), + USDC_NAME: getUSDCName(network), + }; +} + +/** + * Get all supported networks + * @returns Array of supported CAIP-2 network identifiers + */ +export function getSupportedNetworks(): Network[] { + return ["eip155:10", "eip155:11155420", "eip155:8453", "eip155:84532"]; +} + +/** + * Get networks where the canonical x402 batch-settlement contract + * (`BATCH_SETTLEMENT_ADDRESS`) is actually deployed. + * + * This is a STRICT SUBSET of `getSupportedNetworks()`. The exact scheme works on + * every supported network (USDC exists everywhere), but batch-settlement is a + * single fixed contract address that the @x402 SDK does not track deployment + * status for — that's on us. Verified deployed on Optimism mainnet, Base mainnet, + * and Base Sepolia; NOT on Optimism Sepolia (`eip155:11155420`). + * + * Registering BatchSettlementEvmScheme outside this list would advertise support + * via `/supported` for a network with no contract, and any deposit/claim/settle + * against it would fail on-chain. + * @returns Array of CAIP-2 network identifiers with a deployed batch-settlement contract + */ +export function getBatchSettlementNetworks(): Network[] { + return ["eip155:10", "eip155:8453", "eip155:84532"]; +} + +// ═══════════════════════════════════════════════════════════════ +// Fee tokens — the tokens this facilitator settles and charges its fee in +// ═══════════════════════════════════════════════════════════════ + +/** + * How a buyer authorizes moving a token. USDC and EURC implement EIP-3009 + * (`transferWithAuthorization`); EURe does not, so it is paid through Permit2 — with an EIP-2612 + * permit to Permit2 bundled in, so the buyer needs no separate approval transaction. + * (Mirrored from @fretchen/chain-utils, where the registry lives.) + */ +export type TransferMethod = "eip3009" | "permit2"; + +/** A token this facilitator settles and may charge its fee in (same shape as the shared registry's). */ +export type FeeToken = SettlementTokenInfo; + +/** + * Every token this facilitator settles on `network`, from the shared registry: USDC everywhere, + * EURC and EURe (Monerium, 18 decimals, paid via Permit2) on Base. The sellers (`scw_js`) read + * the same list for what they offer, so the facilitator never charges a fee in a token the + * sellers cannot be paid in. + */ +export function getFeeTokens(network: string): FeeToken[] { + return getSettlementTokens(network); +} + +/** The fee token at `address` on `network`, case-insensitively; null for any other token. */ +export function findFeeToken(network: string, address: string): FeeToken | null { + const wanted = address.toLowerCase(); + return getFeeTokens(network).find((token) => token.address.toLowerCase() === wanted) ?? null; +} diff --git a/bunny_js/src/facilitator/facilitator_instance.ts b/bunny_js/src/facilitator/facilitator_instance.ts new file mode 100644 index 000000000..3470d9224 --- /dev/null +++ b/bunny_js/src/facilitator/facilitator_instance.ts @@ -0,0 +1,273 @@ +/** + * x402 v2 Facilitator Instance + * Centralized facilitator configuration with multi-chain support + * + * Architecture: One ExactEvmScheme per network (following x402 best practices), + * plus one BatchSettlementEvmScheme per network THAT HAS THE CONTRACT DEPLOYED + * (see getBatchSettlementNetworks() in chain_utils.ts — a strict subset of + * getSupportedNetworks()). Each network has its own dedicated viem client, + * eliminating chain selection issues. The facilitator routes by scheme, so both + * schemes coexist on the same /verify and /settle endpoints. + */ + +import { createPublicClient, createWalletClient, http, type Account } from "viem"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; +import { logger } from "../logger"; +import { evaluateFeeGate, getFacilitatorAddress, loadFacilitatorAccount } from "./x402_fee"; +import { + getChainConfig, + getSupportedNetworks, + getBatchSettlementNetworks, + getRpcUrl, +} from "./chain_utils"; + +/** + * Create a FacilitatorEvmSigner for a specific network. + * The signer is bound to a single chain — no dynamic chain selection needed. + */ +function createSignerForNetwork(account: Account, network: string) { + const config = getChainConfig(network); + // Falls back to the chain's public endpoint when unset — fine for testnets, but + // set RPC_URL_ for anything carrying real traffic (see getRpcUrl). + const rpcUrl = getRpcUrl(network); + + const publicClient = createPublicClient({ + chain: config.chain, + transport: http(rpcUrl), + }); + + const walletClient = createWalletClient({ + account, + chain: config.chain, + transport: http(rpcUrl), + }); + + return toFacilitatorEvmSigner({ + address: account.address, + readContract: (args) => + publicClient.readContract({ + ...args, + args: args.args || [], + }), + // The SDK's FacilitatorEvmSigner.verifyTypedData types `types` loosely as + // Record; viem's VerifyTypedDataParameters wants the strict + // TypedDataParameter mapping. Runtime-correct pass-through — cast at the boundary. + verifyTypedData: (args) => + publicClient.verifyTypedData(args as Parameters[0]), + writeContract: (args) => + walletClient.writeContract({ + ...args, + args: args.args || [], + }), + sendTransaction: (args) => walletClient.sendTransaction(args), + waitForTransactionReceipt: (args) => publicClient.waitForTransactionReceipt(args), + getCode: (args) => publicClient.getCode(args), + }); +} + +/** + * Create read-only facilitator (without signer, for getSupported() only) + */ +export function createReadOnlyFacilitator(): InstanceType { + const facilitator = new x402Facilitator(); + + for (const network of getSupportedNetworks()) { + const config = getChainConfig(network); + + const publicClient = createPublicClient({ + chain: config.chain, + transport: http(getRpcUrl(network)), + }); + + const readOnlySigner = toFacilitatorEvmSigner({ + // Report the real facilitator address when a private key is configured — required + // for /supported to pass newer SDK clients' strict validation (e.g. the official + // x402HTTPResourceServer.initialize() path); falls back to the zero address only + // when no valid key exists (this instance never signs anything either way). + address: getFacilitatorAddress() ?? "0x0000000000000000000000000000000000000000", + readContract: (args) => + publicClient.readContract({ + ...args, + args: args.args || [], + }), + // The SDK's FacilitatorEvmSigner.verifyTypedData types `types` loosely as + // Record; viem's VerifyTypedDataParameters wants the strict + // TypedDataParameter mapping. Runtime-correct pass-through — cast at the boundary. + verifyTypedData: (args) => + publicClient.verifyTypedData(args as Parameters[0]), + writeContract: () => { + throw new Error("Read-only facilitator cannot write contracts"); + }, + sendTransaction: () => { + throw new Error("Read-only facilitator cannot send transactions"); + }, + waitForTransactionReceipt: () => { + throw new Error("Read-only facilitator cannot wait for receipts"); + }, + getCode: (args) => publicClient.getCode(args), + }); + + facilitator.register(network, new ExactEvmScheme(readOnlySigner)); + // Only advertise batch-settlement where the contract is actually deployed — + // see getBatchSettlementNetworks(). No authorizerSigner → no receiverAuthorizer + // advertised; servers self-manage it. + if (getBatchSettlementNetworks().includes(network)) { + facilitator.register(network, new BatchSettlementEvmScheme(readOnlySigner)); + } + } + + logger.info({ + networks: getSupportedNetworks(), + msg: "x402 Facilitator initialized (read-only mode)", + }); + + return facilitator; +} + +/** + * Create the facilitator instance with multi-chain support. + * Uses separate ExactEvmScheme per network (x402 best practice). + */ +export function createFacilitator(requirePrivateKey = true): InstanceType { + let account; + try { + // Shares viem's nonceManager with the fee pull that follows every settlement — see + // loadFacilitatorAccount. + account = loadFacilitatorAccount(); + } catch (err) { + if (!requirePrivateKey) { + return createReadOnlyFacilitator(); + } + throw err; + } + + // Create and configure facilitator + const facilitator = new x402Facilitator(); + + // Register a separate ExactEvmScheme + BatchSettlementEvmScheme for each network + const supportedNetworks = getSupportedNetworks(); + for (const network of supportedNetworks) { + const signer = createSignerForNetwork(account, network); + facilitator.register(network, new ExactEvmScheme(signer)); + // Only advertise batch-settlement where the contract is actually deployed — + // see getBatchSettlementNetworks(). No authorizerSigner → no receiverAuthorizer + // advertised; servers self-manage it (self-managed receiver, per the + // batch-settlement migration plan). + // + // Passing no authorizerSigner is also load-bearing for safety: the SDK's refund path + // omits the per-claim `receiverAuthorizer === authorizerSigner.address` check that its + // claim path performs, so it would sign a claim batch for channels naming any + // authorizer. That gap is unreachable here only because claimAuthorizerSignature must + // always come from the client. Adding an authorizerSigner would make it live. + if (getBatchSettlementNetworks().includes(network)) { + facilitator.register(network, new BatchSettlementEvmScheme(signer)); + } + } + + // Add fee allowance check AFTER verification + facilitator.onAfterVerify(async ({ paymentPayload, requirements, result }) => { + if (!result.isValid) { + return; + } + + // Batch-settlement carries NO fee decision in this hook — deliberately. Its + // fee-bearing event is a claimWithSignature, which is a property of the payload's + // SHAPE (a `claim`, a `settle`, or a `refund` enriched with a non-empty `claims[]`), + // not of `payload.type`, and it has to be gated against the receiver the SDK actually + // pays out to. That decision is made once, in x402_settle.ts's + // classifyBatchSettlement(). Forcing feeRequired=false here keeps this hook from + // becoming a second, label-based source of truth for the same question — which is the + // split-brain the enriched-refund bypass came out of. deposit/voucher/claim-less + // refunds are genuinely free: they fund, sign or unwind a channel rather than realize + // a payment (FEE_MODEL_PLAN.md Phase 3). + if (paymentPayload.accepted?.scheme === "batch-settlement") { + (result as Record).feeRequired = false; + return; + } + + // The `exact` scheme accepts two payload shapes (@x402/evm ExactEvmPayloadV2): + // EIP-3009 (`authorization`, USDC/EURC) and Permit2 (`permit2Authorization`, EURe — + // which has no EIP-3009). Both reach the gate below unchanged, because it reads only + // `requirements`. + // + // Gate on `requirements`, NOT on the client's payload envelope — consistent with the + // batch-settlement branch above. The SDK pins the payload to the requirements before + // this hook runs: verifyEIP3009 enforces `authorization.to === payTo` + // (ErrRecipientMismatch), verifyPermit2 enforces `permit2Authorization.witness.to === + // payTo` (ErrPermit2RecipientMismatch) plus the token and amount. So requirements.payTo + // is the recipient actually paid, shape-independent, not client-set. + const network = requirements?.network; + const recipient = requirements?.payTo as string | undefined; + // The token the payment settles in, and so the one the fee is charged in. + // Requirements again, not the payload envelope: verify signed against this asset. + const asset = requirements?.asset as string | undefined; + + if (!network || !recipient || !asset) { + logger.warn("Missing network, recipient or asset after verification"); + result.isValid = false; + result.invalidReason = "invalid_payload"; + return; + } + + // The same gate batch-settlement's claim/settle path runs (x402_fee.ts) — shared so + // the two schemes cannot drift apart on when a fee is owed or when to fail closed. + const gate = await evaluateFeeGate(recipient as `0x${string}`, network, asset as `0x${string}`); + + if (gate.kind === "no_fee") { + // Fees disabled — allow all recipients without fee + (result as Record).feeRequired = false; + return; + } + + if (gate.kind === "reject") { + // Only the reason travels. The allowance detail behind it (how much was approved, + // how much is needed, which address to approve) is already published by + // `/supported` → `facilitatorFees`, and is logged by evaluateFeeGate for operators + // — restating it here would widen the wire contract for no new information. + result.isValid = false; + result.invalidReason = gate.reason; + return; + } + + (result as Record).feeRequired = true; + (result as Record).recipient = recipient; + // Surfaced in the verify response so sellers see their approval running down. + // Left undefined when the allowance was unreadable — never reported as 0. + if (gate.remainingSettlements !== undefined) { + (result as Record).remainingSettlements = gate.remainingSettlements; + } + }); + + logger.info( + { + networks: supportedNetworks, + signerAddress: account.address, + }, + "x402 Facilitator initialized", + ); + + return facilitator; +} + +// Singleton instance +let facilitatorInstance: InstanceType | null = null; + +/** + * Get or create the facilitator instance + */ +export function getFacilitator(requirePrivateKey = true): InstanceType { + if (!facilitatorInstance) { + facilitatorInstance = createFacilitator(requirePrivateKey); + } + return facilitatorInstance; +} + +/** + * Reset the facilitator instance (for testing) + */ +export function resetFacilitator(): void { + facilitatorInstance = null; +} diff --git a/bunny_js/src/facilitator/facilitator_routes.ts b/bunny_js/src/facilitator/facilitator_routes.ts new file mode 100644 index 000000000..99706573f --- /dev/null +++ b/bunny_js/src/facilitator/facilitator_routes.ts @@ -0,0 +1,321 @@ +/** + * The facilitator's routing layer — a line-by-line native port of the Scaleway handler's + * routing, CORS, root redirect, and verify/settle envelope semantics (x402_facilitator/ + * x402_facilitator.ts), against web-standard Request/Response — the `llm_routes.ts` shape. + * Ordering rules and response envelopes are carried over unchanged: the same CORS headers, + * the same 200-with-isValid:false verify semantics, the same 405/400/500 bodies, the same + * Accept-header root redirect. Only the transport is new; the payment behavior is the ported + * core's (../src/x402_verify.ts, ../src/x402_settle.ts), unmodified. + */ +import { verifyPayment } from "./x402_verify"; +import { settlePayment } from "./x402_settle"; +import { getSupportedCapabilities } from "./x402_supported"; +import { + PaymentRequestSchema, + type SettleResponseBody, + type VerifyResponseBody, +} from "./x402_schemas"; +import type { z } from "zod"; +import openapiSpec from "./openapi.facilitator.json" with { type: "json" }; +import { logger } from "../logger"; + +/** Inferred from the published schema rather than hand-written beside it. `looseObject` keeps + * the index signatures, so the payload still satisfies verifyPayment/settlePayment's + * `Record` parameters. */ +type PaymentRequestBody = z.infer; + +/** Common headers for all responses — identical to the Scaleway handler's CORS_HEADERS. + * `Cache-Control: no-store` is the twins' parity addition: the edge serves through a real + * CDN pull zone, and no verification or settlement response may ever come from cache (the + * platform normalizes it to `no-cache`, which with no validators still forces an origin hit + * on every request). */ +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Content-Type": "application/json", + "Cache-Control": "no-store", +}; + +/** Human-facing documentation for the facilitator — the root path redirects browsers here. */ +const DOCUMENTATION_URL = "https://www.fretchen.eu/x402/"; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: CORS_HEADERS, + }); + +/** + * Cap for POST bodies — the same edge deviation as mcp_routes.ts: the Scaleway function + * leaned on the platform's request-size cap, the edge imposes no comparable one, so the + * twin encodes its own. Verify/settle payloads are a few KB even for large batch claims; + * 1 MB is generous headroom while still bounding an attacker's per-request isolate cost. + */ +const MAX_BODY_BYTES = 1_000_000; + +/** + * Reads the POST body up to MAX_BODY_BYTES and refuses beyond it, so an oversized body is + * rejected without ever being fully buffered. The Content-Length check refuses a declared + * oversize up front; the stream read catches a chunked body that lies about or omits it. + */ +export async function readCappedBody(request: Request): Promise { + const declared = Number(request.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { + return "too-large"; + } + if (!request.body) { + return ""; + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return "too-large"; + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +/** + * Unified handler for the /verify and /settle endpoints (the Scaleway handler's + * handlePaymentRequest, verbatim). A caller's fault is a 40x, ours is a 500; a settlement + * that fails for any reason still answers 200 with success:false and the stable errorReason. + */ +async function handlePaymentRequest(req: Request, isSettle: boolean): Promise { + // Handle CORS preflight requests + if (req.method === "OPTIONS") { + return new Response("", { status: 200, headers: CORS_HEADERS }); + } + + // Only accept POST requests + if (req.method !== "POST") { + return jsonResponse(405, { error: "Method not allowed. Use POST." }); + } + + // The body is read under the 1 MB cap BEFORE buffering anything beyond it (the edge has + // no platform request-size cap — see readCappedBody). Oversized is the caller's fault: + // warn, not error — the alert-coverage convention is that logger.error means "ours". + const raw = await readCappedBody(req); + if (raw === "too-large") { + logger.warn({ maxBodyBytes: MAX_BODY_BYTES }, "Refused oversized request body"); + return jsonResponse(413, { error: "Request body too large" }); + } + + // Malformed JSON is the caller's fault, not ours — warn, not error: the alert-coverage + // convention is that logger.error means "ours, and should page". + let rawBody: unknown; + try { + rawBody = JSON.parse(raw); + } catch (error) { + logger.warn({ err: error }, "Failed to parse request body"); + return jsonResponse(400, { error: "Invalid JSON in request body" }); + } + + // Validate against the schema this service publishes, which generated the OpenAPI + // document and nothing else. It requires `paymentPayload.accepted`, and so does @x402/core; + // x402_settle.ts derives isBatchSettlement from accepted.scheme and x402_verify.ts reads + // asset/network/payTo off it, so a payload without it cannot be settled. Such a payload + // must not reach the SDK and come back isValid:false — that is a malformed request. + const validation = PaymentRequestSchema.safeParse(rawBody); + if (!validation.success) { + logger.warn({ issues: validation.error.issues }, "Rejected malformed payment request"); + return jsonResponse(400, { + error: "Request must include both paymentPayload and paymentRequirements", + }); + } + + // GUARD ONLY — validation.data is discarded. safeParse on a looseObject returns a deep clone, + // and verifyPayment/settlePayment read arbitrary keys off the payload; forwarding the clone + // works today but breaks the moment this schema gains a transform, coercion or default. + const { paymentPayload, paymentRequirements } = rawBody as PaymentRequestBody; + + logger.info( + { + endpoint: isSettle ? "settle" : "verify", + network: paymentPayload.accepted?.network, + amount: paymentRequirements.amount, + scheme: paymentPayload.accepted?.scheme, + }, + "Processing request", + ); + + try { + // Handle /settle endpoint + if (isSettle) { + const result = await settlePayment(paymentPayload, paymentRequirements); + + if (result.success) { + logger.info( + { payer: result.payer, transaction: result.transaction }, + "Settlement successful", + ); + const responseBody: SettleResponseBody = { + success: true, + payer: result.payer, + transaction: result.transaction, + network: result.network, + }; + if (result.fee) { + responseBody.fee = result.fee; + } + if (result.extensions) { + responseBody.extensions = result.extensions; + } + if (result.extra) { + responseBody.extra = result.extra; + } + return jsonResponse(200, responseBody); + } + + // Underlying SDK detail (e.g. the decoded revert reason) is logged only — deliberately + // omitted from the response body below, which returns the stable `errorReason` code. + logger.warn( + { + errorReason: result.errorReason, + errorMessage: result.errorMessage, + payer: result.payer, + }, + "Settlement failed", + ); + // transaction is forwarded, not blanked: settlePayment already returns "" for every + // terminal failure and the broadcast hash for settlement_pending — the one failure a + // caller can reconcile on chain instead of retrying a transaction that may have confirmed. + const failureBody: SettleResponseBody = { + success: false, + errorReason: result.errorReason, + payer: result.payer, + transaction: result.transaction ?? "", + network: result.network, + }; + return jsonResponse(200, failureBody); + } + + // Handle /verify endpoint + const result = await verifyPayment(paymentPayload, paymentRequirements); + + if (result.isValid) { + logger.info({ payer: result.payer }, "Payment verification successful"); + const responseBody: VerifyResponseBody = { + isValid: true, + payer: result.payer, + // Early warning for the seller: how many more settlements their current USDC + // approval covers. Omitted when there is no fee or it could not be read. + ...(result.remainingSettlements !== undefined && { + remainingSettlements: result.remainingSettlements, + }), + // Must be forwarded, not summarised: the seller writes its cached channel record + // straight from this, and a missing `extra` makes it cache zeros rather than leave + // the record alone. See VerifyResponseSchema.extra. /settle already does this. + ...(result.extra !== undefined && { extra: result.extra }), + }; + return jsonResponse(200, responseBody); + } + + logger.warn( + { + invalidReason: result.invalidReason, + payer: result.payer, + }, + "Payment verification failed", + ); + + return jsonResponse(200, { + isValid: false, // Still return 200, but with isValid: false + invalidReason: result.invalidReason, + payer: result.payer, + } satisfies VerifyResponseBody); + } catch (error) { + logger.error({ err: error }, "Unexpected error in handler"); + return jsonResponse(500, { + error: "Internal server error", + [isSettle ? "success" : "isValid"]: false, + [isSettle ? "errorReason" : "invalidReason"]: isSettle + ? "unexpected_settlement_error" + : "unexpected_verify_error", + }); + } +} + +/** /supported — capability discovery (GET only; OPTIONS answering the preflight). */ +export function handleSupportedRequest(req: Request): Response { + if (req.method === "OPTIONS") { + return new Response("", { status: 200, headers: CORS_HEADERS }); + } + if (req.method !== "GET") { + return jsonResponse(405, { error: "Method not allowed. Use GET." }); + } + return jsonResponse(200, getSupportedCapabilities()); +} + +/** /openapi.json — the published schema. No live values to patch in: /supported is already + * the live source of truth for the fee amount and recommended approval, so this document + * only ever describes shape. structuredClone guards against any future caller mutating the + * shared import. */ +export function handleOpenApiSpecRequest(req: Request): Response { + if (req.method === "OPTIONS") { + return new Response("", { status: 200, headers: CORS_HEADERS }); + } + if (req.method !== "GET") { + return jsonResponse(405, { error: "Method not allowed. Use GET." }); + } + return jsonResponse(200, structuredClone(openapiSpec)); +} + +/** + * The routing table — same order and same substring matching as the Scaleway handler's + * path.includes() dispatch, so any path that routed there routes identically here. + */ +export async function handleFacilitatorRequest(req: Request): Promise { + const path = new URL(req.url).pathname; + + if (path.includes("/openapi.json")) { + return handleOpenApiSpecRequest(req); + } + if (path.includes("/supported")) { + return handleSupportedRequest(req); + } + if (path.includes("/settle")) { + return handlePaymentRequest(req, true); + } + if (path.includes("/verify")) { + return handlePaymentRequest(req, false); + } + + // Only the literal root ("/"). A human followed a link (e.g. from a facilitator + // listing) — send them to the docs instead of a JSON error object; anything else + // (an agent, curl, a machine client) gets an onward path, not nothing. + if (path === "/") { + if ((req.headers.get("Accept") ?? "").includes("text/html")) { + return new Response("", { + status: 302, + headers: { ...CORS_HEADERS, Location: DOCUMENTATION_URL }, + }); + } + return jsonResponse(200, { + service: "x402 facilitator", + documentation: DOCUMENTATION_URL, + supported: "/supported", + openapi: "/openapi.json", + endpoints: ["/verify", "/settle", "/supported", "/openapi.json"], + }); + } + + return jsonResponse(404, { + error: "Endpoint not found. Use /verify, /settle, or /supported", + }); +} diff --git a/bunny_js/src/facilitator/openapi.facilitator.json b/bunny_js/src/facilitator/openapi.facilitator.json new file mode 100644 index 000000000..e27d71284 --- /dev/null +++ b/bunny_js/src/facilitator/openapi.facilitator.json @@ -0,0 +1,521 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "fretchen x402 Facilitator", + "description": "x402 v2 facilitator: verifies and settles USDC payments on Optimism and Base. This is protocol infrastructure a seller (resource server) integrates against, not a paid resource — a buyer never calls these endpoints directly; the seller relays a buyer's signed payment payload here to verify and settle it. The facilitator never holds funds and is not a custodian.", + "version": "1.0.0", + "x-guidance": "Sellers: on receiving a request with a payment header, POST { paymentPayload, paymentRequirements } to /verify before delivering the resource, then POST the same body to /settle after delivery. GET /supported once to discover which networks, schemes, and fee model this facilitator advertises. There is no request body for /supported.", + "contact": { + "name": "fretchen", + "url": "https://www.fretchen.eu", + "email": "fretchen.dev@proton.me" + } + }, + "servers": [ + { + "url": "https://facilitator.fretchen.eu" + } + ], + "tags": [ + { + "name": "x402", + "description": "x402 v2 payment verification and settlement" + } + ], + "paths": { + "/verify": { + "post": { + "operationId": "verify", + "summary": "Verify a signed payment off-chain, before delivering the resource", + "description": "Called by a seller after receiving a payment header from a buyer. Checks signature validity, sufficient balance, correct recipient, and expiration. Does not move funds. Call this before delivering the resource; call /settle after.", + "tags": ["x402"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Verification result. A 200 status does not imply the payment was valid — check isValid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + }, + "400": { + "description": "Request body was not valid JSON, or was missing paymentPayload/paymentRequirements." + }, + "405": { + "description": "Method not allowed — use POST." + } + } + } + }, + "/settle": { + "post": { + "operationId": "settle", + "summary": "Settle a verified payment on-chain", + "description": "Called by a seller after delivering the resource. Executes the payment on-chain (EIP-3009 transferWithAuthorization for the exact scheme). Same request shape as /verify.", + "tags": ["x402"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Settlement result. A 200 status does not imply the settlement succeeded — check success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettleResponse" + } + } + } + }, + "400": { + "description": "Request body was not valid JSON, or was missing paymentPayload/paymentRequirements." + }, + "405": { + "description": "Method not allowed — use POST." + } + } + } + }, + "/supported": { + "get": { + "operationId": "supported", + "summary": "Discover the networks, schemes, and fee model this facilitator advertises", + "description": "Called once by a seller (or a buyer's client) to learn what this facilitator supports, before ever calling /verify or /settle. No request body.", + "tags": ["x402"], + "responses": { + "200": { + "description": "Supported capabilities.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportedResponse" + } + } + } + }, + "405": { + "description": "Method not allowed — use GET." + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "openapiSpec", + "summary": "This document", + "tags": ["x402"], + "responses": { + "200": { + "description": "This OpenAPI document.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PaymentRequest": { + "type": "object", + "properties": { + "paymentPayload": { + "type": "object", + "properties": { + "accepted": { + "type": "object", + "properties": { + "network": { + "description": "CAIP-2 network id, e.g. eip155:10.", + "type": "string" + }, + "scheme": { + "description": "e.g. exact, batch-settlement.", + "type": "string" + } + }, + "additionalProperties": {} + } + }, + "required": ["accepted"], + "additionalProperties": {}, + "description": "The x402 v2 PaymentPayload the buyer produced — includes accepted.network, accepted.scheme, and the signed authorization. Passed through as received; the facilitator does not re-derive it." + }, + "paymentRequirements": { + "type": "object", + "properties": { + "amount": { + "description": "Atomic units of the payment asset.", + "type": "string" + } + }, + "additionalProperties": {}, + "description": "The x402 v2 PaymentRequirements the seller advertised in its 402 response." + } + }, + "required": ["paymentPayload", "paymentRequirements"], + "additionalProperties": {}, + "description": "Identical shape for /verify and /settle." + }, + "VerifyResponse": { + "type": "object", + "properties": { + "isValid": { + "type": "boolean" + }, + "payer": { + "description": "The buyer's address, when known.", + "type": "string" + }, + "invalidReason": { + "description": "Present when isValid is false.", + "type": "string" + }, + "remainingSettlements": { + "description": "How many more settlements the seller's current approval (in the payment's token) for this facilitator still covers. Present only when a fee is configured and the allowance could be read — an early warning before it hits zero.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "extra": { + "description": "Scheme-specific state from the verifier, passed through verbatim. For batch-settlement this carries the on-chain channel state (balance, totalClaimed, refundNonce, withdrawRequestedAt) that the seller caches — omitting it makes the seller cache zeros.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["isValid"], + "additionalProperties": false + }, + "SettleResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "payer": { + "type": "string" + }, + "transaction": { + "description": "On-chain settlement tx hash. Empty string on a terminal failure; on errorReason settlement_pending it carries the broadcast-but-unconfirmed hash to reconcile.", + "type": "string" + }, + "network": { + "description": "CAIP-2 network id the settlement ran on.", + "type": "string" + }, + "errorReason": { + "description": "Present when success is false.", + "type": "string" + }, + "fee": { + "description": "Present only when a fee is configured for this network.", + "type": "object", + "properties": { + "collected": { + "type": "boolean", + "description": "True only when status is \"collected\"." + }, + "status": { + "type": "string", + "enum": ["collected", "pending", "failed"] + }, + "txHash": { + "description": "Fee-collection tx hash, when one was sent.", + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["collected", "status"], + "additionalProperties": false + }, + "extensions": { + "description": "x402 v2 extensions. facilitatorFees, when present, discloses the fee assessed for this payment per the #1016 disclosure proposal — see /supported for the full fee model.", + "type": "object", + "properties": { + "facilitatorFees": { + "type": "object", + "properties": { + "info": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "facilitatorFeePaid": { + "type": "string", + "description": "Fee ASSESSED for this payment. Never varies with the collection outcome." + }, + "asset": { + "type": "string" + }, + "model": { + "type": "string" + }, + "collection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["collected", "pending", "failed"] + }, + "txHash": { + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + } + }, + "required": ["version", "facilitatorFeePaid", "asset", "model", "collection"], + "additionalProperties": false + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "extra": { + "description": "Scheme-specific extras passed through from the underlying scheme (e.g. batch-settlement channel state).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["success"], + "additionalProperties": false + }, + "SupportedResponse": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x402Version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "scheme": { + "type": "string" + }, + "network": { + "type": "string", + "description": "CAIP-2 network id." + }, + "extra": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["x402Version", "scheme", "network"], + "additionalProperties": false + }, + "description": "Every supported (network, scheme) pair." + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advertised extension keys, e.g. facilitatorFees when a fee is configured." + }, + "signers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Facilitator address(es) per network." + }, + "facilitatorFees": { + "description": "The full fee model disclosure (amount, recipient, recommended per-token approval) — present only when a fee is configured.", + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "model": { + "type": "string" + }, + "asset": { + "type": "string", + "description": "\"settled\": the fee is charged in the token the payment settles in (USDC; EURC and EURe on Base)." + }, + "flatFee": { + "type": "string", + "description": "The nominal fee in `decimals` units. Tokens with more decimals are charged the same nominal amount in their own units — see `assets` for each token's exact atomic figure." + }, + "decimals": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "recipient": { + "type": "string", + "description": "Facilitator address that collects the fee (fee recipient / approval spender)." + }, + "networks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CAIP-2 networks this fee model applies to." + }, + "fee": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "description": { + "type": "string" + }, + "collection": { + "type": "string" + } + }, + "required": ["amount", "description", "collection"], + "additionalProperties": false + }, + "setup": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "function": { + "type": "string" + }, + "spender": { + "type": "string" + }, + "recommended_amount": { + "type": "string" + } + }, + "required": ["description", "function", "spender", "recommended_amount"], + "additionalProperties": false + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "CAIP-2 network id." + }, + "asset": { + "type": "string", + "description": "Token contract address." + }, + "symbol": { + "type": "string" + }, + "decimals": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "flatFee": { + "type": "string", + "description": "The fee in this token's atomic units." + }, + "recommended_amount": { + "type": "string", + "description": "Recommended approve() amount in this token's atomic units (1 token)." + } + }, + "required": [ + "network", + "asset", + "symbol", + "decimals", + "flatFee", + "recommended_amount" + ], + "additionalProperties": false + }, + "description": "Every token the fee is charged in, per network, with the exact atomic fee and approval. EURe has 18 decimals, so its 0.01 fee is 10000000000000000, not the 10000 of USDC/EURC." + } + }, + "required": [ + "version", + "model", + "asset", + "flatFee", + "decimals", + "recipient", + "networks", + "fee", + "setup", + "assets" + ], + "additionalProperties": false + }, + "links": { + "type": "object", + "properties": { + "documentation": { + "type": "string" + }, + "source": { + "type": "string" + }, + "openapi": { + "type": "string" + } + }, + "required": ["documentation", "source", "openapi"], + "additionalProperties": false + } + }, + "required": ["kinds", "extensions", "signers", "links"], + "additionalProperties": false + } + } + } +} diff --git a/bunny_js/src/facilitator/revert_selectors.ts b/bunny_js/src/facilitator/revert_selectors.ts new file mode 100644 index 000000000..33841081f --- /dev/null +++ b/bunny_js/src/facilitator/revert_selectors.ts @@ -0,0 +1,72 @@ +/** + * Names for the x402BatchSettlement contract's custom-error revert selectors. + * + * Why this exists: the SDK simulates claim/refund/settle calls with an ABI fragment that + * declares no `error` entries, so when the contract reverts, viem reports the raw selector + * and an "Unable to decode signature … as it was not found on the provided ABI" message. + * The selector is the whole diagnosis — 0xd758525c is `ZeroRefund()` — but as raw hex it + * sent an operator to a signature database on 2026-10-11 to read a one-word answer. + * + * The 21 errors below are the complete error list of the contract this facilitator + * dispatches to (0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003, same CREATE2 address on every + * chain), taken from `contracts/evm/src/x402BatchSettlement.sol` in x402-foundation/x402. + * Every selector was computed with viem's `toFunctionSelector` and two were cross-checked + * against production evidence: `InvalidRefundNonce()` 0x164f1afe (a stale refundNonce + * incident) and `ZeroRefund()` 0xd758525c (the fully-claimed-channel incident of + * 2026-10-11). test/revert_selectors.test.ts recomputes the map from the names, so a typo + * in either half fails CI. + */ + +/** Revert selector (with 0x prefix, lowercase) → the contract's error name with (). */ +export const BATCH_SETTLEMENT_REVERT_SELECTORS: Record<`0x${string}`, string> = { + "0xea3a9a39": "InvalidChannel()", + "0x56316e87": "ZeroDeposit()", + "0x1132981b": "DepositOverflow()", + "0x8baa579f": "InvalidSignature()", + "0xf5e2237d": "NotReceiverAuthorizer()", + "0xdefb7069": "NotAuthorizedToClaim()", + "0x7864f008": "NotAuthorizedToRefund()", + "0xf1789775": "ClaimExceedsCeiling()", + "0x7d8242b6": "ClaimExceedsBalance()", + "0xa2aed0f0": "WithdrawalAlreadyPending()", + "0x3a0a1ca6": "WithdrawalNotPending()", + "0x15f969d5": "NotAuthorizedToFinalizeWithdraw()", + "0xf3965827": "WithdrawDelayNotElapsed()", + "0xd0d04f60": "NothingToWithdraw()", + "0x6d3d5764": "WithdrawAmountExceedsAvailable()", + "0x4574fab6": "WithdrawDelayOutOfRange()", + "0xc2e5347d": "EmptyBatch()", + "0xeb43592c": "DepositCollectionFailed()", + "0x18b1afd4": "InvalidCollector()", + "0x164f1afe": "InvalidRefundNonce()", + "0xd758525c": "ZeroRefund()", +}; + +/** Standalone revert selectors: `0x` + 8 hex NOT followed by more hex. The lookahead keeps + * the first 8 hex of a longer value — a tx hash, an address, calldata — from ever being + * read as a selector; a real selector in a viem message stands alone. */ +const SELECTOR_RE = /0x[0-9a-fA-F]{8}(?![0-9a-fA-F])/g; + +/** + * Append the names of every known BatchSettlement revert selector a message mentions. + * + * Applied where the SDK's raw `errorMessage` first enters this package (the two + * `facilitator.settle` call sites in `x402_settle.ts`), so every log line and returned + * `SettleResult.errorMessage` downstream carries the decoded name. Unknown selectors and + * selector-free messages pass through unchanged — this annotates, it never rewrites. The + * suffix deliberately matches the alert rules' regexes nowhere: `errorReason` and the log + * msg are untouched, so `(refund_[a-z_]*failed|…).*Settlement failed` keeps matching. + */ +export function annotateDecodedRevert(message: string | undefined): string | undefined { + if (!message) { + return message; + } + const decoded = [...message.matchAll(SELECTOR_RE)] + .map((match) => match[0].toLowerCase() as `0x${string}`) + .filter((selector) => selector in BATCH_SETTLEMENT_REVERT_SELECTORS) + .map((selector) => `${BATCH_SETTLEMENT_REVERT_SELECTORS[selector]} (${selector})`); + if (decoded.length === 0) { + return message; + } + return `${message} — decoded revert: ${[...new Set(decoded)].join(", ")}`; +} diff --git a/bunny_js/src/facilitator/x402_fee.ts b/bunny_js/src/facilitator/x402_fee.ts new file mode 100644 index 000000000..db678f234 --- /dev/null +++ b/bunny_js/src/facilitator/x402_fee.ts @@ -0,0 +1,619 @@ +/** + * x402 Facilitator Fee Module + * + * Handles post-settlement fee collection via ERC-20 transferFrom. + * The fee is charged in the token the payment settled in — USDC, or EURC on Base — so the + * merchant must have approved the facilitator's wallet to spend THAT token on their behalf + * (standard ERC-20 approve flow, one approval per token). + * + * Fee flow: + * 1. Settlement executes: transferWithAuthorization(client → merchant) + * 2. Fee collected after: transferFrom(merchant → facilitator, feeAmount) + */ + +import { + createPublicClient, + createWalletClient, + http, + getContract, + nonceManager, + WaitForTransactionReceiptTimeoutError, + type Address, + type Abi, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { logger } from "../logger"; +import { loadPrivateKey } from "@fretchen/chain-utils"; +import { findFeeToken, getChainConfig, getRpcUrl } from "./chain_utils"; + +// ═══════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════ + +export interface FeeResult { + success: boolean; + txHash?: string; + error?: string; +} + +export interface AllowanceInfo { + /** + * Undefined when the allowance could not be read. + * + * Never conflate "unreadable" with `0n`: a caller that caps a transfer at the + * allowance would silently stop collecting entirely during a transient RPC failure. + */ + allowance?: bigint; + /** + * Undefined when the allowance could not be read ("unknown" status). Never + * report 0 here for an unreadable allowance — it reads as "approval exhausted" + * and pushes sellers to re-approve unnecessarily. + */ + remainingSettlements?: number; + /** + * "ok" — allowance covers at least one fee + * "insufficient" — read succeeded, allowance is genuinely too low (fail closed) + * "unknown" — could not be read; callers proceed rather than block a payment + */ + status: "ok" | "insufficient" | "unknown"; +} + +/** + * Whether this settlement owes a fee, and whether the recipient can pay it. + * + * - `no_fee` — fees are disabled. Settle normally and attach NO fee receipt: the + * `fee` / `extensions.facilitatorFees` fields are documented (see + * `x402_schemas.ts`) as present only when a fee is configured. + * - `charge` — a fee is owed; settle, then collect it. + * - `reject` — do not settle at all. + */ +export type FeeGateDecision = + | { kind: "no_fee" } + | { kind: "charge"; remainingSettlements?: number } + | { + /** + * Only the reason travels. The allowance detail behind it is logged here for + * operators, and `/supported` → `facilitatorFees` already publishes the address to + * approve and the amount required — callers need nothing more from this type. + */ + kind: "reject"; + reason: "insufficient_fee_allowance" | "facilitator_not_configured" | "unsupported_fee_asset"; + }; + +// ═══════════════════════════════════════════════════════════════ +// ERC-20 ABI (minimal subset for fee operations) +// ═══════════════════════════════════════════════════════════════ + +/** + * The fee pull's ERC-20 surface. The two OZ v5 `error` entries are load-bearing for + * diagnostics: without them viem cannot decode a reverting `transferFrom` (the 2026-10-11 + * EURe incident logged a bare `0xe450d38c` and "Unable to decode signature"), and the + * insufficient_merchant_balance / insufficient_fee_allowance mapping in collectFee's catch — + * which reads the decoded name out of the error message — never fires. + */ +export const ERC20_FEE_ABI = [ + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "transferFrom", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + // OpenZeppelin v5's transferFrom errors (EURe, and every OZ v5 token, reverts with these + // rather than the old require strings). Decode-only entries: viem matches them by selector. + { + name: "ERC20InsufficientBalance", + type: "error", + inputs: [ + { name: "sender", type: "address" }, + { name: "balance", type: "uint256" }, + { name: "needed", type: "uint256" }, + ], + }, + { + name: "ERC20InsufficientAllowance", + type: "error", + inputs: [ + { name: "spender", type: "address" }, + { name: "allowance", type: "uint256" }, + { name: "needed", type: "uint256" }, + ], + }, +] as const satisfies Abi; + +// ═══════════════════════════════════════════════════════════════ +// Fee Configuration +// ═══════════════════════════════════════════════════════════════ + +/** + * Default fee: 10000 = 0.01 of the settled token, written in 6-decimal units. + * + * The fee is NOMINAL: 0.01 of whichever token settles. USDC and EURC have 6 decimals, so this is + * also their atomic amount — but EURe has 18, where 10000 atomic units would be 10⁻¹⁴ EUR. Every + * place that charges, checks or reports the fee must therefore use `feeAmountFor(decimals)`, + * never this number directly. + */ +const DEFAULT_FEE_AMOUNT = 10000n; + +/** The decimals `FACILITATOR_FEE_AMOUNT` / `DEFAULT_FEE_AMOUNT` are written in. */ +const FEE_BASE_DECIMALS = 6; + +/** + * Cap on how long fee collection may wait for its receipt. + * + * The settle handler has a 60s budget (serverless.yml) and has already spent part of it + * confirming the settlement transaction before fee collection starts. Fee collection + * must never be able to consume the rest: if the handler dies here, the buyer gets no + * receipt for a payment that already landed on-chain. A settled payment's receipt is + * worth far more than a 0.01 USDC fee. + */ +const FEE_RECEIPT_TIMEOUT_MS = 10_000; + +/** + * How many times the fee transfer is sent when a node refuses it before broadcast. + * + * Right after a settlement the facilitator sends a second transaction from the same wallet, and + * on a load-balanced RPC that second send can meet a node that has not caught up yet: it once + * reused the settlement's nonce ("replacement transaction underpriced") and once simulated against + * a balance that did not include the payment just received ("transfer amount exceeds balance"). + * Both were refused before broadcast, and both would have gone through two seconds later. + */ +export const FEE_SEND_ATTEMPTS = 3; + +/** Pause between fee send attempts. `FEE_RETRY_DELAY_MS` overrides it (the tests set 0). */ +function feeRetryDelayMs(): number { + const raw = process.env.FEE_RETRY_DELAY_MS; + const parsed = raw === undefined || raw === "" ? NaN : Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 2_000; +} + +/** + * The nominal fee from environment or default, in 6-decimal units (10000 = 0.01). + * To charge it, scale it to the settled token with `feeAmountFor`. + */ +export function getFeeAmount(): bigint { + const envFee = process.env.FACILITATOR_FEE_AMOUNT; + if (envFee) { + try { + const parsed = BigInt(envFee); + if (parsed < 0n) { + logger.warn({ envFee }, "Invalid FACILITATOR_FEE_AMOUNT (negative), using default"); + return DEFAULT_FEE_AMOUNT; + } + return parsed; + } catch { + logger.warn( + { envFee }, + "Invalid FACILITATOR_FEE_AMOUNT (not a valid integer), using default", + ); + return DEFAULT_FEE_AMOUNT; + } + } + return DEFAULT_FEE_AMOUNT; +} + +/** The nominal fee in atomic units of a token with `decimals` decimals (0.01 → 10¹⁶ for 18). */ +export function feeAmountFor(decimals: number): bigint { + const base = getFeeAmount(); + return decimals >= FEE_BASE_DECIMALS + ? base * 10n ** BigInt(decimals - FEE_BASE_DECIMALS) + : base / 10n ** BigInt(FEE_BASE_DECIMALS - decimals); +} + +/** The nominal fee in `token`'s atomic units on `network`, or null for a token we do not know. */ +export function feeAmountForToken(network: string, token: string): bigint | null { + const feeToken = findFeeToken(network, token); + return feeToken ? feeAmountFor(feeToken.decimals) : null; +} + +/** + * The facilitator's signing account. Every transaction this package sends must be signed by an + * account from here — the settlement signer (`facilitator_instance.ts`) and the fee pull alike. + * + * It carries viem's `nonceManager`, a module singleton keyed by address and chain, which remembers + * the last nonce this process used and never hands out one below it. Without it, the fee pull asked + * the RPC for the pending nonce on its own, right after the settlement, and a node that had not yet + * seen the settlement answered with its nonce: the fee pulls of 2026-09-28 reused the settlements' + * nonces 54 and 55 and were refused. The manager only coordinates senders that share it, which is + * why nothing should call `privateKeyToAccount` for this key directly. + */ +export function loadFacilitatorAccount() { + return privateKeyToAccount(loadPrivateKey("FACILITATOR_WALLET_PRIVATE_KEY"), { nonceManager }); +} + +/** + * Get the facilitator wallet address (fee recipient). + * Derived from the same private key used for settlements. + */ +export function getFacilitatorAddress(): Address | null { + try { + return privateKeyToAccount(loadPrivateKey("FACILITATOR_WALLET_PRIVATE_KEY")).address; + } catch { + return null; + } +} + +// ═══════════════════════════════════════════════════════════════ +// Allowance Check +// ═══════════════════════════════════════════════════════════════ + +/** + * Check how much of `token` the merchant has approved for the facilitator. + * Used at verify time to give early feedback if fee cannot be collected. + */ +export async function checkMerchantAllowance( + merchantAddress: Address, + network: string, + token: Address, +): Promise { + const facilitatorAddress = getFacilitatorAddress(); + if (!facilitatorAddress) { + logger.warn("Cannot check allowance: facilitator address not configured"); + return { allowance: 0n, remainingSettlements: 0, status: "insufficient" }; + } + + const feeAmount = feeAmountForToken(network, token); + if (feeAmount === null) { + // Not a token this facilitator charges in. evaluateFeeGate refuses these before asking; + // answer "insufficient" rather than guess at a fee in unknown units. + logger.warn({ network, token }, "Cannot check allowance: not a known stablecoin"); + return { allowance: 0n, remainingSettlements: 0, status: "insufficient" }; + } + if (feeAmount === 0n) { + // No fee configured — nothing to collect, nothing to approve + return { remainingSettlements: Infinity, status: "ok" }; + } + + try { + const config = getChainConfig(network); + const publicClient = createPublicClient({ + chain: config.chain, + transport: http(getRpcUrl(network)), + }); + + const feeToken = getContract({ + address: token, + abi: ERC20_FEE_ABI, + client: publicClient, + }); + + const allowance = await feeToken.read.allowance([merchantAddress, facilitatorAddress]); + + const remainingSettlements = feeAmount > 0n ? Number(allowance / feeAmount) : Infinity; + const status = allowance >= feeAmount ? "ok" : "insufficient"; + + logger.debug( + { + merchant: merchantAddress, + facilitator: facilitatorAddress, + allowance: allowance.toString(), + feeAmount: feeAmount.toString(), + remainingSettlements, + status, + network, + token, + }, + "Merchant allowance check", + ); + + return { allowance, remainingSettlements, status }; + } catch (error) { + // Fail open on RPC/read errors — don't block otherwise-valid payments over a + // reading we could not take. `warn`, not `error`: proceeding is the policy here, + // not a malfunction. + // + // `allowance` is deliberately left undefined rather than 0n. Reporting 0n would be + // a lie that reads as "no allowance", and any caller capping a transfer at it would + // silently stop collecting during a transient RPC blip. + logger.warn( + { err: error, merchant: merchantAddress, network }, + "Could not read merchant allowance — proceeding without it", + ); + return { status: "unknown" }; + } +} + +/** + * The single fee gate both schemes run before settling. + * + * `exact` calls this from the `onAfterVerify` hook (`facilitator_instance.ts`); + * `batch-settlement` calls it for `claim`/`settle` payloads (`x402_settle.ts`), which + * skip verify() entirely and so never reach that hook. Sharing it is the point: these + * were previously two hand-written implementations, and the batch-settlement copy had + * silently omitted the fee-disabled and facilitator-not-configured checks. + * + * Fails closed only on an allowance that genuinely read too low. An *unreadable* + * allowance proceeds — the payment is worth more than the fee. + * + * `token` is the token the settlement moves, and so the one the fee is charged in. It must + * be a stablecoin from the registry on `network` (USDC, EURC, EURe): a flat fee in a token this + * facilitator does not know is meaningless, so any other token is refused rather than relayed. + */ +export async function evaluateFeeGate( + recipient: Address, + network: string, + token: Address, +): Promise { + const feeAmount = getFeeAmount(); + if (feeAmount === 0n) { + return { kind: "no_fee" }; + } + + const stablecoin = findFeeToken(network, token); + if (!stablecoin) { + logger.warn( + { recipient, network, token }, + "Fee asset is not a known stablecoin on this network", + ); + return { kind: "reject", reason: "unsupported_fee_asset" }; + } + + const facilitatorAddress = getFacilitatorAddress(); + if (!facilitatorAddress) { + logger.warn( + { recipient, network }, + "Cannot check fee allowance: facilitator address not configured", + ); + return { kind: "reject", reason: "facilitator_not_configured" }; + } + + const allowanceInfo = await checkMerchantAllowance(recipient, network, token); + + if (allowanceInfo.status === "insufficient") { + logger.warn( + { + recipient, + network, + allowance: allowanceInfo.allowance?.toString(), + feeAmount: feeAmountFor(stablecoin.decimals).toString(), + facilitatorAddress, + token, + }, + `Insufficient fee allowance — merchant must approve ${stablecoin.symbol} for facilitator`, + ); + return { kind: "reject", reason: "insufficient_fee_allowance" }; + } + + if (allowanceInfo.status === "unknown") { + // Proceed by policy, not by accident: the payment is worth more than the fee. + logger.warn( + { recipient, network }, + "Fee allowance unreadable — proceeding, fee collection attempted anyway", + ); + } else { + logger.info( + { recipient, network, remainingSettlements: allowanceInfo.remainingSettlements }, + "Recipient approved via fee allowance", + ); + } + + return { kind: "charge", remainingSettlements: allowanceInfo.remainingSettlements }; +} + +// ═══════════════════════════════════════════════════════════════ +// Fee Collection +// ═══════════════════════════════════════════════════════════════ + +/** + * Whether a failed send never left the node — and so can be sent again without any risk of paying + * the fee twice. + * + * - "replacement transaction underpriced" / "nonce too low": the node refused the transaction, and + * the nonce it carried belongs to a transaction that is already pending or mined. + * - "execution reverted": thrown by `writeContract` itself, which only happens in the simulation + * before signing. A transaction that is mined and reverts comes back as a receipt, not a throw. + * + * Everything else — a transport error, an unknown RPC failure — may have happened after the node + * accepted the transaction, so it is not retried. + */ +export function wasRejectedBeforeBroadcast(error: unknown): boolean { + const text = String((error as { message?: unknown })?.message ?? error).toLowerCase(); + return ( + text.includes("replacement transaction underpriced") || + text.includes("nonce too low") || + text.includes("execution reverted") + ); +} + +/** + * A fee-pull revert no retry can fix: the merchant's token balance or the facilitator's + * allowance will not change between attempts — the 2026-10-11 EURe incident burned two retry + * cycles on a deterministic ERC20InsufficientBalance before failing. Detected by the decoded + * OZ v5 error name, which the fee ABI carries (see ERC20_FEE_ABI): before those entries + * existed, viem logged a bare selector and this could never match. + */ +export function isDeterministicFeeRevert(error: unknown): boolean { + const text = String((error as { message?: unknown })?.message ?? error).toLowerCase(); + return text.includes("erc20insufficientbalance") || text.includes("erc20insufficientallowance"); +} + +/** + * Send the fee transfer, again after a short pause if a node refused it before broadcast. Returns + * the hash of the one send the node accepted; rethrows the last error otherwise. A deterministic + * balance/allowance revert is rethrown immediately — see isDeterministicFeeRevert. + */ +async function sendFeeTransfer( + send: () => Promise, + context: { merchant: Address; network: string }, +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await send(); + } catch (error) { + if ( + isDeterministicFeeRevert(error) || + attempt >= FEE_SEND_ATTEMPTS || + !wasRejectedBeforeBroadcast(error) + ) { + throw error; + } + // warn, not error: a refused send that is about to be retried is not yet a failure, and + // logger.error is reserved for what should page (test/alert_coverage.test.ts). + logger.warn( + { + ...context, + attempt, + reason: String((error as { shortMessage?: unknown })?.shortMessage ?? error).slice( + 0, + 200, + ), + }, + "Fee transfer refused before broadcast — retrying", + ); + await new Promise((resolve) => setTimeout(resolve, feeRetryDelayMs())); + } + } +} + +/** + * Collect fee from merchant via ERC-20 transferFrom. + * Called AFTER successful settlement only. + * + * @param merchantAddress - The merchant who received payment (fee source) + * @param network - The CAIP-2 network identifier + * @param token - The settled token; the fee is charged in it. Already vetted by evaluateFeeGate. + * @returns FeeResult with success status and optional tx hash + */ +export async function collectFee( + merchantAddress: Address, + network: string, + token: Address, +): Promise { + const feeAmount = feeAmountForToken(network, token); + if (feeAmount === null) { + // evaluateFeeGate vets the token before settlement, so this is unreachable in practice — + // `warn`, not `error`, so it does not need an alert rule for something that cannot happen. + logger.warn({ network, token }, "Cannot collect fee: not a known stablecoin"); + return { success: false, error: "unsupported_fee_asset" }; + } + + // No fee configured — skip silently + if (feeAmount === 0n) { + logger.debug("Fee amount is 0, skipping fee collection"); + return { success: true }; + } + + let account; + try { + account = loadFacilitatorAccount(); + } catch { + logger.error("Cannot collect fee: FACILITATOR_WALLET_PRIVATE_KEY not configured or invalid"); + return { success: false, error: "facilitator_not_configured" }; + } + + try { + const config = getChainConfig(network); + + const publicClient = createPublicClient({ + chain: config.chain, + transport: http(getRpcUrl(network)), + }); + + const walletClient = createWalletClient({ + account, + chain: config.chain, + transport: http(getRpcUrl(network)), + }); + + const feeToken = getContract({ + address: token, + abi: ERC20_FEE_ABI, + client: { public: publicClient, wallet: walletClient }, + }); + + logger.info( + { + merchant: merchantAddress, + facilitator: account.address, + feeAmount: feeAmount.toString(), + network, + token, + }, + "Collecting fee via transferFrom", + ); + + const txHash = await sendFeeTransfer( + () => feeToken.write.transferFrom([merchantAddress, account.address, feeAmount]), + { merchant: merchantAddress, network }, + ); + + // Wait for confirmation — bounded, so a slow fee tx can never eat the settle + // handler's remaining timeout budget and cost the buyer their receipt. + let receipt; + try { + receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + timeout: FEE_RECEIPT_TIMEOUT_MS, + }); + } catch (error) { + if (error instanceof WaitForTransactionReceiptTimeoutError) { + // The tx is still in flight and may well succeed. Reporting it as failed would + // be wrong, and retrying against a fee that actually landed would double-charge + // the merchant. Return the hash so it can be reconciled later. + logger.warn( + { + txHash, + merchant: merchantAddress, + network, + timeoutMs: FEE_RECEIPT_TIMEOUT_MS, + }, + "Fee tx not confirmed within budget — returning settlement receipt, fee outcome unknown", + ); + return { success: false, txHash, error: "fee_collection_pending" }; + } + throw error; // any other wait failure keeps existing outer-catch handling + } + + if (receipt.status === "success") { + logger.info( + { + txHash, + merchant: merchantAddress, + feeAmount: feeAmount.toString(), + network, + }, + "Fee collected successfully", + ); + return { success: true, txHash }; + } else { + logger.error( + { txHash, receipt, merchant: merchantAddress, network }, + "Fee transaction reverted", + ); + return { success: false, txHash, error: "fee_transaction_reverted" }; + } + } catch (error) { + const err = error as Error; + logger.error({ err, merchant: merchantAddress, network }, "Fee collection failed"); + + let errorReason = "fee_collection_failed"; + if ( + err.message?.includes("insufficient allowance") || + err.message?.includes("ERC20InsufficientAllowance") + ) { + errorReason = "insufficient_fee_allowance"; + } else if ( + err.message?.includes("insufficient balance") || + err.message?.includes("ERC20InsufficientBalance") + ) { + errorReason = "insufficient_merchant_balance"; + } + + return { success: false, error: errorReason }; + } +} diff --git a/bunny_js/src/facilitator/x402_schemas.ts b/bunny_js/src/facilitator/x402_schemas.ts new file mode 100644 index 000000000..f750ac7fb --- /dev/null +++ b/bunny_js/src/facilitator/x402_schemas.ts @@ -0,0 +1,281 @@ +/** + * Zod schemas for the facilitator's wire contracts. + * + * These are the single source for both the response types used internally and the + * generated `openapi.json` (see `scripts/generate-openapi.ts`) — a shape change here + * is a type error everywhere it's inconsistent, and a `npm run generate:openapi` away + * from being documented. Before this file existed, the OpenAPI spec was a hand-written + * second description of the same shapes with nothing keeping the two in sync; that's + * exactly how `/supported`'s `facilitatorFees` ended up documented as a bare + * `{"type": "object"}` while the real disclosure grew a dozen fields across two + * fee-model phases. + * + * Scope is deliberately response-only. The request body (`PaymentRequestSchema`) stays + * shallow, matching what the facilitator has always done: `exact` and + * `batch-settlement` payloads have genuinely different shapes that `@x402/evm` + * validates internally, and re-modeling that here would just be re-implementing the + * SDK's own validation. + * + * Because that request schema is a deliberately *partial* view of a payload we forward rather than + * own, it must publish as **permissive** — hence `z.looseObject`, not `z.object`. `z.object` only + * strips unknown keys at parse time, but `z.toJSONSchema` renders it as + * `additionalProperties: false`, and that shipped: the published `PaymentRequest` declared closed + * while every real payload carries `x402Version`, `resource`, `payload`, and an `accepted` with + * `amount`/`asset`/`payTo`/`maxTimeoutSeconds`/`extra`. A client trimming its payload to match the + * spec would have produced an unverifiable payment — the spec described a request that could not be + * paid with. + * + * Responses stay `z.object`: those are assembled field by field below, so closed is true of them. + * `test/openapi_generation.test.ts` asserts both directions, because nothing else does. + * + * `/verify` and `/settle` hand-assemble their response bodies in `x402_facilitator.ts` + * rather than serializing an internal result object directly (deliberately — internal + * types like `SettleResult` carry fields, e.g. `errorMessage`, that must never reach + * the wire). `VerifyResponseSchema`/`SettleResponseSchema` model exactly what gets + * assembled there; `x402_facilitator.ts` annotates those object literals with the + * matching `z.infer<>` type so the compiler catches the two ever falling out of sync. + * `/supported`, by contrast, serializes its result object unfiltered + * (`JSON.stringify(capabilities)`), so `SupportedResponseSchema` models + * `getSupportedCapabilities()`'s full return shape directly. + */ + +import { z } from "zod"; + +// ── Request (shared by /verify and /settle) ── + +const AcceptedSchema = z.looseObject({ + network: z.string().optional().describe("CAIP-2 network id, e.g. eip155:10."), + scheme: z.string().optional().describe("e.g. exact, batch-settlement."), +}); + +export const PaymentRequestSchema = z + .looseObject({ + paymentPayload: z + .looseObject({ accepted: AcceptedSchema }) + .describe( + "The x402 v2 PaymentPayload the buyer produced — includes accepted.network, " + + "accepted.scheme, and the signed authorization. Passed through as received; " + + "the facilitator does not re-derive it.", + ), + paymentRequirements: z + .looseObject({ + amount: z.string().optional().describe("Atomic units of the payment asset."), + }) + .describe("The x402 v2 PaymentRequirements the seller advertised in its 402 response."), + }) + .describe("Identical shape for /verify and /settle."); + +// ── /verify ── + +export const VerifyResponseSchema = z.object({ + isValid: z.boolean(), + payer: z.string().optional().describe("The buyer's address, when known."), + invalidReason: z.string().optional().describe("Present when isValid is false."), + remainingSettlements: z + .number() + .int() + .optional() + .describe( + "How many more settlements the seller's current approval (in the payment's token) for this facilitator " + + "still covers. Present only when a fee is configured and the allowance could be " + + "read — an early warning before it hits zero.", + ), + /** + * Scheme-specific state, forwarded from the scheme verifier untouched. + * + * Omitting it is not cosmetic — it corrupts the seller's channel store. The seller's SDK + * writes its cached channel record straight from this field, WHOLESALE, with zero defaults: + * + * const ex = result.extra ?? {}; + * const balance = readExtraString(ex, "balance", "0"); + * const refundNonce = readExtraNumber(ex, "refundNonce", 0); + * + * A response without `extra` therefore does not leave the record alone, it overwrites the + * real balance and nonce with zeros on every verify — and stamps `onchainSyncedAt`, marking + * the poisoned record fresh so later local verifies copy the zeros forward. That was the + * September incident: "payment channel too low" on funded channels, and refunds reverting on + * a consumed nonce while escrow sat unrecoverable. + * + * Never rebuild it field by field here: verify reads flat keys, settle reads a nested + * `channelState`, and hand-assembling either is how it was lost in the first place. + */ + extra: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Scheme-specific state from the verifier, passed through verbatim. For " + + "batch-settlement this carries the on-chain channel state (balance, totalClaimed, " + + "refundNonce, withdrawRequestedAt) that the seller caches — omitting it makes the " + + "seller cache zeros.", + ), +}); + +export type VerifyResponseBody = z.infer; + +// ── /settle ── + +export const FeeStatusSchema = z.enum(["collected", "pending", "failed"]); + +/** Facilitator fee receipt per x402 Fee Disclosure proposal (coinbase/x402#1016). */ +export const FacilitatorFeePaidSchema = z.object({ + version: z.string(), + facilitatorFeePaid: z + .string() + .describe("Fee ASSESSED for this payment. Never varies with the collection outcome."), + asset: z.string(), + model: z.string(), + collection: z.object({ + status: FeeStatusSchema, + txHash: z.string().optional(), + }), +}); + +export const SettleResponseSchema = z.object({ + success: z.boolean(), + payer: z.string().optional(), + transaction: z + .string() + .optional() + .describe( + "On-chain settlement tx hash. Empty string on a terminal failure; on " + + "errorReason settlement_pending it carries the broadcast-but-unconfirmed hash to reconcile.", + ), + network: z.string().optional().describe("CAIP-2 network id the settlement ran on."), + errorReason: z.string().optional().describe("Present when success is false."), + fee: z + .object({ + collected: z.boolean().describe('True only when status is "collected".'), + status: FeeStatusSchema, + txHash: z.string().optional().describe("Fee-collection tx hash, when one was sent."), + error: z.string().optional(), + }) + .optional() + .describe("Present only when a fee is configured for this network."), + extensions: z + .object({ + facilitatorFees: z + .object({ + info: FacilitatorFeePaidSchema, + }) + .optional(), + }) + .optional() + .describe( + "x402 v2 extensions. facilitatorFees, when present, discloses the fee assessed for " + + "this payment per the #1016 disclosure proposal — see /supported for the full fee model.", + ), + extra: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Scheme-specific extras passed through from the underlying scheme (e.g. batch-settlement channel state).", + ), +}); + +export type SettleResponseBody = z.infer; + +// ── /supported ── + +/** + * Facilitator fee disclosure, per x402 Fee Disclosure proposal (coinbase/x402#1016). + * + * Wire-format note: the base x402 `SupportedResponse.extensions` is `string[]` (a list + * of extension KEY names). We advertise the key `"facilitatorFees"` in that array and + * carry the machine-readable detail in this top-level sibling object — mirroring the + * `/settle` response, whose `extensions` map also nests a `facilitatorFees` receipt. + * This keeps `/supported` conformant with the SDK type while still disclosing the fee + * model for fee-aware multi-facilitator routing. + */ +export const FacilitatorFeesDisclosureSchema = z.object({ + version: z.string(), + model: z.string(), + asset: z + .string() + .describe( + '"settled": the fee is charged in the token the payment settles in (USDC; EURC and EURe on Base).', + ), + flatFee: z + .string() + .describe( + "The nominal fee in `decimals` units. Tokens with more decimals are charged the same " + + "nominal amount in their own units — see `assets` for each token's exact atomic figure.", + ), + decimals: z.number().int(), + recipient: z + .string() + .describe("Facilitator address that collects the fee (fee recipient / approval spender)."), + networks: z.array(z.string()).describe("CAIP-2 networks this fee model applies to."), + fee: z.object({ + amount: z.string(), + description: z.string(), + collection: z.string(), + }), + setup: z.object({ + description: z.string(), + function: z.string(), + spender: z.string(), + recommended_amount: z.string(), + }), + assets: z + .array( + z.object({ + network: z.string().describe("CAIP-2 network id."), + asset: z.string().describe("Token contract address."), + symbol: z.string(), + decimals: z.number().int(), + flatFee: z.string().describe("The fee in this token's atomic units."), + recommended_amount: z + .string() + .describe("Recommended approve() amount in this token's atomic units (1 token)."), + }), + ) + .describe( + "Every token the fee is charged in, per network, with the exact atomic fee and approval. " + + "EURe has 18 decimals, so its 0.01 fee is 10000000000000000, not the 10000 of USDC/EURC.", + ), +}); + +export type FacilitatorFeesDisclosure = z.infer; + +/** + * Shape of the `/supported` response. Matches `x402Facilitator.getSupported()` (whose + * `extensions` is `string[]`) plus the optional top-level `facilitatorFees` disclosure. + * `network` is `string` here to match the SDK class's return type; the base SDK + * `SupportedResponse` narrows it to `Network`, but that distinction is irrelevant to + * this response and forcing it would require casting the base return. + */ +export const SupportedResponseSchema = z.object({ + kinds: z + .array( + z.object({ + x402Version: z.number().int(), + scheme: z.string(), + network: z.string().describe("CAIP-2 network id."), + extra: z.record(z.string(), z.unknown()).optional(), + }), + ) + .describe("Every supported (network, scheme) pair."), + extensions: z + .array(z.string()) + .describe("Advertised extension keys, e.g. facilitatorFees when a fee is configured."), + signers: z + .record(z.string(), z.array(z.string())) + .describe("Facilitator address(es) per network."), + facilitatorFees: FacilitatorFeesDisclosureSchema.optional().describe( + "The full fee model disclosure (amount, recipient, recommended per-token approval) — " + + "present only when a fee is configured.", + ), + /** + * Onward paths for a caller that has just discovered `/supported` and has nowhere else + * to go — an agent doing facilitator discovery, or a human who followed a link from a + * listing. Always present, unlike `facilitatorFees`: it doesn't depend on a fee being + * configured. + */ + links: z.object({ + documentation: z.string(), + source: z.string(), + openapi: z.string(), + }), +}); + +export type SupportedResponseBody = z.infer; diff --git a/bunny_js/src/facilitator/x402_settle.ts b/bunny_js/src/facilitator/x402_settle.ts new file mode 100644 index 000000000..625c258ce --- /dev/null +++ b/bunny_js/src/facilitator/x402_settle.ts @@ -0,0 +1,653 @@ +/** + * x402 v2 Facilitator - Settlement Logic + * Uses centralized x402Facilitator instance + * Includes post-settlement fee collection + */ + +import { getFacilitator } from "./facilitator_instance"; +import { verifyPayment } from "./x402_verify"; +import { + collectFee, + evaluateFeeGate, + feeAmountForToken, + type FeeGateDecision, + type FeeResult, +} from "./x402_fee"; +// The SDK's own dispatch predicates. Imported rather than reimplemented so this file's +// notion of "what will this payload do on-chain" cannot drift from the SDK's — drift +// between the two is exactly what the enriched-refund bypass was. +import { + isBatchSettlementClaimPayload, + isBatchSettlementEnrichedRefundPayload, + isBatchSettlementSettlePayload, +} from "@x402/evm"; +import type { Address } from "viem"; +import type { z } from "zod"; +import type { FeeStatusSchema, FacilitatorFeePaidSchema, SettleResponseBody } from "./x402_schemas"; +import { isTestWalletBypassed } from "./x402_whitelist"; +import { annotateDecodedRevert } from "./revert_selectors"; +import { logger } from "../logger"; + +/** + * The wire shapes below are derived from the Zod schemas in `x402_schemas.ts`, which are + * also what generate `openapi.json` — so the internal type, the published spec, and the + * response the handler builds cannot drift apart. Do not restate these fields by hand. + */ + +/** How this payment's fee stands once the settlement returns. */ +export type FeeStatus = z.infer; + +/** Facilitator fee receipt per x402 Fee Disclosure proposal (coinbase/x402#1016) */ +export type FacilitatorFeePaid = z.infer; + +/** + * The `/settle` response body, plus one field that never reaches the wire. + * + * `errorMessage` is the underlying failure detail from the SDK (e.g. the decoded EVM + * revert reason behind a generic `errorReason` like `..._deposit_transaction_failed`). + * **Logged, never returned over HTTP** — it is SDK-generated text that can embed + * addresses and calldata, and callers get the stable `errorReason` code instead. Without + * it the real cause is silently discarded, which has previously turned a one-line revert + * into days of guessing. It is deliberately absent from `SettleResponseSchema`. + */ +export type SettleResult = SettleResponseBody & { errorMessage?: string }; + +/** + * The one `success: false` that is **not** a terminal failure. + * + * Added by @x402/evm 2.23 for every settle path (eip3009/permit2 for `exact` and `upto`, and + * batch-settlement's settle/claim/deposit/refund): when the transaction has been **broadcast** but + * waiting for its receipt fails — an RPC error, a timeout — the chain may still confirm it. Before + * 2.23 that came back as a terminal failure, so a caller could conclude "settlement failed" about a + * transaction that in fact succeeded, and retry it. + * + * What makes it recoverable is the `transaction` hash that comes with it, so the caller can + * reconcile on chain before deciding anything. Which is precisely why the failure branches below + * must stop hard-coding `transaction: ""`. + * + * Declared here rather than imported: neither `@x402/core` nor `@x402/evm` exports its constant + * from a public entry point (it lives in an internal chunk in both), so the string is the contract. + */ +const SETTLEMENT_PENDING = "settlement_pending"; + +/** + * Return the one value every entry shares, or null when any is missing or they differ. + * Case-insensitive: addresses arrive in mixed EIP-55 checksum casing, so a raw string + * comparison would reject a legitimate batch. + */ +function singleAddress(values: Array): string | null { + if (values.length === 0 || values.some((value) => !value)) { + return null; + } + const unique = new Set(values.map((value) => value!.toLowerCase())); + return unique.size === 1 ? values[0]! : null; +} + +/** + * Derive the single receiver a batch-settlement claim/settle command pays out to, and the + * single token it pays out in — the token the fee is then charged in. + * + * Read straight from the payload because that is what the SDK acts on: + * `executeSettle()` takes its target from `payload.receiver` and `payload.token`, and + * `executeClaimWithSignature()` builds its claim args solely from `payload.claims` — + * neither reads `paymentRequirements.payTo` or `.asset`. + * + * A channel is a (payer, receiver, token, …) tuple, so a claim batch is one seller + * sweeping many of its own payer channels: many vouchers, **one** receiver. The contract + * would structurally accept a batch spanning channels with different receivers, but this + * facilitator charges one flat fee against one allowance, so such a batch would let one + * seller's allowance pay for another seller's payout. Reject it instead — returning null + * here makes the caller refuse the command. The same holds for tokens: one flat fee is + * charged in one token, so a batch mixing USDC and EURC channels is refused too. + * + * Returns null when the payload carries no usable receiver or token, or when a claim batch + * names more than one of either. + */ +function getBatchSettlementTarget( + payload: Record | undefined, +): { receiver: string; token: string } | null { + if (payload?.type === "settle") { + const receiver = payload?.receiver as string | undefined; + const token = payload?.token as string | undefined; + return receiver && token ? { receiver, token } : null; + } + + const claims = payload?.claims as + Array<{ voucher?: { channel?: { receiver?: string; token?: string } } }> | undefined; + if (!Array.isArray(claims)) { + return null; + } + + const receiver = singleAddress(claims.map((claim) => claim?.voucher?.channel?.receiver)); + const token = singleAddress(claims.map((claim) => claim?.voucher?.channel?.token)); + return receiver && token ? { receiver, token } : null; +} + +/** + * What a batch-settlement payload will actually do on-chain, and so whether it owes a fee. + * + * - `command` — `claim`/`settle`. Skip verify (the SDK has no verify() branch for + * them), gate, settle, charge. + * - `claiming-refund` — a refund carrying claims. Verify first, then gate and charge it + * exactly like a claim. + * - `free` — deposit, voucher, claim-less refund. These fund, sign or unwind a + * channel without paying out to a receiver (FEE_MODEL_PLAN.md + * Phase 3). + * - `reject` — malformed, or claims that do not belong to the verified channel. + */ +export type BatchRoute = + | { kind: "command"; feeRecipient: string; feeToken: string } + | { kind: "claiming-refund"; feeRecipient: string; feeToken: string } + | { kind: "free" } + | { kind: "reject"; errorReason: string }; + +/** + * Decide how a batch-settlement payload must be handled, keyed on the payload SHAPE the + * SDK dispatches on — never on `payload.type` alone. + * + * Shape and label disagree for exactly one payload: an *enriched refund*, `type: "refund"` + * carrying a non-empty `claims[]`. @x402/evm routes it to `executeRefundWithSignature()`, + * which submits `multicall([claimWithSignature, refundWithSignature])` — the identical + * on-chain payout a `type: "claim"` performs. Trusting the label let that shape skip the + * allowance gate and the fee outright, so a caller could have its claims relayed for free + * simply by relabelling them. **The fee follows the claim, not the label.** + * + * A claim-less refund stays free: it returns the payer's own escrow and pays out to no one. + */ +export function classifyBatchSettlement(payload: Record | undefined): BatchRoute { + if (isBatchSettlementClaimPayload(payload) || isBatchSettlementSettlePayload(payload)) { + const target = getBatchSettlementTarget(payload); + return target + ? { kind: "command", feeRecipient: target.receiver, feeToken: target.token } + : { kind: "reject", errorReason: "invalid_batch_settlement_evm_payload_type" }; + } + + if (isBatchSettlementEnrichedRefundPayload(payload)) { + const claims = (payload as Record).claims; + // The SDK's guards are shape-only (`"claims" in payload`), so a non-array `claims` + // reaches here fully "narrowed". Treat it as malformed rather than as free. + if (!Array.isArray(claims)) { + return { kind: "reject", errorReason: "invalid_batch_settlement_evm_payload_type" }; + } + if (claims.length === 0) { + return { kind: "free" }; + } + + const claimsTarget = getBatchSettlementTarget(payload); + if (!claimsTarget) { + return { kind: "reject", errorReason: "invalid_batch_settlement_evm_payload_type" }; + } + + // verify() validates ONLY payload.voucher and payload.channelConfig (verifyVoucher → + // validateChannelConfig, which is what pins channelConfig.receiver to + // requirements.payTo). It never looks at payload.claims. So without this check a + // caller could submit a perfectly valid refund for a channel it owns while smuggling + // claims that pay out to arbitrary unrelated receivers, relayed by the facilitator's + // hot wallet. Anchoring every claim to the one channel verify vouches for closes that. + const channelConfig = (payload as Record).channelConfig as + { receiver?: string; token?: string } | undefined; + const channelReceiver = channelConfig?.receiver; + if (!channelReceiver || channelReceiver.toLowerCase() !== claimsTarget.receiver.toLowerCase()) { + return { kind: "reject", errorReason: "invalid_batch_settlement_evm_receiver_mismatch" }; + } + // Same anchoring for the token: the fee is charged in it, so claims in a token other + // than the verified channel's could otherwise pick which allowance pays. + const channelToken = channelConfig?.token; + if (!channelToken || channelToken.toLowerCase() !== claimsTarget.token.toLowerCase()) { + return { kind: "reject", errorReason: "invalid_batch_settlement_evm_token_mismatch" }; + } + + // Take recipient and token from channelConfig, not from the claims: they are now proven + // equal, and channelConfig is what verify() binds via computeChannelId. + return { kind: "claiming-refund", feeRecipient: channelReceiver, feeToken: channelToken }; + } + + // Labelled a command but failed the shape guard above — e.g. a `settle` with no + // `receiver`. Malformed, not free. The SDK's verify() has no branch for these types + // either, so letting them fall through would settle nothing and surface a misleading + // `unexpected_verify_error` instead of naming the real problem. + // + // This reads `payload.type`, but only to sharpen the error for a payload already + // excluded from every paying path: it can reject, never admit, so it cannot become a + // way to buy free passage with a label. + const payloadType = payload?.type; + if (payloadType === "claim" || payloadType === "settle") { + return { kind: "reject", errorReason: "invalid_batch_settlement_evm_payload_type" }; + } + + return { kind: "free" }; +} + +/** + * Settle a payment by executing transferWithAuthorization on-chain. + * If fee is configured, collect fee after successful settlement. + */ +/** + * Describe a fee collection outcome for the receipt. + * + * "pending" is distinct from "failed": the transfer was sent but its receipt wait timed + * out (see FEE_RECEIPT_TIMEOUT_MS in x402_fee.ts), so it may still land. The tx hash is + * returned alongside it, which is how the seller resolves it themselves. + */ +function feeStatusOf(result: FeeResult): FeeStatus { + if (result.success) { + return "collected"; + } + return result.error === "fee_collection_pending" ? "pending" : "failed"; +} + +/** + * Collect the flat fee after a successful settlement and build the #1016 receipt. + * Shared by the `exact` branch and the batch-settlement `claim`/`settle` branch — both + * charge the identical flat fee, on-chain, immediately after their settlement lands. + */ +async function collectAndReportFee( + recipient: Address, + network: string, + token: Address, +): Promise<{ + fee: NonNullable; + extensions: SettleResult["extensions"]; +}> { + // In the settled token's own units: 0.01 EURe is 10¹⁶, not the 10000 a 6-decimal token uses. + // Null only for a token that is not a fee token, which the verify gate already refuses; were + // one to get here, collectFee charges nothing, so nothing is assessed either. + const feeAmount = feeAmountForToken(network, token) ?? 0n; + const feeResult = await collectFee(recipient, network, token); + const feeStatus = feeStatusOf(feeResult); + + if (feeStatus === "collected") { + logger.info( + { recipient, network, feeTxHash: feeResult.txHash }, + "Fee collected successfully after settlement", + ); + } else if (feeStatus === "pending") { + // Fee tx was sent but not confirmed within its bounded wait. It may still land, + // so this is not a failure — it is an unknown outcome carrying a tx hash, which + // is returned in the receipt so the seller can resolve it themselves. + logger.warn( + { recipient, network, feeTxHash: feeResult.txHash }, + "Fee tx pending at response time — outcome unknown", + ); + } else { + // Fee collection failed — settlement still succeeded. The fee is not retried: + // at 0.01 of a stablecoin the bookkeeping to recover it costs far more than the fee. + logger.warn( + { recipient, network, feeError: feeResult.error }, + "Fee collection failed after successful settlement", + ); + } + + // `facilitatorFeePaid` always reports the fee ASSESSED for this payment, never a + // collection outcome. Zeroing it because collection failed would understate what + // the payment actually cost and make the facilitator look cheaper than it is — the + // worse distortion for a transparency extension. The outcome lives in + // `collection.status` instead. + return { + fee: { + collected: feeStatus === "collected", + status: feeStatus, + txHash: feeResult.txHash, + error: feeResult.error, + }, + extensions: { + facilitatorFees: { + info: { + version: "1", + facilitatorFeePaid: feeAmount.toString(), + asset: `${network}/erc20:${token}`, + model: "flat", + collection: { + status: feeStatus, + ...(feeResult.txHash && { txHash: feeResult.txHash }), + }, + }, + }, + }, + }; +} + +export async function settlePayment( + paymentPayload: Record, + paymentRequirements: Record, +): Promise { + try { + const accepted = paymentPayload.accepted as Record | undefined; + const payload = paymentPayload.payload as Record | undefined; + + // Every batch-settlement fee decision is made here, once, from the payload's SHAPE. + // The onAfterVerify hook deliberately forces feeRequired=false for this whole scheme + // so it cannot become a second, label-based source of truth for the same question. + const isBatchSettlement = accepted?.scheme === "batch-settlement"; + const route: BatchRoute = isBatchSettlement + ? classifyBatchSettlement(payload) + : { kind: "free" }; + + if (route.kind === "reject") { + logger.warn( + { errorReason: route.errorReason, network: paymentRequirements.network }, + "Batch-settlement payload rejected before settlement", + ); + return { + success: false, + errorReason: route.errorReason, + transaction: "", + network: paymentRequirements.network as string | undefined, + }; + } + + // Batch-settlement "claim" and "settle" payloads are settlement COMMANDS, not + // future payments to verify — the SDK's own scheme.verify() has no branch for + // them at all (only deposit/voucher/refund are verifiable) and unconditionally + // rejects them with invalid_batch_settlement_evm_payload_type. Skip the + // verify-first gate for these two types and settle directly; the scheme's own + // settle() does its own type-appropriate validation internally (e.g. + // executeClaimWithSignature verifies the claimAuthorizerSignature and each + // voucher signature on-chain before moving funds). + // + // An enriched refund does the same payout but IS verifiable, so it deliberately does + // NOT come through here — it takes the verify-first path below, which is what pins + // its channelConfig.receiver to requirements.payTo. + if (route.kind === "command") { + // Claim/settle payloads never reach verifyPayment()/onAfterVerify() (see the + // comment above), so this is the only gate they ever pass through. These are the + // two payload types that actually realize a payment ("usage"), so — unlike + // deposit/voucher/refund, which are open and fee-free — they carry the same flat + // fee `exact` charges, gated by the same allowance check (in the channel's token) + // (FEE_MODEL_PLAN.md Phase 3). + // + // The gate input must come from what the SDK actually executes on, never from + // the client's `accepted` envelope or `paymentRequirements.payTo`: + // - `requirements.payTo` is bound to the channel's receiver only by + // validateChannelConfig(), which runs inside verify() — the very path this + // branch skips. On the settle path it is an unchecked, caller-supplied string. + // - network: verify() enforces `accepted.network === requirements.network`, but + // settle() does not, while executeClaimWithSignature()/executeSettle() both + // dispatch on `requirements.network`. Gating on `accepted.network` would let a + // caller claim a testnet (admitting BATCH_SETTLEMENT_TEST_WALLETS) while the + // transaction executes on mainnet. + const network = paymentRequirements.network as string | undefined; + // One seller per batch — see getBatchSettlementReceiver, applied by + // classifyBatchSettlement. The fee is charged once against that seller's allowance, + // same as `exact`. + const feeRecipient = route.feeRecipient; + + if (!network) { + logger.warn({ network }, "Batch-settlement claim/settle has no network to dispatch on"); + return { + success: false, + errorReason: "invalid_batch_settlement_evm_payload_type", + transaction: "", + network, + }; + } + + // A testnet test wallet skips the gate entirely (CI/local dev convenience); it then + // takes the same no-receipt path fees-disabled does. + const gate: FeeGateDecision = isTestWalletBypassed(feeRecipient, network) + ? { kind: "no_fee" } + : await evaluateFeeGate(feeRecipient as Address, network, route.feeToken as Address); + + if (gate.kind === "reject") { + return { + success: false, + errorReason: gate.reason, + transaction: "", + network, + }; + } + + const facilitator = getFacilitator(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await facilitator.settle(paymentPayload as any, paymentRequirements as any); + // Decoded once here, at the boundary where the SDK's raw text enters this package — + // every log and the returned SettleResult below carry the annotated message. + const errorMessage = annotateDecodedRevert(result.errorMessage); + const claims = payload?.claims as + Array<{ voucher?: { channel?: { payer?: string } } }> | undefined; + const payer = claims?.[0]?.voucher?.channel?.payer; + + if (!result.success) { + const pending = result.errorReason === SETTLEMENT_PENDING; + logger.warn( + { + errorReason: result.errorReason, + errorMessage, + // Only meaningful when pending — a terminal failure never broadcast anything. + ...(pending && { transaction: result.transaction, network }), + }, + pending + ? "Batch-settlement claim/settle broadcast but unconfirmed — reconcile on chain" + : "Batch-settlement claim/settle failed", + ); + return { + success: false, + errorReason: result.errorReason, + errorMessage, + payer, + // Pass the broadcast hash through. Hard-coding "" here would throw away the one thing + // that makes settlement_pending recoverable, leaving the caller unable to tell a + // transaction that never happened from one that may already have confirmed. + transaction: pending ? (result.transaction ?? "") : "", + network, + }; + } + + logger.info( + { hash: result.transaction, network }, + "Batch-settlement claim/settle transaction confirmed", + ); + + if (gate.kind === "no_fee") { + // Fees disabled, or a testnet test wallet. Attach no fee receipt — `fee` and + // `extensions.facilitatorFees` are documented as present only when a fee is + // configured, and `exact` omits them under the same conditions. + return { + success: true, + payer, + transaction: result.transaction, + network, + extra: result.extra, + }; + } + + logger.info({ feeRecipient, network }, "Settlement succeeded, collecting fee"); + const { fee, extensions } = await collectAndReportFee( + feeRecipient as Address, + network, + route.feeToken as Address, + ); + + return { + success: true, + payer, + transaction: result.transaction, + network, + fee, + extensions, + extra: result.extra, + }; + } + + // First verify the payment (includes fee allowance check) + logger.info("Verifying payment before settlement"); + const verifyResult = await verifyPayment(paymentPayload, paymentRequirements); + + if (!verifyResult.isValid) { + logger.warn({ invalidReason: verifyResult.invalidReason }, "Payment verification failed"); + return { + success: false, + errorReason: verifyResult.invalidReason, + payer: verifyResult.payer, + transaction: "", + network: accepted?.network as string, + }; + } + + // An enriched refund performs a claimWithSignature — the same payout `type: "claim"` + // performs — so it owes the same fee and must clear the same allowance gate. verify() + // has just pinned channelConfig.receiver to requirements.payTo, and + // classifyBatchSettlement pinned every claim to that same receiver, so this recipient + // is now a verified address rather than a caller-supplied string. Gate BEFORE + // settling: the allowance is the merchant's authorization to be relayed at all, so it + // has to hold before the facilitator's hot wallet spends any gas. + const settleNetwork = paymentRequirements.network as string | undefined; + let refundGate: FeeGateDecision | undefined; + if (route.kind === "claiming-refund") { + if (!settleNetwork) { + logger.warn({}, "Claim-carrying refund has no network to dispatch on"); + return { + success: false, + errorReason: "invalid_batch_settlement_evm_payload_type", + payer: verifyResult.payer, + transaction: "", + network: settleNetwork, + }; + } + + // Same testnet test-wallet carve-out the claim/settle branch gets. + refundGate = isTestWalletBypassed(route.feeRecipient, settleNetwork) + ? { kind: "no_fee" } + : await evaluateFeeGate( + route.feeRecipient as Address, + settleNetwork, + route.feeToken as Address, + ); + + if (refundGate.kind === "reject") { + return { + success: false, + errorReason: refundGate.reason, + payer: verifyResult.payer, + transaction: "", + network: settleNetwork, + }; + } + } + + // Execute settlement via x402 Facilitator + const facilitator = getFacilitator(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await facilitator.settle(paymentPayload as any, paymentRequirements as any); + // Decoded once here, at the boundary where the SDK's raw text enters this package — + // every log and the returned SettleResult below carry the annotated message. + const errorMessage = annotateDecodedRevert(result.errorMessage); + + if (!result.success) { + const pending = result.errorReason === SETTLEMENT_PENDING; + logger.warn( + { + errorReason: result.errorReason, + errorMessage, + ...(pending && { transaction: result.transaction, network: accepted?.network }), + }, + pending ? "Settlement broadcast but unconfirmed — reconcile on chain" : "Settlement failed", + ); + return { + success: false, + errorReason: result.errorReason, + errorMessage, + payer: verifyResult.payer, + // See SETTLEMENT_PENDING: the hash is what the caller reconciles against. + transaction: pending ? (result.transaction ?? "") : "", + network: accepted?.network as string, + }; + } + + logger.info({ hash: result.transaction, network: accepted?.network }, "Transaction confirmed"); + + // Settlement succeeded — check if fee collection is needed. Two mutually exclusive + // sources, never both: + // - batch-settlement: only a claim-carrying refund owes anything, decided by `route` + // above. Deposit/voucher/claim-less refunds fund, sign or unwind a channel and stay + // free. + // - exact: the onAfterVerify hook set feeRequired + recipient. The `!isBatchSettlement` + // guard keeps that arm off batch payloads entirely, so the two can never double-charge + // the same settlement even if the hook is later changed. + // The exact fee is charged in `paymentRequirements.asset`: the requirements verify just + // ran against, and the token the onAfterVerify gate checked the allowance of. + const network = accepted?.network as string | undefined; + const exactAsset = paymentRequirements.asset as string | undefined; + const chargeable: { recipient: Address; network: string; token: Address } | undefined = + route.kind === "claiming-refund" && refundGate?.kind === "charge" && settleNetwork + ? { + recipient: route.feeRecipient as Address, + network: settleNetwork, + token: route.feeToken as Address, + } + : verifyResult.feeRequired && + !isBatchSettlement && + verifyResult.recipient && + network && + exactAsset + ? { + recipient: verifyResult.recipient as Address, + network, + token: exactAsset as Address, + } + : undefined; + + if (chargeable) { + logger.info( + { recipient: chargeable.recipient, network: chargeable.network }, + "Settlement succeeded, collecting fee", + ); + const { fee, extensions } = await collectAndReportFee( + chargeable.recipient, + chargeable.network, + chargeable.token, + ); + + return { + success: true, + payer: verifyResult.payer, + transaction: result.transaction, + network: accepted?.network as string, + fee, + extensions, + extra: result.extra, + }; + } + + // No fee required (fee=0 or feeRequired not set) — this is the path batch-settlement + // deposit/voucher payloads always take (feeRequired is forced false for them), so + // result.extra (e.g. channelState.channelId) must be passed through here too. + return { + success: true, + payer: verifyResult.payer, + transaction: result.transaction, + network: accepted?.network as string, + extra: result.extra, + }; + } catch (error) { + const err = error as Error; + // Distinct phrase from the ordinary "Settlement failed" result below (a caller error, + // returned rather than thrown) so an alert rule can page on this one without also firing + // on every bad signature. The SDK returns transaction failures rather than throwing them + // (see x402/evm's parseEip3009TransferError), so reaching this catch at all means + // something outside the scheme's own validation broke — a hook abort, no scheme + // registered, or getFacilitator() itself failing. + logger.error({ err }, "Settlement threw"); + + // Always the generic reason. Caller errors (reused nonce, expiry, balance) come back + // from the SDK as returned reasons, never through here, so there is nothing to recover + // by sniffing the message — and sniffing once reported OUR wallet running out of gas + // ("insufficient funds for gas") as the caller's fault. + const payload = paymentPayload.payload as Record | undefined; + // The payer sits at authorization.from (EIP-3009) or permit2Authorization.from (Permit2). + // Permit2 wins when both shapes are present — the SDK's isPermit2Payload() discriminates + // the same way, so a crafted payload settling as Permit2 must be attributed to its payer, + // not to the EIP-3009 field paired with it. + const authorization = (payload?.permit2Authorization ?? payload?.authorization) as + Record | undefined; + const accepted = paymentPayload.accepted as Record | undefined; + + return { + success: false, + errorReason: "settlement_failed", + payer: authorization?.from as string | undefined, + transaction: "", + network: accepted?.network as string | undefined, + }; + } +} diff --git a/bunny_js/src/facilitator/x402_supported.ts b/bunny_js/src/facilitator/x402_supported.ts new file mode 100644 index 000000000..c6fbfa00f --- /dev/null +++ b/bunny_js/src/facilitator/x402_supported.ts @@ -0,0 +1,112 @@ +/** + * x402 v2 Supported Capabilities Module + * Creates fresh read-only facilitator instance (no singleton caching) + */ + +import { createReadOnlyFacilitator } from "./facilitator_instance"; +import { formatUnits, parseUnits } from "viem"; +import { getFeeTokens } from "./chain_utils"; +import { feeAmountFor, getFeeAmount, getFacilitatorAddress } from "./x402_fee"; +import type { SupportedResponseBody } from "./x402_schemas"; + +/** + * Shape of the `/supported` response. Derived from `SupportedResponseSchema` + * (`x402_schemas.ts`) — that Zod schema is also what generates `openapi.json`'s + * `SupportedResponse`, so this type and the published spec can't drift apart. + */ +type SupportedCapabilities = SupportedResponseBody; + +/** Extension key advertised in `extensions` when a fee is configured. */ +const FACILITATOR_FEE_EXTENSION_KEY = "facilitator_fee"; +const FACILITATOR_FEES_EXTENSION_KEY = "facilitatorFees"; +/** + * The @x402/evm extension carrying an EIP-2612 permit to Permit2 inside the payment, so an EURe + * buyer needs no separate approve() transaction. The exact scheme verifies and settles it from the + * payload alone (settleWithPermit); a resource server reads this key from `/supported` to decide + * whether to offer it in its 402. + */ +const EIP2612_GAS_SPONSORING_EXTENSION_KEY = "eip2612GasSponsoring"; + +const DOCUMENTATION_URL = "https://www.fretchen.eu/x402/"; +const SOURCE_URL = "https://github.com/fretchen/fretchen.github.io/tree/main/x402_facilitator"; +const OPENAPI_URL = "https://facilitator.fretchen.eu/openapi.json"; + +/** + * Get supported payment schemes and networks. + * Creates a new read-only facilitator instance each time (no private key required). + */ +export function getSupportedCapabilities(): SupportedCapabilities { + const facilitator = createReadOnlyFacilitator(); + + // Base response: { kinds, extensions: string[], signers } + const base = facilitator.getSupported(); + const supported: SupportedCapabilities = { + ...base, + extensions: [...(base.extensions ?? []), EIP2612_GAS_SPONSORING_EXTENSION_KEY], + links: { documentation: DOCUMENTATION_URL, source: SOURCE_URL, openapi: OPENAPI_URL }, + }; + + const feeAmount = getFeeAmount(); + const facilitatorAddress = getFacilitatorAddress(); + + // Advertise the fee only when it is actually chargeable: a positive amount AND a + // configured facilitator address to collect it. In read-only mode (no key) both the + // extension keys and the disclosure object are omitted. + if (feeAmount > 0n && facilitatorAddress) { + supported.extensions.push(FACILITATOR_FEE_EXTENSION_KEY, FACILITATOR_FEES_EXTENSION_KEY); + + // Derive networks from `kinds` to stay consistent with the advertised response. + const networks = [...new Set(supported.kinds.map((k) => k.network))]; + supported.facilitatorFees = { + version: "1", + model: "flat", + // Not a token: the fee is charged in whichever token the payment settles in. + asset: "settled", + flatFee: feeAmount.toString(), + decimals: 6, + recipient: facilitatorAddress, + networks, + fee: { + amount: feeAmount.toString(), + description: + `${formatUnits(feeAmount, 6)} of the settled token (USDC; EURC and EURe on Base) per ` + + "settlement (exact), or per on-chain claim " + + "or settle transaction (batch-settlement) — same flat amount either way. " + + "batch-settlement charges whatever realizes a payment: claim, settle, and a refund " + + "that carries claims (which settles them in the same transaction). Deposits, " + + "vouchers, and claim-less refunds are not charged.", + collection: "post_settlement_transferFrom", + }, + setup: { + description: + "Recurring approval, one per token you are paid in: call approve() on the USDC contract " + + "(any network) and/or the EURC and EURe contracts (Base) for the facilitator's address. " + + "EURe has 18 decimals: approve the per-token amount listed in `assets`. A settlement " + + "in a token you have not approved is refused. Applies to both schemes: exact recipients and " + + "batch-settlement claim/settle recipients draw from the same per-token allowance. The recommended amount is deliberately small: the spender is a hot " + + "wallet, so a large standing allowance is a standing risk. Re-approve when remainingSettlements " + + "(in the /verify response) runs low; revoke any time with approve(spender, 0).", + function: "approve(address spender, uint256 amount)", + spender: facilitatorAddress, + // Deliberately small. The spender is the same key that signs every settlement + // (FACILITATOR_WALLET_PRIVATE_KEY, a hot secret), so this figure is the per-merchant + // blast radius of a key compromise — not a convenience setting. Raise it only with + // that tradeoff in mind; the test in x402_supported.test.js bounds it. + recommended_amount: "1000000", // 1 token (6 decimals) = 100 settlements + }, + // The same 0.01 fee and 1-token approval, in each token's own units. + assets: networks.flatMap((network) => + getFeeTokens(network).map((token) => ({ + network, + asset: token.address, + symbol: token.symbol, + decimals: token.decimals, + flatFee: feeAmountFor(token.decimals).toString(), + recommended_amount: parseUnits("1", token.decimals).toString(), + })), + ), + }; + } + + return supported; +} diff --git a/bunny_js/src/facilitator/x402_verify.ts b/bunny_js/src/facilitator/x402_verify.ts new file mode 100644 index 000000000..6b4b6b155 --- /dev/null +++ b/bunny_js/src/facilitator/x402_verify.ts @@ -0,0 +1,114 @@ +/** + * x402 v2 Payment Verification Module + * Uses centralized x402Facilitator instance + */ + +import { getFacilitator } from "./facilitator_instance"; +import { logger } from "../logger"; + +export interface VerifyResult { + isValid: boolean; + invalidReason?: string; + payer?: string; + recipient?: string; + /** Whether fee collection is required at settle time */ + feeRequired?: boolean; + /** How many more settlements the current allowance covers. Surfaced to the seller. */ + remainingSettlements?: number; + /** Scheme-specific extras from the facilitator (e.g. batch-settlement's channelState). Already + * passed through at runtime via the `...result` spread below — declared here for typing/documentation. */ + extra?: Record; +} + +/** + * Verify payment authorization using x402 Facilitator + */ +export async function verifyPayment( + paymentPayload: Record, + paymentRequirements: Record, +): Promise { + try { + // Log authorization data for debugging (only at debug level) + const payload = paymentPayload?.payload as Record | undefined; + const auth = payload?.authorization as Record | undefined; + if (auth && logger.isLevelEnabled("debug")) { + logger.debug( + { + authTypes: { + value: typeof auth.value, + validAfter: typeof auth.validAfter, + validBefore: typeof auth.validBefore, + }, + authValues: { + value: auth.value, + validAfter: auth.validAfter, + validBefore: auth.validBefore, + }, + }, + "Authorization data received", + ); + } + + // Log EIP-712 domain parameters for debugging (only at debug level) + const accepted = paymentPayload?.accepted as Record | undefined; + const extra = accepted?.extra as Record | undefined; + if (logger.isLevelEnabled("debug")) { + logger.debug( + { + extraName: extra?.name, + extraVersion: extra?.version, + asset: accepted?.asset, + network: accepted?.network, + payTo: accepted?.payTo, + }, + "EIP-712 Domain reconstruction parameters", + ); + } + + // Pre-validate signature format before the RPC-heavy verify path. + // The x402 library passes raw signatures to viem which throws on malformed input + // rather than returning a structured failure — this would cause RPC timeouts first. + const signature = payload?.signature as string | undefined; + if (signature !== undefined && !signature.startsWith("0x")) { + logger.warn({ signature: `${signature.slice(0, 8)}…` }, "Signature missing 0x prefix"); + return { isValid: false, invalidReason: "invalid_exact_evm_signature" }; + } + + const facilitator = getFacilitator(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await facilitator.verify(paymentPayload as any, paymentRequirements as any); + + if (result.isValid) { + logger.info( + { + payer: result.payer, + amount: auth?.value, + network: accepted?.network, + }, + "Payment verification successful", + ); + } else { + logger.warn( + { invalidReason: result.invalidReason, payer: result.payer }, + "Payment verification failed", + ); + } + + // Pass through fee status from onAfterVerify hook + const resultWithExtras = result as Record; + return { + ...result, + feeRequired: resultWithExtras.feeRequired as boolean | undefined, + recipient: resultWithExtras.recipient as string | undefined, + remainingSettlements: resultWithExtras.remainingSettlements as number | undefined, + }; + } catch (error) { + // { err: error } (not just the message) so pino serializes the stack trace too — this + // is the unclassified path, so the stack is what makes it debuggable. + logger.error({ err: error }, "Unexpected error during payment verification"); + return { + isValid: false, + invalidReason: "unexpected_verify_error", + }; + } +} diff --git a/bunny_js/src/facilitator/x402_whitelist.ts b/bunny_js/src/facilitator/x402_whitelist.ts new file mode 100644 index 000000000..7eccd78da --- /dev/null +++ b/bunny_js/src/facilitator/x402_whitelist.ts @@ -0,0 +1,38 @@ +/** + * x402 Batch-Settlement Test-Wallet Bypass + * + * `batch-settlement` gates its on-chain `claim`/`settle` transactions the same way + * `exact` gates recipients: a USDC allowance fee check (see `x402_fee.ts`). This module + * carries only the testnet convenience carve-out — a fixed list of test wallets that + * skip the allowance check on testnets, so CI/local dev doesn't need a funded USDC + * allowance to exercise the scheme. + * + * `deposit`, `voucher`, and `refund` payloads are not gated here or anywhere else — + * they are not "usage" (payment realization), so they carry no fee and no check. + * + * History: this file used to also carry `BATCH_SETTLEMENT_MANUAL_WHITELIST`, an + * explicit allowlist gating every batch-settlement payload type. Retired once the + * claim/settle allowance check took over as the abuse gate (FEE_MODEL_PLAN.md Phase 3). + */ + +import { isTestnet } from "@fretchen/chain-utils"; + +function parseAddressList(envVar: string | undefined): string[] { + if (!envVar) { + return []; + } + return envVar.split(",").map((address) => address.trim().toLowerCase()); +} + +function getTestWallets(): string[] { + return parseAddressList(process.env.BATCH_SETTLEMENT_TEST_WALLETS); +} + +/** + * Check whether a batch-settlement `claim`/`settle` receiver is a testnet test wallet + * that should skip the allowance/fee check. + */ +export function isTestWalletBypassed(address: string, network: string): boolean { + const normalizedAddress = address.toLowerCase(); + return isTestnet(network) && getTestWallets().includes(normalizedAddress); +} diff --git a/bunny_js/src/logger.ts b/bunny_js/src/logger.ts index 928caa58e..d869f0d90 100644 --- a/bunny_js/src/logger.ts +++ b/bunny_js/src/logger.ts @@ -29,10 +29,22 @@ function nsTimestamp(): string { return `${Date.now()}${String(++nsCounter).padStart(6, "0")}`; } +/** + * pino serializes Error values (the `err` key) into message + stack; JSON.stringify does not + * (it yields `{}`). The ported core logs caught exceptions as `{ err: Error }` and pino parity + * matters both for debugging and for `| json` reads in Grafana — without this, a swallowed + * exception logs as `"err":{}` and its cause is invisible. + */ +function stringifyLine(value: unknown): string { + return JSON.stringify(value, (_key, v) => + v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v, + ); +} + function emit(level: string, arg: unknown, maybeMsg?: string): void { const [obj, msg] = typeof arg === "string" ? [{}, arg] : [(arg as object) ?? {}, maybeMsg ?? ""]; // pino prints `level then message then context`; JSON-lines keeps the same fields. - const line = JSON.stringify({ level, time: Date.now(), msg, ...obj }); + const line = stringifyLine({ level, time: Date.now(), msg, ...obj }); if (level === "error") { console.error(line); } else if (level === "warn") { @@ -67,7 +79,7 @@ export async function flushLogs(): Promise { const byLevel = new Map(); for (const { level, event } of batch) { const vals = byLevel.get(level) ?? []; - vals.push([nsTimestamp(), JSON.stringify(event)]); + vals.push([nsTimestamp(), stringifyLine(event)]); byLevel.set(level, vals); } try { @@ -116,9 +128,23 @@ export function withLogFlush( }; } +/** + * pino-parity for the ported core: it gates debug logging behind this (pino's Gating API), + * e.g. `if (logger.isLevelEnabled("debug"))`. The same env gate as scw's pino instance: + * LOG_LEVEL (default info) lets debug be turned on in the dashboard without a deploy. + */ +const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 } as const; +function configuredLevel(): keyof typeof LEVEL_ORDER { + const raw = (process.env.LOG_LEVEL ?? "info").toLowerCase(); + return raw in LEVEL_ORDER ? (raw as keyof typeof LEVEL_ORDER) : "info"; +} + export const logger = { info: (arg: unknown, msg?: string) => emit("info", arg, msg), warn: (arg: unknown, msg?: string) => emit("warn", arg, msg), error: (arg: unknown, msg?: string) => emit("error", arg, msg), debug: (arg: unknown, msg?: string) => emit("debug", arg, msg), + isLevelEnabled: (level: string): boolean => + LEVEL_ORDER[configuredLevel()] <= + (LEVEL_ORDER[level as keyof typeof LEVEL_ORDER] ?? LEVEL_ORDER.info), }; diff --git a/bunny_js/test/facilitator/chain_utils.test.ts b/bunny_js/test/facilitator/chain_utils.test.ts new file mode 100644 index 000000000..8d52a7a90 --- /dev/null +++ b/bunny_js/test/facilitator/chain_utils.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { getFeeTokens, findFeeToken } from "../../src/facilitator/chain_utils.js"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; + +const BASE = "eip155:8453"; +const BASE_SEPOLIA = "eip155:84532"; +const OP = "eip155:10"; + +describe("fee tokens", () => { + it("settles USDC, EURC and EURe on both Base networks", () => { + for (const network of [BASE, BASE_SEPOLIA]) { + expect(getFeeTokens(network).map((t) => t.symbol)).toEqual(["USDC", "EURC", "EURe"]); + } + }); + + it("settles USDC only on Optimism, where neither EURC nor EURe is deployed", () => { + expect(getFeeTokens(OP).map((t) => t.symbol)).toEqual(["USDC"]); + }); + + it("marks EURe as an 18-decimal Permit2 token, and USDC/EURC as 6-decimal EIP-3009", () => { + // Read on-chain 2026-10-04: EURe decimals() 18, EIP-2612 permit present, EIP-3009 absent. + const byToken = Object.fromEntries(getFeeTokens(BASE).map((t) => [t.symbol, t])); + expect(byToken.EURe).toMatchObject({ + address: "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", + decimals: 18, + transferMethod: "permit2", + }); + expect(byToken.USDC).toMatchObject({ decimals: 6, transferMethod: "eip3009" }); + expect(byToken.EURC).toMatchObject({ decimals: 6, transferMethod: "eip3009" }); + }); + + it("finds a fee token case-insensitively, and only on its own network", () => { + expect(findFeeToken(BASE, EURE_ADDRESSES[BASE].toLowerCase())?.symbol).toBe("EURe"); + expect(findFeeToken(OP, EURE_ADDRESSES[BASE])).toBeNull(); + expect(findFeeToken(BASE, "0x0000000000000000000000000000000000000001")).toBeNull(); + }); +}); diff --git a/bunny_js/test/facilitator/eip712_reference.test.ts b/bunny_js/test/facilitator/eip712_reference.test.ts new file mode 100644 index 000000000..cd9670bcf --- /dev/null +++ b/bunny_js/test/facilitator/eip712_reference.test.ts @@ -0,0 +1,189 @@ +/** + * EIP-712 Reference Test + * + * Diese Tests validieren unsere EIP-712 Implementation gegen die offiziellen + * Test-Vektoren aus der EIP-712 Spezifikation. + * + * Quelle: https://eips.ethereum.org/assets/eip-712/Example.js + */ + +import { describe, test, expect } from "vitest"; +import { hashTypedData, keccak256, toHex } from "viem"; + +describe("EIP-712 Reference Tests", () => { + // Offizielle Test-Daten aus der EIP-712 Spezifikation + const typedData = { + types: { + Person: [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ], + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ], + }, + primaryType: "Mail", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + from: { + name: "Cow", + wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + }, + to: { + name: "Bob", + wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + contents: "Hello, Bob!", + }, + } as const; + + test("viem hashTypedData matches EIP-712 reference", () => { + const hash = hashTypedData({ + domain: typedData.domain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }); + + // Erwarteter Hash aus der offiziellen EIP-712 Spezifikation + // Der Hash aus der Spec hatte einen Tippfehler - korrigiert basierend auf viem's Output + const expectedHash = "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"; + + expect(hash).toBe(expectedHash); + }); + + test("USDC TransferWithAuthorization structure", () => { + // Test mit unserem echten USDC Use-Case + const usdcTypes = { + 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 usdcDomain = { + name: "USDC", + version: "2", + chainId: 11155420, + verifyingContract: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7", + } as const; + + const message = { + from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + value: BigInt("10000"), + validAfter: BigInt("1740672089"), + validBefore: BigInt("9999999999"), + nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", + }; + + // Hash sollte deterministisch sein + const hash1 = hashTypedData({ + domain: usdcDomain, + types: usdcTypes, + primaryType: "TransferWithAuthorization", + message, + }); + + const hash2 = hashTypedData({ + domain: usdcDomain, + types: usdcTypes, + primaryType: "TransferWithAuthorization", + message, + }); + + // Beide Hashes müssen identisch sein (Determinismus) + expect(hash1).toBe(hash2); + + // Der Hash aus unserem generate_test_signature Script + const expectedHash = "0x2ffe322a269aef1318b6cfa83e381fe06a1657df078abb0668f71ad1f603b0c8"; + expect(hash1).toBe(expectedHash); + }); + + test("Type encoding is deterministic", () => { + // Test dass die Type-Kodierung deterministisch ist + // Encode den Type manuell (wie EIP-712 spec) + const typeString = + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; + const typeHash = keccak256(toHex(typeString)); + + // Der typeHash sollte konstant sein + expect(typeHash).toBe("0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267"); + }); + + test("Domain separator calculation", () => { + // Test Domain Separator Berechnung + const domain = { + name: "USDC", + version: "2", + chainId: 11155420, + verifyingContract: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7", + } as const; + + // Hash mit nur Domain + const hash1 = hashTypedData({ + domain, + types: { + Test: [{ name: "value", type: "uint256" }], + }, + primaryType: "Test", + message: { value: BigInt(1) }, + }); + + // Hash mit gleicher Domain, anderer Message + const hash2 = hashTypedData({ + domain, + types: { + Test: [{ name: "value", type: "uint256" }], + }, + primaryType: "Test", + message: { value: BigInt(2) }, + }); + + // Die Hashes müssen unterschiedlich sein (Domain allein reicht nicht) + expect(hash1).not.toBe(hash2); + }); + + test("BigInt values are correctly encoded", () => { + // Test dass BigInt Werte korrekt enkodiert werden + const types = { + Test: [{ name: "amount", type: "uint256" }], + }; + + const domain = { + name: "Test", + version: "1", + chainId: 1, + verifyingContract: "0x0000000000000000000000000000000000000001", + } as const; + + // Mit BigInt + const hashWithBigInt = hashTypedData({ + domain, + types, + primaryType: "Test", + message: { amount: BigInt("10000") }, + }); + + // Sollte deterministisch sein + const hashWithBigInt2 = hashTypedData({ + domain, + types, + primaryType: "Test", + message: { amount: BigInt("10000") }, + }); + + expect(hashWithBigInt).toBe(hashWithBigInt2); + }); +}); diff --git a/bunny_js/test/facilitator/facilitator_alert_coverage.test.ts b/bunny_js/test/facilitator/facilitator_alert_coverage.test.ts new file mode 100644 index 000000000..b5aa4a51f --- /dev/null +++ b/bunny_js/test/facilitator/facilitator_alert_coverage.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +// Builtins-only parser, imported by relative path exactly like scw_js's and +// x402_facilitator's copies of this test — no node_modules of this package are needed, so the +// CI job that runs these tests does not need observability/ installed. See its header. +import { + extractRules, + rulesForFunctions, + isCovered, +} from "../../../observability/alertCoverage.js"; + +/** + * The twins have NO alert_coverage test on purpose: their lines carry `level` as a real Loki + * label, so `{level="error"}` covers every logger.error by construction and any test would be + * a tautology (see the reasoning in observability/alerts/bunny.yaml's header). The facilitator + * breaks that argument: its most valuable alertable lines are `logger.warn` BY DESIGN (a + * caller's fault is warn), so the bunny rule carries a phrase alternation for it — and a + * phrase list can silently drift away from the call sites it was written for, which is the + * exact failure this test family exists to catch (see the #683 history). This test pins the + * ported facilitator files in this package to that alternation in three ways: + * 1. every `logger.error` message matches the alternation, + * 2. the warn-level alert phrases (synthetic lines in the ported logger's wire format — + * message BEFORE the merging object) match it, including the our-fault reason filter, + * 3. a caller-fault "Settlement failed" line doesn't match it (the reason filter is load- + * bearing: without it every caller error pages). + * + * The reason list below is payments.yaml's our-fault list, verbatim — not an invention: the + * scw rule chose these reasons for the facilitator and this port is parity, not new semantics. + */ + +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = path.join(TEST_DIR, "..", "..", "src", "facilitator"); +const ALERTS_DIR = path.join(TEST_DIR, "../../../observability/alerts"); + +/** The facilitator's surface in this package — payment core + routing + instance. */ +const FACILITATOR_FILES = [ + "facilitator_routes.ts", + "facilitator_instance.ts", + "chain_utils.ts", + "revert_selectors.ts", + "x402_fee.ts", + "x402_schemas.ts", + "x402_settle.ts", + "x402_supported.ts", + "x402_verify.ts", + "x402_whitelist.ts", +]; + +const messages = FACILITATOR_FILES.flatMap((file) => { + const text = fs.readFileSync(path.join(SRC_DIR, file), "utf8"); + const callSites = [...text.matchAll(/logger\.error\(([\s\S]*?)\);/g)]; + return callSites.map((call, i) => { + const literals = [...call[1].matchAll(/"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g)]; + const raw = literals[literals.length - 1][0]; + const message = raw.startsWith("`") ? raw.slice(1, -1).split("${")[0] : raw.slice(1, -1); + return { file: `${file}#${call.index ?? i}`, message }; + }); +}); + +const rules = rulesForFunctions(extractRules(ALERTS_DIR), ["facilitator"]); +const alternation = rules[0]?.filters[0]; + +if (!alternation || alternation.op !== "~") { + throw new Error('bunny.yaml\'s facilitator branch must be the first `|~ "…"` filter in its rule'); +} + +const matches = (line: string): boolean => new RegExp(alternation.value).test(line); + +describe("facilitator alert coverage (bunny rule alternation vs this package's call sites)", () => { + it("found a realistic number of logger.error call sites", () => { + // A floor, not an exact count — guards against the extraction silently matching nothing, + // which would make the second test vacuously pass. + expect(messages.length).toBeGreaterThanOrEqual(5); + }); + + it("every ported logger.error message is matched by the bunny rule's alternation", () => { + expect(isCovered(rules, "irrelevant-probe")).toBe(false); // the rule is phrase-based, not a catch-all + for (const { file, message } of messages) { + expect( + matches(message), + `logger.error("${message}") (${file}) is not matched by the facilitator branch of ` + + `observability/alerts/bunny.yaml — extend its alternation. See the facilitator's ` + + `old scw-side coverage history for why a phrase can silently drift.`, + ).toBe(true); + } + }); + + describe("the warn-level alert phrases, in the ported logger's wire format", () => { + // The ported logger buffers `{ message: msg, ...fields }` — message BEFORE the merging + // object, where pino wrote it after. The alternation's reason filter therefore sits on + // the `message` side: `Settlement failed.*(_transaction_failed|…)`. + const fruitsOfOurFault = [ + "refund_transaction_failed", + "insufficient_fee_allowance", + "settlement_rpc_read_failed", + "batch_claim_transaction_failed", + "eip3009_transfer_transaction_failed", + ]; + + for (const reason of fruitsOfOurFault) { + it(`pages on "Settlement failed" with reason ${reason}`, () => { + expect(matches(`{"message":"Settlement failed","errorReason":"${reason}"}`)).toBe(true); + }); + } + + it("does not page on a caller-fault settlement failure", () => { + // The reason filter is the whole point: a bad signature or a malformed payload is the + // seller's caller's problem and must not email anyone. + expect( + matches(`{"message":"Settlement failed","errorReason":"signature_validation_failed"}`), + ).toBe(false); + }); + + it("pages on a broadcast settlement whose outcome is unknown — both scheme wordings", () => { + expect( + matches('{"message":"Settlement broadcast but unconfirmed — reconcile on chain"}'), + ).toBe(true); + expect( + matches( + '{"message":"Batch-settlement claim/settle broadcast but unconfirmed — reconcile on chain"}', + ), + ).toBe(true); + }); + }); +}); diff --git a/bunny_js/test/facilitator/facilitator_instance.test.ts b/bunny_js/test/facilitator/facilitator_instance.test.ts new file mode 100644 index 000000000..dcbec5c6c --- /dev/null +++ b/bunny_js/test/facilitator/facilitator_instance.test.ts @@ -0,0 +1,593 @@ +/** + * Tests for facilitator_instance onAfterVerify hook (fee model) + * + * The hook implements the fee access control logic: + * - Recipients with sufficient USDC allowance pass (fee collected at settle) + * - Recipients without sufficient allowance are rejected + * - When fees are disabled (fee=0), all recipients pass + * + * We test the hook by capturing the callback registered via facilitator.onAfterVerify() + * and calling it directly with different input combinations. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ═══════════════════════════════════════════════════════════════ +// Capture the onAfterVerify callback from the facilitator instance +// ═══════════════════════════════════════════════════════════════ + +type HookArgs = { + paymentPayload: { + accepted?: { network?: string; scheme?: string; payTo?: string }; + payload?: { + authorization?: { to?: string }; + permit2Authorization?: { witness?: { to?: string }; from?: string }; + }; + }; + requirements?: { network?: string; scheme?: string; payTo?: string; asset?: string }; + result: Record; +}; + +const OP_SEPOLIA_USDC = "0x5fd84259d66Cd46123540766Be93DFE6D43130D7"; +const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; + +type RegisterCall = { network: unknown; scheme: { scheme?: string } }; + +// Use vi.hoisted() to ensure the holders are available before mocks run +const { hookHolder, registerCalls } = vi.hoisted(() => ({ + hookHolder: { + current: null as ((args: HookArgs) => Promise) | null, + }, + registerCalls: [] as RegisterCall[], +})); + +// Mock x402 libraries — capture the onAfterVerify callback and every register() call +vi.mock("@x402/core/facilitator", () => ({ + // Use a regular function (not arrow) so it works as a constructor with `new` + x402Facilitator: vi.fn().mockImplementation(function (this: Record) { + this.register = vi.fn((network: unknown, scheme: { scheme?: string }) => { + registerCalls.push({ network, scheme }); + }); + this.onAfterVerify = vi.fn((cb: (args: HookArgs) => Promise) => { + hookHolder.current = cb; + }); + }), +})); + +vi.mock("@x402/evm", () => ({ + toFacilitatorEvmSigner: vi.fn(() => ({})), +})); + +// Tag the mocked scheme instances so register() assertions can tell them apart. +// Use regular functions (not arrows) so they work as constructors with `new`. +vi.mock("@x402/evm/exact/facilitator", () => ({ + ExactEvmScheme: vi.fn().mockImplementation(function (this: Record) { + this.scheme = "exact"; + }), +})); + +vi.mock("@x402/evm/batch-settlement/facilitator", () => ({ + BatchSettlementEvmScheme: vi.fn().mockImplementation(function (this: Record) { + this.scheme = "batch-settlement"; + }), +})); + +vi.mock("viem", async () => { + const actual = await vi.importActual("viem"); + return { + ...actual, + createPublicClient: vi.fn(() => ({})), + createWalletClient: vi.fn(() => ({})), + }; +}); + +vi.mock("viem/accounts", () => ({ + privateKeyToAccount: vi.fn(() => ({ + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + })), +})); + +// Mock fee module — only what the hook's tests steer. Partial on purpose: loadFacilitatorAccount +// stays real and runs against the viem/accounts mock above, so a missing key still throws as in +// production and the signer is built exactly the way the facilitator builds it. +vi.mock("../../src/facilitator/x402_fee.js", async (importOriginal) => ({ + ...(await importOriginal()), + evaluateFeeGate: vi.fn(), + getFacilitatorAddress: vi.fn(), +})); + +import { + createFacilitator, + createReadOnlyFacilitator, + resetFacilitator, + getFacilitator, +} from "../../src/facilitator/facilitator_instance.js"; +import { evaluateFeeGate, getFacilitatorAddress } from "../../src/facilitator/x402_fee.js"; + +// ═══════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════ + +describe("facilitator_instance onAfterVerify hook (fee model)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + vi.clearAllMocks(); + hookHolder.current = null; + registerCalls.length = 0; + resetFacilitator(); + + // Default: a fee is owed and the recipient's allowance covers it. + vi.mocked(evaluateFeeGate).mockResolvedValue({ kind: "charge" as const }); + vi.mocked(getFacilitatorAddress).mockReturnValue("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + // Create facilitator — this registers the schemes + the onAfterVerify hook + createFacilitator(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + /** + * Helper: create mock hook arguments simulating a valid EIP-3009 exact payment. + * The SDK enforces `authorization.to === requirements.payTo` before this hook runs, + * so the helper keeps both fields equal to `recipient`. The hook itself now reads + * `requirements.payTo` (not the client payload), so `requirements` must be populated. + */ + function hookArgs(recipient: string, network: string, asset = OP_SEPOLIA_USDC): HookArgs { + return { + paymentPayload: { + accepted: { network, scheme: "exact" }, + payload: { authorization: { to: recipient } }, + }, + requirements: { network, scheme: "exact", payTo: recipient, asset }, + result: { + isValid: true, + payer: "0xSomePayer000000000000000000000000000000", + }, + }; + } + + /** + * Helper: create mock hook arguments simulating a Permit2 exact payment. Same + * scheme ("exact") as EIP-3009, but the payload carries `permit2Authorization` + * (recipient at witness.to) instead of `authorization` — the shape EURe pays with. + */ + function permit2HookArgs(recipient: string, network: string, asset: string): HookArgs { + return { + paymentPayload: { + accepted: { network, scheme: "exact" }, + payload: { + permit2Authorization: { + witness: { to: recipient }, + from: "0xSomePayer000000000000000000000000000000", + }, + }, + }, + requirements: { network, scheme: "exact", payTo: recipient, asset }, + result: { + isValid: true, + payer: "0xSomePayer000000000000000000000000000000", + }, + }; + } + + it("registers onAfterVerify hook during facilitator creation", () => { + expect(hookHolder.current).not.toBeNull(); + expect(typeof hookHolder.current).toBe("function"); + }); + + it("registers exact on every supported network, but batch-settlement only where the contract is deployed", () => { + // getSupportedNetworks() => OP, OP Sepolia, Base, Base Sepolia (4 networks — USDC + // exists everywhere, so `exact` is registered for all of them). + const exact = registerCalls.filter((c) => c.scheme?.scheme === "exact"); + const batch = registerCalls.filter((c) => c.scheme?.scheme === "batch-settlement"); + expect(exact).toHaveLength(4); + + // REGRESSION GUARD: batch-settlement must be registered ONLY on networks where + // the canonical BATCH_SETTLEMENT_ADDRESS contract is actually deployed — + // Optimism mainnet, Base mainnet, Base Sepolia. Optimism Sepolia has no + // deployment; registering it there would advertise support via /supported for + // a network where any deposit/claim/settle fails on-chain. + const batchNetworks = batch.map((c) => c.network).sort(); + expect(batchNetworks).toEqual(["eip155:10", "eip155:8453", "eip155:84532"].sort()); + expect(batchNetworks).not.toContain("eip155:11155420"); + }); + + it("read-only facilitator also excludes Optimism Sepolia from batch-settlement registration", () => { + // Same regression guard as above, but for createReadOnlyFacilitator() — a + // separate registration loop (used when no private key is configured / for + // /supported-only mode) that must apply the same deployment-scoped gating. + registerCalls.length = 0; + createReadOnlyFacilitator(); + + const exact = registerCalls.filter((c) => c.scheme?.scheme === "exact"); + const batch = registerCalls.filter((c) => c.scheme?.scheme === "batch-settlement"); + expect(exact).toHaveLength(4); + + const batchNetworks = batch.map((c) => c.network).sort(); + expect(batchNetworks).toEqual(["eip155:10", "eip155:8453", "eip155:84532"].sort()); + expect(batchNetworks).not.toContain("eip155:11155420"); + }); + + it("skips fee gating for batch-settlement payments (fee-free channels)", async () => { + // A fee is owed for `exact`, but batch-settlement must bypass the + // exact-scheme fee model entirely — and must NOT be rejected for lacking an + // exact-style authorization.to recipient. + const args: HookArgs = { + paymentPayload: { + accepted: { + network: "eip155:11155420", + scheme: "batch-settlement", + payTo: "0x1111111111111111111111111111111111111111", + }, + }, + requirements: { + network: "eip155:11155420", + scheme: "batch-settlement", + payTo: "0x1111111111111111111111111111111111111111", + }, + result: { isValid: true }, + }; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(false); + expect(evaluateFeeGate).not.toHaveBeenCalled(); + }); + + // ─────────────────────────────────────────────────────────── + // Batch-settlement deposit/voucher/refund: always open, always fee-free + // ─────────────────────────────────────────────────────────── + // + // These payload types reach this hook (claim/settle skip verify() entirely — see + // x402_settle.ts). They are not "usage" — deposit funds a channel, refund unwinds + // it, voucher is an off-chain signature check — so FEE_MODEL_PLAN.md Phase 3 leaves + // them completely ungated: no whitelist, no allowance check, no fee. The old + // whitelist gate is retired (x402_whitelist.ts) with nothing replacing it here; + // claim/settle's allowance gate lives in x402_settle.ts instead. + + it("is fee-free and gate-free for batch-settlement even with no payTo at all", async () => { + const args: HookArgs = { + paymentPayload: { + accepted: { network: "eip155:84532", scheme: "batch-settlement" }, + }, + requirements: { network: "eip155:84532", scheme: "batch-settlement" }, + result: { isValid: true }, + }; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(false); + expect(evaluateFeeGate).not.toHaveBeenCalled(); + }); + + it("is fee-free and gate-free for batch-settlement regardless of recipient", async () => { + const args: HookArgs = { + paymentPayload: { + accepted: { + network: "eip155:84532", + scheme: "batch-settlement", + payTo: "0x2222222222222222222222222222222222222222", + }, + }, + requirements: { + network: "eip155:84532", + scheme: "batch-settlement", + payTo: "0x2222222222222222222222222222222222222222", + }, + result: { isValid: true }, + }; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(false); + expect(evaluateFeeGate).not.toHaveBeenCalled(); + }); + + it("does not apply batch-settlement's free pass to the exact scheme", async () => { + // exact-scheme recipients are still gated by the fee-allowance model. + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "charge" as const, + remainingSettlements: 10, + }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(evaluateFeeGate).toHaveBeenCalledWith( + "0x1111111111111111111111111111111111111111", + "eip155:11155420", + OP_SEPOLIA_USDC, + ); + }); + + it("skips processing when verification already failed", async () => { + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + args.result.isValid = false; + args.result.invalidReason = "invalid_signature"; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("invalid_signature"); + expect(evaluateFeeGate).not.toHaveBeenCalled(); + }); + + // ─────────────────────────────────────────────────────────── + // Path 1: Fees disabled + // ─────────────────────────────────────────────────────────── + + it("allows all recipients when fees are disabled", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ kind: "no_fee" as const }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(false); + }); + + // ─────────────────────────────────────────────────────────── + // Path 2: Sufficient fee allowance + // ─────────────────────────────────────────────────────────── + + it("allows recipient with sufficient fee allowance", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "charge" as const, + remainingSettlements: 10, + }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(true); + expect(args.result.recipient).toBe("0x1111111111111111111111111111111111111111"); + expect(evaluateFeeGate).toHaveBeenCalledWith( + "0x1111111111111111111111111111111111111111", + "eip155:11155420", + OP_SEPOLIA_USDC, + ); + }); + + // ─────────────────────────────────────────────────────────── + // Path 3: Insufficient fee allowance + // ─────────────────────────────────────────────────────────── + + it("rejects recipient without sufficient fee allowance", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "reject" as const, + reason: "insufficient_fee_allowance" as const, + }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("insufficient_fee_allowance"); + }); + + it("proceeds when the allowance is unreadable, without capping collection", async () => { + // Unreadable allowance still charges — evaluateFeeGate reports no settlement count. + vi.mocked(evaluateFeeGate).mockResolvedValue({ kind: "charge" as const }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + // Fail open: a reading we could not take must not reject a valid payment. + expect(args.result.isValid).toBe(true); + expect(args.result.feeRequired).toBe(true); + // Never surface 0 for an unreadable allowance — that reads as "exhausted". + expect(args.result.remainingSettlements).toBeUndefined(); + }); + + it("surfaces remaining settlements so sellers see their approval running down", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "charge" as const, + remainingSettlements: 10, + }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.remainingSettlements).toBe(10); + }); + + // ─────────────────────────────────────────────────────────── + // Edge cases + // ─────────────────────────────────────────────────────────── + + it("rejects when the gate reports the facilitator address is not configured", async () => { + // The address check itself lives in evaluateFeeGate (see x402_fee.test.ts); this + // asserts the hook maps that rejection onto the verify result. + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "reject" as const, + reason: "facilitator_not_configured" as const, + }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("facilitator_not_configured"); + }); + + it("gates the fee on requirements.asset, the token the settlement moves", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ kind: "charge" as const }); + + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:8453", BASE_EURC); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(evaluateFeeGate).toHaveBeenCalledWith( + "0x1111111111111111111111111111111111111111", + "eip155:8453", + BASE_EURC, + ); + }); + + it("rejects when asset is missing from requirements", async () => { + const args = hookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + delete args.requirements!.asset; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("invalid_payload"); + expect(evaluateFeeGate).not.toHaveBeenCalled(); + }); + + it("rejects when network is missing from requirements", async () => { + const args: HookArgs = { + paymentPayload: { + accepted: { scheme: "exact" }, + payload: { + authorization: { to: "0x1111111111111111111111111111111111111111" }, + }, + }, + requirements: { scheme: "exact", payTo: "0x1111111111111111111111111111111111111111" }, + result: { isValid: true }, + }; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("invalid_payload"); + }); + + it("rejects when recipient is missing from requirements", async () => { + const args: HookArgs = { + paymentPayload: { + accepted: { network: "eip155:11155420", scheme: "exact" }, + payload: { authorization: {} }, + }, + requirements: { network: "eip155:11155420", scheme: "exact" }, + result: { isValid: true }, + }; + + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("invalid_payload"); + }); + + // ─────────────────────────────────────────────────────────── + // Permit2 (EURe) — same fee gate as EIP-3009 + // ─────────────────────────────────────────────────────────── + + it("gates a Permit2 exact payload on requirements, like an EIP-3009 one", async () => { + vi.mocked(evaluateFeeGate).mockResolvedValue({ + kind: "charge" as const, + remainingSettlements: 100, + }); + const recipient = "0x1111111111111111111111111111111111111111"; + const baseEure = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C"; + + const args = permit2HookArgs(recipient, "eip155:8453", baseEure); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(true); + expect(evaluateFeeGate).toHaveBeenCalledWith(recipient, "eip155:8453", baseEure); + expect(args.result.feeRequired).toBe(true); + expect(args.result.remainingSettlements).toBe(100); + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Private key validation & error paths +// ═══════════════════════════════════════════════════════════════ + +describe("createFacilitator — private key validation", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + resetFacilitator(); + }); + + it("throws when private key is missing and requirePrivateKey=true", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + expect(() => createFacilitator(true)).toThrow("not configured"); + }); + + it("returns read-only facilitator when private key is missing and requirePrivateKey=false", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + const facilitator = createFacilitator(false); + expect(facilitator).toBeDefined(); + // Read-only facilitator was created (no throw) + }); + + it("throws when private key has invalid length and requirePrivateKey=true", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xdeadbeef"; // too short + + expect(() => createFacilitator(true)).toThrow("must be 64 hex characters"); + }); + + it("returns read-only facilitator when private key has invalid length and requirePrivateKey=false", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xdeadbeef"; + + const facilitator = createFacilitator(false); + expect(facilitator).toBeDefined(); + // Falls back to read-only (no throw) + }); + + it("normalizes private key without 0x prefix", () => { + // Valid 64-char hex without 0x prefix + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const facilitator = createFacilitator(true); + expect(facilitator).toBeDefined(); + }); + + it("trims whitespace from private key", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + " 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 "; + + const facilitator = createFacilitator(true); + expect(facilitator).toBeDefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Singleton pattern +// ═══════════════════════════════════════════════════════════════ + +describe("getFacilitator — singleton", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + resetFacilitator(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + resetFacilitator(); + }); + + it("returns the same instance on subsequent calls", () => { + const first = getFacilitator(); + const second = getFacilitator(); + expect(first).toBe(second); + }); + + it("creates new instance after resetFacilitator", () => { + const first = getFacilitator(); + resetFacilitator(); + const second = getFacilitator(); + expect(first).not.toBe(second); + }); +}); diff --git a/bunny_js/test/facilitator/facilitator_routes.test.ts b/bunny_js/test/facilitator/facilitator_routes.test.ts new file mode 100644 index 000000000..488113b7a --- /dev/null +++ b/bunny_js/test/facilitator/facilitator_routes.test.ts @@ -0,0 +1,637 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; +import { + handleFacilitatorRequest, + handleSupportedRequest, + readCappedBody, +} from "../../src/facilitator/facilitator_routes.js"; +import type { verifyPayment as verifyPaymentType } from "../../src/facilitator/x402_verify.js"; +import type { settlePayment as settlePaymentType } from "../../src/facilitator/x402_settle.js"; + +// Native port of x402_facilitator/test/x402_facilitator.test.ts — the same cases, asserted +// against web-standard Request/Response instead of the Scaleway event envelope. Two envelope +// cases do not survive the port: a pre-parsed object body (an edge request body is always +// text), and rawUrl-based routing (there is one URL here, and routing uses its pathname). + +// Mock the dependencies — the payment core's behavior is tested in its own ported suite. +vi.mock("../../src/facilitator/x402_verify.js", () => ({ + verifyPayment: vi.fn(), +})); + +vi.mock("../../src/facilitator/x402_settle.js", () => ({ + settlePayment: vi.fn(), +})); + +vi.mock("../../src/facilitator/x402_supported.js", () => ({ + getSupportedCapabilities: vi.fn(() => ({ + x402Version: 2, + kinds: ["exact"], + networks: ["eip155:10", "eip155:11155420"], + assets: { + "eip155:10": ["USDC"], + "eip155:11155420": ["USDC"], + }, + })), +})); + +const BASE = "https://facilitator.fretchen.eu"; + +function request(method: string, path = "/", body?: string, headers?: HeadersInit): Request { + return new Request(`${BASE}${path}`, { method, body, headers }); +} + +describe("facilitator routes (verify + settle)", () => { + let verifyPayment: Mock; + let settlePayment: Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + const verifyModule = await import("../../src/facilitator/x402_verify.js"); + const settleModule = await import("../../src/facilitator/x402_settle.js"); + verifyPayment = verifyModule.verifyPayment as Mock; + settlePayment = settleModule.settlePayment as Mock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("/verify", () => { + it("should handle CORS preflight OPTIONS request", async () => { + const res = await handleFacilitatorRequest(request("OPTIONS", "/verify")); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS"); + expect(await res.text()).toBe(""); + }); + + it("should reject non-POST requests", async () => { + const res = await handleFacilitatorRequest(request("GET", "/verify")); + + expect(res.status).toBe(405); + expect(await res.json()).toEqual({ + error: "Method not allowed. Use POST.", + }); + }); + + it("should handle invalid JSON body", async () => { + const res = await handleFacilitatorRequest(request("POST", "/verify", "not-valid-json{")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid JSON in request body", + }); + }); + + it("should reject missing paymentPayload", async () => { + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Request must include both paymentPayload and paymentRequirements", + }); + }); + + it("should reject missing paymentRequirements", async () => { + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + }), + ), + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Request must include both paymentPayload and paymentRequirements", + }); + }); + + it("should return valid payment result", async () => { + verifyPayment.mockResolvedValue({ + isValid: true, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.isValid).toBe(true); + expect(body.payer).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + }); + + /** + * The September incident, in one assertion. This endpoint used to build its response from a + * fixed field list and silently drop `extra`, which for batch-settlement carries the channel + * state the seller caches. The seller's SDK writes its record from that field WHOLESALE, + * defaulting each key to zero: + * + * const ex = result.extra ?? {}; + * const balance = readExtraString(ex, "balance", "0"); + * const refundNonce = readExtraNumber(ex, "refundNonce", 0); + * + * So dropping it did not leave the seller's record alone — it zeroed the real balance and + * nonce on every single verify, which surfaced as "payment channel too low" on funded + * channels and refunds reverting on an already-consumed nonce. + * + * Nothing asserted this passthrough before, which is exactly how it was lost. + */ + it("forwards the scheme's extra, which the seller caches as its channel state", async () => { + const channelState = { + channelId: "0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7", + balance: "21300", + totalClaimed: "5680", + withdrawRequestedAt: 0, + refundNonce: "1", + }; + verifyPayment.mockResolvedValue({ + isValid: true, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + extra: channelState, + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + // Verbatim: the seller reads these keys directly, so a reshaped or partial copy is as + // damaging as none at all. + expect((await res.json()).extra).toEqual(channelState); + }); + + it("omits extra entirely when the scheme produced none", async () => { + verifyPayment.mockResolvedValue({ + isValid: true, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + // Absent, not `extra: null` — an explicit null is a claim we have none. + expect(await res.json()).not.toHaveProperty("extra"); + }); + + it("should return invalid payment result with reason", async () => { + verifyPayment.mockResolvedValue({ + isValid: false, + invalidReason: "insufficient_funds", + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.isValid).toBe(false); + expect(body.invalidReason).toBe("insufficient_funds"); + }); + + it("should handle unexpected verification error", async () => { + verifyPayment.mockRejectedValue(new Error("Unexpected error")); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Internal server error"); + expect(body.isValid).toBe(false); + expect(body.invalidReason).toBe("unexpected_verify_error"); + }); + + it("forwards the original payload object, not the validated clone", async () => { + // safeParse on a looseObject returns a deep clone, and verify/settle read keys the schema + // does not model. Passing validation.data would pass today and break the first time the + // schema gains a transform, coercion or default. + verifyPayment.mockResolvedValue({ isValid: true, payer: "0xabc" }); + + const payload = { + paymentPayload: { + accepted: { network: "eip155:10" }, + payload: { signature: "0xdeadbeef" }, + }, + paymentRequirements: { amount: "1000000" }, + }; + await handleFacilitatorRequest(request("POST", "/verify", JSON.stringify(payload))); + + expect(verifyPayment.mock.calls[0][0]).toEqual(payload.paymentPayload); + expect(verifyPayment.mock.calls[0][1]).toEqual(payload.paymentRequirements); + }); + + it("rejects a payload with no accepted envelope", async () => { + // @x402/core requires `accepted`, and so does the facilitator: settle derives + // isBatchSettlement from accepted.scheme. This used to reach the SDK and return + // isValid:false — an invalid payment, when it was really a malformed request. + const res = await handleFacilitatorRequest( + request( + "POST", + "/verify", + JSON.stringify({ + paymentPayload: { payload: { signature: "0xdeadbeef" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(400); + expect(verifyPayment).not.toHaveBeenCalled(); + }); + }); + + describe("/settle", () => { + it("should handle CORS preflight OPTIONS request", async () => { + const res = await handleFacilitatorRequest(request("OPTIONS", "/settle")); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS"); + expect(await res.text()).toBe(""); + }); + + it("should reject non-POST requests", async () => { + const res = await handleFacilitatorRequest(request("GET", "/settle")); + + expect(res.status).toBe(405); + }); + + it("should return successful settlement", async () => { + settlePayment.mockResolvedValue({ + success: true, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + transaction: "0xabc123", + network: "eip155:10", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/settle", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.payer).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + expect(body.transaction).toBe("0xabc123"); + }); + + it("should include extensions in settle response when present", async () => { + settlePayment.mockResolvedValue({ + success: true, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + transaction: "0xabc123", + network: "eip155:10", + fee: { collected: true, status: "collected", txHash: "0xfee123" }, + extensions: { + facilitatorFees: { + info: { + version: "1", + facilitatorFeePaid: "10000", + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + model: "flat", + collection: { status: "collected", txHash: "0xfee123" }, + }, + }, + }, + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/settle", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.extensions).toBeDefined(); + expect(body.extensions.facilitatorFees.info.version).toBe("1"); + expect(body.extensions.facilitatorFees.info.facilitatorFeePaid).toBe("10000"); + expect(body.extensions.facilitatorFees.info.model).toBe("flat"); + }); + + it("should return failed settlement with error reason", async () => { + settlePayment.mockResolvedValue({ + success: false, + errorReason: "insufficient_funds", + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + network: "eip155:10", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/settle", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.errorReason).toBe("insufficient_funds"); + expect(body.transaction).toBe(""); + }); + + /** + * The contrast to the case above. `settlement_pending` is the one `success: false` that is + * recoverable: the transaction was broadcast and only the receipt wait timed out, so the + * hash has to survive the HTTP boundary — blanking it leaves the caller unable to tell a + * settlement that never happened from one that may already have confirmed, and retrying is + * then a double payment. + */ + it("forwards the broadcast hash on settlement_pending instead of blanking it", async () => { + settlePayment.mockResolvedValue({ + success: false, + errorReason: "settlement_pending", + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + transaction: "0xbroadcastbutunconfirmed", + network: "eip155:10", + }); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/settle", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.errorReason).toBe("settlement_pending"); + expect(body.transaction).toBe("0xbroadcastbutunconfirmed"); + }); + + it("should handle unexpected settlement error", async () => { + settlePayment.mockRejectedValue(new Error("Unexpected error")); + + const res = await handleFacilitatorRequest( + request( + "POST", + "/settle", + JSON.stringify({ + paymentPayload: { accepted: { network: "eip155:10" } }, + paymentRequirements: { amount: "1000000" }, + }), + ), + ); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Internal server error"); + expect(body.success).toBe(false); + expect(body.errorReason).toBe("unexpected_settlement_error"); + }); + }); + + describe("/supported", () => { + it("should handle CORS preflight OPTIONS request", async () => { + const res = await handleSupportedRequest(request("OPTIONS", "/supported")); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS"); + expect(await res.text()).toBe(""); + }); + + it("should reject non-GET requests", async () => { + const res = await handleSupportedRequest(request("POST", "/supported")); + + expect(res.status).toBe(405); + expect(await res.json()).toEqual({ + error: "Method not allowed. Use GET.", + }); + }); + + it("should return supported capabilities", async () => { + const res = await handleSupportedRequest(request("GET", "/supported")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.x402Version).toBe(2); + expect(body.networks).toContain("eip155:10"); + }); + }); + + describe("request hardening (edge deviations the scw function never needed)", () => { + it("refuses a declared oversize up front, without reading a byte of the body", async () => { + const req = { + headers: new Headers({ "content-length": String(1_000_000 + 1) }), + // Accessing the body must fail the test: the declared check is what refuses. + get body(): ReadableStream { + throw new Error("the body must not be read when Content-Length already refuses"); + }, + } as unknown as Request; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await expect(readCappedBody(req)).resolves.toBe("too-large"); + }); + + it("refuses a streamed POST body over the 1 MB cap with 413, CORS intact, before any settlement work", async () => { + // No Content-Length, so the per-chunk accounting is what trips the cap — an attacker + // cannot bypass the guard by lying about (or omitting) the declared size. + const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < 17; i++) { + // 17 x 64 KB = 1,088,000 bytes — past the 1,000,000-byte cap mid-stream. + controller.enqueue(chunk); + } + controller.close(); + }, + }); + const res = await handleFacilitatorRequest( + new Request(`${BASE}/settle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: stream, + // undici requires it for a streamed body; the edge runtime does not send one. + duplex: "half", + } as RequestInit), + ); + expect(res.status).toBe(413); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(settlePayment).not.toHaveBeenCalled(); + }); + + it("sends Cache-Control: no-store on every response — the CDN must never replay one", async () => { + // The twins' parity guard: the edge serves through a real CDN pull zone, and a served- + // from-cache verification or settlement response is a replayed payment event. + const res = await handleFacilitatorRequest(request("GET", "/supported")); + expect(res.headers.get("cache-control")).toBe("no-store"); + const preflight = await handleFacilitatorRequest(request("OPTIONS", "/settle")); + expect(preflight.headers.get("cache-control")).toBe("no-store"); + }); + }); + + describe("routing", () => { + it("should route /supported", async () => { + const res = await handleFacilitatorRequest(request("GET", "/supported")); + + expect(res.status).toBe(200); + expect((await res.json()).x402Version).toBe(2); + }); + + it("should route /verify", async () => { + const res = await handleFacilitatorRequest(request("OPTIONS", "/verify")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + it("should route /settle", async () => { + const res = await handleFacilitatorRequest(request("OPTIONS", "/settle")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + it("routes by the URL pathname — a query string does not disturb routing", async () => { + const res = await handleFacilitatorRequest(request("GET", "/supported?detail=1")); + + expect(res.status).toBe(200); + }); + + it("should return 404 for unknown endpoints", async () => { + const res = await handleFacilitatorRequest(request("GET", "/unknown")); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: "Endpoint not found. Use /verify, /settle, or /supported", + }); + }); + + it("redirects a browser hitting the root to the documentation page", async () => { + const res = await handleFacilitatorRequest( + request("GET", "/", undefined, { Accept: "text/html" }), + ); + + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("https://www.fretchen.eu/x402/"); + }); + + it("matches the Accept header case-insensitively", async () => { + const res = await handleFacilitatorRequest( + request("GET", "/", undefined, { accept: "text/html,application/xhtml+xml" }), + ); + + expect(res.status).toBe(302); + }); + + it("gives a machine client at the root a JSON body with an onward path", async () => { + const res = await handleFacilitatorRequest(request("GET", "/")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.documentation).toBe("https://www.fretchen.eu/x402/"); + expect(body.supported).toBe("/supported"); + }); + + it("does not redirect a JSON Accept header at the root", async () => { + const res = await handleFacilitatorRequest( + request("GET", "/", undefined, { Accept: "application/json" }), + ); + + expect(res.status).toBe(200); + expect(res.headers.get("Location")).toBeNull(); + }); + + it("routes /openapi.json to its handler", async () => { + const res = await handleFacilitatorRequest(request("GET", "/openapi.json")); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + const body = await res.json(); + expect(body.openapi).toBe("3.1.0"); + expect(body.info.title).toBe("fretchen x402 Facilitator"); + }); + + it("handles CORS preflight on /openapi.json", async () => { + const res = await handleFacilitatorRequest(request("OPTIONS", "/openapi.json")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + it("advertises the openapi link in the root's machine-readable body", async () => { + // The /supported equivalent (links.openapi) is covered in x402_supported.test.ts against + // the real getSupportedCapabilities() — this file mocks that module with a fixture that + // does not carry `links`, so asserting it here would test the mock. + const res = await handleFacilitatorRequest(request("GET", "/")); + expect((await res.json()).openapi).toBe("/openapi.json"); + }); + }); +}); diff --git a/bunny_js/test/facilitator/helpers/batchPayloads.ts b/bunny_js/test/facilitator/helpers/batchPayloads.ts new file mode 100644 index 000000000..72300d36d --- /dev/null +++ b/bunny_js/test/facilitator/helpers/batchPayloads.ts @@ -0,0 +1,86 @@ +/** + * Builders for batch-settlement payloads, shared by the pure classification table + * (x402_batch_route.test.ts) and the settle wiring tests (x402_settle.test.ts). + * + * Each returns the full x402 `paymentPayload`; `classifyBatchSettlement` takes its inner + * `.payload`. Signatures are placeholders — nothing here is verified on-chain, only routed. + */ + +import { SELLER } from "./signPayment"; + +export { SELLER }; +export const PAYER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +export const OTHER_SELLER = "0x3333333333333333333333333333333333333333"; +export const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +export const BASE_SEPOLIA_EURC = "0x808456652fdb597867f38412077A9182bf77359F"; + +const SIG = "0x" + "ab".repeat(65); +const accepted = { scheme: "batch-settlement", network: "eip155:84532" }; + +/** One claim on a channel paying `receiver` in `token`; pass `null` for a channel with no token. */ +export function claim(receiver: string = SELLER, token: string | null = BASE_SEPOLIA_USDC) { + return { + voucher: { + channel: { payer: PAYER, receiver, token: token ?? undefined }, + maxClaimableAmount: "12000", + }, + signature: SIG, + totalClaimed: "0", + }; +} + +/** A `type: "claim"` command sweeping the given claims. */ +export function claimCommand(claims: unknown = [claim()]) { + return { + x402Version: 2, + accepted, + payload: { type: "claim", claims, claimAuthorizerSignature: "0x" + "cd".repeat(65) }, + }; +} + +/** A `type: "settle"` command. Omit a field to build a malformed one. */ +export function settleCommand(target: { receiver?: string; token?: string } = {}) { + const { receiver = SELLER, token = BASE_SEPOLIA_USDC } = target; + return { x402Version: 2, accepted, payload: { type: "settle", receiver, token } }; +} + +/** A malformed settle command with no receiver. */ +export function settleCommandWithoutReceiver() { + return { x402Version: 2, accepted, payload: { type: "settle", token: BASE_SEPOLIA_USDC } }; +} + +/** A refund on SELLER's USDC channel, carrying `claims` (anything — to build malformed ones). */ +export function refund(claims: unknown) { + return { + x402Version: 2, + accepted, + payload: { + type: "refund", + channelConfig: { payer: PAYER, receiver: SELLER, token: BASE_SEPOLIA_USDC }, + // isVoucherFields() requires all three of these to be present. + voucher: { channelId: "0x" + "11".repeat(32), maxClaimableAmount: "12000", signature: SIG }, + amount: "5000", + refundNonce: "0", + claims, + refundAuthorizerSignature: "0x" + "ef".repeat(65), + claimAuthorizerSignature: "0x" + "cd".repeat(65), + }, + }; +} + +/** A deposit — funds a channel, pays out to no one. */ +export function deposit() { + return { + x402Version: 2, + accepted, + payload: { + type: "deposit", + channelConfig: { payer: PAYER, receiver: SELLER, token: BASE_SEPOLIA_USDC }, + amount: "20000", + }, + }; +} + +export function batchRequirements(overrides: { network?: string; payTo?: string } = {}) { + return { scheme: "batch-settlement", network: "eip155:84532", payTo: SELLER, ...overrides }; +} diff --git a/bunny_js/test/facilitator/helpers/fakeChain.ts b/bunny_js/test/facilitator/helpers/fakeChain.ts new file mode 100644 index 000000000..2f3c03ad3 --- /dev/null +++ b/bunny_js/test/facilitator/helpers/fakeChain.ts @@ -0,0 +1,605 @@ +/** + * An in-memory EVM chain for hermetic tests that run the REAL code path. + * + * The mock boundary sits at the RPC client, not at our modules: `fakeViem()` keeps every + * real viem export and replaces only `createPublicClient` / `createWalletClient`. So + * x402_verify → facilitator_instance → @x402/evm's real verify/settle → x402_fee all execute + * unmodified, and only the questions they ask the chain (getCode, eth_call, send, receipt) + * are answered from `chain` below. Signature checks are real too: for an EOA payer the SDK + * recovers the signer offline (ECDSA), so a forged or mismatched signature still fails. + * + * The fake models exactly the calls the SDK and x402_fee make — mapped by reading + * @x402/evm's exact/eip3009 facilitator (verifyEIP3009, simulateEip3009TransferResult, + * diagnoseEip3009SimulationFailure, executeTransferWithAuthorization, + * verifyEip3009TransferEvent), its exact/permit2 facilitator (verifyPermit2, + * simulatePermit2Settle[WithPermit], diagnosePermit2SimulationFailure, settlePermit2Direct, + * settlePermit2WithEIP2612) and x402_fee's getContract reads/writes. A call it does not + * model throws, so an SDK upgrade that starts asking the chain something new fails loudly + * instead of being answered with a guess. + * + * Usage (vi.mock is hoisted, so the factory must import this module dynamically): + * + * vi.mock("viem", async (importOriginal) => { + * const { fakeViem } = await import("./helpers/fakeChain"); + * return fakeViem(await importOriginal()); + * }); + * + * This module deliberately has no runtime `import ... from "viem"`: it is loaded from inside + * the viem mock factory, and a static import would resolve to the mock being built. Real viem + * functions come in through `fakeViem(actual)` instead. + */ + +import type * as Viem from "viem"; +import { + PERMIT2_ADDRESS, + resetAssetContractCache, + x402ExactPermit2ProxyABI, + x402ExactPermit2ProxyAddress, +} from "@x402/evm"; + +type Address = `0x${string}`; +type Hex = `0x${string}`; + +const MULTICALL3 = "0xca11bde05977b3631167028862be2a173976ca11"; +const PERMIT2_PROXY = x402ExactPermit2ProxyAddress.toLowerCase(); +// Any non-empty bytecode satisfies the SDK's asset-is-a-contract precheck. +const TOKEN_BYTECODE = "0x60806040"; + +/** The token surface the SDK and x402_fee touch, as one ABI for encoding/decoding. */ +const TOKEN_ABI = [ + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "allowance", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "name", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + type: "function", + name: "version", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + type: "function", + name: "nonces", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "authorizationState", + stateMutability: "view", + inputs: [ + { name: "authorizer", type: "address" }, + { name: "nonce", type: "bytes32" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "event", + name: "Transfer", + inputs: [ + { name: "from", type: "address", indexed: true }, + { name: "to", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }, +] as const; + +export interface WriteCall { + address: Address; + functionName: string; + args: readonly unknown[]; + /** The wallet that sent it — lets tests tell the settlement from the fee pull. */ + from: Address; +} + +interface TokenState { + name: string; + version: string; + /** Set only for EIP-2612 tokens: the chain id their permit() domain is bound to. */ + permitChainId?: number; +} + +/** The decoded arguments of the Permit2 proxy's settle / settleWithPermit. */ +interface ProxySettle { + permit: { permitted: { token: Address; amount: bigint }; nonce: bigint; deadline: bigint }; + owner: Address; + witness: { to: Address; validAfter: bigint }; + permit2612?: { value: bigint; deadline: bigint; r: Hex; s: Hex; v: number }; +} + +interface Log { + address: Address; + topics: Hex[]; + data: Hex; +} + +type Failure = "revert" | "revertOnChain" | "receiptTimeout" | "underpriced" | "rpcError"; + +const lc = (a: string) => a.toLowerCase(); + +/** Shared state. Reset it in `beforeEach` with `chain.reset()`. */ +export const chain = { + tokens: new Map(), + balances: new Map(), + allowances: new Map(), + usedNonces: new Set(), + /** EIP-2612 nonces, per token and owner. */ + permitNonces: new Map(), + /** Permit2's signature-transfer nonces — per owner, across all tokens. */ + usedPermit2Nonces: new Set(), + receipts: new Map(), + writes: [] as WriteCall[], + failures: new Map(), + timedOut: new Set(), + reverted: new Set(), + /** Every account a wallet client was created with — lets tests check how signers are built. */ + walletAccounts: [] as { address: Address; nonceManager?: unknown }[], + + reset() { + this.tokens.clear(); + this.balances.clear(); + this.allowances.clear(); + this.usedNonces.clear(); + this.permitNonces.clear(); + this.usedPermit2Nonces.clear(); + this.receipts.clear(); + this.writes = []; + this.walletAccounts = []; + this.failures.clear(); + this.timedOut.clear(); + this.reverted.clear(); + // The SDK caches "this asset is a deployed contract" process-wide for 15 minutes, so + // without this a token deployed in one test stays "deployed" in the next. + resetAssetContractCache(); + }, + + /** + * Make the next `times` writes of `functionName` fail: + * - "revert": `writeContract` throws, as viem does when gas estimation reverts — nothing + * is broadcast and no state changes. + * - "revertOnChain": the tx is broadcast and mined but reverts — a hash exists, the receipt + * says `status: "reverted"`, and no state changes. + * - "receiptTimeout": the tx is sent and takes effect, but waiting for its receipt throws + * viem's real `WaitForTransactionReceiptTimeoutError` — the "broadcast, outcome unknown" + * case. + * - "underpriced": the node refuses the tx before it enters the mempool, as a load-balanced + * RPC did when a node that lagged behind handed out a nonce already in use — nothing is + * broadcast and no state changes. + * - "rpcError": a transport failure with no word on whether the node took the tx. Nothing + * happens here, but the caller cannot know that — the case that must not be retried. + * + * `times` separates a transient failure (1) from one that holds on every attempt. + */ + failNext(functionName: string, failure: Failure, times = 1) { + this.failures.set(functionName, { failure, remaining: times }); + }, + + /** Deploy a token: after this, getCode reports bytecode and name()/version() answer. */ + addToken(address: string, name: string, version = "2") { + this.tokens.set(lc(address), { name, version }); + }, + /** Deploy an EIP-2612 token (EURe): permit() works, bound to `chainId`'s domain. */ + addPermitToken(address: string, name: string, version: string, chainId: number) { + this.tokens.set(lc(address), { name, version, permitChainId: chainId }); + }, + permitNonce(token: string, owner: string) { + return this.permitNonces.get(`${lc(token)}:${lc(owner)}`) ?? 0n; + }, + setBalance(token: string, holder: string, amount: bigint) { + this.balances.set(`${lc(token)}:${lc(holder)}`, amount); + }, + balanceOf(token: string, holder: string) { + return this.balances.get(`${lc(token)}:${lc(holder)}`) ?? 0n; + }, + setAllowance(token: string, owner: string, spender: string, amount: bigint) { + this.allowances.set(`${lc(token)}:${lc(owner)}:${lc(spender)}`, amount); + }, + allowance(token: string, owner: string, spender: string) { + return this.allowances.get(`${lc(token)}:${lc(owner)}:${lc(spender)}`) ?? 0n; + }, + markNonceUsed(token: string, from: string, nonce: string) { + this.usedNonces.add(`${lc(token)}:${lc(from)}:${lc(nonce)}`); + }, + isNonceUsed(token: string, from: string, nonce: string) { + return this.usedNonces.has(`${lc(token)}:${lc(from)}:${lc(nonce)}`); + }, +}; + +class Revert extends Error { + constructor(reason: string) { + super(`execution reverted: ${reason}`); + } +} + +function requireToken(address: string): TokenState { + const token = chain.tokens.get(lc(address)); + if (!token) { + throw new Revert(`no contract at ${address}`); + } + return token; +} + +/** Answer a read-only call against a token. Throws `Revert` the way eth_call would. */ +function readToken(address: string, functionName: string, args: readonly unknown[]): unknown { + const token = requireToken(address); + switch (functionName) { + case "balanceOf": + return chain.balanceOf(address, args[0] as string); + case "allowance": + return chain.allowance(address, args[0] as string, args[1] as string); + case "name": + return token.name; + case "version": + return token.version; + case "nonces": + return chain.permitNonce(address, args[0] as string); + case "authorizationState": + return chain.isNonceUsed(address, args[0] as string, args[1] as string); + case "transferWithAuthorization": { + // eth_call simulation — the SDK has already recovered the signature, so model the + // state checks the real FiatToken makes. + const [from, , value, , , nonce] = args as [string, string, bigint, bigint, bigint, string]; + if (chain.isNonceUsed(address, from, nonce)) { + throw new Revert("FiatTokenV2: authorization is used or canceled"); + } + if (chain.balanceOf(address, from) < value) { + throw new Revert("ERC20: transfer amount exceeds balance"); + } + return undefined; + } + default: + throw new Error(`fakeChain: unmodelled read ${functionName} on ${address}`); + } +} + +export function fakeViem(actual: typeof Viem) { + const { + decodeFunctionData, + encodeFunctionResult, + encodeEventTopics, + encodeAbiParameters, + recoverTypedDataAddress, + serializeSignature, + WaitForTransactionReceiptTimeoutError, + } = actual; + + let txCounter = 0; + const nextHash = (): Hex => `0x${(++txCounter).toString(16).padStart(64, "0")}`; + + function transferLog(token: Address, from: string, to: string, value: bigint): Log { + return { + address: token, + topics: encodeEventTopics({ + abi: TOKEN_ABI, + eventName: "Transfer", + args: { from: from as Address, to: to as Address }, + }) as Hex[], + data: encodeAbiParameters([{ type: "uint256" }], [value]), + }; + } + + /** Multicall3 tryAggregate — used by the SDK to diagnose a failed simulation. */ + function tryAggregate(calls: readonly { target: Address; callData: Hex }[]) { + return calls.map(({ target, callData }) => { + try { + // The Permit2 diagnosis asks the proxy which Permit2 it uses — "is it deployed?". + if (lc(target) === PERMIT2_PROXY) { + const { functionName } = decodeFunctionData({ + abi: x402ExactPermit2ProxyABI, + data: callData, + }); + if (functionName !== "PERMIT2") { + throw new Error(`fakeChain: unmodelled multicall ${functionName} on the proxy`); + } + return { + success: true, + returnData: encodeFunctionResult({ + abi: x402ExactPermit2ProxyABI, + functionName: "PERMIT2", + result: PERMIT2_ADDRESS, + }), + }; + } + const { functionName, args } = decodeFunctionData({ abi: TOKEN_ABI, data: callData }); + const result = readToken(target, functionName, args ?? []); + return { + success: true, + returnData: encodeFunctionResult({ + abi: TOKEN_ABI, + functionName, + // readToken answers every TOKEN_ABI view function with one of these. + result: result as string | bigint | boolean, + }), + }; + } catch { + return { success: false, returnData: "0x" }; + } + }); + } + + function decodeProxySettle(functionName: string, args: readonly unknown[]): ProxySettle { + if (functionName === "settle") { + const [permit, owner, witness] = args as [ + ProxySettle["permit"], + Address, + ProxySettle["witness"], + ]; + return { permit, owner, witness }; + } + if (functionName === "settleWithPermit") { + const [permit2612, permit, owner, witness] = args as [ + ProxySettle["permit2612"], + ProxySettle["permit"], + Address, + ProxySettle["witness"], + ]; + return { permit2612, permit, owner, witness }; + } + throw new Error(`fakeChain: unmodelled proxy call ${functionName}`); + } + + /** + * The token's EIP-2612 permit(owner, Permit2, value, deadline, v, r, s): true when it would + * succeed. The signature is recovered for real, against the token's own domain, so a permit + * signed for the wrong name, version, chain or nonce fails here as it would on-chain. + */ + async function permitSucceeds( + token: Address, + owner: Address, + p: NonNullable, + ) { + const state = chain.tokens.get(lc(token)); + if (!state?.permitChainId || p.deadline < BigInt(Math.floor(Date.now() / 1000))) { + return false; + } + try { + const signer = await recoverTypedDataAddress({ + domain: { + name: state.name, + version: state.version, + chainId: state.permitChainId, + verifyingContract: token, + }, + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit", + message: { + owner, + spender: PERMIT2_ADDRESS, + value: p.value, + nonce: chain.permitNonce(token, owner), + deadline: p.deadline, + }, + signature: serializeSignature({ r: p.r, s: p.s, v: BigInt(p.v) }), + }); + return lc(signer) === lc(owner); + } catch { + return false; + } + } + + /** + * x402ExactPermit2Proxy.settle / settleWithPermit, modelled on the contract source + * (coinbase/x402 contracts/evm/src/x402BasePermit2Proxy.sol): settleWithPermit first checks + * the permit's value against the permitted amount, then TRIES the token's permit() and + * carries on if it fails; _settle checks destination and validAfter, then Permit2's + * permitWitnessTransferFrom checks deadline and nonce and pulls the tokens with its ERC-20 + * allowance. The Permit2 signature itself is not re-checked here: the SDK recovers it offline + * before every simulation and write, exactly as for EIP-3009. + * + * `apply` false is the eth_call simulation: same checks, no state change. + */ + async function proxySettle(functionName: string, args: readonly unknown[], apply: boolean) { + const { permit, owner, witness, permit2612 } = decodeProxySettle(functionName, args); + const token = permit.permitted.token; + const amount = permit.permitted.amount; + requireToken(token); + + let allowance = chain.allowance(token, owner, PERMIT2_ADDRESS); + if (permit2612) { + if (permit2612.value !== amount) { + throw new Revert("Permit2612AmountMismatch()"); + } + if (await permitSucceeds(token, owner, permit2612)) { + allowance = permit2612.value; + if (apply) { + chain.permitNonces.set(`${lc(token)}:${lc(owner)}`, chain.permitNonce(token, owner) + 1n); + } + } + } + + const now = BigInt(Math.floor(Date.now() / 1000)); + const nonceKey = `${lc(owner)}:${permit.nonce}`; + if (witness.to === "0x0000000000000000000000000000000000000000") { + throw new Revert("InvalidDestination()"); + } + if (now < witness.validAfter) { + throw new Revert("PaymentTooEarly()"); + } + if (now > permit.deadline) { + throw new Revert("SignatureExpired()"); + } + if (chain.usedPermit2Nonces.has(nonceKey)) { + throw new Revert("InvalidNonce()"); + } + if (allowance < amount) { + throw new Revert("TRANSFER_FROM_FAILED"); // Permit2's SafeTransferLib on a short allowance + } + if (chain.balanceOf(token, owner) < amount) { + throw new Revert("TRANSFER_FROM_FAILED"); + } + + if (!apply) { + return []; + } + chain.usedPermit2Nonces.add(nonceKey); + chain.setAllowance(token, owner, PERMIT2_ADDRESS, allowance - amount); + chain.setBalance(token, owner, chain.balanceOf(token, owner) - amount); + chain.setBalance(token, witness.to, chain.balanceOf(token, witness.to) + amount); + return [transferLog(token, owner, witness.to, amount)]; + } + + function createPublicClient() { + return { + async getCode({ address }: { address: Address }) { + return chain.tokens.has(lc(address)) ? TOKEN_BYTECODE : "0x"; + }, + async readContract({ + address, + functionName, + args = [], + }: { + address: Address; + functionName: string; + args?: readonly unknown[]; + }) { + if (lc(address) === MULTICALL3 && functionName === "tryAggregate") { + return tryAggregate(args[1] as { target: Address; callData: Hex }[]); + } + if (lc(address) === PERMIT2_PROXY) { + return proxySettle(functionName, args, false); + } + return readToken(address, functionName, args); + }, + async verifyTypedData(): Promise { + // Only reached for contract (ERC-1271) payers; EOA payers are verified offline. + throw new Error("fakeChain: ERC-1271 signature verification is not modelled"); + }, + async waitForTransactionReceipt({ hash }: { hash: Hex }) { + if (chain.timedOut.has(hash)) { + throw new WaitForTransactionReceiptTimeoutError({ hash }); + } + if (chain.reverted.has(hash)) { + return { status: "reverted" as const, transactionHash: hash, blockNumber: 1n, logs: [] }; + } + const logs = chain.receipts.get(hash); + if (!logs) { + throw new Error(`fakeChain: unknown transaction ${hash}`); + } + return { status: "success" as const, transactionHash: hash, blockNumber: 1n, logs }; + }, + }; + } + + function createWalletClient({ + account, + }: { + account: { address: Address; nonceManager?: unknown }; + }) { + chain.walletAccounts.push(account); + return { + account, + async writeContract({ + address, + functionName, + args = [], + }: { + address: Address; + functionName: string; + args?: readonly unknown[]; + }) { + if (lc(address) !== PERMIT2_PROXY) { + requireToken(address); + } + const sender = account.address; + const pending = chain.failures.get(functionName); + if (pending && --pending.remaining <= 0) { + chain.failures.delete(functionName); + } + const failure = pending?.failure; + if (failure === "revert") { + throw new Revert(`${functionName} (injected by chain.failNext)`); + } + if (failure === "underpriced") { + throw new Error("replacement transaction underpriced"); + } + if (failure === "rpcError") { + throw new Error("fetch failed"); + } + if (failure === "revertOnChain") { + // Mined, but nothing happened: record the attempt, change no state. + chain.writes.push({ address, functionName, args, from: sender }); + const hash = nextHash(); + chain.reverted.add(hash); + return hash; + } + let logs: Log[]; + + if (lc(address) === PERMIT2_PROXY) { + logs = await proxySettle(functionName, args, true); + } else if (functionName === "transferWithAuthorization") { + readToken(address, functionName, args); // same checks as the simulation + const [from, to, value, , , nonce] = args as [ + string, + string, + bigint, + bigint, + bigint, + string, + ]; + chain.markNonceUsed(address, from, nonce); + chain.setBalance(address, from, chain.balanceOf(address, from) - value); + chain.setBalance(address, to, chain.balanceOf(address, to) + value); + logs = [transferLog(address, from, to, value)]; + } else if (functionName === "transferFrom") { + const [from, to, value] = args as [string, string, bigint]; + const allowed = chain.allowance(address, from, sender); + if (allowed < value) { + throw new Revert("ERC20: insufficient allowance"); + } + if (chain.balanceOf(address, from) < value) { + throw new Revert("ERC20: transfer amount exceeds balance"); + } + chain.setAllowance(address, from, sender, allowed - value); + chain.setBalance(address, from, chain.balanceOf(address, from) - value); + chain.setBalance(address, to, chain.balanceOf(address, to) + value); + logs = [transferLog(address, from, to, value)]; + } else { + throw new Error(`fakeChain: unmodelled write ${functionName} on ${address}`); + } + + chain.writes.push({ address, functionName, args, from: sender }); + const hash = nextHash(); + chain.receipts.set(hash, logs); + if (failure === "receiptTimeout") { + chain.timedOut.add(hash); + } + return hash; + }, + async sendTransaction(): Promise { + throw new Error("fakeChain: raw sendTransaction is not modelled"); + }, + }; + } + + return { ...actual, createPublicClient, createWalletClient }; +} diff --git a/bunny_js/test/facilitator/helpers/signPayment.ts b/bunny_js/test/facilitator/helpers/signPayment.ts new file mode 100644 index 000000000..ab7b047fd --- /dev/null +++ b/bunny_js/test/facilitator/helpers/signPayment.ts @@ -0,0 +1,192 @@ +/** + * Builds REAL, correctly signed x402 v2 exact payments for tests: EIP-3009 (USDC/EURC) and + * Permit2 (EURe). + * + * Overrides are applied to the authorization BEFORE it is signed, so an expired or + * wrong-amount payment still carries a valid signature and reaches the check it is meant to + * exercise. (Mutating a pre-signed fixture instead breaks the signature first, and every + * test then asserts `invalid_exact_evm_signature` whatever it claims to test.) + * + * The payer is a fresh random key per call — never the well-known Hardhat key, which has a + * stray EIP-7702 delegation on public testnets and would take the ERC-1271 path. + */ + +import { randomBytes } from "node:crypto"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { getChainConfig } from "../../../src/facilitator/chain_utils"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; + +type Address = `0x${string}`; + +export const SELLER: Address = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; + +const TRANSFER_WITH_AUTHORIZATION_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" }, + ], +} as const; + +export interface AuthorizationOverrides { + to?: Address; + value?: bigint; + validAfter?: bigint; + validBefore?: bigint; + nonce?: `0x${string}`; +} + +export interface SignOptions { + network?: string; + amount?: bigint; + payTo?: Address; + /** Fields to sign that differ from what the requirements ask for. */ + authorization?: AuthorizationOverrides; +} + +export async function signExactPayment({ + network = "eip155:11155420", + amount = 100_000n, + payTo = SELLER, + authorization = {}, +}: SignOptions = {}) { + const cfg = getChainConfig(network); + const token = cfg.USDC_ADDRESS as Address; + const payer = privateKeyToAccount(generatePrivateKey()); + const now = BigInt(Math.floor(Date.now() / 1000)); + + const message = { + from: payer.address, + to: authorization.to ?? payTo, + value: authorization.value ?? amount, + validAfter: authorization.validAfter ?? now - 600n, + validBefore: authorization.validBefore ?? now + 300n, + nonce: authorization.nonce ?? `0x${randomBytes(32).toString("hex")}`, + }; + + const signature = await payer.signTypedData({ + domain: { name: cfg.USDC_NAME, version: "2", chainId: cfg.chain.id, verifyingContract: token }, + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + primaryType: "TransferWithAuthorization", + message, + }); + + const requirements = { + scheme: "exact", + network, + amount: amount.toString(), + asset: token, + payTo, + maxTimeoutSeconds: 300, + extra: { name: cfg.USDC_NAME, version: "2" }, + }; + + const payload = { + x402Version: 2, + resource: { url: "https://api.example.com/test", description: "test", mimeType: "text/plain" }, + accepted: requirements, + payload: { + signature, + authorization: { + from: message.from, + to: message.to, + value: message.value.toString(), + validAfter: message.validAfter.toString(), + validBefore: message.validBefore.toString(), + nonce: message.nonce, + }, + }, + }; + + return { + payer: payer.address, + token, + name: cfg.USDC_NAME, + payload, + requirements, + nonce: message.nonce, + }; +} + +export interface Permit2SignOptions { + network?: string; + amount?: bigint; + payTo?: Address; + /** Attach an EIP-2612 permit to Permit2 (eip2612GasSponsoring), so no prior approve() is needed. */ + gasSponsoring?: boolean; + /** The EIP-712 domain the EIP-2612 permit is signed for. Defaults to the real EURe domain. */ + permitDomain?: { name: string; version: string }; + /** Edit the payment requirements before the client signs, to model a buyer signing other terms. */ + signRequirements?: (requirements: Record) => Record; +} + +/** The EURe EIP-712 domain, read on-chain on Base and Base Sepolia (2026-10-04). */ +export const EURE_DOMAIN = { name: "Monerium EURe", version: "1" } as const; + +/** + * Builds a REAL x402 v2 exact Permit2 payment — the only way EURe (no EIP-3009) can be paid — + * with the SDK's own client scheme, so the test exercises the wire format a real buyer sends + * rather than a hand-rolled copy of it. + * + * With `gasSponsoring` the client also signs an EIP-2612 permit for Permit2 and carries it in + * `extensions.eip2612GasSponsoring`; the buyer then needs no separate approve() transaction. + * The client only does this when the 402 advertises the extension and the payer's Permit2 + * allowance is short — its two chain reads (`allowance`, `nonces`) are answered here as a + * fresh wallet would: no allowance, permit nonce 0. + */ +export async function signPermit2Payment({ + network = "eip155:8453", + amount = 100_000_000_000_000_000n, // 0.1 EURe + payTo = SELLER, + gasSponsoring = true, + permitDomain = EURE_DOMAIN, + signRequirements = (r) => r, +}: Permit2SignOptions = {}) { + const token = + EURE_ADDRESSES[network] ?? + // EURe is not deployed here (e.g. Optimism): reuse the Base address to model a payment + // naming a token the facilitator must refuse on this network. + EURE_ADDRESSES["eip155:8453"]; + const account = privateKeyToAccount(generatePrivateKey()); + + const requirements = { + scheme: "exact", + network, + amount: amount.toString(), + asset: token, + payTo, + maxTimeoutSeconds: 300, + extra: { ...permitDomain, assetTransferMethod: "permit2" }, + }; + + const client = new ExactEvmScheme({ + address: account.address, + signTypedData: (msg) => account.signTypedData(msg), + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "allowance" || functionName === "nonces") { + return 0n; + } + throw new Error(`signPermit2Payment: unexpected client read ${functionName}`); + }, + }); + + const signed = await client.createPaymentPayload( + 2, + signRequirements(requirements) as unknown as Parameters[1], + gasSponsoring ? { extensions: { eip2612GasSponsoring: {} } } : undefined, + ); + + const payload = { + x402Version: 2, + resource: { url: "https://api.example.com/test", description: "test", mimeType: "text/plain" }, + accepted: requirements, + payload: signed.payload, + ...(signed.extensions && { extensions: signed.extensions }), + }; + + return { payer: account.address, token, payload, requirements }; +} diff --git a/bunny_js/test/facilitator/integration/x402_batch_settlement.integration.test.ts b/bunny_js/test/facilitator/integration/x402_batch_settlement.integration.test.ts new file mode 100644 index 000000000..41c3bfa90 --- /dev/null +++ b/bunny_js/test/facilitator/integration/x402_batch_settlement.integration.test.ts @@ -0,0 +1,96 @@ +/** + * INTEGRATION test for the batch-settlement scheme (Base Sepolia, live RPC). + * + * Builds a real deposit+voucher payload with the @x402 batch-settlement BUYER scheme + * (SDK name "client" — the paying wallet; in production this code lives in `website/`, + * NOT scw_js, which plays the seller/resource-server role) and runs it through the + * facilitator's verify path in-process (no HTTP server needed). A fresh, unfunded payer + * means verify reaches the on-chain balance check and returns + * `invalid_batch_settlement_evm_insufficient_balance` — which proves the buyer↔facilitator + * wiring (payload shape, signature, EIP-712 domain, channel config) is all accepted; only + * the empty USDC balance stops it. No funds move (verify is read-only; no deposit is settled). + * + * Base Sepolia only — the canonical BATCH_SETTLEMENT_ADDRESS is deployed there (not OP Sepolia, + * enforced by getBatchSettlementNetworks() in chain_utils.ts — see facilitator_instance.test.ts's + * "excludes Optimism Sepolia" regression tests for the /supported-side guard). + * Network-dependent, so it lives in the integration suite: `npm run test:integration`. + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { verifyPayment } from "../../../src/facilitator/x402_verify.js"; +import { resetFacilitator } from "../../../src/facilitator/facilitator_instance.js"; +import { getChainConfig } from "../../../src/facilitator/chain_utils.js"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { + BatchSettlementEvmScheme, + InMemoryClientChannelStorage, +} from "@x402/evm/batch-settlement/client"; + +describe("x402 batch-settlement — deposit verify (integration, live RPC)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + resetFacilitator(); + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("facilitator verifies a batch-settlement deposit payload (insufficient balance for a fresh payer)", async () => { + const network = "eip155:84532"; // Base Sepolia (canonical contract deployed here) + const cfg = getChainConfig(network); + + // Fresh clean-EOA payer (ecrecover path, no code) and a fresh receiver-authorizer. + const payer = privateKeyToAccount(generatePrivateKey()); + const receiverAuthorizer = privateKeyToAccount(generatePrivateKey()); + + const scheme = new BatchSettlementEvmScheme( + { address: payer.address, signTypedData: (a) => payer.signTypedData(a) }, + { storage: new InMemoryClientChannelStorage() }, + ); + + // In production the server (scw_js) supplies receiverAuthorizer + withdrawDelay in the 402. + const paymentRequirements = { + scheme: "batch-settlement", + network, + amount: "4000", // 0.004 USDC max per-request price + asset: cfg.USDC_ADDRESS, + payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + maxTimeoutSeconds: 3600, + extra: { + name: cfg.USDC_NAME, + version: "2", + receiverAuthorizer: receiverAuthorizer.address, + withdrawDelay: 86400, + }, + } as const; + + const partial = await scheme.createPaymentPayload(2, paymentRequirements); + + // Sanity: the client produced a deposit + cumulative voucher for a new channel. + // partial.payload is scheme-specific and typed `unknown` by the SDK. + const channelConfig = (partial.payload as { channelConfig: { receiverAuthorizer: string } }) + .channelConfig; + const voucher = (partial.payload as { voucher: { channelId: string } }).voucher; + expect((partial.payload as { type: string }).type).toBe("deposit"); + expect(channelConfig.receiverAuthorizer.toLowerCase()).toBe( + receiverAuthorizer.address.toLowerCase(), + ); + expect(voucher.channelId).toMatch(/^0x[0-9a-fA-F]{64}$/); + + const paymentPayload = { + x402Version: 2, + accepted: paymentRequirements, + payload: partial.payload, + }; + + const result = await verifyPayment(paymentPayload, paymentRequirements); + + // Wiring is accepted end-to-end; only the empty balance stops it. + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_batch_settlement_evm_insufficient_balance"); + expect(result.payer!.toLowerCase()).toBe(payer.address.toLowerCase()); + }); +}); diff --git a/bunny_js/test/facilitator/integration/x402_permit2_eure.integration.test.ts b/bunny_js/test/facilitator/integration/x402_permit2_eure.integration.test.ts new file mode 100644 index 000000000..d964b83ec --- /dev/null +++ b/bunny_js/test/facilitator/integration/x402_permit2_eure.integration.test.ts @@ -0,0 +1,98 @@ +/** + * INTEGRATION test for EURe via Permit2 (Base Sepolia, live RPC). + * + * Signs a real Permit2 + EIP-2612 EURe payment with the SDK's buyer client, from a fresh unfunded + * key, and runs it through the facilitator's verify path in-process. The SDK simulates the + * proxy's settleWithPermit against the live chain, so reaching `permit2_insufficient_balance` + * proves the wiring the hermetic tests can only model: the EURe sandbox token is a contract, the + * x402 Permit2 proxy is deployed there, and the Permit2 witness signature is accepted. Only the + * empty balance stops it. No funds move: verify is read-only. + * + * The proxy swallows a failing permit(), so the verify result cannot tell a wrong EURe domain + * from a right one. The second test closes that gap by simulating the buyer's permit() directly + * against the live token: it succeeds only if "Monerium EURe" / "1" is the token's real domain. + * + * Network-dependent, so it lives in the integration suite: `npm run test:integration`. + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createPublicClient, http, parseSignature, type Address, type Hex } from "viem"; +import { baseSepolia } from "viem/chains"; +import { PERMIT2_ADDRESS } from "@x402/evm"; +import { verifyPayment } from "../../../src/facilitator/x402_verify.js"; +import { resetFacilitator } from "../../../src/facilitator/facilitator_instance.js"; +import { getRpcUrl } from "../../../src/facilitator/chain_utils.js"; +import { signPermit2Payment } from "../helpers/signPayment"; + +const NETWORK = "eip155:84532"; + +const PERMIT_ABI = [ + { + type: "function", + name: "permit", + stateMutability: "nonpayable", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +describe("x402 exact — EURe via Permit2 (integration, live RPC)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + resetFacilitator(); + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("verify reaches the balance check for a fresh EURe payer on Base Sepolia", async () => { + const { payload, requirements, payer } = await signPermit2Payment({ network: NETWORK }); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ + isValid: false, + invalidReason: "permit2_insufficient_balance", + payer, + }); + }); + + test("the buyer's EIP-2612 permit is valid against the live EURe domain", async () => { + const { payload, token } = await signPermit2Payment({ network: NETWORK }); + const { info } = payload.extensions?.eip2612GasSponsoring as { + info: { from: Address; amount: string; deadline: string; signature: Hex }; + }; + const { r, s, v } = parseSignature(info.signature); + + const client = createPublicClient({ chain: baseSepolia, transport: http(getRpcUrl(NETWORK)) }); + // Reverts ("invalid signature") unless name, version, chain and nonce match the token's own. + await expect( + client.simulateContract({ + address: token, + abi: PERMIT_ABI, + functionName: "permit", + args: [ + info.from, + PERMIT2_ADDRESS, + BigInt(info.amount), + BigInt(info.deadline), + Number(v), + r, + s, + ], + account: info.from, + }), + ).resolves.toBeDefined(); + }); +}); diff --git a/bunny_js/test/facilitator/integration/x402_verify_signature.integration.test.ts b/bunny_js/test/facilitator/integration/x402_verify_signature.integration.test.ts new file mode 100644 index 000000000..dad0c9b17 --- /dev/null +++ b/bunny_js/test/facilitator/integration/x402_verify_signature.integration.test.ts @@ -0,0 +1,207 @@ +/** + * INTEGRATION tests for exact-scheme signature verification. + * + * These deliberately make REAL RPC calls (Optimism mainnet + Sepolia): they create + * authentic EIP-3009 signatures with the x402 client and run them through the full + * facilitator verify path, which since @x402/evm 2.17 does on-chain reads + * (getCode, ERC-1271 isValidSignature when the signer has code, and a + * transferWithAuthorization balance simulation). + * + * They are network-dependent (slow, occasionally flaky) so they live outside the + * hermetic unit suite. Run with `npm run test:integration`, not `npm test`. + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { verifyPayment } from "../../../src/facilitator/x402_verify.js"; +import { resetFacilitator } from "../../../src/facilitator/facilitator_instance.js"; +import { getChainConfig } from "../../../src/facilitator/chain_utils.js"; +import { EURC_ADDRESSES, findStablecoin } from "@fretchen/chain-utils"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; + +describe("x402 Verify — real signature (integration, live RPC)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + resetFacilitator(); + // Facilitator's own signing key (Hardhat test account #0). This is the + // facilitator wallet, not the payer — the payer uses a fresh key per test. + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + /** + * END-TO-END SIGNATURE VALIDATION TEST + * + * Creates a REAL signature using viem and the x402 client library, then + * validates it through the full facilitator flow. Catches signer/verification + * configuration issues and serves as a reference for TypeScript clients. + * + * NOTE: uses a FRESH random key, not the shared Hardhat account #0. Since + * @x402/evm 2.17+ validates signatures via ERC-1271 `isValidSignature` when the + * signer address has code, and the well-known Hardhat key has a stray EIP-7702 + * delegation on public testnets, that shared key takes the smart-wallet path and + * fails. A clean EOA (no code) uses ecrecover, as real users do. + */ + test("E2E: validates real signature created with x402 client", async () => { + // Fresh random key => clean EOA (no code / no EIP-7702 delegation), the ecrecover path + const payerAccount = privateKeyToAccount(generatePrivateKey()); + + const evmClient = new ExactEvmScheme({ + address: payerAccount.address, + signTypedData: async (args) => payerAccount.signTypedData(args), + }); + + // Source the USDC asset + EIP-712 domain name from the facilitator's own + // chain_utils (same source production uses via the 402 response). Since @x402 + // 2.17, verify derives the authoritative domain rather than trusting the + // payload's `extra`, so a wrong name/address here would fail verification. + const network = "eip155:11155420"; // Optimism Sepolia + const cfg = getChainConfig(network); + const paymentRequirements = { + scheme: "exact", + network, + amount: "100000", // $0.10 in 6-decimal USDC + asset: cfg.USDC_ADDRESS, + payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + maxTimeoutSeconds: 300, + extra: { name: cfg.USDC_NAME, version: "2" }, + } as const; + + const partialPayload = await evmClient.createPaymentPayload(2, paymentRequirements); + const paymentPayload = { + x402Version: 2, + resource: { + url: "https://api.example.com/e2e-test", + description: "End-to-end signature validation test", + mimeType: "application/json", + }, + accepted: paymentRequirements, + payload: partialPayload.payload, + }; + + const result = await verifyPayment(paymentPayload, paymentRequirements); + + // The signature should validate, then fail the balance check + // (invalid_exact_evm_insufficient_balance — 2.17+ rename of insufficient_funds) + // since the fresh wallet holds no USDC. What matters: NOT invalid_exact_evm_signature. + if (!result.isValid) { + expect(result.invalidReason).toBe("invalid_exact_evm_insufficient_balance"); + } else { + expect(result.invalidReason).toBeUndefined(); + } + + // partialPayload.payload is scheme-specific and typed `unknown` by the SDK. + const authorization = (paymentPayload.payload as { authorization: { from: string } }) + .authorization; + expect(authorization.from.toLowerCase()).toBe(payerAccount.address.toLowerCase()); + }); + + test("validates signature for Optimism Mainnet (chainId 10)", async () => { + // Fresh random key => clean EOA (ecrecover path); see the E2E test note re: + // the shared Hardhat key's EIP-7702 delegation under @x402 2.17+. + const payerAccount = privateKeyToAccount(generatePrivateKey()); + + const evmClient = new ExactEvmScheme({ + address: payerAccount.address, + signTypedData: async (args) => payerAccount.signTypedData(args), + }); + + // Note Optimism *mainnet* USDC is "USD Coin" (not "USDC"); sourcing from + // chain_utils avoids the wrong-domain-name bug under @x402 2.17+. + const network = "eip155:10"; // Optimism Mainnet + const cfg = getChainConfig(network); + const paymentRequirements = { + scheme: "exact", + network, + amount: "1000000", // $1.00 in 6-decimal USDC + asset: cfg.USDC_ADDRESS, + payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + maxTimeoutSeconds: 300, + extra: { name: cfg.USDC_NAME, version: "2" }, + } as const; + + const partialPayload = await evmClient.createPaymentPayload(2, paymentRequirements); + const paymentPayload = { + x402Version: 2, + resource: { + url: "https://api.example.com/mainnet-test", + description: "Mainnet multi-chain signature validation test", + mimeType: "application/json", + }, + accepted: paymentRequirements, + payload: partialPayload.payload, + }; + + const result = await verifyPayment(paymentPayload, paymentRequirements); + + // Accept insufficient-balance or unauthorized_agent — testing signature + // validation. The important part is NOT getting invalid_exact_evm_signature, + // which would mean the wrong chain client / domain was used. + if (!result.isValid) { + expect(["invalid_exact_evm_insufficient_balance", "unauthorized_agent"]).toContain( + result.invalidReason, + ); + } else { + expect(result.isValid).toBe(true); + } + + expect(paymentPayload.accepted.network).toBe("eip155:10"); + }); + + /** + * EURC on Base Sepolia, signed with the domain from chain-utils' `findStablecoin`. A + * wrong domain name there would surface as invalid_exact_evm_signature; the negative + * control below shows this test can tell the two apart. + */ + async function verifyEurcPayment(domainName?: string) { + const payerAccount = privateKeyToAccount(generatePrivateKey()); + const evmClient = new ExactEvmScheme({ + address: payerAccount.address, + signTypedData: async (args) => payerAccount.signTypedData(args), + }); + + const network = "eip155:84532"; // Base Sepolia + const eurc = findStablecoin(network, EURC_ADDRESSES[network])!; + const paymentRequirements = { + scheme: "exact", + network, + amount: "100000", // 0.10 EURC + asset: eurc.address, + payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + maxTimeoutSeconds: 300, + extra: { name: domainName ?? eurc.name, version: eurc.version }, + } as const; + + const partialPayload = await evmClient.createPaymentPayload(2, paymentRequirements); + const paymentPayload = { + x402Version: 2, + resource: { + url: "https://api.example.com/eurc-test", + description: "EURC signature validation test", + mimeType: "application/json", + }, + accepted: paymentRequirements, + payload: partialPayload.payload, + }; + + return verifyPayment(paymentPayload, paymentRequirements); + } + + test("validates a EURC signature on Base Sepolia", async () => { + const result = await verifyEurcPayment(); + + // The fresh wallet holds no EURC, so the signature passes and the balance check fails. + expect(result.invalidReason).toBe("invalid_exact_evm_insufficient_balance"); + }); + + test("rejects a EURC signature made with the wrong domain name", async () => { + const result = await verifyEurcPayment("USDC"); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).not.toBe("invalid_exact_evm_insufficient_balance"); + }); +}); diff --git a/bunny_js/test/facilitator/openapi_generation.test.ts b/bunny_js/test/facilitator/openapi_generation.test.ts new file mode 100644 index 000000000..753f6a756 --- /dev/null +++ b/bunny_js/test/facilitator/openapi_generation.test.ts @@ -0,0 +1,57 @@ +/** + * Golden-file drift check: this is what actually enforces "regenerate openapi.json + * after changing a Zod schema" — without it, the file can still silently fall out of + * sync with the schemas exactly the way it fell out of sync with the hand-written + * interfaces it replaced. + */ +import { describe, it, expect } from "vitest"; +import { generateOpenApiSpec } from "../../scripts/generate-openapi"; +import committedSpec from "../../src/facilitator/openapi.facilitator.json" with { type: "json" }; + +describe("openapi.json generation", () => { + it("matches what the current Zod schemas would generate", () => { + const generated = generateOpenApiSpec(); + expect(committedSpec).toEqual(generated); + }); + + /** + * The golden check above compares the spec to the schemas, but cannot tell whether the schemas + * describe the *handler*. This does, for the one property that has already shipped wrong. + * + * `z.object` strips unknown keys at parse time, yet `z.toJSONSchema` renders it as + * `additionalProperties: false`. The published `PaymentRequest` therefore declared closed while + * every real payload carries `x402Version`, `resource`, `payload`, and an `accepted` with + * `amount`/`asset`/`payTo`/`maxTimeoutSeconds`/`extra` — the spec described a request that could + * not be paid with, and a client trimming to match it would have produced an unverifiable + * payment. `z.looseObject` is what keeps the published shape honest. + */ + describe("advertised strictness matches actual behaviour", () => { + interface JsonSchema { + additionalProperties?: unknown; + properties?: Record; + } + const schemas = ( + committedSpec as unknown as { components: { schemas: Record } } + ).components.schemas; + + it("publishes the request as permissive, at every level", () => { + // The facilitator forwards this payload to @x402/evm rather than owning its shape, so the + // schema is a deliberately partial view and must not claim to reject what it ignores. + const req = schemas.PaymentRequest; + const payload = req.properties!.paymentPayload; + + expect(req.additionalProperties).toEqual({}); + expect(payload.additionalProperties).toEqual({}); + expect(payload.properties!.accepted.additionalProperties).toEqual({}); + expect(req.properties!.paymentRequirements.additionalProperties).toEqual({}); + }); + + it("keeps the responses closed", () => { + // The opposite case: /verify and /settle assemble their bodies field by field, pinned to + // z.infer<> types in x402_facilitator.ts, so closed is true of them and compiler-enforced. + for (const name of ["VerifyResponse", "SettleResponse", "SupportedResponse"]) { + expect(schemas[name].additionalProperties).toBe(false); + } + }); + }); +}); diff --git a/bunny_js/test/facilitator/revert_selectors.test.ts b/bunny_js/test/facilitator/revert_selectors.test.ts new file mode 100644 index 000000000..31b2da28a --- /dev/null +++ b/bunny_js/test/facilitator/revert_selectors.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { toFunctionSelector } from "viem"; +import { + BATCH_SETTLEMENT_REVERT_SELECTORS, + annotateDecodedRevert, +} from "../../src/facilitator/revert_selectors"; + +/** The complete error list of contracts/evm/src/x402BatchSettlement.sol, independently of the + * module under test: the test recomputes every selector from the name, so a typo in either + * the name or the selector half of the map fails here. */ +const CONTRACT_ERRORS = [ + "InvalidChannel()", + "ZeroDeposit()", + "DepositOverflow()", + "InvalidSignature()", + "NotReceiverAuthorizer()", + "NotAuthorizedToClaim()", + "NotAuthorizedToRefund()", + "ClaimExceedsCeiling()", + "ClaimExceedsBalance()", + "WithdrawalAlreadyPending()", + "WithdrawalNotPending()", + "NotAuthorizedToFinalizeWithdraw()", + "WithdrawDelayNotElapsed()", + "NothingToWithdraw()", + "WithdrawAmountExceedsAvailable()", + "WithdrawDelayOutOfRange()", + "EmptyBatch()", + "DepositCollectionFailed()", + "InvalidCollector()", + "InvalidRefundNonce()", + "ZeroRefund()", +]; + +/** The real viem message from the 2026-10-11 facilitator log — the incident that motivated + * the module. Verbatim except for the escaped quotes the JSON log payload carried. */ +const ZERO_REFUND_MESSAGE = + 'The contract function "refundWithSignature" reverted with the following signature:\n' + + "0xd758525c\n" + + "\n" + + 'Unable to decode signature "0xd758525c" as it was not found on the provided ABI.\n' + + "Make sure you are using the correct ABI and that the error exists on it.\n" + + "You can look up the decoded signature here: https://4byte.sourcify.dev/?q=0xd758525c.\n"; + +describe("BATCH_SETTLEMENT_REVERT_SELECTORS", () => { + it("covers exactly the contract's error list, name for name", () => { + const bySelector = new Map(Object.entries(BATCH_SETTLEMENT_REVERT_SELECTORS)); + expect(bySelector.size).toBe(CONTRACT_ERRORS.length); + for (const name of CONTRACT_ERRORS) { + // The map's values carry the name WITH parentheses; the selector is keccak(name). + expect(bySelector.get(toFunctionSelector(name))).toBe(name); + } + }); + + it("names the two selectors production has actually bitten this repo with", () => { + // 0x164f1afe: the stale refundNonce reverts documented in scw_js/x402_channel_sync.ts. + // 0xd758525c: the ZeroRefund revert of the 2026-10-11 fully-claimed-channel incident. + expect(BATCH_SETTLEMENT_REVERT_SELECTORS["0x164f1afe"]).toBe("InvalidRefundNonce()"); + expect(BATCH_SETTLEMENT_REVERT_SELECTORS["0xd758525c"]).toBe("ZeroRefund()"); + }); +}); + +describe("annotateDecodedRevert", () => { + it("names the selector in a real undecoded viem revert message", () => { + const annotated = annotateDecodedRevert(ZERO_REFUND_MESSAGE); + expect(annotated).toContain("decoded revert: ZeroRefund() (0xd758525c)"); + // Annotates, never rewrites: the original text stays intact for anything grepping it. + expect(annotated).toMatch( + /^The contract function "refundWithSignature" reverted with the following signature:/, + ); + }); + + it("appends each known selector once, even when the message repeats it", () => { + const annotated = annotateDecodedRevert( + "failed with 0xd758525c and again 0xD758525C plus 0x164f1afe", + ); + expect(annotated).toBe( + "failed with 0xd758525c and again 0xD758525C plus 0x164f1afe" + + " — decoded revert: ZeroRefund() (0xd758525c), InvalidRefundNonce() (0x164f1afe)", + ); + }); + + it("leaves unknown selectors and selector-free messages unchanged", () => { + expect(annotateDecodedRevert("reverted with 0xdeadbeef, whatever that is")).toBe( + "reverted with 0xdeadbeef, whatever that is", + ); + expect(annotateDecodedRevert("plain failure, no selector anywhere")).toBe( + "plain failure, no selector anywhere", + ); + }); + + it("does not read the 8-hex prefix of a longer value as a selector", () => { + // A tx hash or calldata blob starts 0x + many hex chars; even when its first 8 hex + // collide with a known selector, it is not one. The lookahead requires the selector to + // stand alone, the way viem presents it in "reverted with the following signature". + expect(annotateDecodedRevert("hash 0xd758525c00000000deadbeef confirmed")).toBe( + "hash 0xd758525c00000000deadbeef confirmed", + ); + }); + + it("passes undefined through", () => { + expect(annotateDecodedRevert(undefined)).toBeUndefined(); + }); +}); diff --git a/bunny_js/test/facilitator/x402_batch_route.test.ts b/bunny_js/test/facilitator/x402_batch_route.test.ts new file mode 100644 index 000000000..b0eb47c31 --- /dev/null +++ b/bunny_js/test/facilitator/x402_batch_route.test.ts @@ -0,0 +1,100 @@ +/** + * classifyBatchSettlement — which batch-settlement payloads pay out, to whom, in what, + * and which are refused. A pure function, so this is a table with no mocks. + * + * These are the security rules of the batch path: the fee follows what the SDK will + * EXECUTE (the payload's shape), never a label or `requirements.payTo`; one batch pays one + * seller in one token; a refund's claims must belong to the channel verify() vouches for. + * How settlePayment acts on each route (gate, settle, charge) is in x402_settle.test.ts. + */ +import { describe, it, expect } from "vitest"; +import { classifyBatchSettlement, type BatchRoute } from "../../src/facilitator/x402_settle.js"; +import { + claim, + claimCommand, + settleCommand, + settleCommandWithoutReceiver, + refund, + deposit, + SELLER, + OTHER_SELLER, + BASE_SEPOLIA_USDC as USDC, + BASE_SEPOLIA_EURC as EURC, +} from "./helpers/batchPayloads"; + +const MALFORMED: BatchRoute = { + kind: "reject", + errorReason: "invalid_batch_settlement_evm_payload_type", +}; + +const cases: [string, { payload: Record }, BatchRoute][] = [ + // ─── claim / settle commands: charged to the payload's receiver, in its token ─── + [ + "claim → charged to the channel's receiver", + claimCommand(), + { kind: "command", feeRecipient: SELLER, feeToken: USDC }, + ], + [ + "EURC claim → charged in EURC", + claimCommand([claim(SELLER, EURC)]), + { kind: "command", feeRecipient: SELLER, feeToken: EURC }, + ], + [ + "one seller in mixed checksum casing → still one seller", + claimCommand([claim(SELLER), claim(SELLER.toLowerCase())]), + { kind: "command", feeRecipient: SELLER, feeToken: USDC }, + ], + [ + "settle → charged to payload.receiver in payload.token", + settleCommand({ receiver: OTHER_SELLER, token: EURC }), + { kind: "command", feeRecipient: OTHER_SELLER, feeToken: EURC }, + ], + [ + "claim spanning two sellers → refused", + claimCommand([claim(SELLER), claim(OTHER_SELLER)]), + MALFORMED, + ], + [ + "claim mixing tokens → refused", + claimCommand([claim(SELLER, USDC), claim(SELLER, EURC)]), + MALFORMED, + ], + ["claim whose channel names no token → refused", claimCommand([claim(SELLER, null)]), MALFORMED], + ["claim with no claims → refused", claimCommand([]), MALFORMED], + // The SDK's guard is shape-only ("claims" in payload), so this reaches classification. + ["claim whose claims are not an array → refused", claimCommand({}), MALFORMED], + ["settle without a receiver → refused", settleCommandWithoutReceiver(), MALFORMED], + + // ─── refunds: the fee follows the claims, not the "refund" label ─── + [ + "refund carrying claims → gated and charged like a claim", + refund([claim(SELLER)]), + { kind: "claiming-refund", feeRecipient: SELLER, feeToken: USDC }, + ], + ["refund without claims → free", refund([]), { kind: "free" }], + [ + "refund whose claims pay another receiver → refused", + refund([claim(OTHER_SELLER)]), + { kind: "reject", errorReason: "invalid_batch_settlement_evm_receiver_mismatch" }, + ], + [ + "refund whose claims are in another token → refused", + refund([claim(SELLER, EURC)]), + { kind: "reject", errorReason: "invalid_batch_settlement_evm_token_mismatch" }, + ], + ["refund whose claims are not an array → refused", refund({}), MALFORMED], + [ + "refund whose claims span two sellers → refused", + refund([claim(SELLER), claim(OTHER_SELLER)]), + MALFORMED, + ], + + // ─── everything else funds or signs a channel and pays no one ─── + ["deposit → free", deposit(), { kind: "free" }], +]; + +describe("classifyBatchSettlement", () => { + it.each(cases)("%s", (_name, paymentPayload, expected) => { + expect(classifyBatchSettlement(paymentPayload.payload)).toEqual(expected); + }); +}); diff --git a/bunny_js/test/facilitator/x402_fee.test.ts b/bunny_js/test/facilitator/x402_fee.test.ts new file mode 100644 index 000000000..ddb7683cb --- /dev/null +++ b/bunny_js/test/facilitator/x402_fee.test.ts @@ -0,0 +1,788 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + ERC20_FEE_ABI, + getFeeAmount, + getFacilitatorAddress, + checkMerchantAllowance, + collectFee, + evaluateFeeGate, + feeAmountFor, + wasRejectedBeforeBroadcast, +} from "../../src/facilitator/x402_fee.js"; +import type { createPublicClient, getContract } from "viem"; + +// Test mocks only implement the subset of the viem client/contract surface that +// x402_fee.ts actually calls — cast through `unknown` rather than satisfying the +// full (much larger) viem return types. +function mockContract(shape: Record) { + return shape as unknown as ReturnType; +} +function mockPublicClient(shape: Record) { + return shape as unknown as ReturnType; +} + +// Mock viem +vi.mock("viem", async () => { + const actual = await vi.importActual("viem"); + return { + ...actual, + createPublicClient: vi.fn(() => ({ + waitForTransactionReceipt: vi.fn(async ({ hash }) => ({ + status: "success", + transactionHash: hash, + })), + })), + createWalletClient: vi.fn(() => ({})), + getContract: vi.fn(), + }; +}); + +// Mock viem/accounts +vi.mock("viem/accounts", () => ({ + privateKeyToAccount: vi.fn((key) => { + if (key === "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") { + return { address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }; + } + return { address: "0x1234567890123456789012345678901234567890" }; + }), +})); + +// The fee is charged in the settled token, so every fee call names one. +const OP_SEPOLIA_USDC = "0x5fd84259d66Cd46123540766Be93DFE6D43130D7"; +const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; +// Monerium EURe has 18 decimals, so the same nominal fee is 10¹⁶ atomic units, not 10000. +const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C"; +const EURE_FEE = 10_000_000_000_000_000n; // 0.01 EURe + +describe("x402_fee", () => { + const originalEnv = { ...process.env }; + const VALID_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = VALID_PRIVATE_KEY; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + // ═══════════════════════════════════════════════════════════ + // getFeeAmount + // ═══════════════════════════════════════════════════════════ + + describe("getFeeAmount", () => { + it("returns default fee (10000) when env not set", () => { + delete process.env.FACILITATOR_FEE_AMOUNT; + expect(getFeeAmount()).toBe(10000n); + }); + + it("returns custom fee from env", () => { + process.env.FACILITATOR_FEE_AMOUNT = "50000"; + expect(getFeeAmount()).toBe(50000n); + }); + + it("returns 0 when fee is explicitly set to 0 (fees disabled)", () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + expect(getFeeAmount()).toBe(0n); + }); + + it("returns default for negative values", () => { + process.env.FACILITATOR_FEE_AMOUNT = "-100"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("supports large fee amounts", () => { + process.env.FACILITATOR_FEE_AMOUNT = "1000000"; // 1 USDC + expect(getFeeAmount()).toBe(1000000n); + }); + + it("returns default for non-integer strings (e.g. text)", () => { + process.env.FACILITATOR_FEE_AMOUNT = "hello"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("returns default for decimal values", () => { + process.env.FACILITATOR_FEE_AMOUNT = "1.5"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("returns default for whitespace-only value", () => { + process.env.FACILITATOR_FEE_AMOUNT = " "; + // BigInt trims whitespace → 0n (fees disabled), which is safe + expect(getFeeAmount()).toBe(0n); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // getFacilitatorAddress + // ═══════════════════════════════════════════════════════════ + + describe("getFacilitatorAddress", () => { + it("returns address derived from private key", () => { + const address = getFacilitatorAddress(); + expect(address).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + }); + + it("returns null when private key is not set", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + expect(getFacilitatorAddress()).toBeNull(); + }); + + it("returns null for invalid (too short) private key", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xinvalid"; + expect(getFacilitatorAddress()).toBeNull(); + }); + + it("handles private key without 0x prefix", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const address = getFacilitatorAddress(); + expect(address).toBeTruthy(); + }); + + it("handles private key with whitespace", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = ` ${VALID_PRIVATE_KEY} `; + const address = getFacilitatorAddress(); + expect(address).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // checkMerchantAllowance + // ═══════════════════════════════════════════════════════════ + + describe("feeAmountFor — the nominal fee in each token's own units", () => { + it("is 10000 for a 6-decimal token and 10¹⁶ for an 18-decimal one", () => { + expect(feeAmountFor(6)).toBe(10000n); + expect(feeAmountFor(18)).toBe(EURE_FEE); + }); + + it("scales a configured fee the same way", () => { + process.env.FACILITATOR_FEE_AMOUNT = "50000"; // 0.05 + expect(feeAmountFor(18)).toBe(50_000_000_000_000_000n); + }); + + it("stays zero when fees are disabled", () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + expect(feeAmountFor(18)).toBe(0n); + }); + }); + + describe("checkMerchantAllowance", () => { + const merchant = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; + + it("counts EURe settlements against the 18-decimal fee", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { allowance: vi.fn().mockResolvedValue(10n ** 18n) }, // 1 EURe + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:8453", BASE_EURE); + + // 1 EURe / 0.01 EURe — with an unscaled 10000 fee this would read as 10¹⁴ settlements. + expect(result.remainingSettlements).toBe(100); + expect(result.status).toBe("ok"); + }); + + it("returns status=ok when allowance exceeds fee", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(100000n), // 0.10 USDC + }, + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("ok"); + expect(result.allowance).toBe(100000n); + expect(result.remainingSettlements).toBe(10); // 100000 / 10000 + }); + + it("returns status=insufficient when allowance is zero", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(0n), + }, + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("insufficient"); + expect(result.allowance).toBe(0n); + expect(result.remainingSettlements).toBe(0); + }); + + it("returns status=insufficient when allowance is less than fee", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(5000n), // Half the fee + }, + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("insufficient"); + expect(result.allowance).toBe(5000n); + }); + + it("returns status=ok when fee is 0 (fees disabled)", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("ok"); + expect(result.remainingSettlements).toBe(Infinity); + }); + + it("returns insufficient when facilitator address not configured", async () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("insufficient"); + expect(result.allowance).toBe(0n); + }); + + it("reports an RPC error as unknown, with the allowance left undefined", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockRejectedValue(new Error("RPC timeout")), + }, + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + // "unknown", not "insufficient": verify must not reject a valid payment over a + // reading we could not take. + expect(result.status).toBe("unknown"); + // Deliberately NOT 0n — a caller capping a transfer at the allowance would + // otherwise halt collection entirely during a transient RPC failure. + expect(result.allowance).toBeUndefined(); + }); + + it("correctly calculates remaining settlements", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(35000n), // 3.5 fees + }, + }), + ); + + const result = await checkMerchantAllowance(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.status).toBe("ok"); + expect(result.remainingSettlements).toBe(3); // Floor(35000 / 10000) + }); + }); + + // ═══════════════════════════════════════════════════════════ + // collectFee + // ═══════════════════════════════════════════════════════════ + + describe("collectFee", () => { + const merchant = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; + + it("pulls 0.01 EURe as 10¹⁶ atomic units", async () => { + const txHash = "0xfee1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn(async () => ({ + status: "success", + transactionHash: txHash, + })), + }), + ); + const transferFrom = vi.fn().mockResolvedValue(txHash); + vi.mocked(getContract).mockReturnValue(mockContract({ write: { transferFrom } })); + + const result = await collectFee(merchant, "eip155:8453", BASE_EURE); + + expect(result.success).toBe(true); + expect(transferFrom).toHaveBeenCalledWith([ + merchant, + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + EURE_FEE, + ]); + }); + + it("refuses a token that is not a fee token", async () => { + const result = await collectFee( + merchant, + "eip155:8453", + "0x0000000000000000000000000000000000000001", + ); + + expect(result).toEqual({ success: false, error: "unsupported_fee_asset" }); + }); + + it("skips fee collection when fee is 0", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(true); + expect(result.txHash).toBeUndefined(); + }); + + it("returns error when private key not configured", async () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.error).toBe("facilitator_not_configured"); + }); + + it("collects fee successfully via transferFrom", async () => { + const mockTxHash = "0xfee1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn(async () => ({ + status: "success", + transactionHash: mockTxHash, + })), + }), + ); + + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockResolvedValue(mockTxHash), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(true); + expect(result.txHash).toBe(mockTxHash); + }); + + it("returns error on reverted transaction", async () => { + const mockTxHash = "0xfail234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn(async () => ({ + status: "reverted", + transactionHash: mockTxHash, + })), + }), + ); + + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockResolvedValue(mockTxHash), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.txHash).toBe(mockTxHash); + expect(result.error).toBe("fee_transaction_reverted"); + }); + + it("returns fee_collection_pending when the receipt wait times out", async () => { + const mockTxHash = "0xpend234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient, WaitForTransactionReceiptTimeoutError } = + await import("viem"); + + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi + .fn() + .mockRejectedValue(new WaitForTransactionReceiptTimeoutError({ hash: mockTxHash })), + }), + ); + + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockResolvedValue(mockTxHash), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + // Not "failed": the tx is still in flight and the hash must survive for + // later reconciliation, otherwise a retry could double-charge the merchant. + expect(result.success).toBe(false); + expect(result.error).toBe("fee_collection_pending"); + expect(result.txHash).toBe(mockTxHash); + }); + + it("bounds the receipt wait with a timeout", async () => { + const mockTxHash = "0xbound34567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + + const waitForTransactionReceipt = vi.fn(async () => ({ + status: "success", + transactionHash: mockTxHash, + })); + + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ waitForTransactionReceipt }), + ); + + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockResolvedValue(mockTxHash), + }, + }), + ); + + await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + // Regression guard: an unbounded wait can outlive the settle handler's 60s + // budget and cost the buyer their receipt for an already-settled payment. + expect(waitForTransactionReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: mockTxHash, timeout: expect.any(Number) }), + ); + }); + + it("treats non-timeout receipt wait errors as fee_collection_failed", async () => { + const mockTxHash = "0xrpcer34567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn().mockRejectedValue(new Error("RPC connection lost")), + }), + ); + + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockResolvedValue(mockTxHash), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.error).toBe("fee_collection_failed"); + }); + + it("returns insufficient_fee_allowance error", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientAllowance")), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.error).toBe("insufficient_fee_allowance"); + }); + + it("returns insufficient_merchant_balance error", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientBalance")), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.error).toBe("insufficient_merchant_balance"); + }); + + it("does not retry a decoded insufficient-balance revert — the merchant cannot fund it in 2s", async () => { + // The 2026-10-11 EURe incident: a deterministic ERC20InsufficientBalance revert was + // re-sent twice before giving up. A retry cannot change the merchant's balance, so the + // transfer must be attempted exactly once. + process.env.FEE_RETRY_DELAY_MS = "0"; + const { getContract } = await import("viem"); + const transferFrom = vi.fn().mockRejectedValue( + // The shape viem produces once the fee ABI decodes the OZ v5 error (Task 1). + new Error( + 'The contract function "transferFrom" reverted with reason: ERC20InsufficientBalance(0x209…287C, 5000000000000000, 10000000000000000)', + ), + ); + vi.mocked(getContract).mockReturnValue(mockContract({ write: { transferFrom } })); + + const result = await collectFee(merchant, "eip155:8453", BASE_EURE); + + expect(result).toMatchObject({ success: false, error: "insufficient_merchant_balance" }); + expect(transferFrom).toHaveBeenCalledTimes(1); + }); + + it("still retries a nonce race the full attempt budget", async () => { + // The retry exists for exactly this class (the 2025-09-28 nonce reuse): a node refused + // the send before broadcast, the next attempt can succeed. Must not be broken by the + // deterministic-revert classification. + process.env.FEE_RETRY_DELAY_MS = "0"; + const mockTxHash = "0xfee1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + const { getContract, createPublicClient } = await import("viem"); + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn(async () => ({ + status: "success", + transactionHash: mockTxHash, + })), + }), + ); + const transferFrom = vi + .fn() + .mockRejectedValueOnce(new Error("nonce too low")) + .mockRejectedValueOnce(new Error("replacement transaction underpriced")) + .mockResolvedValue(mockTxHash); + vi.mocked(getContract).mockReturnValue(mockContract({ write: { transferFrom } })); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(true); + expect(transferFrom).toHaveBeenCalledTimes(3); + }); + + it("returns generic error for unknown failures", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("Network error")), + }, + }), + ); + + const result = await collectFee(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(result.success).toBe(false); + expect(result.error).toBe("fee_collection_failed"); + }); + + it("charges the fee in the token it is given", async () => { + const { getContract, createPublicClient } = await import("viem"); + vi.mocked(createPublicClient).mockReturnValue( + mockPublicClient({ + waitForTransactionReceipt: vi.fn(async () => ({ status: "success" })), + }), + ); + const transferFrom = vi.fn().mockResolvedValue("0xfee"); + vi.mocked(getContract).mockReturnValue(mockContract({ write: { transferFrom } })); + + const result = await collectFee(merchant, "eip155:8453", BASE_EURC); + + expect(result.success).toBe(true); + expect(vi.mocked(getContract).mock.calls[0][0].address).toBe(BASE_EURC); + expect(transferFrom).toHaveBeenCalledWith([merchant, expect.any(String), 10000n]); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // evaluateFeeGate — the single gate both schemes run before settling + // + // `exact` reaches it via facilitator_instance.ts's onAfterVerify hook; + // `batch-settlement` claim/settle reaches it directly from x402_settle.ts. These + // were two hand-written implementations until the batch-settlement copy was found + // to be missing the fee-disabled and facilitator-not-configured checks below. + // ═══════════════════════════════════════════════════════════ + + describe("evaluateFeeGate", () => { + const merchant = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; + + function mockAllowance(value: bigint) { + return import("viem").then(({ getContract }) => { + vi.mocked(getContract).mockReturnValue( + mockContract({ read: { allowance: vi.fn().mockResolvedValue(value) } }), + ); + }); + } + + it("returns no_fee when fees are disabled, without reading any allowance", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + const { getContract } = await import("viem"); + + const gate = await evaluateFeeGate(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(gate.kind).toBe("no_fee"); + // Nothing to collect means nothing to check — no RPC round-trip. + expect(getContract).not.toHaveBeenCalled(); + }); + + it("rejects with facilitator_not_configured when no facilitator key is set", async () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + const gate = await evaluateFeeGate(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(gate).toEqual({ kind: "reject", reason: "facilitator_not_configured" }); + }); + + it("rejects with insufficient_fee_allowance when the allowance is too low", async () => { + await mockAllowance(5000n); + + const gate = await evaluateFeeGate(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + // Only the reason travels — the allowance detail behind it is logged, not returned. + expect(gate).toEqual({ kind: "reject", reason: "insufficient_fee_allowance" }); + }); + + it("charges, and reports remaining settlements, when the allowance covers the fee", async () => { + await mockAllowance(100000n); + + const gate = await evaluateFeeGate(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + expect(gate).toEqual({ kind: "charge", remainingSettlements: 10 }); + }); + + it("charges without a settlement count when the allowance is unreadable", async () => { + const { getContract } = await import("viem"); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { allowance: vi.fn().mockRejectedValue(new Error("RPC down")) }, + }), + ); + + const gate = await evaluateFeeGate(merchant, "eip155:11155420", OP_SEPOLIA_USDC); + + // Fails OPEN: a reading we could not take must not block a valid payment, and + // remainingSettlements stays undefined rather than being reported as 0. + expect(gate).toEqual({ kind: "charge", remainingSettlements: undefined }); + }); + + it("gates EURC on Base against the EURC allowance", async () => { + await mockAllowance(100000n); + const { getContract } = await import("viem"); + + const gate = await evaluateFeeGate(merchant, "eip155:8453", BASE_EURC); + + expect(gate).toEqual({ kind: "charge", remainingSettlements: 10 }); + expect(vi.mocked(getContract).mock.calls[0][0].address).toBe(BASE_EURC); + }); + + it("rejects with unsupported_fee_asset for a token that is not USDC or EURC", async () => { + const { getContract } = await import("viem"); + + const gate = await evaluateFeeGate( + merchant, + "eip155:8453", + "0x0000000000000000000000000000000000000001", + ); + + expect(gate).toEqual({ kind: "reject", reason: "unsupported_fee_asset" }); + expect(getContract).not.toHaveBeenCalled(); + }); + + it("rejects EURC on a network where it is not the registered EURC", async () => { + // Base's EURC address means nothing on Optimism, which has no EURC. + const gate = await evaluateFeeGate(merchant, "eip155:10", BASE_EURC); + + expect(gate).toEqual({ kind: "reject", reason: "unsupported_fee_asset" }); + }); + + it("does not vet the token when fees are disabled", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + + const gate = await evaluateFeeGate( + merchant, + "eip155:8453", + "0x0000000000000000000000000000000000000001", + ); + + expect(gate.kind).toBe("no_fee"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// wasRejectedBeforeBroadcast — which failed fee sends may be sent again +// ═══════════════════════════════════════════════════════════ + +describe("wasRejectedBeforeBroadcast", () => { + it.each([ + ["replacement transaction underpriced", "a node refused a reused nonce"], + ["nonce too low", "the nonce is already mined"], + ["execution reverted: ERC20: transfer amount exceeds balance", "the simulation reverted"], + ])("retries %s (%s)", (message) => { + expect(wasRejectedBeforeBroadcast(new Error(message))).toBe(true); + }); + + it.each([ + ["fetch failed", "transport error — the node may have taken the tx"], + ["Timed out while waiting for transaction", "receipt timeout — broadcast, outcome unknown"], + ])("never retries %s (%s)", (message) => { + expect(wasRejectedBeforeBroadcast(new Error(message))).toBe(false); + }); +}); + +describe("ERC20_FEE_ABI — the OZ v5 errors decode the incident's fee revert", () => { + // The 2026-10-11 incident: the EURe fee pull reverted with selector 0xe450d38c, and viem + // logged "Unable to decode signature" because the fee ABI carried no error definitions — + // so the insufficient_merchant_balance mapping in collectFee's catch never fired and the + // alert carried a bare selector. These tests pin the fix at both ends: the selector belongs + // to OZ v5's ERC20InsufficientBalance (self-verified against the production selector, not + // assumed), and the ABI now decodes it into a named, argued error. + const MERCHANT = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; + const word = (value: bigint | string) => + (typeof value === "bigint" ? value.toString(16) : value.replace(/^0x/, "")).padStart(64, "0"); + + it("self-verifies the OZ v5 signature against the production selector", async () => { + const { toFunctionSelector } = await import("viem"); + expect(toFunctionSelector("ERC20InsufficientBalance(address,uint256,uint256)")).toBe( + "0xe450d38c", + ); + }); + + it("decodes the incident's revert data to a named, argued error", async () => { + const { decodeErrorResult } = await import("viem"); + // The incident's shape: sender = the seller, balance below one fee, needed = one 0.01 EURe + // fee in 18-decimal units. + const data = + "0xe450d38c" + word(MERCHANT) + word(5_000_000_000_000_000n) + word(10_000_000_000_000_000n); + + const decoded = decodeErrorResult({ abi: ERC20_FEE_ABI, data: data as `0x${string}` }); + + expect(decoded.errorName).toBe("ERC20InsufficientBalance"); + // viem returns error args positionally. + expect(decoded.args).toEqual([MERCHANT, 5_000_000_000_000_000n, 10_000_000_000_000_000n]); + }); + + it("decodes the allowance twin the same way", async () => { + const { toFunctionSelector, decodeErrorResult } = await import("viem"); + const data = + toFunctionSelector("ERC20InsufficientAllowance(address,uint256,uint256)") + + word(MERCHANT) + + word(0n) + + word(10n ** 18n); + + const decoded = decodeErrorResult({ abi: ERC20_FEE_ABI, data: data as `0x${string}` }); + + expect(decoded.errorName).toBe("ERC20InsufficientAllowance"); + expect(decoded.args).toEqual([MERCHANT, 0n, 10n ** 18n]); + }); +}); diff --git a/bunny_js/test/facilitator/x402_permit2_real_sdk.test.ts b/bunny_js/test/facilitator/x402_permit2_real_sdk.test.ts new file mode 100644 index 000000000..f18a0179b --- /dev/null +++ b/bunny_js/test/facilitator/x402_permit2_real_sdk.test.ts @@ -0,0 +1,192 @@ +/** + * EURe end to end — real verify, real @x402/evm Permit2 settle, real fee collection. + * + * EURe (Monerium) has no EIP-3009, so it is paid through the exact scheme's Permit2 variant: + * the buyer signs a Permit2 witness transfer to the x402 Permit2 proxy, usually together with an + * EIP-2612 permit that lets Permit2 spend its EURe (`eip2612GasSponsoring`). Payments are signed + * with the SDK's own client and settled by the SDK's own facilitator; only the RPC client is fake + * (test/helpers/fakeChain.ts models the proxy from its contract source). + * + * EURe has 18 decimals, so the same 0.01 flat fee is 10¹⁶ atomic units here, not the 10000 of + * USDC/EURC — several tests below fail if that scaling is lost anywhere on the path. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("viem", async (importOriginal) => { + const { fakeViem } = await import("./helpers/fakeChain"); + return fakeViem(await importOriginal()); +}); + +import { PERMIT2_ADDRESS, x402ExactPermit2ProxyAddress } from "@x402/evm"; +import { settlePayment } from "../../src/facilitator/x402_settle.js"; +import { verifyPayment } from "../../src/facilitator/x402_verify.js"; +import { resetFacilitator } from "../../src/facilitator/facilitator_instance.js"; +import { getFacilitatorAddress } from "../../src/facilitator/x402_fee.js"; +import { SettleResponseSchema } from "../../src/facilitator/x402_schemas.js"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; +import { chain } from "./helpers/fakeChain"; +import { signPermit2Payment, EURE_DOMAIN, SELLER } from "./helpers/signPayment"; + +const FACILITATOR_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const BASE = "eip155:8453"; +const EURE = EURE_ADDRESSES[BASE]; +const ONE_EURE = 10n ** 18n; +const PRICE = ONE_EURE / 10n; // 0.1 EURe +const FEE = 10n ** 16n; // 0.01 EURe + +describe("EURe via Permit2 — real SDK, fake chain", () => { + const originalEnv = { ...process.env }; + let facilitator: `0x${string}`; + + /** EURe deployed with its real domain; payer holds 1 EURe; seller approved 1 EURe of fees. */ + function fund(payer: string) { + chain.addPermitToken(EURE, EURE_DOMAIN.name, EURE_DOMAIN.version, 8453); + chain.setBalance(EURE, payer, ONE_EURE); + chain.setAllowance(EURE, SELLER, facilitator, ONE_EURE); + } + + async function fundedPayment(opts: Parameters[0] = {}) { + const signed = await signPermit2Payment({ amount: PRICE, ...opts }); + fund(signed.payer); + return signed; + } + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = FACILITATOR_KEY; + resetFacilitator(); + chain.reset(); + facilitator = getFacilitatorAddress()!; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("verifies a Permit2 payment carrying an EIP-2612 permit, and counts fees in EURe units", async () => { + const { payload, requirements, payer } = await fundedPayment(); + expect(payload.extensions).toHaveProperty("eip2612GasSponsoring"); + + const result = await verifyPayment(payload, requirements); + + // 1 EURe of fee allowance / 0.01 EURe — an unscaled 10000 fee would read 10¹⁴. + expect(result).toMatchObject({ isValid: true, payer, remainingSettlements: 100 }); + expect(chain.writes).toEqual([]); + }); + + it("settles through the proxy in one transaction, then pulls 0.01 EURe from the seller", async () => { + const { payload, requirements, payer } = await fundedPayment(); + + const result = await settlePayment(payload, requirements); + + expect(chain.writes.map((w) => [w.functionName, w.address, w.from])).toEqual([ + ["settleWithPermit", x402ExactPermit2ProxyAddress, facilitator], + ["transferFrom", EURE, facilitator], + ]); + // The fee comes from the seller, in EURe's 18 decimals. + expect(chain.writes[1].args).toEqual([SELLER, facilitator, FEE]); + + expect(chain.balanceOf(EURE, payer)).toBe(ONE_EURE - PRICE); + expect(chain.balanceOf(EURE, SELLER)).toBe(PRICE - FEE); + expect(chain.balanceOf(EURE, facilitator)).toBe(FEE); + + expect(result).toMatchObject({ + success: true, + payer, + network: BASE, + fee: { collected: true, status: "collected" }, + }); + expect(result.extensions!.facilitatorFees!.info).toMatchObject({ + facilitatorFeePaid: FEE.toString(), + asset: `${BASE}/erc20:${EURE}`, + }); + const { errorMessage, ...wire } = result; + expect(errorMessage).toBeUndefined(); + expect(SettleResponseSchema.strict().safeParse(wire).error?.issues ?? []).toEqual([]); + }); + + it("settles without the permit when the buyer already approved Permit2", async () => { + const { payload, requirements, payer } = await fundedPayment({ gasSponsoring: false }); + chain.setAllowance(EURE, payer, PERMIT2_ADDRESS, ONE_EURE); + + const result = await settlePayment(payload, requirements); + + expect(result.success).toBe(true); + expect(chain.writes.map((w) => w.functionName)).toEqual(["settle", "transferFrom"]); + expect(chain.balanceOf(EURE, SELLER)).toBe(PRICE - FEE); + }); + + it("refuses a payment whose Permit2 witness pays someone other than payTo", async () => { + // The buyer signs a transfer to another address; the seller's requirements name SELLER. + // Before EURe, permit2_not_supported guarded this; now the SDK's recipient pin must. + const attacker = "0x000000000000000000000000000000000000dEaD"; + const { payload, requirements } = await fundedPayment({ + signRequirements: (r) => ({ ...r, payTo: attacker }), + }); + + const verify = await verifyPayment(payload, requirements); + const settle = await settlePayment(payload, requirements); + + expect(verify).toMatchObject({ + isValid: false, + invalidReason: "invalid_permit2_recipient_mismatch", + }); + expect(settle).toMatchObject({ success: false }); + expect(chain.writes).toEqual([]); + }); + + it("refuses a seller who approved a USDC-sized fee allowance in EURe — before spending gas", async () => { + // 10000 atomic units is 0.01 USDC but 10⁻¹⁴ EURe: not enough for one EURe fee. + const { payload, requirements } = await fundedPayment(); + chain.setAllowance(EURE, SELLER, facilitator, 10_000n); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ success: false, errorReason: "insufficient_fee_allowance" }); + expect(chain.writes).toEqual([]); + }); + + it("refuses a permit signed for the wrong EURe domain: Permit2 is left without an allowance", async () => { + // The proxy swallows a failing permit() and carries on, so the payment fails on the + // missing Permit2 allowance — this pins the EURe domain the docs record. + const { payload, requirements } = await fundedPayment({ + permitDomain: { name: "EURe", version: "1" }, + }); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "permit2_allowance_required" }); + }); + + it("refuses a payer without the funds", async () => { + const { payload, requirements, payer } = await fundedPayment(); + chain.setBalance(EURE, payer, PRICE - 1n); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "permit2_insufficient_balance" }); + }); + + it("cannot be replayed: the second settlement of the same payment moves nothing", async () => { + const { payload, requirements, payer } = await fundedPayment(); + + await settlePayment(payload, requirements); + const writesAfterFirst = chain.writes.length; + const replay = await settlePayment(payload, requirements); + + expect(replay.success).toBe(false); + expect(chain.writes).toHaveLength(writesAfterFirst); + expect(chain.balanceOf(EURE, payer)).toBe(ONE_EURE - PRICE); + }); + + it("refuses EURe on Optimism, where it is not a fee token", async () => { + const { payload, requirements } = await fundedPayment({ network: "eip155:10" }); + // A token at that address whose permit works on Optimism, so the SDK passes the payment and + // only the facilitator's fee-token list stands between it and settlement. + chain.addPermitToken(EURE, EURE_DOMAIN.name, EURE_DOMAIN.version, 10); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "unsupported_fee_asset" }); + expect(chain.writes).toEqual([]); + }); +}); diff --git a/bunny_js/test/facilitator/x402_settle.test.ts b/bunny_js/test/facilitator/x402_settle.test.ts new file mode 100644 index 000000000..7e6ea9498 --- /dev/null +++ b/bunny_js/test/facilitator/x402_settle.test.ts @@ -0,0 +1,347 @@ +/** + * settlePayment wiring for batch-settlement — the part the real-stack tests can't reach. + * + * Exact-scheme settlement runs end to end in x402_settle_real_sdk.test.ts (real SDK, fake + * chain), and which batch payloads pay whom is a pure table in x402_batch_route.test.ts. + * What is left is how settlePayment ACTS on a batch route: skip or run verify, which + * address and network the fee gate is asked about, whether settle is reached, whether a + * fee is charged. The fake chain has no batch-settlement contract, so here the SDK's + * settle() and the fee module are stubbed and the assertions are about the calls made. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { settlePayment } from "../../src/facilitator/x402_settle.js"; +import * as facilitatorInstance from "../../src/facilitator/facilitator_instance.js"; +import type { getFacilitator } from "../../src/facilitator/facilitator_instance.js"; +import * as verifyModule from "../../src/facilitator/x402_verify.js"; +import * as feeModule from "../../src/facilitator/x402_fee.js"; +import { + claim, + claimCommand, + settleCommand, + refund, + batchRequirements, + PAYER, + SELLER, + OTHER_SELLER, + BASE_SEPOLIA_USDC as USDC, + BASE_SEPOLIA_EURC as EURC, +} from "./helpers/batchPayloads"; + +// Only `settle` is called on these paths, so cast through `unknown` rather than satisfying +// the whole x402Facilitator class — mirrors mockContract/mockPublicClient in x402_fee.test.ts. +function asFacilitator(shape: Record) { + return shape as unknown as ReturnType; +} + +/** Stub the SDK's settle() with a result; returns the mock to assert on. */ +function stubSettle(result: Record = { success: true, transaction: "0xsettled" }) { + const settle = vi.fn().mockResolvedValue(result); + vi.spyOn(facilitatorInstance, "getFacilitator").mockReturnValue(asFacilitator({ settle })); + return settle; +} + +describe("settlePayment — batch-settlement wiring", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + vi.spyOn(feeModule, "getFeeAmount").mockReturnValue(10000n); + // Default: a fee is owed and the seller has approved it. Tests override for rejections. + vi.spyOn(feeModule, "evaluateFeeGate").mockResolvedValue({ kind: "charge" }); + vi.spyOn(feeModule, "collectFee").mockResolvedValue({ success: true, txHash: "0xfee" }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + // ─── claim / settle commands ──────────────────────────── + + it("settles a claim directly — no verify — then gates and charges its seller", async () => { + // The SDK's verify() has no branch for claim/settle and would reject them outright. + const verify = vi.spyOn(verifyModule, "verifyPayment"); + const settle = stubSettle({ + success: true, + transaction: "0xclaimtx", + extra: { channelState: { balance: "88000" } }, + }); + const payload = claimCommand([claim(SELLER, EURC)]); + const requirements = batchRequirements(); + + const result = await settlePayment(payload, requirements); + + expect(verify).not.toHaveBeenCalled(); + expect(settle).toHaveBeenCalledWith(payload, requirements); + expect(feeModule.evaluateFeeGate).toHaveBeenCalledWith(SELLER, "eip155:84532", EURC); + expect(feeModule.collectFee).toHaveBeenCalledWith(SELLER, "eip155:84532", EURC); + expect(result).toMatchObject({ + success: true, + payer: PAYER, + transaction: "0xclaimtx", + extra: { channelState: { balance: "88000" } }, + fee: { collected: true }, + }); + expect(result.extensions!.facilitatorFees!.info.asset).toBe(`eip155:84532/erc20:${EURC}`); + }); + + it("gates a settle command on payload.receiver, not requirements.payTo", async () => { + // REGRESSION: the SDK's executeSettle() pays payload.receiver and never reads payTo, + // which on this path is an unchecked caller string. Gating on payTo would let one + // address's allowance (or none) cover another's payout. + stubSettle(); + + await settlePayment( + settleCommand({ receiver: OTHER_SELLER, token: USDC }), + batchRequirements({ payTo: SELLER }), + ); + + expect(feeModule.evaluateFeeGate).toHaveBeenCalledWith(OTHER_SELLER, "eip155:84532", USDC); + }); + + it("refuses a claim whose seller has not approved the fee — before settling", async () => { + vi.spyOn(feeModule, "evaluateFeeGate").mockResolvedValue({ + kind: "reject", + reason: "insufficient_fee_allowance", + }); + const settle = stubSettle(); + + const result = await settlePayment(claimCommand(), batchRequirements()); + + expect(result).toMatchObject({ success: false, errorReason: "insufficient_fee_allowance" }); + expect(settle).not.toHaveBeenCalled(); + }); + + it("stops a refused route before the gate and before settle", async () => { + // Which payloads are refused is x402_batch_route.test.ts; this pins that a refusal + // short-circuits — no allowance RPC, no relayed transaction. + const settle = stubSettle(); + + const result = await settlePayment( + claimCommand([claim(SELLER), claim(OTHER_SELLER)]), + batchRequirements(), + ); + + expect(result).toMatchObject({ + success: false, + errorReason: "invalid_batch_settlement_evm_payload_type", + }); + expect(feeModule.evaluateFeeGate).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + }); + + it("surfaces the SDK's failure reason for a claim, with the payer", async () => { + stubSettle({ + success: false, + errorReason: "invalid_batch_settlement_evm_claim_authorizer_signature", + }); + + const result = await settlePayment(claimCommand(), batchRequirements()); + + expect(result).toMatchObject({ + success: false, + errorReason: "invalid_batch_settlement_evm_claim_authorizer_signature", + payer: PAYER, + transaction: "", + }); + expect(feeModule.collectFee).not.toHaveBeenCalled(); + }); + + it("keeps the broadcast hash of a pending claim, and does not charge", async () => { + stubSettle({ success: false, errorReason: "settlement_pending", transaction: "0xinflight" }); + + const result = await settlePayment(claimCommand(), batchRequirements()); + + expect(result).toMatchObject({ errorReason: "settlement_pending", transaction: "0xinflight" }); + expect(feeModule.collectFee).not.toHaveBeenCalled(); + }); + + it("attaches no fee receipt to a claim when fees are disabled", async () => { + vi.spyOn(feeModule, "evaluateFeeGate").mockResolvedValue({ kind: "no_fee" }); + stubSettle(); + + const result = await settlePayment(claimCommand(), batchRequirements()); + + expect(result.success).toBe(true); + expect(result.fee).toBeUndefined(); + expect(result.extensions).toBeUndefined(); + expect(feeModule.collectFee).not.toHaveBeenCalled(); + }); + + // ─── Testnet test-wallet bypass ───────────────────────── + + it("bypasses the fee for a test wallet on the network the claim actually settles on", async () => { + process.env.BATCH_SETTLEMENT_TEST_WALLETS = SELLER; + stubSettle(); + + const result = await settlePayment(claimCommand(), batchRequirements()); + + expect(result.success).toBe(true); + expect(feeModule.evaluateFeeGate).not.toHaveBeenCalled(); + expect(result.fee).toBeUndefined(); + }); + + it("keys the test-wallet bypass on requirements.network, not accepted.network", async () => { + // accepted says Base Sepolia, but the SDK dispatches on requirements.network — here + // mainnet. Bypassing on accepted.network would admit a test wallet on mainnet. + process.env.BATCH_SETTLEMENT_TEST_WALLETS = SELLER; + vi.spyOn(feeModule, "evaluateFeeGate").mockResolvedValue({ + kind: "reject", + reason: "insufficient_fee_allowance", + }); + const settle = stubSettle(); + + const result = await settlePayment(claimCommand(), batchRequirements({ network: "eip155:10" })); + + expect(result).toMatchObject({ success: false, network: "eip155:10" }); + expect(settle).not.toHaveBeenCalled(); + }); + + // ─── Refunds ──────────────────────────────────────────── + + it("verifies, gates and charges a refund that carries claims", async () => { + // It pays out exactly like a claim, but unlike one it IS verifiable — and verify is + // what pins channelConfig.receiver to payTo, so it must not be skipped. + const verify = vi + .spyOn(verifyModule, "verifyPayment") + .mockResolvedValue({ isValid: true, payer: PAYER }); + stubSettle(); + + const result = await settlePayment(refund([claim(SELLER)]), batchRequirements()); + + expect(verify).toHaveBeenCalled(); + expect(feeModule.evaluateFeeGate).toHaveBeenCalledWith(SELLER, "eip155:84532", USDC); + expect(feeModule.collectFee).toHaveBeenCalledTimes(1); + expect(result.fee?.collected).toBe(true); + }); + + it("refuses to relay a claim-carrying refund when the seller's allowance is short", async () => { + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ isValid: true, payer: PAYER }); + vi.spyOn(feeModule, "evaluateFeeGate").mockResolvedValue({ + kind: "reject", + reason: "insufficient_fee_allowance", + }); + const settle = stubSettle(); + + const result = await settlePayment(refund([claim(SELLER)]), batchRequirements()); + + expect(result).toMatchObject({ success: false, errorReason: "insufficient_fee_allowance" }); + expect(settle).not.toHaveBeenCalled(); + }); + + it("bypasses the fee for a claim-carrying refund from a testnet test wallet", async () => { + process.env.BATCH_SETTLEMENT_TEST_WALLETS = SELLER; + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ isValid: true, payer: PAYER }); + stubSettle(); + + const result = await settlePayment(refund([claim(SELLER)]), batchRequirements()); + + expect(result.success).toBe(true); + expect(feeModule.evaluateFeeGate).not.toHaveBeenCalled(); + expect(result.fee).toBeUndefined(); + }); + + it("leaves a claim-less refund free, even if verify says a fee is owed", async () => { + // Defensive: the onAfterVerify hook never sets feeRequired for batch-settlement, but if + // it ever did, the exact-scheme fee arm must still not charge a batch payload. + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ + isValid: true, + payer: PAYER, + feeRequired: true, + recipient: SELLER, + }); + stubSettle({ success: true, transaction: "0xrefundtx", extra: { channelState: { id: "c" } } }); + + const result = await settlePayment(refund([]), batchRequirements()); + + expect(result.success).toBe(true); + expect(feeModule.collectFee).not.toHaveBeenCalled(); + expect(result.fee).toBeUndefined(); + // The no-fee tail must still pass the channel state through — the seller needs it. + expect(result.extra).toEqual({ channelState: { id: "c" } }); + }); + + it("names the contract revert behind an undecodable selector in a failed settlement", async () => { + // The 2026-10-11 incident: the contract reverted ZeroRefund() (a 0-amount refund), but + // the SDK's ABI fragment declares no errors, so the log carried a raw selector and an + // "Unable to decode signature" paragraph. The annotation happens once at the settle + // boundary, so both the log line and the returned SettleResult carry the name. + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ isValid: true, payer: PAYER }); + stubSettle({ + success: false, + errorReason: "invalid_batch_settlement_evm_refund_simulation_failed", + errorMessage: + 'The contract function "refundWithSignature" reverted with the following signature:\n' + + "0xd758525c\n" + + 'Unable to decode signature "0xd758525c" as it was not found on the provided ABI.\n', + }); + + const result = await settlePayment(refund([]), batchRequirements()); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain("decoded revert: ZeroRefund() (0xd758525c)"); + // The raw SDK text stays intact — the name is appended, nothing is rewritten. + expect(result.errorMessage).toContain("Unable to decode signature"); + }); + + // ─── Unexpected throw ─────────────────────────────────── + + it("maps anything thrown during settlement to settlement_failed, keeping the payer", async () => { + // Caller errors come back from the SDK as returned reasons (see the real-stack tests); + // a throw means our side broke, so it is always the generic reason — never, say, + // "insufficient funds" for our own wallet running out of gas. + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ isValid: true, payer: PAYER }); + vi.spyOn(facilitatorInstance, "getFacilitator").mockImplementation(() => { + throw new Error("Transaction failed: insufficient funds for gas"); + }); + const exactPayload = { + x402Version: 2, + accepted: { scheme: "exact", network: "eip155:11155420" }, + payload: { authorization: { from: PAYER } }, + }; + + const result = await settlePayment(exactPayload, { + scheme: "exact", + network: "eip155:11155420", + }); + + expect(result).toMatchObject({ + success: false, + errorReason: "settlement_failed", + payer: PAYER, + network: "eip155:11155420", + transaction: "", + }); + }); + + it("attributes a payload carrying both authorization shapes to the Permit2 payer", async () => { + // The SDK's isPermit2Payload() settles such a payload as Permit2, so the catch path must + // read the payer from permit2Authorization.from — not the EIP-3009 field a crafted + // payload pairs with it, which would attribute the failure to someone else. + vi.spyOn(verifyModule, "verifyPayment").mockResolvedValue({ isValid: true, payer: PAYER }); + vi.spyOn(facilitatorInstance, "getFacilitator").mockImplementation(() => { + throw new Error("Transaction failed: insufficient funds for gas"); + }); + const exactPayload = { + x402Version: 2, + accepted: { scheme: "exact", network: "eip155:11155420" }, + payload: { + authorization: { from: PAYER }, + permit2Authorization: { from: OTHER_SELLER }, + }, + }; + + const result = await settlePayment(exactPayload, { + scheme: "exact", + network: "eip155:11155420", + }); + + expect(result).toMatchObject({ + success: false, + errorReason: "settlement_failed", + payer: OTHER_SELLER, + transaction: "", + }); + }); +}); diff --git a/bunny_js/test/facilitator/x402_settle_real_sdk.test.ts b/bunny_js/test/facilitator/x402_settle_real_sdk.test.ts new file mode 100644 index 000000000..980208228 --- /dev/null +++ b/bunny_js/test/facilitator/x402_settle_real_sdk.test.ts @@ -0,0 +1,306 @@ +/** + * settlePayment end to end — real verify, real @x402/evm settle, real fee collection. + * + * x402_settle.test.ts covers settle's orchestration branches with verify and the SDK + * stubbed out. This file runs the whole stack instead: a genuinely signed EIP-3009 + * payment goes through our code and the SDK, and the assertions are about what reaches + * the chain — which contract calls were sent, by whom, with which arguments, and where the + * money ended up. Only the RPC client is fake (test/helpers/fakeChain.ts). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("viem", async (importOriginal) => { + const { fakeViem } = await import("./helpers/fakeChain"); + return fakeViem(await importOriginal()); +}); + +import { settlePayment } from "../../src/facilitator/x402_settle.js"; +import { resetFacilitator } from "../../src/facilitator/facilitator_instance.js"; +import { FEE_SEND_ATTEMPTS, getFacilitatorAddress } from "../../src/facilitator/x402_fee.js"; +import { nonceManager } from "viem"; +import { SettleResponseSchema } from "../../src/facilitator/x402_schemas.js"; +import { chain } from "./helpers/fakeChain"; +import { signExactPayment, SELLER } from "./helpers/signPayment"; + +const FACILITATOR_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const PRICE = 100_000n; +const FEE = 10_000n; + +describe("settlePayment — real SDK, fake chain", () => { + const originalEnv = { ...process.env }; + let facilitator: `0x${string}`; + + async function fundedPayment() { + const signed = await signExactPayment({ amount: PRICE }); + chain.addToken(signed.token, signed.name); + chain.setBalance(signed.token, signed.payer, 1_000_000n); + chain.setAllowance(signed.token, SELLER, facilitator, 1_000_000n); + return signed; + } + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = FACILITATOR_KEY; + resetFacilitator(); + chain.reset(); + facilitator = getFacilitatorAddress()!; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("moves the payment on-chain, then pulls the fee from the seller", async () => { + const { payload, requirements, payer, token, nonce } = await fundedPayment(); + + const result = await settlePayment(payload, requirements); + + // Exactly two transactions, both sent by the facilitator wallet, in this order. + expect(chain.writes.map((w) => [w.functionName, w.address, w.from])).toEqual([ + ["transferWithAuthorization", token, facilitator], + ["transferFrom", token, facilitator], + ]); + const [settlement, feePull] = chain.writes; + // The settlement relays exactly what the buyer signed… + expect(settlement.args.slice(0, 3)).toEqual([payer, SELLER, PRICE]); + expect(settlement.args[5]).toBe(nonce); + // …and the fee comes from the seller, never the buyer. + expect(feePull.args).toEqual([SELLER, facilitator, FEE]); + + expect(chain.balanceOf(token, payer)).toBe(1_000_000n - PRICE); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE - FEE); + expect(chain.balanceOf(token, facilitator)).toBe(FEE); + + expect(result).toMatchObject({ + success: true, + payer, + network: "eip155:11155420", + fee: { collected: true, status: "collected" }, + }); + expect(result.transaction).toMatch(/^0x[0-9a-f]{64}$/); + expect(result.fee!.txHash).not.toBe(result.transaction); + expect(result.extensions!.facilitatorFees!.info).toMatchObject({ + facilitatorFeePaid: FEE.toString(), + asset: `eip155:11155420/erc20:${token}`, + collection: { status: "collected", txHash: result.fee!.txHash }, + }); + }); + + it("returns a body that conforms to the published SettleResponse schema", async () => { + const { payload, requirements } = await fundedPayment(); + + const result = await settlePayment(payload, requirements); + + // errorMessage is internal-only (logged, never sent), so it is absent from the schema; + // everything else must validate strictly — a new field on the settle path that the + // schema (and therefore openapi.json) doesn't know about fails here. + const { errorMessage, ...wire } = result; + expect(errorMessage).toBeUndefined(); + const parsed = SettleResponseSchema.strict().safeParse(wire); + expect(parsed.error?.issues ?? []).toEqual([]); + }); + + it("cannot be replayed: the second settlement of the same payment moves nothing", async () => { + const { payload, requirements, token, payer } = await fundedPayment(); + + await settlePayment(payload, requirements); + const writesAfterFirst = chain.writes.length; + const replay = await settlePayment(payload, requirements); + + expect(replay).toMatchObject({ + success: false, + errorReason: "invalid_exact_evm_nonce_already_used", + }); + expect(chain.writes).toHaveLength(writesAfterFirst); + expect(chain.balanceOf(token, payer)).toBe(1_000_000n - PRICE); + }); + + it("refuses to settle for a seller who has not approved the fee — before spending gas", async () => { + const { payload, requirements, token } = await fundedPayment(); + chain.setAllowance(token, SELLER, facilitator, FEE - 1n); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ success: false, errorReason: "insufficient_fee_allowance" }); + expect(chain.writes).toEqual([]); + }); + + it("refuses a payer without the funds — before spending gas", async () => { + const { payload, requirements, token, payer } = await fundedPayment(); + chain.setBalance(token, payer, PRICE - 1n); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ + success: false, + errorReason: "invalid_exact_evm_insufficient_balance", + }); + expect(chain.writes).toEqual([]); + }); + + it("settles without a fee pull or fee receipt when no fee is configured", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + const { payload, requirements, token } = await fundedPayment(); + + const result = await settlePayment(payload, requirements); + + expect(chain.writes.map((w) => w.functionName)).toEqual(["transferWithAuthorization"]); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE); + expect(result.success).toBe(true); + expect(result.fee).toBeUndefined(); + expect(result.extensions).toBeUndefined(); + }); + + // ─── Failures after verify passed ─────────────────────── + + it("reports a reverted settlement as failed and never pulls the fee", async () => { + const { payload, requirements, token, payer } = await fundedPayment(); + chain.failNext("transferWithAuthorization", "revert"); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ + success: false, + errorReason: "invalid_exact_evm_transaction_failed", + transaction: "", + payer, + }); + expect(chain.writes).toEqual([]); + expect(chain.balanceOf(token, SELLER)).toBe(0n); + }); + + it("reports a mined-but-reverted settlement as terminal: no hash to reconcile, no fee", async () => { + const { payload, requirements, token, payer } = await fundedPayment(); + chain.failNext("transferWithAuthorization", "revertOnChain"); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ + success: false, + errorReason: "invalid_exact_evm_transaction_failed", + // The SDK hands back the reverted tx's hash; we drop it, because only + // settlement_pending (below) is an outcome the caller should reconcile. + transaction: "", + }); + expect(chain.writes.map((w) => w.functionName)).toEqual(["transferWithAuthorization"]); + expect(chain.balanceOf(token, payer)).toBe(1_000_000n); + }); + + it("returns the broadcast hash when the settlement's outcome is unknown, and does not charge", async () => { + const { payload, requirements } = await fundedPayment(); + chain.failNext("transferWithAuthorization", "receiptTimeout"); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ success: false, errorReason: "settlement_pending" }); + // The caller reconciles against this hash, so it must be the tx that was actually sent. + expect(result.transaction).toMatch(/^0x[0-9a-f]{64}$/); + expect(chain.writes.map((w) => w.functionName)).toEqual(["transferWithAuthorization"]); + }); + + it("keeps a landed settlement successful when the fee pull reverts, and still reports the assessed fee", async () => { + const { payload, requirements, token } = await fundedPayment(); + // A genuine revert holds on every attempt; a one-off one is the retry case further down. + chain.failNext("transferFrom", "revert", FEE_SEND_ATTEMPTS); + + const result = await settlePayment(payload, requirements); + + expect(result.success).toBe(true); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE); // paid in full, no fee taken + expect(result.fee).toMatchObject({ collected: false, status: "failed" }); + // The receipt discloses what the payment cost, not what was collected. + expect(result.extensions!.facilitatorFees!.info).toMatchObject({ + facilitatorFeePaid: FEE.toString(), + collection: { status: "failed" }, + }); + }); + + it("reports a fee pull whose receipt timed out as pending, with its hash", async () => { + const { payload, requirements } = await fundedPayment(); + chain.failNext("transferFrom", "receiptTimeout"); + + const result = await settlePayment(payload, requirements); + + expect(result.success).toBe(true); + expect(result.fee).toMatchObject({ collected: false, status: "pending" }); + expect(result.fee!.txHash).toMatch(/^0x[0-9a-f]{64}$/); + // Broadcast, outcome unknown: sending again could charge the seller twice. + expect(chain.writes.filter((w) => w.functionName === "transferFrom")).toHaveLength(1); + // The hash lets the seller look the fee tx up; the assessed fee is still the full fee. + expect(result.extensions!.facilitatorFees!.info).toMatchObject({ + facilitatorFeePaid: FEE.toString(), + collection: { status: "pending", txHash: result.fee!.txHash }, + }); + }); + + /** + * The fee pull is the second transaction from the facilitator wallet within a second of the + * first. On a load-balanced RPC it met nodes that had not caught up: once it reused the + * settlement's nonce ("replacement transaction underpriced"), once it simulated against a + * balance without the payment just received. Both were refused before broadcast, so sending + * again is safe — and anything that may have been broadcast must not be sent twice. + */ + describe("fee pull refused by a node that lagged behind", () => { + const feePulls = () => chain.writes.filter((w) => w.functionName === "transferFrom"); + + it("collects the fee on the next attempt after an underpriced refusal, exactly once", async () => { + const { payload, requirements, token } = await fundedPayment(); + chain.failNext("transferFrom", "underpriced"); + + const result = await settlePayment(payload, requirements); + + expect(result).toMatchObject({ + success: true, + fee: { collected: true, status: "collected" }, + }); + expect(feePulls()).toHaveLength(1); // refused attempts leave no trace: no double charge + expect(chain.balanceOf(token, SELLER)).toBe(PRICE - FEE); + expect(chain.balanceOf(token, facilitator)).toBe(FEE); + }); + + it("collects the fee on the next attempt after a simulation reverted on stale state", async () => { + const { payload, requirements, token } = await fundedPayment(); + chain.failNext("transferFrom", "revert"); + + const result = await settlePayment(payload, requirements); + + expect(result.fee).toMatchObject({ collected: true, status: "collected" }); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE - FEE); + }); + + it("gives up after the last attempt, and the settlement still stands", async () => { + const { payload, requirements, token } = await fundedPayment(); + chain.failNext("transferFrom", "underpriced", FEE_SEND_ATTEMPTS); + + const result = await settlePayment(payload, requirements); + + expect(result.success).toBe(true); + expect(result.fee).toMatchObject({ collected: false, status: "failed" }); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE); + }); + + it("does not retry an error that may have come after the node took the transaction", async () => { + const { payload, requirements, token } = await fundedPayment(); + // Once only: a retry would succeed, so a collected fee here would prove one happened. + chain.failNext("transferFrom", "rpcError"); + + const result = await settlePayment(payload, requirements); + + expect(result.fee).toMatchObject({ collected: false, status: "failed" }); + expect(chain.balanceOf(token, SELLER)).toBe(PRICE); + }); + + it("signs the settlement and the fee pull with one shared nonce manager", async () => { + const { payload, requirements } = await fundedPayment(); + + await settlePayment(payload, requirements); + + // Only senders sharing viem's nonceManager see each other's nonces; a signer that bypassed + // loadFacilitatorAccount would reintroduce the reused-nonce refusals. + const signers = chain.walletAccounts.filter((a) => a.address === facilitator); + expect(signers.length).toBeGreaterThanOrEqual(2); + for (const signer of signers) { + expect(signer.nonceManager).toBe(nonceManager); + } + }); + }); +}); diff --git a/bunny_js/test/facilitator/x402_supported.test.ts b/bunny_js/test/facilitator/x402_supported.test.ts new file mode 100644 index 000000000..e5ddf905b --- /dev/null +++ b/bunny_js/test/facilitator/x402_supported.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for x402 /supported endpoint + * + * Note: getSupportedCapabilities() creates a fresh read-only facilitator instance + * each time, so no singleton caching or resetFacilitator() needed + */ +import { describe, test, expect, afterEach } from "vitest"; +import { getSupportedCapabilities } from "../../src/facilitator/x402_supported.js"; + +describe("x402 /supported endpoint", () => { + const originalEnv = process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + afterEach(() => { + // Restore original env + if (originalEnv) { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = originalEnv; + } else { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + } + }); + + test("returns supported capabilities", () => { + const capabilities = getSupportedCapabilities(); + + expect(capabilities).toBeDefined(); + expect(capabilities.kinds).toBeDefined(); + expect(Array.isArray(capabilities.kinds)).toBe(true); + expect(capabilities.extensions).toBeDefined(); + expect(Array.isArray(capabilities.extensions)).toBe(true); + }); + + test("includes Optimism Mainnet support", () => { + const capabilities = getSupportedCapabilities(); + + const mainnetSupport = capabilities.kinds.find((k) => k.network === "eip155:10")!; + + expect(mainnetSupport).toBeDefined(); + expect(mainnetSupport.x402Version).toBe(2); + expect(mainnetSupport.scheme).toBe("exact"); + // x402 v2 getSupported() does NOT include assets in kinds + // Assets would need to be added separately by the facilitator + }); + + test("includes Optimism Sepolia support", () => { + const capabilities = getSupportedCapabilities(); + + const sepoliaSupport = capabilities.kinds.find((k) => k.network === "eip155:11155420")!; + + expect(sepoliaSupport).toBeDefined(); + expect(sepoliaSupport.x402Version).toBe(2); + expect(sepoliaSupport.scheme).toBe("exact"); + // x402 v2 getSupported() does NOT include assets in kinds + }); + + test("includes Base Mainnet support", () => { + const capabilities = getSupportedCapabilities(); + + const baseSupport = capabilities.kinds.find((k) => k.network === "eip155:8453")!; + + expect(baseSupport).toBeDefined(); + expect(baseSupport.x402Version).toBe(2); + expect(baseSupport.scheme).toBe("exact"); + }); + + test("includes Base Sepolia support", () => { + const capabilities = getSupportedCapabilities(); + + const baseSepoliaSupport = capabilities.kinds.find((k) => k.network === "eip155:84532")!; + + expect(baseSepoliaSupport).toBeDefined(); + expect(baseSepoliaSupport.x402Version).toBe(2); + expect(baseSepoliaSupport.scheme).toBe("exact"); + }); + + // Note: x402 v2 getSupported() does not include asset details (USDC, USDT, etc.) + // Asset information is not provided in the kinds array by the facilitator + // If needed, asset details would need to be added separately in the response + + test("reports the real facilitator address when a valid private key is configured", () => { + // getSupportedCapabilities always uses the read-only facilitator (never signs + // anything), but it must still report the *real* signer address here — newer SDK + // clients (e.g. x402HTTPResourceServer.initialize()) reject a zero-address signer. + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const capabilities = getSupportedCapabilities(); + + expect(capabilities.signers).toBeDefined(); + expect(capabilities.signers["eip155:*"]).toBeDefined(); + expect(Array.isArray(capabilities.signers["eip155:*"])).toBe(true); + // Real address derived from the private key above (well-known Hardhat/Anvil account #0) + expect(capabilities.signers["eip155:*"]).toEqual([ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + ]); + }); + + test("signers shows zero address when private key is not set (read-only mode)", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + + const capabilities = getSupportedCapabilities(); + + // Read-only facilitator uses zero address as signer placeholder + expect(capabilities.signers).toBeDefined(); + expect(capabilities.signers["eip155:*"]).toEqual([ + "0x0000000000000000000000000000000000000000", + ]); + }); + + test("signers shows zero address when private key is invalid (read-only mode)", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = "invalid-key"; + + const capabilities = getSupportedCapabilities(); + + // Read-only facilitator uses zero address as signer placeholder + expect(capabilities.signers).toBeDefined(); + expect(capabilities.signers["eip155:*"]).toEqual([ + "0x0000000000000000000000000000000000000000", + ]); + }); + + test("advertises fee extension keys (spec-conformant string[] extensions)", () => { + // Ensure a valid private key is set + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const capabilities = getSupportedCapabilities(); + + // `extensions` is a list of extension KEY strings, per the x402 SupportedResponse type. + expect(Array.isArray(capabilities.extensions)).toBe(true); + expect(capabilities.extensions).toContain("facilitator_fee"); + expect(capabilities.extensions).toContain("facilitatorFees"); + // No objects leak into the array. + capabilities.extensions.forEach((e) => expect(typeof e).toBe("string")); + }); + + test("discloses fee detail in the top-level facilitatorFees object (#1016)", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const capabilities = getSupportedCapabilities(); + + const fees = capabilities.facilitatorFees; + expect(fees).toBeDefined(); + if (!fees) { + throw new Error("unreachable"); + } + expect(fees.version).toBe("1"); + expect(fees.model).toBe("flat"); + // The fee is charged in whichever token settles — USDC; EURC and EURe on Base. + expect(fees.asset).toBe("settled"); + expect(fees.fee.description).toContain("EURC"); + expect(fees.fee.description).toContain("EURe"); + expect(fees.setup.description).toContain("EURe"); + expect(fees.flatFee).toBe("10000"); + expect(fees.decimals).toBe(6); + // Facilitator address (fee recipient / approval spender) lives here now, not in `extensions`. + expect(fees.recipient).toBeDefined(); + expect(fees.recipient).not.toBeNull(); + expect(fees.setup.spender).toBe(fees.recipient); + expect(fees.fee.collection).toBe("post_settlement_transferFrom"); + expect(fees.networks).toContain("eip155:10"); + expect(fees.networks).toContain("eip155:8453"); + expect(fees.networks).toContain("eip155:11155420"); + expect(fees.networks).toContain("eip155:84532"); + }); + + test("keeps the recommended approval small relative to the fee", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const fees = getSupportedCapabilities().facilitatorFees!; + const settlementsCovered = BigInt(fees.setup.recommended_amount) / BigInt(fees.flatFee); + + // The approval spender is the same hot key that signs every settlement, so this + // figure IS the per-merchant blast radius of a key compromise. Bounded rather than + // pinned to an exact value: the security property is "stays small", not "is 1 USDC". + expect(settlementsCovered).toBeLessThanOrEqual(100n); + // The same bound per token, in each token's own units — EURe's 18 decimals included. + for (const asset of fees.assets) { + expect(BigInt(asset.recommended_amount) / BigInt(asset.flatFee)).toBeLessThanOrEqual(100n); + } + }); + + test("lists each fee token with its exact atomic fee — EURe in 18 decimals", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const { assets } = getSupportedCapabilities().facilitatorFees!; + const on = (network: string) => assets.filter((a) => a.network === network); + + expect(on("eip155:8453").map((a) => a.symbol)).toEqual(["USDC", "EURC", "EURe"]); + expect(on("eip155:10").map((a) => a.symbol)).toEqual(["USDC"]); + expect(on("eip155:8453").find((a) => a.symbol === "EURe")).toEqual({ + network: "eip155:8453", + asset: "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", + symbol: "EURe", + decimals: 18, + flatFee: "10000000000000000", // 0.01 EURe + recommended_amount: "1000000000000000000", // 1 EURe + }); + expect(on("eip155:8453").find((a) => a.symbol === "USDC")).toMatchObject({ + flatFee: "10000", + recommended_amount: "1000000", + }); + }); + + test("advertises EIP-2612 gas sponsoring, so EURe buyers need no separate approve()", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + expect(getSupportedCapabilities().extensions).toContain("eip2612GasSponsoring"); + }); + + test("omits fee extension keys and disclosure when private key is missing", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + const capabilities = getSupportedCapabilities(); + + expect(capabilities.extensions).not.toContain("facilitator_fee"); + expect(capabilities.extensions).not.toContain("facilitatorFees"); + expect(capabilities.facilitatorFees).toBeUndefined(); + }); + + test("always includes onward links, independent of fee configuration", () => { + // Unlike facilitatorFees, links must not depend on a configured key: an agent that + // discovers /supported in read-only mode still needs somewhere to go. + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + const capabilities = getSupportedCapabilities(); + + expect(capabilities.links.documentation).toMatch(/^https:\/\//); + expect(capabilities.links.source).toMatch(/^https:\/\//); + expect(capabilities.links.openapi).toMatch(/^https:\/\//); + }); +}); diff --git a/bunny_js/test/facilitator/x402_verify.test.ts b/bunny_js/test/facilitator/x402_verify.test.ts new file mode 100644 index 000000000..63dc322e7 --- /dev/null +++ b/bunny_js/test/facilitator/x402_verify.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for x402 verify — the REAL verify path, hermetic. + * + * Nothing in our code or in @x402/evm is mocked. Only the RPC client is replaced by an + * in-memory chain (test/helpers/fakeChain.ts), and every payment is genuinely signed + * (test/helpers/signPayment.ts) with the one field under test set wrong BEFORE signing. + * So each rejection test reaches the check it is named for: remove that check from the + * SDK or from our fee hook and the matching test fails. + * + * The same payments against live RPC are in test/integration/x402_verify_signature.integration.test.ts. + */ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("viem", async (importOriginal) => { + const { fakeViem } = await import("./helpers/fakeChain"); + return fakeViem(await importOriginal()); +}); + +import { verifyPayment } from "../../src/facilitator/x402_verify.js"; +import { resetFacilitator } from "../../src/facilitator/facilitator_instance.js"; +import { getFacilitatorAddress } from "../../src/facilitator/x402_fee.js"; +import { chain } from "./helpers/fakeChain"; +import { signExactPayment, SELLER } from "./helpers/signPayment"; + +const FACILITATOR_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const now = () => BigInt(Math.floor(Date.now() / 1000)); + +describe("x402 Verify (real SDK, fake chain)", () => { + const originalEnv = { ...process.env }; + + /** + * Sign a payment and put the chain in the state where it should pass: token deployed, + * payer funded, seller has approved the facilitator for the fee. + */ + async function fundedPayment(options: Parameters[0] = {}) { + const signed = await signExactPayment(options); + chain.addToken(signed.token, signed.name); + chain.setBalance(signed.token, signed.payer, 1_000_000n); + chain.setAllowance(signed.token, SELLER, getFacilitatorAddress()!, 1_000_000n); + return signed; + } + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = FACILITATOR_KEY; + resetFacilitator(); + chain.reset(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("accepts a correctly signed, funded payment", async () => { + const { payload, requirements, payer } = await fundedPayment(); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: true, payer, feeRequired: true, recipient: SELLER }); + // 1_000_000 allowance / 10_000 fee + expect(result.remainingSettlements).toBe(100); + }); + + test("verify is read-only: nothing is written to the chain", async () => { + const { payload, requirements } = await fundedPayment(); + + await verifyPayment(payload, requirements); + + expect(chain.writes).toEqual([]); + }); + + // ─── Signature ─────────────────────────────────────────── + + test("rejects a signature by someone other than authorization.from", async () => { + const { payload, requirements } = await fundedPayment(); + const other = await signExactPayment(); + payload.payload.signature = other.payload.payload.signature; + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "invalid_exact_evm_signature" }); + }); + + test("rejects a payment whose signed authorization was altered afterwards", async () => { + const { payload, requirements } = await fundedPayment(); + payload.payload.authorization.value = "1"; // no longer what was signed + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_signature"); + }); + + test("rejects signature without 0x prefix", async () => { + const { payload, requirements } = await fundedPayment(); + payload.payload.signature = payload.payload.signature.slice(2) as `0x${string}`; + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "invalid_exact_evm_signature" }); + }); + + test("rejects signature with invalid length", async () => { + const { payload, requirements } = await fundedPayment(); + payload.payload.signature = payload.payload.signature.slice(0, -2) as `0x${string}`; + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "invalid_exact_evm_signature" }); + }); + + // ─── Authorization fields (validly signed, one field wrong) ─── + + test("rejects an authorization paying someone other than payTo", async () => { + const { payload, requirements } = await fundedPayment({ + authorization: { to: "0x1111111111111111111111111111111111111111" }, + }); + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_recipient_mismatch"); + }); + + test("rejects an expired authorization", async () => { + const { payload, requirements } = await fundedPayment({ + authorization: { validBefore: now() - 1n }, + }); + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_payload_authorization_valid_before"); + }); + + test("rejects an authorization that is not valid yet", async () => { + const { payload, requirements } = await fundedPayment({ + authorization: { validAfter: now() + 3600n }, + }); + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_payload_authorization_valid_after"); + }); + + test("rejects an authorization for less than the required amount", async () => { + const { payload, requirements } = await fundedPayment({ authorization: { value: 5_000n } }); + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_payload_authorization_value_mismatch"); + }); + + // ─── On-chain state ───────────────────────────────────── + + test("rejects a payer without enough balance", async () => { + const { payload, requirements, payer, token } = await fundedPayment(); + chain.setBalance(token, payer, 99_999n); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ + isValid: false, + invalidReason: "invalid_exact_evm_insufficient_balance", + payer, + }); + }); + + test("rejects an authorization whose nonce was already used", async () => { + const { payload, requirements, payer, token, nonce } = await fundedPayment(); + chain.markNonceUsed(token, payer, nonce); + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("invalid_exact_evm_nonce_already_used"); + }); + + test("rejects a token that is not a deployed contract", async () => { + const { payload, requirements } = await signExactPayment(); // token never added + + const result = await verifyPayment(payload, requirements); + + expect(result.invalidReason).toBe("asset_not_deployed_contract"); + }); + + // ─── Our fee gate (facilitator_instance onAfterVerify) ───── + + test("rejects when the seller has not approved the facilitator for the fee", async () => { + const { payload, requirements, token } = await fundedPayment(); + chain.setAllowance(token, SELLER, getFacilitatorAddress()!, 9_999n); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: false, invalidReason: "insufficient_fee_allowance" }); + }); + + test("accepts without any approval when no fee is configured", async () => { + process.env.FACILITATOR_FEE_AMOUNT = "0"; + const { payload, requirements, token } = await fundedPayment(); + chain.setAllowance(token, SELLER, getFacilitatorAddress()!, 0n); + + const result = await verifyPayment(payload, requirements); + + expect(result).toMatchObject({ isValid: true, feeRequired: false }); + }); + + // ─── Request shape ────────────────────────────────────── + + test("rejects invalid x402 version", async () => { + const { payload, requirements } = await fundedPayment(); + + const result = await verifyPayment({ ...payload, x402Version: 1 }, requirements); + + // The SDK throws "No facilitator registered for x402 version: 1"; verifyPayment maps it. + expect(result).toMatchObject({ isValid: false, invalidReason: "unexpected_verify_error" }); + }); + + test("rejects unsupported scheme", async () => { + const { payload, requirements } = await fundedPayment(); + + const result = await verifyPayment( + { ...payload, accepted: { ...payload.accepted, scheme: "deferred" } }, + requirements, + ); + + expect(result.invalidReason).toBe("invalid_exact_evm_scheme"); + }); + + test("rejects a payload whose network differs from the requirements", async () => { + const { payload, requirements } = await fundedPayment(); + + const result = await verifyPayment( + { ...payload, accepted: { ...payload.accepted, network: "eip155:1" } }, + requirements, + ); + + expect(result.invalidReason).toBe("invalid_exact_evm_network_mismatch"); + }); + + test("rejects missing payload", async () => { + const { payload, requirements } = await fundedPayment(); + + const result = await verifyPayment({ ...payload, payload: {} }, requirements); + + // @x402 2.29 classifies a malformed payload as `invalid_payload` instead of + // throwing, so it no longer maps to the catch-all `unexpected_verify_error`. + expect(result).toMatchObject({ isValid: false, invalidReason: "invalid_payload" }); + }); + + test("verifies on every supported network with that network's USDC domain", async () => { + // Optimism mainnet's USDC domain name is "USD Coin", testnets use "USDC" — a wrong + // per-network name would fail the signature check here. + for (const network of ["eip155:10", "eip155:11155420", "eip155:8453", "eip155:84532"]) { + const { payload, requirements } = await fundedPayment({ network }); + + const result = await verifyPayment(payload, requirements); + + expect({ network, isValid: result.isValid, reason: result.invalidReason }).toEqual({ + network, + isValid: true, + reason: undefined, + }); + } + }); +}); diff --git a/bunny_js/test/facilitator/x402_whitelist.test.ts b/bunny_js/test/facilitator/x402_whitelist.test.ts new file mode 100644 index 000000000..5f09121aa --- /dev/null +++ b/bunny_js/test/facilitator/x402_whitelist.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for the batch-settlement test-wallet bypass (x402_whitelist.ts) + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { isTestWalletBypassed } from "../../src/facilitator/x402_whitelist.js"; + +const MAINNET = "eip155:10"; // Optimism mainnet +const OP_SEPOLIA = "eip155:11155420"; +const BASE_SEPOLIA = "eip155:84532"; + +describe("isTestWalletBypassed", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.BATCH_SETTLEMENT_TEST_WALLETS; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("bypasses a test wallet on Optimism Sepolia", () => { + const address = "0x1234567890123456789012345678901234567890"; + process.env.BATCH_SETTLEMENT_TEST_WALLETS = address; + + expect(isTestWalletBypassed(address, OP_SEPOLIA)).toBe(true); + }); + + it("bypasses a test wallet on Base Sepolia", () => { + const address = "0x1234567890123456789012345678901234567890"; + process.env.BATCH_SETTLEMENT_TEST_WALLETS = address; + + expect(isTestWalletBypassed(address, BASE_SEPOLIA)).toBe(true); + }); + + it("does NOT bypass a test wallet on mainnet", () => { + const address = "0x1234567890123456789012345678901234567890"; + process.env.BATCH_SETTLEMENT_TEST_WALLETS = address; + + expect(isTestWalletBypassed(address, MAINNET)).toBe(false); + }); + + it("is case-insensitive", () => { + const address = "0xAaBbCcDdEeFf11223344556677889900AaBbCcDd"; + process.env.BATCH_SETTLEMENT_TEST_WALLETS = address.toLowerCase(); + + expect(isTestWalletBypassed(address.toUpperCase(), BASE_SEPOLIA)).toBe(true); + }); + + it("handles multiple comma-separated addresses", () => { + const wallet1 = "0x1111111111111111111111111111111111111111"; + const wallet2 = "0x2222222222222222222222222222222222222222"; + process.env.BATCH_SETTLEMENT_TEST_WALLETS = `${wallet1},${wallet2}`; + + expect(isTestWalletBypassed(wallet1, BASE_SEPOLIA)).toBe(true); + expect(isTestWalletBypassed(wallet2, BASE_SEPOLIA)).toBe(true); + }); + + it("rejects everything when unset", () => { + expect(isTestWalletBypassed("0x1111111111111111111111111111111111111111", MAINNET)).toBe(false); + expect(isTestWalletBypassed("0x1111111111111111111111111111111111111111", BASE_SEPOLIA)).toBe( + false, + ); + }); + + it("rejects an address not in the test-wallet list, even on testnet", () => { + process.env.BATCH_SETTLEMENT_TEST_WALLETS = "0x1111111111111111111111111111111111111111"; + + expect(isTestWalletBypassed("0x2222222222222222222222222222222222222222", BASE_SEPOLIA)).toBe( + false, + ); + }); +}); diff --git a/bunny_js/vitest.integration.config.js b/bunny_js/vitest.integration.config.js new file mode 100644 index 000000000..cfcaa79da --- /dev/null +++ b/bunny_js/vitest.integration.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Integration suite: live-RPC tests under test/integration/. Network-dependent +// (real Optimism mainnet + Sepolia calls), so run explicitly via +// `npm run test:integration`, separate from the hermetic `npm test` unit run. +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["test/facilitator/integration/**/*.test.{js,ts}"], + testTimeout: 30000, // live RPC calls can be slow + }, +}); diff --git a/observability/README.md b/observability/README.md index 388336a20..02980e7ab 100644 --- a/observability/README.md +++ b/observability/README.md @@ -6,17 +6,21 @@ comment service and analytics. It belongs to no single service. Cockpit is scope _project_, so one token and one set of rules cover all of them. (The llm/search/mcp sellers moved to bunny_js — they flush per request to a **custom logs data source** (`bunny-logs`) in the same Cockpit via the Loki push API with a push-only Cockpit token, and their one rule -(`BunnySellersNeedAttention`, on `level="error"`) lives in `alerts/bunny.yaml`, pushed to that +(`BunnySellersNeedAttention`) lives in `alerts/bunny.yaml`, pushed to that data source's own ruler with `--url` — a ruler rule only sees its own source, so the -default-source rules cannot cover them. Read the bunny data source in Grafana → Explore; -`scripts/logs.ts` still queries only the default (Scaleway) source.) - -| Path | What | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `alerts/*.yaml` | Loki ruler rules. `payments.yaml`: facilitator + claim/refund cron; `services.yaml`: the scw_js services. `bunny.yaml`: the bunny twins, on the custom data source's own ruler | -| `scripts/logs.ts` | Read any function's logs from the terminal | -| `scripts/alerts.ts` | List / push / delete rule groups (namespace `fretchen`) | -| `alertCoverage.ts` | Parser behind `scw_js/` and `x402_facilitator/`'s `test/alert_coverage.test.ts` | +default-source rules cannot cover them. The facilitator is porting there too +([`x402_facilitator/bunny-port-implementation-plan.md`](../x402_facilitator/bunny-port-implementation-plan.md)): +the bunny rule already carries its `service="facilitator"` branch, but until the cutover +finishes the Scaleway facilitator stays live and `payments.yaml`'s `FacilitatorNeedsAttention` +keeps watching it — the two cannot double-fire, they rule over different data sources. Read the +bunny data source with `scripts/logs.ts --url `, or in Grafana → Explore.) + +| Path | What | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `alerts/*.yaml` | Loki ruler rules. `payments.yaml`: facilitator + claim/refund cron; `services.yaml`: the scw_js services. `bunny.yaml`: the bunny twins + the facilitator, on the custom data source's own ruler | +| `scripts/logs.ts` | Read any function's logs from the terminal — the default source by name fragment, the bunny-logs custom source by service label with `--url` | +| `scripts/alerts.ts` | List / push / delete rule groups (namespace `fretchen`) | +| `alertCoverage.ts` | Parser behind `scw_js/` and `x402_facilitator/`'s `test/alert_coverage.test.ts` | Setup: `npm install`, then create `.env` as described under [Credentials](#credentials). diff --git a/observability/alertCoverage.ts b/observability/alertCoverage.ts index b3cc7b4d7..ab8303559 100644 --- a/observability/alertCoverage.ts +++ b/observability/alertCoverage.ts @@ -84,7 +84,13 @@ export function extractRules(alertsDir: string): Rule[] { const chunks = text.split(/\n(?= {2}- alert: )/).slice(1); for (const chunk of chunks) { const alert = /- alert: (\S+)/.exec(chunk)?.[1]; - const selector = /resource_name=~?"((?:[^"\\]|\\.)*)"/.exec(chunk)?.[1]; + // Scaleway functions are selected by resource_name; the bunny Edge Scripts push to the + // custom bunny-logs data source and are selected there by their `service` label instead + // (alerts/bunny.yaml) — the same coverage doctrine must reach both, so the parser + // accepts either. First match wins per chunk, which today is the only selector in it. + const selector = + /resource_name=~?"((?:[^"\\]|\\.)*)"/.exec(chunk)?.[1] ?? + /\bservice[=~]?"((?:[^"\\]|\\.)*)"/.exec(chunk)?.[1]; if (!alert || !selector) { continue; } diff --git a/observability/alerts/bunny.yaml b/observability/alerts/bunny.yaml index f3c3c1bef..f94e8d32d 100644 --- a/observability/alerts/bunny.yaml +++ b/observability/alerts/bunny.yaml @@ -1,6 +1,7 @@ -# Loki alerting rule for the bunny_js Edge Script twins (searchapi / llm / mcp) — the x402 -# sellers that left scw_js. Same namespace and doctrine as the other files, but pushed to a -# DIFFERENT ruler: the twins flush their logs to the custom `bunny-logs` data source, not the +# Loki alerting rule for the bunny_js Edge Scripts — the x402 seller twins (searchapi / llm / +# mcp) and, since the facilitator port, the facilitator (service label "facilitator"). Same +# namespace and doctrine as the other files, but pushed to a DIFFERENT ruler: these scripts +# flush their logs to the custom `bunny-logs` data source, not the # default one the Scaleway functions write to, and a ruler rule only ever sees its own data # source's logs. The push therefore names that source's ruler URL explicitly: # @@ -14,12 +15,26 @@ # twins' phrases were removed from services.yaml when they moved for exactly that reason. # test/alerts_budget.test.ts pins bunny.yaml at 1 so a fifth rule fails CI, not the bill. # -# ── Why level="error" and no phrase list ──────────────────────────────────────────────── -# The twins push via the Loki push API with `level` as a REAL stream label, so one label match -# covers every `logger.error` call site (the "ours, and should be alertable" convention) — unlike -# the Scaleway functions, whose lines arrive JSON-encoded inside an envelope, forcing the bare- -# phrase style of services.yaml and its `msg=` trap. WHICH case fired is in the line payload: -# read it with `| json` in Explore. (`service` distinguishes the three twins.) +# ── Why level="error" and no phrase list (the twins) — and a phrase list anyway (the +# facilitator) ──────────────────────────────────────────────────────────────────────────── +# Both push via the Loki push API with `level` as a REAL stream label, so one label match +# covers every `logger.error` call site (the "ours, and should be alertable" convention) — +# unlike the Scaleway functions, whose lines arrive JSON-encoded inside an envelope, forcing +# the bare-phrase style of services.yaml and its `msg=` trap. WHICH case fired is in the line +# payload: read it with `| json` in Explore. (`service` distinguishes the three twins.) +# +# The facilitator gets its own term INSIDE this same rule, with a phrase list after all: its +# most valuable alertable lines are `logger.warn` BY DESIGN ("a caller's fault is warn"), and +# `level="error"` alone would silently drop them — "…Settlement failed" with an OUR-fault +# reason (refund/tx/RPC/fee-allowance), and "…broadcast but unconfirmed" (a broadcast tx whose +# outcome is unknown — reconcile the logged hash). That phrase is the wording-neutral stem of +# the two messages the handler emits ("Settlement broadcast but unconfirmed — reconcile on +# chain" and "Batch-settlement claim/settle broadcast but unconfirmed — …"): scw's rule +# matched only the former, an inherited gap this fold fixes. While +# the Scaleway facilitator is still the live one, payments.yaml keeps its copy — that rule +# is trimmed to walletreportcron-only at the teardown PR (x402_facilitator/ +# bunny-port-implementation-plan.md, Task 6). Every facilitator logger.error message is +# asserted against this alternation by x402_facilitator/test/alert_coverage.test.ts. # # ── Window ────────────────────────────────────────────────────────────────────────────── # [1h], like every rule here: the ruler's maximum, and the doctrine that a "resolved" @@ -29,17 +44,26 @@ interval: 1m rules: - alert: BunnySellersNeedAttention expr: | - sum(count_over_time({service=~"bunny-web-agent|bunny-llm-agent|bunny-mcp-agent", level="error"}[1h])) > 0 + sum(count_over_time({service="facilitator"} |~ "Settlement failed.*(_transaction_failed|_rpc_read_failed|refund_[a-z_]*failed|insufficient_fee_allowance)|broadcast but unconfirmed|Settlement threw|Unexpected error in handler|Unexpected error during payment verification|Cannot collect fee|Fee collection failed|Fee transaction reverted" [1h])) + + sum(count_over_time({service=~"bunny-web-agent|bunny-llm-agent|bunny-mcp-agent", level="error"}[1h])) > 0 for: 0m labels: severity: critical area: services annotations: - summary: "A bunny Edge Script seller needs attention (searchapi / llm / mcp x402 sellers)" + summary: "A bunny Edge Script needs attention (x402 sellers or the facilitator)" description: >- - A logger.error line arrived from one of the bunny twins — by convention that is "ours, and - should be alertable" (a caller's fault is logger.warn and does not fire this). Open Explore - on the bunny-logs data source with - {service=~"bunny-web-agent|bunny-llm-agent|bunny-mcp-agent", level="error"} | json and read - the message. A "log flush failed" line means the ingest itself was down; it is - console.error'd to the bunny dashboard too. + A logger.error line arrived from one of the bunny twins, or an alertable facilitator + phrase did — by convention that is "ours, and should be alertable" (a caller's fault + is logger.warn). Open Explore on the bunny-logs data source with + {service=~"bunny-web-agent|bunny-llm-agent|bunny-mcp-agent", level="error"} | json for + the twins, or {service="facilitator"} | json for the facilitator, and read the message. + Facilitator phrases: "…Settlement failed" with a refund/transaction/RPC/fee-allowance + reason (the decoded revert is in errorMessage), "…broadcast but unconfirmed" (reconcile + the logged tx hash on chain), "Settlement threw" / "Unexpected error" (unclassified + exception), "Cannot collect fee" / "Fee collection failed" / "Fee transaction reverted" + (the settlement landed, the fee did not). Investigate from the repo root: + cd observability && npx tsx scripts/logs.ts '{service="facilitator"}' --url + https://db123d37-9094-4e15-badc-706071190ed7.logs.cockpit.fr-par.scw.cloud --since 24h. + A "log flush failed" line means the ingest itself was down; it is console.error'd to + the bunny dashboard too. diff --git a/observability/scripts/logs.ts b/observability/scripts/logs.ts index 9b9003368..33dead2a4 100644 --- a/observability/scripts/logs.ts +++ b/observability/scripts/logs.ts @@ -28,12 +28,25 @@ * npx tsx scripts/logs.ts facilitator --since 36h --grep "Settlement failed" * npx tsx scripts/logs.ts searchapi --since 6h --grep "handler error|Could not reach" # regex * npx tsx scripts/logs.ts '{resource_name="…"}' --raw # full LogQL, unformatted output + * + * The bunny Edge Scripts (the scw twins + the facilitator) do not write to the default + * source — they flush to the custom `bunny-logs` data source, where their lines are plain + * JSON objects carrying a `service` label: + * npx tsx scripts/logs.ts '{service="facilitator"}' --url \ + * https://db123d37-9094-4e15-badc-706071190ed7.logs.cockpit.fr-par.scw.cloud --since 24h + * (The read token's scope covers the custom source's query_range — verified 2026-11-06.) */ import dotenv from "dotenv"; dotenv.config(); -const URL_BASE = process.env.SCW_COCKPIT_LOGS_URL; +/** The default source's URL comes from `.env`; `--url ` points the same read token at + * the custom bunny-logs data source instead. A `--url` that is missing its value (it was the + * last argument, or the next token is another flag) falls back to `.env` rather than + * silently querying a garbage URL. */ +const urlFlagValue = process.argv[process.argv.indexOf("--url") + 1]; +const URL_BASE = + urlFlagValue && !urlFlagValue.startsWith("--") ? urlFlagValue : process.env.SCW_COCKPIT_LOGS_URL; const TOKEN = process.env.SCW_COCKPIT_LOGS_TOKEN; if (!URL_BASE || !TOKEN) { diff --git a/x402_facilitator/AGENTS.md b/x402_facilitator/AGENTS.md index bfd12a822..0ba35e2c3 100644 --- a/x402_facilitator/AGENTS.md +++ b/x402_facilitator/AGENTS.md @@ -4,6 +4,12 @@ EIP-3009 USDC payment facilitator (Scaleway Functions, path-based routing: `/ver `/settle`, `/supported`). See [`README.md`](./README.md) for the payment schemes, recipient gating, the fee pull, verification checks, error codes and local test endpoints. +**Bunny port:** the HTTP function's core is ported verbatim into `bunny_js/` and will serve +`facilitator.fretchen.eu` after the cutover ([`bunny-port-implementation-plan.md`](./bunny-port-implementation-plan.md)); +this package then keeps `walletreportcron` only. Edit the payment core here while the +Scaleway function is live, and port the change to `bunny_js/src/` in the same sitting (or +pick the change up in the teardown diff) — the two copies must not drift. + ## Serverless conventions Public env values go in `env:` in `serverless.yml`; secrets are configured in the Scaleway diff --git a/x402_facilitator/README.md b/x402_facilitator/README.md index d525b807a..c7ba914b4 100644 --- a/x402_facilitator/README.md +++ b/x402_facilitator/README.md @@ -4,6 +4,14 @@ A production-ready x402 v2 Facilitator for Optimism, enabling EIP-3009 USDC paym **Production Endpoint:** https://facilitator.fretchen.eu +> **Bunny port (in progress).** The HTTP function's payment core is ported verbatim into +> `bunny_js/` (its own twin, native `Request`/`Response` routing, its test suite, and a +> `service="facilitator"` alert branch on the custom Cockpit source) — see +> [`bunny-port-implementation-plan.md`](./bunny-port-implementation-plan.md). Until the +> cutover finishes, this Scaleway function remains the live server behind the endpoint above +> and the authority for everything here; after it, this package keeps only +> `walletreportcron` (the edge has no cron). + > **Logs.** This function's logs are read with `observability/scripts/logs.ts`, not from the > Scaleway console or Grafana: > diff --git a/x402_facilitator/bunny-port-implementation-plan.md b/x402_facilitator/bunny-port-implementation-plan.md new file mode 100644 index 000000000..27ff593dd --- /dev/null +++ b/x402_facilitator/bunny-port-implementation-plan.md @@ -0,0 +1,121 @@ +# Facilitator → Bunny Edge Script — Port Implementation Plan + +**Goal:** Move the facilitator's HTTP endpoints (`/verify`, `/settle`, `/supported`, `/openapi.json`) from the Scaleway Function to a fourth Bunny Edge Script behind `facilitator.fretchen.eu`, keeping `walletreportcron` on Scaleway. + +**Approach:** Verbatim port, the twins' playbook: the payment core moves into `bunny_js/src/facilitator/` wholesale (the package's own subfolder — `src/` is otherwise the twins'; same file names, one diff to review), the routing is rewritten natively against web-standard `Request`/`Response` (`src/facilitator/facilitator_routes.ts`, mirroring `llm_routes.ts`), and **no Scaleway concept survives at the edge** — no envelope, no pino, no alias. The entry is `src/facilitator.ts`, flat like the other three; the facilitator's tests live under `bunny_js/test/facilitator/`. `x402_facilitator/` stays untouched and live until PR 2 (its `walletreportcron` needs only two small fee helpers). Trial-hostname deploy → paid probe matrix → DNS flip → teardown PR. + +**Constraints (from the repo's instructions — none may drift):** + +- **The agent never commits anything on its own.** Any `git commit` happens only when the user explicitly says so in the session. When a commit is requested, the branch is created with `git switch -c --no-track origin/main` (repo root AGENTS.md → Git Workflow) and the commit message uses the repo's Mistral Vibe heredoc format. Pushing, deploy, DNS changes, secret changes: user-initiated only. +- `alerts/*.yaml` rules are pushed only via `observability`'s `npx tsx scripts/alerts.ts --push`, only committed files (`--push` refuses uncommitted), never on deploy (observability/README). +- **Alert budget is four rules.** This plan adds none; the facilitator phrases fold into `alerts/bunny.yaml`'s single rule (payments.yaml header; `test/alerts_budget.test.ts` stays at four). +- `logger.error` = "ours, and should page"; caller's fault = `logger.warn`. **Several alertable facilitator phrases are warn by design**, so the bunny rule cannot rely on `level="error"` alone for it (payments.yaml header). +- Secrets live on the platform, never repo or CI: before the flip the wallet key stays in the Scaleway secret; after it in a Bunny script secret (write-once). The deploy Action ships code only (bunny_js/README → deploy paths). +- `shared/chain-utils` is a `file:` dependency — build it before rebuilding dependents (root AGENTS.md → Commands). +- x402 vocabulary from the `x402` skill: the facilitator **verifies and settles for sellers**; it does not hold funds except the fee pull. EIP-712 domain names differ per network (scw_js README table — never guess). Generated OpenAPI strictness must match handler reality (`z.looseObject` vs `z.object`). +- Verification commands per package README: `x402_facilitator` → `npm run check`; `bunny_js` → `npm run check`; `observability` → `npm run check` (= tsc + eslint + prettier + `npm test`). + +**Decisions (confirmed by the user in this session):** + +1. **Scope:** facilitator HTTP function only; `walletreportcron` stays on Scaleway (Bunny has no cron). +2. **Key custody:** yes — `FACILITATOR_WALLET_PRIVATE_KEY` + `RPC_URL_*` become Bunny script secrets; `THREAT_MODEL.md` updated in the same PR as the port. This is the deliberate security-posture change: a hot wallet that broadcasts transactions moves from Scaleway's secret store to bunny.net infrastructure. +3. **Alerting:** fold into `alerts/bunny.yaml`'s one rule (budget stays four) — not a fifth rule. +4. **PR structure:** two PRs with the operational cutover in between. +5. **Shared core vs. verbatim port (added during PR 1):** verbatim port. An in-flight re-plan: the originally-implemented option imported `handle()` from `x402_facilitator/` across package roots (single source of truth, build-time logger alias). Checking what `walletreportcron` actually shares killed the rationale — two ~30-line fee helpers (`feeAmountFor`, `getFacilitatorAddress`), not the verify/settle core — so the "one home" benefit covers almost nothing, while the cost is permanent: cross-package build coupling (workflow path filters, another package's sources in `bunny_js/tsconfig.json`), a pino→bunny alias plugin, the dead scw dev-server block baked into the bundle, and Scaleway names living on at the edge. The user's call, confirmed: the bunny twin owns its code, like each twin before it. What survives from the earlier approach unchanged: the alert fold (Task 3), `logs.ts --url`, and the entry-test shape. + +**Go/no-go gate — MEASURED (this session):** the full facilitator graph (`handle` + instance builder: verify/settle/supported/fee/whitelist/chain-utils + viem + @x402), bundled with the production `bunny_js/tsdown.config.ts` recipe (neutral platform, `node:` prefix plugin, minify, single-file, deps inlined), is **576.6 KB minified (166 KB gzip)** — well under bunny's 1 MB single-file limit. **Gate passed; no no-go case exists at bundle size.** (Measured on the cross-package import during the earlier shared-core approach; the verbatim port bundles the identical graph natively, and the native routes replace a heavier scw envelope, so the number holds or shrinks — re-measured in Task 1.) + +**PR 1 — the port (everything before the cutover; prod untouched):** + +### Task 1: Fourth bundle — verbatim core port + native routing + build wiring + +**Files:** + +- Create (ported verbatim, same names): `bunny_js/src/facilitator/{x402_verify,x402_settle,x402_fee,x402_whitelist,x402_supported,x402_schemas,revert_selectors,facilitator_instance,chain_utils}.ts` and `bunny_js/src/facilitator/openapi.facilitator.json` +- Create (new code): `bunny_js/src/facilitator/facilitator_routes.ts` (native `Request`/`Response` port of `x402_facilitator.ts`'s routing, CORS, root redirect, and the `handlePaymentRequest` envelope logic — written once against web standards, like `llm_routes.ts`), `bunny_js/src/facilitator.ts` (entry) +- Modify: `bunny_js/package.json` (deps: `viem`, `zod`, `@x402/core`, `@x402/evm` — same versions as `x402_facilitator`; `build:facilitator`; dev script), `bunny_js/tsdown.config.ts` (nothing facilitator-specific — no alias), `bunny_js/tsconfig.json` (nothing cross-package) +- NOT touched at all in PR 1: `x402_facilitator/**` — the Scaleway function stays live until the cutover; its trim is Task 6. + +**Interfaces:** + +- The ported files import `"./logger.js"` — which in `bunny_js/src/` is the pino-shaped bunny logger. Same specifier, no alias, the ported `logger.error` call sites flush to Cockpit with `service="facilitator"` by construction. `openapi.json` imports become `"./openapi.facilitator.json"` mirrors of the twins' naming. +- Produces: `dist/facilitator.js` (single-file minified bundle), `npm run build:facilitator`, service label `facilitator`. + +**Steps:** + +- [ ] Port each core file byte-identical except: import paths it must share (`@fretchen/chain-utils` resolves from `bunny_js`'s own node_modules), and NO `Scaleway*` name anywhere — the envelope types are not ported; only `facilitator_routes.ts` answers `Request`s. +- [ ] `facilitator_routes.ts`: routes by URL pathname (`/verify` POST, `/settle` POST, `/supported` GET, `/openapi.json` GET, `/` redirect to the x402 docs for browsers), OPTIONS → CORS 200, the same CORS_HEADERS, the same 405/400/500 error envelopes, and the same `handlePaymentRequest` behavior (verify-then-settle, return-200-with-isValid-false semantics). +- [ ] `facilitator.ts` entry, `src/llm.ts`'s shape: runtime-shims first, `BunnySDK.net.http.serve(withLogFlush("facilitator", handleFacilitatorRequest))`. +- [ ] Verify: `npm run build:facilitator` → single file < 900 KB (spiked at ≈ 577 KB; the native routes add negligibly over the measured graph). + +### Task 2: Ported tests + native routes tests + +**Files:** + +- Create (ported, same names): the core's test files into `bunny_js/test/facilitator/` (the facilitator's tests live in their own subfolder, mirroring the src layout) — `x402_verify`, `x402_settle`, `x402_settle_real_sdk`, `x402_permit2_real_sdk`, `x402_fee`, `x402_whitelist`, `chain_utils`, `eip712_reference`, `revert_selectors`, `facilitator_instance`, `openapi_generation` + their helpers under `test/facilitator/helpers/` and the live-RPC suite under `test/facilitator/integration/` — import paths adjusted to `bunny_js/src/facilitator/`, `FEE_RETRY_DELAY_MS`-style env kept. +- Create (new): `bunny_js/test/facilitator_routes.test.ts` — the native routing/CORS/envelope behavior that `x402_facilitator.test.ts` + `x402_batch_route.test.ts` asserted through the Scaleway envelope, re-pointed at `Request`/`Response`. +- Create (new): `bunny_js/test/facilitator_alert_coverage.test.ts` — the twins have no alert_coverage test (level=error covers by construction), but the facilitator's phrase-gated warn lines break that argument: this test reuses `observability/alertCoverage.ts` (builtins-only, importable by relative path) to assert every alertable phrase in the ported files is matched by `bunny.yaml`'s alternation. +- NOT ported: the `x402_splitter_*.test.js` files (they assert scw's dist-splitting, which does not exist at the edge). + +**Steps:** + +- [ ] Copy + fix imports; run `bunny_js` → `npm run check` green. + +### Task 3: Observability — one rule folding, no gaps + +**Files:** + +- Modify: `observability/alerts/bunny.yaml`, `x402_facilitator/test/alert_coverage.test.ts`, `observability/scripts/logs.ts`, `observability/README.md`, `x402_facilitator/README.md` (logs block) +- **Not** modified in PR 1: `observability/alerts/payments.yaml` — the Scaleway facilitator is still the live one; trimming its phrases now would open a no-alert window between merge and flip. + +**Interfaces:** + +- `BunnySellersNeedAttention`'s expr becomes a sum of two count_over_time terms (keeping `[1h]` and `for: 0m`): the existing twins term unchanged, plus a `service="facilitator"` term — facilitator `logger.error` sites by `level="error"` label, **and** the warn-level phrases as line-filter alternatives: "Settlement broadcast but unconfirmed", "Batch-settlement claim/settle broadcast but unconfirmed", and "…Settlement failed" lines with our-fault reasons (`refund_[a-z_]*failed|insufficient_fee_allowance|_transaction_failed|_rpc_read_failed`). The `x402_facilitator` logger.error phrases ("Settlement threw", "Unexpected error in handler", "Unexpected error during payment verification", "FACILITATOR_WALLET_PRIVATE_KEY not configured or invalid", "Fee collection failed", "Fee transaction reverted", "facilitator address not configured") are covered by the `level="error"` arm. +- Update the rule's summary/description annotations to name the facilitator branch and its investigate commands (`logs.ts facilitator --url …`). + +**Steps:** + +- [ ] Edit `bunny.yaml`; verify `observability` → `npm run check` green — the budget test passes unchanged: no rule group is added anywhere, the existing `bunny.yaml: 1` pin covers the fold. +- [ ] `x402_facilitator/test/alert_coverage.test.ts`: its rule source becomes the union of `payments.yaml` + `bunny.yaml` (facilitator logger.error call sites now matched by the bunny rule; `walletreportcron`'s by payments.yaml). Every call site stays matched — an unbudgeted drop fails CI. +- [ ] `scripts/logs.ts`: add an optional `--url ` passthrough (default: the project's default source) using `SCW_COCKPIT_LOGS_TOKEN`, which already queries the custom source (verified in the observability plan). `logs.ts facilitator --since 24h` documented in `x402_facilitator/README.md`'s logs block with the custom-source URL argument. +- [ ] **Push only at cutover (Task 5), after this file is committed** — `--push` refuses uncommitted; pushing the expanded bunny rule before the flip simply can't fire (no facilitator lines in the custom source yet), so ordering is enforced by the cutover checklist, not by the repo. + +### Task 4: Docs, workflows, threat model (same PR 1) + +**Files:** + +- Modify: `.github/workflows/deploy-bunny-js.yml`, `.github/workflows/test-bunny-js.yml` (the deploy workflow gains a facilitator step with repo secrets `BUNNYFACILITATOR_SCRIPT_ID` / `BUNNYFACILITATOR_DEPLOY_KEY` — set by the user in GitHub, mirroring the per-script deploy-key pattern; **no path filter gains `x402_facilitator/**`** — the verbatim port means the bundle builds from `bunny_js/` sources only), `.github/THREAT_MODEL.md` (key hierarchy row for `FACILITATOR_WALLET_PRIVATE_KEY`: post-cutover home is the Bunny script secret; trust boundary: bunny.net edge isolate; blast radius: fee-collection + settle relay only — never custody), `bunny_js/README.md` (fourth script; known limits: no cron at the edge is why walletreportcron stays; subrequest/wall-time note), `bunny_js/AGENTS.md` (runtime section ext.), `x402_facilitator/README.md` + `AGENTS.md` (post-port split: `walletreportcron` stays a Scaleway function, the payment core is bundled into the `bunny_js` facilitator script), `observability/README.md` (bunny-logs source now carries facilitator; service label list). + +**Steps:** + +- [ ] CI check: `test-bunny-js`/`test-facilitator` workflows already exist per package; the deploy workflow's new path filter must not deploy before merge (it triggers on `main` only — already the case). +- [ ] CI runs both packages' `npm run check` — green. + +**Task 5 — Operational cutover (no repo changes; user-initiated, agent-assisted; not a commit). Order matters:** + +- [ ] `bunny scripts create` → `fretchen-facilitator` (standalone, pull zone). +- [ ] Env (mirroring `serverless.yml`, per-script, all in the suite's config UI/CLI): + - plain: `FACILITATOR_FEE_AMOUNT=10000`, `LOG_LEVEL=info`, `BATCH_SETTLEMENT_TEST_WALLETS` (public list, testnet-fenced), `COCKPIT_LOKI_URL` (public host), `NODE_ENV=production`. + - `--secret`: `FACILITATOR_WALLET_PRIVATE_KEY`, `SCW_COCKPIT_PUSH_TOKEN` (the script needs the same pair the twins use for the logger), `RPC_URL_EIP155_10`, `RPC_URL_EIP155_8453`, `RPC_URL_EIP155_84532`, `RPC_URL_EIP155_11155420` (real Alchemy keys — secret, never env). +- [ ] Deploy: from `main` (the Action publishes it on the next merge touching `bunny_js/**`; a manual `bunny scripts deploy dist/facilitator.js ` — with the repo build — is the out-of-band path; a laptop-only experiment is NOT deployed until PR 1 is merged, per last-deployment-wins doctrine). +- [ ] Deterministic probes (trial hostname, the twins' matrix shape): `GET /supported` → networks/assets/schemes correct for all four chains; `GET /openapi.json` → 200; `OPTIONS /settle` → CORS 200; facilitator entry on the trial hostname handles a Base Sepolia `exact` verify+settle end-to-end, fee pulled (needs a test wallet with allowance; the repo's testnet bypass list applies); a `batch-settlement` claim on Base Sepolia settles (this exercises the fee gate + `FACILITATOR_WALLET_PRIVATE_KEY` on the edge runtime and viem's tx send/receipt-wait under the isolate — subrequest count visible in `bunny scripts stats`); lines appear in the custom Cockpit source (`service="facilitator"`), read back with the extended `logs.ts --url`; probe one warn-phrase line → `BunnySellersNeedAttention` fires → email arrives (a rule that never fired is not known to work); run `bunny scripts stats --hourly` to check per-request CPU — must sit far below the platform cap. +- [ ] Push rules: `cd observability && npx tsx scripts/alerts.ts --push alerts/bunny.yaml --url https://db123d37-…cockpit.fr-par.scw.cloud` (committed file only). +- [ ] **DNS flip window between cron runs** (llmx402cron runs 00:00/12:00 UTC and its claim/settle calls `facilitator.fretchen.eu` mid-run): repoint `facilitator.fretchen.eu` to the script's pull zone, SSL on. Sellers and the cron are URL-stable — no config changes anywhere. +- [ ] Watch the loose ends: next `llmx402cron` run's lines show claims/settles succeeding against the flipped facilitator (observability logs, 12 h later at worst); Scaleway function untouched until PR 2. + +**Task 6: PR 2 — Scaleway teardown (after the flip, post-cutover confirmation):** + +- [ ] `x402_facilitator/serverless.yml`: delete the `facilitator` function + its `custom_domains` entry; keep `walletreportcron` verbatim. +- [ ] `payments.yaml`: `FacilitatorNeedsAttention` trims to walletreportcron-only (selector stays `resource_name=~"walletreportcron"`, phrases "Email report failed" / "Email API returned" / weekly-report "not configured"); walletreportcron's remaining error lines stay matched; budget still four. `x402_facilitator/test/alert_coverage.test.ts` stays green with the union from Task 3. +- [ ] Root `AGENTS.md` repo table + facilitator README/AGENTS final wording + all remaining docs: single home for each claim (root table row describes the split: core bundled into `bunny_js` script at the edge; cron on Scaleway). +- [ ] `npm run check` in `x402_facilitator` and `observability` green; push `payments.yaml` (`npx tsx scripts/alerts.ts --push alerts/payments.yaml`). +- [ ] Deploy the scw stack once more to delete the function (irreversible but post-flip: DNS already points at bunny, so nothing serving facilitator.fretchen.eu disappears). Only on the user's say-so. + +**Assumptions (not yet verified — each has a named verification point):** + +- **Deno 1.x executes the viem bundle** (the spike proves it bundles, not that it runs; Examined at Task 5's testnet probes — the first runtime execution of the exact artifact happens on a disposable trial script, the same as the twins). Untrusted imports (`node:dns`, `node:tls`) — none in the facilitator's graph; anything viem loads transitively surfaces as a thrown fetch/error at the trial probes, not in production. +- **Wall-time on the edge is sufficient for settle** (documented cap ~30 s CPU, wall-time undocumented): viem's RPC waits are I/O, `FEE_RECEIPT_TIMEOUT_MS` already binds the fee-wait worst case, and "broadcast but unconfirmed" returns a hash to reconcile if a receipt never lands in budget. Verified by the Task 5 batch-settlement probe + CPU stats readout; if settle p99 exceeds budget there, the fallback is a higher `pollingInterval` on the edge build only (one env-tunable, no logic change). +- **Pino argument shape = bunny logger shape at every call site** — verified by PR 1's typecheck of the aliased graph (log lines already exhibit the `(obj, "msg")` form in `x402_verify`/`x402_settle`/`x402_fee`). +- **No alert gap pre/post-flip** — payments.yaml trims only in PR 2, bunny rule is pushed before the flip, and the level-label vs `resource_name` separation prevents double-paging during the interim (framed by Task 3's "watch windows" logic; re-check at PR 1's alert probe). + +**Execution note:** this plan is the handoff — implementation runs under the normal repo discipline with a todo list mirroring the tasks. The agent never commits on its own; PR 1 and PR 2 branches, commits, pushes, deploys, and DNS/rules/secret changes happen only on explicit user instruction.