diff --git a/.agents/skills/x402/SKILL.md b/.agents/skills/x402/SKILL.md index 414caded6..2fed1b7bb 100644 --- a/.agents/skills/x402/SKILL.md +++ b/.agents/skills/x402/SKILL.md @@ -54,7 +54,7 @@ has a buyer calling `/verify`, it is wrong. | ---------------------------- | ----------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **Client / buyer** | `website/` — `/imagegen` and `/assistent` | GitHub Pages | `hooks/useX402ImageGeneration.ts`, `hooks/useX402Chat.ts` — both use `wrapFetchWithPayment` from `@x402/fetch` | | **Resource server / seller** | `scw_js/` (image) + `bunny_js/` (llm, search, mcp) | Scaleway Function + Bunny Edge Scripts | image: `scw_js/genimg_x402_token.ts` → `imagegen-agent.fretchen.eu`; llm/search/mcp: `bunny_js/src/llm_routes.ts`, `searchapi_routes.ts`, `mcp_routes.ts` → `llm-agent` / `web-agent` / `mcp-agent.fretchen.eu` (shared ported `x402_server.ts`) | -| **Facilitator** | `x402_facilitator/` | Scaleway Functions | `x402_facilitator.ts` — `/verify`, `/settle`, `/supported` at `facilitator.fretchen.eu` | +| **Facilitator** | `bunny_js/src/facilitator/` | Bunny Edge Script (`fretchen-facilitator`, `service="facilitator"`) | `facilitator_routes.ts` — `/verify`, `/settle`, `/supported` at `facilitator.fretchen.eu` (moved from the retired Scaleway function; the weekly wallet-report cron in `x402_facilitator/` is all that remains there) | Plus `scw_js/llm_x402_cron.ts` — a 12-hourly cron that claims and settles accumulated batch-settlement channels. It is seller-side infrastructure, not a fourth role. @@ -80,9 +80,11 @@ fallback (see `genimg_x402_token.ts`). - **EIP-712 domain names differ by network.** Mainnet USDC is `"USD Coin"`, testnet is `"USDC"`. The verified per-network table is in `scw_js/README.md` → _Adding New Networks_. Never guess one. -- **The batch-settlement recipient whitelist is OR logic** across a manual list, testnet-only test - wallets, and NFT-holder status — and it checks `payTo`, the recipient, not the payer. See - `x402_facilitator/README.md` → _Whitelist Architecture_. +- **The batch-settlement recipient gate is an allowance check**: the recipient (`payTo`, not the + payer) must have `approve()`d the facilitator's wallet for the fee in the settled token; the + only bypass is `BATCH_SETTLEMENT_TEST_WALLETS`, honored on testnets only (the old manual + allowlist + NFT-holder branch are retired). See `bunny_js/README.md` → _Facilitator: recipient + gating_. - **The facilitator collects a fee** via `transferFrom(merchant, facilitator, fee)`, in the token the payment settled in — so the merchant needs one `approve()` **per token** it takes (USDC, and EURC on Base). Amounts live only in `website/pages/x402/sellers/`. @@ -99,8 +101,9 @@ fallback (see `genimg_x402_token.ts`). `findSettlementToken`, default currency EURe with EURC and USDC one toggle away; its spend-control cap is per-token, `10^decimals`) all read it. `getStablecoins()` remains USDC+EURC only — nothing should read it for EURe. See `scw_js/README.md` → _Stablecoins - and pricing_ and _Known EURe Domain Names_, and `x402_facilitator/README.md` → _EURe via - Permit2_. + and pricing_ and _Known EURe Domain Names_; the EURe-via-Permit2 implementation lives in + `bunny_js/src/facilitator/` (end-to-end walkthrough: + `bunny_js/notebooks/facilitator/x402_exact_permit2.ipynb`). ## Writing about x402 on the site diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index c6628e829..854d27402 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -17,26 +17,26 @@ This is a lightweight, living threat model. It follows the OWASP four-question f What has monetary value, irreversibility, or trust significance in this system: -| Asset | Location | Value | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | -| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | -| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | -| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | -| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | -| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | -| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | -| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | -| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | -| 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; 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 | -| Edge Script deploy keys (`BUNNYJS_`/`BUNNYLLM_`/`BUNNYMCP_DEPLOY_KEY`) | GitHub repo secrets in a public repo (Actions; the paired `*_SCRIPT_ID` secret names the target) | Each publishes arbitrary code to exactly its own script — code execution in a sandbox whose env holds a copy of the seller secret set. Three keys, one per twin; each is bounded to its script, and the workflow triggers stay push-to-`main`-only | -| Bunny Edge Script env secrets (searchapi 97472, llm 97948, mcp 97949) | bunny.net script configuration | Per script: the S3 credential pair (production channel store) and the receiver-authorizer private key on all three; `BRAVE_API_KEY` on searchapi + mcp, `MISTRAL_API_KEY` (metered inference) on llm + mcp. One shared secret set in four copies until the scw_js deletion deploy lands: the three script envs plus the still-deployed scw_js functions (their `serverless.yml` entries are removed; the plugin deletes the functions — and with them the service's use of these secrets — on the next `npm run deploy` in scw_js; a console revoke-check for `BRAVE_API_KEY`/`MISTRAL_API_KEY` belongs right after that deploy). All copies rotate together | +| Asset | Location | Value | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | +| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | +| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | +| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | +| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | +| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | +| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | +| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | +| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | +| 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 | `bunny_js` script secret (`--secret`, write-once; the retired Scaleway facilitator function and its copy are deleted) — `walletreportcron` still carries the key on Scaleway, only to derive the address for the weekly report (a public-address env would remove that copy) | 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 | +| Edge Script deploy keys (`BUNNYJS_`/`BUNNYLLM_`/`BUNNYMCP_DEPLOY_KEY`) | GitHub repo secrets in a public repo (Actions; the paired `*_SCRIPT_ID` secret names the target) | Each publishes arbitrary code to exactly its own script — code execution in a sandbox whose env holds a copy of the seller secret set. Three keys, one per twin; each is bounded to its script, and the workflow triggers stay push-to-`main`-only | +| Bunny Edge Script env secrets (searchapi 97472, llm 97948, mcp 97949) | bunny.net script configuration | Per script: the S3 credential pair (production channel store) and the receiver-authorizer private key on all three; `BRAVE_API_KEY` on searchapi + mcp, `MISTRAL_API_KEY` (metered inference) on llm + mcp. One shared secret set in four copies until the scw_js deletion deploy lands: the three script envs plus the still-deployed scw_js functions (their `serverless.yml` entries are removed; the plugin deletes the functions — and with them the service's use of these secrets — on the next `npm run deploy` in scw_js; a console revoke-check for `BRAVE_API_KEY`/`MISTRAL_API_KEY` belongs right after that deploy). All copies rotate together | Notably absent: user wallet keys (never touch the server), user PII (not collected). @@ -152,14 +152,14 @@ This is the **HOW** that complements §3 (WHO) and §4 (WHERE): the concrete cla ### API / serverless layer — OWASP API Security Top 10 -| Technique (OWASP) | Where it applies | Status | Tracked in | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------- | -| Broken authentication (API2) | EIP-712/EIP-3009 sig verify (facilitator), plus Permit2-witness and EIP-2612-permit verify for EURe (facilitator); agent-wallet whitelist (scw_js); `useWalletAuth` owner-sig bearer (growth **and analytics**); origin whitelist (comment_service) | Mitigated | §4; §7 | -| Broken function-level authz (API5) | x402 `Access-Control-Allow-Origin: *`; bounded by EIP-3009 crypto | Accepted (intentional open protocol) | §4 ★; §7 | +| Technique (OWASP) | Where it applies | Status | Tracked in | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Broken authentication (API2) | EIP-712/EIP-3009 sig verify (facilitator), plus Permit2-witness and EIP-2612-permit verify for EURe (facilitator); agent-wallet whitelist (scw_js); `useWalletAuth` owner-sig bearer (growth **and analytics**); origin whitelist (comment_service) | Mitigated | §4; §7 | +| Broken function-level authz (API5) | x402 `Access-Control-Allow-Origin: *`; bounded by EIP-3009 crypto | Accepted (intentional open protocol) | §4 ★; §7 | | Unrestricted resource consumption (API4) | `/settle` spam gas drain; unpaid MCP `tools/call` 402 generation (accepted — pure CPU: no metered service, no S3, no signing, same posture as the REST sellers' unpaid requests, see §4); serverless cold starts (accepted); LLM pre-charge balance gate (batch-settlement stall; Open, medium); analytics `/hit` request volume (not rate-limited, and **CORS is not the control** — browser-only, and `sendBeacon` sends a simple request with no preflight; accepted, since the consequence is a skewed counter, not cost or exposure); analytics `pages` map cardinality abuse (mitigated via path validation + a 200-entry cap) | Mixed — gas drain, MCP unpaid calls and `/hit` volume accepted; balance-gate open; `pages` cardinality mitigated | §4; bunny_js/SECURITY.md (balance gate, moved with the llm twin); scw_js/SECURITY.md (rest) | -| Unrestricted access to sensitive business flows (API6) | Deliver-before-payment in the LLM and genimg flows | Open (medium) | bunny_js/SECURITY.md (LLM — moved with the twin); scw_js/SECURITY.md (genimg) | -| Security misconfiguration / secret exposure (API8) | comment_service sends `SCW_SECRET_KEY` as the `X-Auth-Token` header to the Scaleway transactional-email API | Open (medium) | §7 | -| Unsafe client trust (API10) | Client-side-only image validation | Open (low) | §7 | +| Unrestricted access to sensitive business flows (API6) | Deliver-before-payment in the LLM and genimg flows | Open (medium) | bunny_js/SECURITY.md (LLM — moved with the twin); scw_js/SECURITY.md (genimg) | +| Security misconfiguration / secret exposure (API8) | comment_service sends `SCW_SECRET_KEY` as the `X-Auth-Token` header to the Scaleway transactional-email API | Open (medium) | §7 | +| Unsafe client trust (API10) | Client-side-only image validation | Open (low) | §7 | ### Agent layer — OWASP Top 10 for LLM Applications @@ -171,7 +171,7 @@ The assistant is itself an LLM agent with a paid tool loop, so its review surfac | Improper output handling (LLM05) | Rendering of model output: ReactMarkdown without rehype-raw does not render raw HTML in model output; CodeBlock injects highlight.js output via `dangerouslySetInnerHTML`, but highlight.js escapes while highlighting | Mitigated | website/components/AssistantChat.tsx; website/components/CodeBlock.tsx | | Excessive agency (LLM06) | Spend-capable tools are confirm-gated in the browser (the generate_image confirm card); the paid LLM endpoint never executes tools on the caller's behalf (a published contract in `scw_js/openapi.llm.json`); the paid web tools spend only from the escrowed batch-settlement channel | Mitigated by design | .agents/skills/chat-tools/SKILL.md; scw_js/openapi.llm.json | | System prompt leakage (LLM07) | The system prompt (including the research variant) is sent to the model every turn; owner-scope tool descriptions are withheld from visitors entirely | Accepted (the prompt is not secret material; owner tools are never offered to visitors) | .agents/skills/chat-tools/SKILL.md | -| Unbounded consumption (LLM10) | Per-message price ceiling (operator absorbs overage), MAX_MESSAGES_BYTES 192 KB, MAX_TOOLS_BYTES 8192, MAX_HOPS/MAX_PAID_CALLS loop caps | Mitigated | bunny_js/src/llm_schemas.ts; website/utils/toolLoop.ts | +| Unbounded consumption (LLM10) | Per-message price ceiling (operator absorbs overage), MAX_MESSAGES_BYTES 192 KB, MAX_TOOLS_BYTES 8192, MAX_HOPS/MAX_PAID_CALLS loop caps | Mitigated | bunny_js/src/llm_schemas.ts; website/utils/toolLoop.ts | --- @@ -189,14 +189,14 @@ The stance, and why it is Accepted: the registry's trust model assumes unauthent For blockchain systems, **Integrity** dominates. Confidentiality is generally low concern (code is public, transactions are public). Availability is partially guaranteed by the L2 itself; the serverless layer is the availability risk. **Repudiation** is a structural strength: all on-chain transactions are cryptographically signed and permanently recorded — no actor can deny having submitted a transaction. -| Component | Confidentiality | Integrity | Availability | Notes | -| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Smart contracts | Low — all code and state is public | **Critical** — funds and metadata are irreversible | High — L2 guarantees liveness | Upgrade path is the integrity weak point | -| x402_facilitator | Low | **High** — invalid settlement = wrong payment routing | Medium — serverless cold starts | Open CORS degrades integrity boundary | -| scw_js | Low | **High** — agent wallet controls on-chain writes | Medium | API key compromise has no on-chain reach | -| website frontend | Low | Medium — client-side only; no server state | Low — static hosting | Wallet auth is the integrity boundary | -| comment_service | Medium — stores anonymous user names/text in S3 | Low — comment content and like counts | Low | Low-value, but holds `SCW_SECRET_KEY` (email) — not zero-secret | -| analytics service | Low — aggregate counts only, no PII; objects are written **private** (no public-read ACL) and the read path is planned as owner-signature-gated, like the Growth API | Low — writes are unauthenticated, so counts are **advisory**: fine for traffic insight, never to be trusted for a security or financial decision | Low — same serverless cold-start profile as other functions | Private-by-omission: the bucket also serves public-read images, so "private" rests on the handler omitting an ACL — locked by a test | +| Component | Confidentiality | Integrity | Availability | Notes | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| Smart contracts | Low — all code and state is public | **Critical** — funds and metadata are irreversible | High — L2 guarantees liveness | Upgrade path is the integrity weak point | +| x402_facilitator | Low | **High** — invalid settlement = wrong payment routing | Medium — edge-isolate cold starts + hot settle key on bunny script env (post-cutover; the settle/fee-relay move put that key on bunny.net infrastructure — §2 asset row) | Open CORS degrades integrity boundary | +| scw_js | Low | **High** — agent wallet controls on-chain writes | Medium | API key compromise has no on-chain reach | +| website frontend | Low | Medium — client-side only; no server state | Low — static hosting | Wallet auth is the integrity boundary | +| comment_service | Medium — stores anonymous user names/text in S3 | Low — comment content and like counts | Low | Low-value, but holds `SCW_SECRET_KEY` (email) — not zero-secret | +| analytics service | Low — aggregate counts only, no PII; objects are written **private** (no public-read ACL) and the read path is planned as owner-signature-gated, like the Growth API | Low — writes are unauthenticated, so counts are **advisory**: fine for traffic insight, never to be trusted for a security or financial decision | Low — same serverless cold-start profile as other functions | Private-by-omission: the bucket also serves public-read images, so "private" rests on the handler omitting an ACL — locked by a test | --- @@ -204,11 +204,11 @@ For blockchain systems, **Integrity** dominates. Confidentiality is generally lo Detailed code-level findings live alongside the code they describe: -| Component | Findings document | Open issues | -| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | -| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | -| Bunny Edge Script twins | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Per-script deploy-key blast radius (bounded by trigger hygiene; rotate-on-suspicion), deployed-runtime SigV4/DNS verification pending, undici-specific error matching on the edge (all fail-closed), secret-set copies spanning two platforms (rotation coupling, until the scw_js deletion deploy lands), LLM pre-charge balance gate (medium) and LLM deliver-before-payment (medium) — both moved with the llm twin, see bunny_js/SECURITY.md | +| Component | Findings document | Open issues | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | +| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | +| Bunny Edge Script twins | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Per-script deploy-key blast radius (bounded by trigger hygiene; rotate-on-suspicion), deployed-runtime SigV4/DNS verification pending, undici-specific error matching on the edge (all fail-closed), secret-set copies spanning two platforms (rotation coupling, until the scw_js deletion deploy lands), LLM pre-charge balance gate (medium) and LLM deliver-before-payment (medium) — both moved with the llm twin, see bunny_js/SECURITY.md | --- diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml index ad241b615..26882f1c3 100644 --- a/.github/workflows/deploy-bunny-js.yml +++ b/.github/workflows/deploy-bunny-js.yml @@ -99,11 +99,10 @@ jobs: - 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). + # The facilitator twin (payment core ported from x402_facilitator/). Live: the DNS + # flip happened, facilitator.fretchen.eu serves this script, and the retired Scaleway + # function is deleted. 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/AGENTS.md b/AGENTS.md index a89ed3b2d..a4978f8d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,19 +14,19 @@ everything. Monorepo for a blockchain-based AI service platform. Each subdirectory is an independent npm package — there is no root `package.json` or workspace manager. -| Directory | Purpose | Further reading | -| --------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `website/` | Frontend (Vike SSR + React 19 + Panda CSS) — also the blog | [`README`](website/README.md) · [`AGENTS.md`](website/AGENTS.md) | -| `eth/` | Solidity smart contracts (Hardhat, Optimism L2) | [`README`](eth/README.md) · [`AGENTS.md`](eth/AGENTS.md) | -| `scw_js/` | Serverless backend (Scaleway Functions: image gen, channel cron, growth API) | [`README`](scw_js/README.md) · [`AGENTS.md`](scw_js/AGENTS.md) | -| `bunny_js/` | Bunny Edge Scripts: the x402 llm/search/mcp sellers (plus the scw twins' origin domains) | [`README`](bunny_js/README.md) · [`AGENTS.md`](bunny_js/AGENTS.md) | -| `x402_facilitator/` | EIP-3009 USDC payment facilitator (Scaleway Functions) | [`README`](x402_facilitator/README.md) · [`AGENTS.md`](x402_facilitator/AGENTS.md) | -| `comment_service/` | Blog comment backend (Scaleway Functions) | [`README`](comment_service/README.md) · [`AGENTS.md`](comment_service/AGENTS.md) | -| `analytics/` | Serverless pageview counter (Scaleway Functions, S3-backed) | [`README`](analytics/README.md) · [`AGENTS.md`](analytics/AGENTS.md) | -| `growth-agent/` | AI growth agent cron container (Python, LangGraph) | [`README`](growth-agent/README.md) · [`AGENTS.md`](growth-agent/AGENTS.md) | -| `observability/` | Log CLI + Loki alert rules for all Scaleway functions | [`README`](observability/README.md) · [`AGENTS.md`](observability/AGENTS.md) | -| `shared/chain-utils/` | Shared blockchain utility library (Viem peer dep) | [`README`](shared/chain-utils/README.md) · [`AGENTS.md`](shared/chain-utils/AGENTS.md) | -| `notebooks/` | Python Jupyter notebooks for analysis | [`README`](notebooks/README.md) | +| Directory | Purpose | Further reading | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `website/` | Frontend (Vike SSR + React 19 + Panda CSS) — also the blog | [`README`](website/README.md) · [`AGENTS.md`](website/AGENTS.md) | +| `eth/` | Solidity smart contracts (Hardhat, Optimism L2) | [`README`](eth/README.md) · [`AGENTS.md`](eth/AGENTS.md) | +| `scw_js/` | Serverless backend (Scaleway Functions: image gen, channel cron, growth API) | [`README`](scw_js/README.md) · [`AGENTS.md`](scw_js/AGENTS.md) | +| `bunny_js/` | Bunny Edge Scripts: the x402 llm/search/mcp **sellers** — plus the facilitator (`facilitator.fretchen.eu`) | [`README`](bunny_js/README.md) · [`AGENTS.md`](bunny_js/AGENTS.md) | +| `x402_facilitator/` | Weekly facilitator wallet report (Scaleway Functions) — the EIP-3009 facilitator itself moved to `bunny_js/src/facilitator/` | [`README`](x402_facilitator/README.md) · [`AGENTS.md`](x402_facilitator/AGENTS.md) | +| `comment_service/` | Blog comment backend (Scaleway Functions) | [`README`](comment_service/README.md) · [`AGENTS.md`](comment_service/AGENTS.md) | +| `analytics/` | Serverless pageview counter (Scaleway Functions, S3-backed) | [`README`](analytics/README.md) · [`AGENTS.md`](analytics/AGENTS.md) | +| `growth-agent/` | AI growth agent cron container (Python, LangGraph) | [`README`](growth-agent/README.md) · [`AGENTS.md`](growth-agent/AGENTS.md) | +| `observability/` | Log CLI + Loki alert rules for all Scaleway functions | [`README`](observability/README.md) · [`AGENTS.md`](observability/AGENTS.md) | +| `shared/chain-utils/` | Shared blockchain utility library (Viem peer dep) | [`README`](shared/chain-utils/README.md) · [`AGENTS.md`](shared/chain-utils/AGENTS.md) | +| `notebooks/` | Python Jupyter notebooks for analysis | [`README`](notebooks/README.md) | ## Commands @@ -75,7 +75,7 @@ llm/web/MCP on the bunny_js Edge Scripts). Three traps, each documented in detail where it belongs: - **EIP-712 domain names differ by network** (mainnet USDC is `"USD Coin"`, testnet is `"USDC"`). Follow the checklist in [`scw_js/README.md`](scw_js/README.md) → _Adding New Networks_, which carries the verified per-network table. -- **The x402 recipient whitelist uses OR logic** across a manual list, testnet-only test wallets, and NFT-holder status. See [`x402_facilitator/README.md`](x402_facilitator/README.md) → _Whitelist Architecture_. +- **The x402 recipient "whitelist" is really an allowance gate**: both schemes require the recipient to have `approve()`d the facilitator's wallet for the fee **in the settled token**; the only bypass is `BATCH_SETTLEMENT_TEST_WALLETS`, honored on testnets only (the old manual allowlist and NFT-holder branch are retired). See [`bunny_js/README.md`](bunny_js/README.md) → _Facilitator: recipient gating_. - **Generated OpenAPI specs must publish the strictness they actually enforce.** `scw_js` and `x402_facilitator` generate their `openapi.*.json` from Zod, and `z.object` renders as `additionalProperties: false` — so a schema that merely _strips_ unknown keys publishes a strictness its handler does not enforce. Use `z.looseObject` for a request the handler forwards or ignores extras on, `z.object` where the body is built field by field (every response, and `genimg`'s request, which really does reject unknown fields). Each package's `test/openapi_*_generation.test.ts` asserts this, because nothing else catches it — and it has shipped wrong: the facilitator's published `PaymentRequest` described a request that could not be paid with, since real payloads carry the very fields it declared forbidden. ## Security @@ -87,7 +87,7 @@ See [`.github/THREAT_MODEL.md`](.github/THREAT_MODEL.md) for the full asset inve - `CONTRACT_OWNER_PRIVATE_KEY` (Hardhat keystore) — dedicated EOA `0x1af51D…fBB20`, controls every upgradeable contract and owns the three ERC-8004 agent identities (Base). Never use it for anything else. - `SEPOLIA_PRIVATE_KEY` (Hardhat keystore) — deployment/script signing key `0x073f26…`. Does NOT own contracts. - Agent wallet `0xAAEBC1…` — backend-only, whitelisted on GenImNFTv4 via `authorizeAgentWallet()`. -- Facilitator wallet — stored as a Scaleway secret, receives USDC fees only. +- Facilitator wallet — stored as a `--secret` on the bunny facilitator script (the retired Scaleway facilitator function and its secret copy are deleted; `walletreportcron` still carries the key, only to derive the wallet address for the weekly report — a public-address env would remove that copy); receives fees in the settled tokens (USDC; EURC/EURe on Base). Its pull semantics: `bunny_js/README.md` → _Facilitator: the fee pull_. - Receiver-authorizer key (`RECEIVER_AUTHORIZER_PRIVATE_KEY`, scw_js Scaleway secret) — signs batch-settlement channel-config and refund authorizations for the `/assistent` sellers; never sends transactions. Compromise means revenue denial (early refunds, stalled claims), not fund redirection. Rules that apply everywhere: diff --git a/README.md b/README.md index 4cd4f84cd..dc4061fb4 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ wired together by x402 USDC payments. | `website/` | The site + blog (Vike SSR, React 19, Panda CSS) | | `eth/` | NFT and payment contracts (Hardhat, UUPS proxies) | | `scw_js/` | Scaleway Functions: image gen, channel cron, growth API | -| `bunny_js/` | Bunny Edge Scripts: the x402 llm/search/mcp sellers | -| `x402_facilitator/` | EIP-3009 USDC payment facilitator | +| `bunny_js/` | Bunny Edge Scripts: the x402 sellers — llm/search/mcp — plus the facilitator | +| `x402_facilitator/` | Weekly facilitator wallet report (facilitator itself moved to `bunny_js`) | | `comment_service/` | Blog comments | | `analytics/` | Pageview counter (S3-backed) | | `growth-agent/` | AI growth agent (Python, LangGraph) | diff --git a/bunny_js/AGENTS.md b/bunny_js/AGENTS.md index a56b700f6..4353d336e 100644 --- a/bunny_js/AGENTS.md +++ b/bunny_js/AGENTS.md @@ -1,8 +1,8 @@ # bunny_js — agent rules 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)). +(the payment core's verbatim twin from `x402_facilitator/` — cutover done, the retired +Scaleway function deleted; the move's plan is preserved in git history). 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 f8c164775..12eca3978 100644 --- a/bunny_js/README.md +++ b/bunny_js/README.md @@ -13,9 +13,48 @@ 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`. +native `Request`/`Response` routing in `src/facilitator_routes.ts`. The cutover happened — +`facilitator.fretchen.eu` serves this script, and the retired Scaleway function is deleted +(the move's decisions + cutover checklist are preserved in git history as +`x402_facilitator/bunny-port-implementation-plan.md`); that package keeps only `walletreportcron` +now, the facilitator's weekly wallet-report cron — the edge has no cron). + +### Facilitator: recipient gating (the "whitelist architecture") + +There is no recipient whitelist. Both schemes gate recipients the same way: an allowance the +recipient has `approve()`d for the facilitator's wallet, **in the token the payment settles +in** — USDC on any network, EURC and EURe on Base. The fee is charged in that same token +(0.01 USDC, 0.01 EURC or 0.01 EURe), so a seller paid in several approves each. EURe has 18 +decimals, so its 0.01 is `10000000000000000` atomic units, not the `10000` of USDC/EURC — and +an EURe approval of `1000000` covers no fee at all. Any other token is refused with +`unsupported_fee_asset`. For `exact` the token is `requirements.asset`; for batch-settlement +it is the channel's token (`payload.token` for `settle`, the claims' channel token for +`claim`, `channelConfig.token` for a claim-carrying refund), and a batch mixing tokens is +refused. `checkMerchantAllowance()` (`src/facilitator/x402_fee.ts`) reads that allowance; an +under-approved recipient is rejected with `insufficient_fee_allowance` rather than settled +for free. `BATCH_SETTLEMENT_TEST_WALLETS` (`src/facilitator/x402_whitelist.ts`) is the one +remaining carve-out: a listed address skips the allowance check, but only on testnets (the +old manual allowlist and NFT-holder branch are retired — `claim`/`settle` moved onto the +allowance check in FEE_MODEL_PLAN.md Phase 3). + +### Facilitator: the fee pull right after the settlement + +The fee is a second transaction from the facilitator wallet, sent within a second of the +settlement. On a load-balanced RPC that second send can reach a node that has not caught up: +fee pulls have reused the settlement's nonce (`replacement transaction underpriced`) and +simulated against a balance that did not yet include the payment. Two measures, both in +`src/facilitator/x402_fee.ts`: + +- Every transaction is signed by an account from `loadFacilitatorAccount()`, which carries + viem's shared `nonceManager`, so the fee pull never takes a nonce below the settlement's. + Build no signer for this key any other way. +- A fee send that the node refused before broadcast (underpriced, nonce too low, or a + reverted simulation) is sent again, up to `FEE_SEND_ATTEMPTS` times, 2 s apart + (`FEE_RETRY_DELAY_MS`). Nothing that may have been broadcast is ever resent; a receipt + timeout stays `fee_collection_pending`. + +If a settlement ever hangs as `settlement_pending` on a healthy RPC after a deploy, suspect a +dropped transaction the nonce manager has already counted: a restart of the isolate clears it. ## Architecture diff --git a/bunny_js/notebooks/facilitator/README.md b/bunny_js/notebooks/facilitator/README.md index 6b8ed72e2..56ea64fe9 100644 --- a/bunny_js/notebooks/facilitator/README.md +++ b/bunny_js/notebooks/facilitator/README.md @@ -3,8 +3,9 @@ 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)): +end-to-end over HTTP. The batch-settlement notebooks were the port's Task 5 probe matrix +(the plan itself is preserved in git history as +`x402_facilitator/bunny-port-implementation-plan.md`): | Notebook | Kernel | Tests what (short form) | | ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | diff --git a/observability/README.md b/observability/README.md index 02980e7ab..01d4b42eb 100644 --- a/observability/README.md +++ b/observability/README.md @@ -1,18 +1,17 @@ # observability -Project-wide log access and alerting for every Scaleway function in this repo: `facilitator`, +Project-wide log access and alerting for every Scaleway function in this repo: `walletreportcron`, `llmx402cron`, `genimgx402token`, `growthapi`, the comment service and analytics. It belongs to no single service. Cockpit is scoped to the Scaleway -_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`) 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. 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 +_project_, so one token and one set of rules cover all of them. (The llm/search/mcp sellers +and the facilitator 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`, `service=` "bunny-*-agent" + "facilitator") 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. The cutover is done: +`payments.yaml`'s `FacilitatorNeedsAttention` now watches **walletreportcron only** (the report's +send failures — its settlement/fee phrases moved with the facilitator to the level-label rule), +so 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 | diff --git a/observability/alerts/payments.yaml b/observability/alerts/payments.yaml index 1c1ac4218..d12a4cd45 100644 --- a/observability/alerts/payments.yaml +++ b/observability/alerts/payments.yaml @@ -76,45 +76,32 @@ rules: Investigate: cd observability && npx tsx scripts/logs.ts llmx402cron --since 24h --grep "still hold escrow|failed|Failed to configure|allowance" Stuck escrow: cd scw_js && npx tsx scripts/recover_channels.ts eip155:10 (dry run; --apply to recover) - # The facilitator and its weekly wallet-report cron. Branches, each its own rule (or part of one) - # until 2026-10-04: - # - # Settlement needs attention — `().*Settlement failed`: only the settle failures that are - # OUR problem; a caller's bad signature or empty allowance must not page. This used to be two - # filters (`|= "Settlement failed" |~ ""`), which cannot be ORed with the other - # branches, so it is one regex now. pino writes the merging object (errorReason) BEFORE msg, so - # the reason precedes "Settlement failed" on the line. `refund_[a-z_]*failed` matches the family - # (it once listed refund_simulation_failed alone and missed refund_transaction_failed); - # `_transaction_failed|_rpc_read_failed` covers both schemes' on-chain-execution reasons, which - # the SDK RETURNS rather than throws — our hot wallet out of gas surfaces exactly this way. - # withdraw_delay_mismatch stays out: a buyer can cause it, and the cron's "still hold escrow" - # catches the version that costs anyone money. - # Settlement pending — "Settlement broadcast but unconfirmed": broadcast, outcome unknown, a tx - # hash to reconcile. The old rule listed settlement_pending among its reasons, but this line's - # msg is not "Settlement failed", so its `|= "Settlement failed"` prefilter dropped it — that - # branch could never fire. Matched by its own msg now. - # Facilitator broken — "Settlement threw", "Unexpected error …", a missing wallet key/address - # (every settlement fails until fixed), or the wallet report unable to send ("Email report - # failed", "Email API returned"): a report that never sends looks identical, from the inbox, to - # a week with nothing to report. - # Fee collection failed — "Fee collection failed", "Fee transaction reverted": the settlement - # succeeded, our flat fee did not follow. Not retried (see collectAndReportFee); a run of these - # is revenue left on the table. (Was a warning; one rule carries one severity.) + # The facilitator's weekly wallet-report cron ONLY — the HTTP facilitator (settlement, fee + # collection, unhandled-error branches) moved to bunny_js: its `logger.error` lines flush to + # the custom bunny-logs source with a real `level` label, covered by BunnySellersNeedAttention + # (alerts/bunny.yaml, service="facilitator"). What stays here is the report's own failure + # surface, on the DEFAULT source the cron logs to: + # Email report failed / Email API returned — the report could not send. From the inbox, + # where the report's output lands, that is indistinguishable from a week with nothing to + # report, so it must page. + # Not configured — FACILITATOR_WALLET_PRIVATE_KEY unset: the report cannot even derive the + # wallet address, and every future run fails the same way until the secret returns. + # ("Failed to build wallet report for network" needs no phrase: that error is folded + # into the email body itself — x402_facilitator/test/alert_coverage.test.ts EXEMPT.) - alert: FacilitatorNeedsAttention expr: | - sum(count_over_time({resource_name=~".+(facilitator|walletreportcron)"} |~ "(refund_[a-z_]*failed|insufficient_fee_allowance|_transaction_failed|_rpc_read_failed).*Settlement failed|Settlement broadcast but unconfirmed|Settlement threw|Unexpected error in handler|Unexpected error during payment verification|FACILITATOR_WALLET_PRIVATE_KEY not configured|facilitator address not configured|Email report failed|Email API returned|Fee collection failed|Fee transaction reverted" [1h])) > 0 + sum(count_over_time({resource_name=~".+walletreportcron"} |~ "Email report failed|Email API returned|not configured" [1h])) > 0 for: 0m labels: severity: critical area: payments annotations: - summary: "The facilitator needs attention (settlement, unhandled error, wallet report, or fee collection)" + summary: "The facilitator wallet report needs attention (could not send, or wallet unset)" description: >- - "… Settlement failed" with a refund/transaction/RPC/fee-allowance reason: a settlement failed - for a reason that is ours; the decoded revert is in errorMessage. "Settlement broadcast but - unconfirmed": reconcile the logged tx hash on chain. "Settlement threw" / "Unexpected - error" / "not configured": an unclassified exception or a missing wallet key/address. - "Email report failed" / "Email API returned": the weekly wallet report did not send. "Fee - collection failed" / "Fee transaction reverted": settled, but our fee was not collected. - Investigate: cd observability && npx tsx scripts/logs.ts facilitator --since 2h --grep "Settlement|Unexpected error|not configured|Fee" - For the report cron: npx tsx scripts/logs.ts walletreportcron --since 8d + "Email report failed" / "Email API returned": the weekly wallet report did not send — + from the inbox that is indistinguishable from a quiet week, so the TEM config or email + endpoint must be fixed. "not configured": FACILITATOR_WALLET_PRIVATE_KEY is unset, so + the report cannot derive the wallet address; every run will fail until it is set. The + facilitator itself (verify/settle/fee) now logs to the custom bunny-logs source and is + alertable via BunnySellersNeedAttention (alerts/bunny.yaml). + Investigate: cd observability && npx tsx scripts/logs.ts walletreportcron --since 8d diff --git a/observability/test/alerts_bunny.test.ts b/observability/test/alerts_bunny.test.ts new file mode 100644 index 000000000..a53864295 --- /dev/null +++ b/observability/test/alerts_bunny.test.ts @@ -0,0 +1,83 @@ +/** + * Phrase-behavior tests for alerts/bunny.yaml — the sole rule on the custom bunny-logs data + * source. Same idea as alerts_payments.test.ts: the rule is pushed as raw text and never + * exercised locally, so nothing else notices a filter that stopped matching the line it was + * written for. + * + * Two sub-filters live in the rule's expr, and each gets its own assertions: + * 1. the facilitator's phrase filter — the settlement/fee/unhandled-error branches that moved + * from payments.yaml when the facilitator moved to bunny_js. Its lines arrive as the flush's + * JSON payload (`{"message":…}` — the flush maps pino's msg onto `message`), reason fields + * AFTER the message. + * 2. the twins' level filter — a real label match, so the "lines" here are whole streams. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const yaml = readFileSync(fileURLToPath(new URL("../alerts/bunny.yaml", import.meta.url)), "utf8"); + +/** Every `|~ "..."` line filter in the rule, as usable RegExps, in file order. */ +const filters = [...yaml.matchAll(/\|~ "([^"]+)"/g)].map((m) => new RegExp(m[1])); +const facilitatorFilter = filters.find((f) => f.source.startsWith("Settlement failed")); +if (!facilitatorFilter) { + throw new Error( + 'No facilitator phrase filter found in bunny.yaml (expected |~ "Settlement failed…")', + ); +} + +/** A facilitator line as the flush writes it into the custom source: message first, then fields. */ +const facilitatorLine = (msg: string, fields: Record = {}) => + `{"message":"${msg}"${Object.entries(fields) + .map(([k, v]) => `,"${k}":"${v}"`) + .join("")}}`; + +describe("alerts/bunny.yaml — BunnySellersNeedAttention", () => { + describe("the facilitator's phrase filter", () => { + it.each([ + { msg: "Settlement failed", errorReason: "refund_simulation_failed" }, + { msg: "Settlement failed", errorReason: "refund_transaction_failed" }, + { msg: "Settlement failed", errorReason: "insufficient_fee_allowance" }, + { msg: "Settlement failed", errorReason: "invalid_exact_evm_transaction_failed" }, + { msg: "Settlement failed", errorReason: "invalid_batch_settlement_evm_rpc_read_failed" }, + ])("pages on $msg with $errorReason", ({ msg, errorReason }) => { + expect(facilitatorFilter.test(facilitatorLine(msg, { errorReason }))).toBe(true); + }); + + it("pages on a settlement broadcast but unconfirmed", () => { + expect( + facilitatorFilter.test( + facilitatorLine("Settlement broadcast but unconfirmed — reconcile on chain"), + ), + ).toBe(true); + }); + + it.each([ + "Settlement threw", + "Unexpected error in handler", + "Unexpected error during payment verification", + "Fee collection failed after successful settlement", + "Fee transaction reverted", + ])("pages on %s", (msg) => { + expect(facilitatorFilter.test(facilitatorLine(msg))).toBe(true); + }); + + /** The settlement branch exists to be narrower than "Settlement failed": a caller's bad + * signature or empty allowance is their problem, and paging on it is how people stop reading + * alerts. Merging it with the other branches must not have widened it. */ + it.each([ + "invalid_signature", + "insufficient_funds", + "authorization_already_used", + "invalid_permit2_recipient_mismatch", + ])("stays quiet on the caller error %s", (errorReason) => { + expect(facilitatorFilter.test(facilitatorLine("Settlement failed", { errorReason }))).toBe( + false, + ); + }); + + it("stays quiet on a facilitator success line", () => { + expect(facilitatorFilter.test(facilitatorLine("Settlement successful"))).toBe(false); + }); + }); +}); diff --git a/observability/test/alerts_payments.test.ts b/observability/test/alerts_payments.test.ts index 17f7cfff2..f79c8bd03 100644 --- a/observability/test/alerts_payments.test.ts +++ b/observability/test/alerts_payments.test.ts @@ -2,8 +2,10 @@ * The alert rules are pushed as raw text by `scripts/alerts.ts` and never exercised locally, so * nothing else would notice a rule that stopped matching the line it was written for. These checks * are the ones a human cannot do by eye: whether a filter still matches real log text — and, since - * the rules were consolidated into one long alternation each, whether it still stays quiet on the - * caller errors it exists to ignore. The window and the rule count are in alerts_budget.test.ts. + * the rules are consolidated into one long alternation each, whether it still stays quiet on the + * lines it exists to ignore. The window and the rule count are in alerts_budget.test.ts; the + * facilitator's own settlement/fee phrases moved with it to alerts/bunny.yaml and are tested in + * alerts_bunny.test.ts. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -24,55 +26,30 @@ function lineFilterOf(alertName: string): RegExp { return new RegExp(match[1]); } -/** A facilitator settle result as pino writes it: the merging object first, `msg` last. */ -const settleLine = (reason: string, msg = "Settlement failed") => - `{"level":40,"errorReason":"${reason}","errorMessage":"…","msg":"${msg}"}`; - describe("alerts/payments.yaml", () => { - describe("FacilitatorNeedsAttention", () => { + describe("FacilitatorNeedsAttention — walletreportcron only", () => { const filter = lineFilterOf("FacilitatorNeedsAttention"); it.each([ - "refund_simulation_failed", - "refund_transaction_failed", - "insufficient_fee_allowance", - "invalid_exact_evm_transaction_failed", - "invalid_batch_settlement_evm_rpc_read_failed", - ])("pages on a settlement failed with %s", (reason) => { - expect(filter.test(settleLine(reason))).toBe(true); - }); - - /** Regression: the old rule listed settlement_pending as a reason, but this line's msg is not - * "Settlement failed", so its `|= "Settlement failed"` prefilter dropped it — never fired. */ - it("pages on a settlement broadcast but unconfirmed", () => { - const line = settleLine( - "settlement_pending", - "Settlement broadcast but unconfirmed — reconcile on chain", - ); - expect(filter.test(line)).toBe(true); - }); - - it.each([ - "Settlement threw", - "Unexpected error in handler", - "FACILITATOR_WALLET_PRIVATE_KEY not configured", "Email report failed", - "Fee collection failed after successful settlement", - "Fee transaction reverted", + "Email API returned 500: boom", + "Cannot send wallet report: FACILITATOR_WALLET_PRIVATE_KEY not configured", ])("pages on %s", (msg) => { expect(filter.test(`{"level":50,"msg":"${msg}"}`)).toBe(true); }); - /** The settlement branch exists to be narrower than "Settlement failed": a caller's bad - * signature or empty allowance is their problem, and paging on it is how people stop reading - * alerts. Merging it with the other branches must not have widened it. */ it.each([ - "invalid_signature", - "insufficient_funds", - "authorization_already_used", - "invalid_permit2_recipient_mismatch", - ])("stays quiet on the caller error %s", (reason) => { - expect(filter.test(settleLine(reason))).toBe(false); + // The facilitator's own failures live in the bunny-logs source now — this rule must not + // match them where they land, or a leftover phrase would widen the cron rule silently. + "Settlement failed", + "Fee collection failed", + "Settlement broadcast but unconfirmed", + ])("stays quiet on the facilitator line %s (bunny.yaml's job)", (msg) => { + expect(filter.test(`{"msg":"${msg}"}`)).toBe(false); + }); + + it("stays quiet on a routine report send", () => { + expect(filter.test(`{"msg":"Facilitator weekly report sent"}`)).toBe(false); }); }); diff --git a/shared/chain-utils/src/abi/EIP3009SplitterV1.ts b/shared/chain-utils/src/abi/EIP3009SplitterV1.ts index ea19a4bb9..fe3869568 100644 --- a/shared/chain-utils/src/abi/EIP3009SplitterV1.ts +++ b/shared/chain-utils/src/abi/EIP3009SplitterV1.ts @@ -4,9 +4,9 @@ * Contains only the functions used by x402_facilitator. * Full ABI available in: eth/abi/contracts/EIP3009SplitterV1.ts * - * Used by: - * - x402_facilitator/x402_splitter_settle.js: executeSplit - * - x402_facilitator/x402_splitter_verify.js: isAuthorizationUsed, facilitatorWallet, fixedFee + * Used by: nothing in-tree anymore — the retired buyer-pays splitter's JS lived in + * x402_facilitator/ (git history, removed with the facilitator's move to bunny_js). The ABI + * stays for decoding the (still on-chain, still readable) splitter interactions of past txs. */ export const EIP3009SplitterV1ABI = [ diff --git a/shared/chain-utils/src/abi/GenImNFTv4.ts b/shared/chain-utils/src/abi/GenImNFTv4.ts index ec7a1bb46..15f993155 100644 --- a/shared/chain-utils/src/abi/GenImNFTv4.ts +++ b/shared/chain-utils/src/abi/GenImNFTv4.ts @@ -6,9 +6,9 @@ * * Used by: * - scw_js/genimg_bfl.js: ownerOf, requestImageUpdate, mintPrice, isImageUpdated - * - scw_js/genimg_x402_token.js: safeMint, safeTransferFrom, mintPrice + * - scw_js/genimg_x402_token.ts: safeMint, safeTransferFrom, mintPrice * - scw_js/readhandler_v2.js: ownerOf, requestImageUpdate, mintPrice, isImageUpdated - * - x402_facilitator/x402_whitelist.js: isAuthorizedAgent + * - eth/test/GenImNFTv4_Functional.ts: isAuthorizedAgent (agent-wallet gating on-chain) * - website/components/PublicNFTList.tsx: getAllPublicTokens * - website/components/MyNFTList.tsx: balanceOf, tokenOfOwnerByIndex * - website/components/NFTCard.tsx: setTokenListed, isTokenListed, burn diff --git a/shared/chain-utils/src/eip3009.ts b/shared/chain-utils/src/eip3009.ts index b8f444e9c..acf0aaa44 100644 --- a/shared/chain-utils/src/eip3009.ts +++ b/shared/chain-utils/src/eip3009.ts @@ -4,8 +4,8 @@ * Canonical shape verified against: * - @x402/evm SDK: src/exact/client/eip3009.ts (signEIP3009Authorization — domain/types/message) * and src/exact/facilitator/eip3009-utils.ts (parsedSig.v ?? parsedSig.yParity — v handling) - * - x402_facilitator/x402_splitter_verify.js (server-side verifyTypedData call, same repo) - * - x402_facilitator/test/eip712_reference.test.js (EIP-712 hash vectors) + * - bunny_js/src/facilitator/x402_verify.ts (server-side verifyTypedData call, same repo) + * - x402_facilitator/test/eip712_reference.test.ts (EIP-712 hash vectors) * * Used by any caller that signs or verifies a `transferWithAuthorization` payload directly * (USDC, EURC, or any other EIP-3009 token) — e.g. SupportV2.donateToken, EIP3009SplitterV1. diff --git a/website/README.md b/website/README.md index 1f8d58276..2045d0a4a 100644 --- a/website/README.md +++ b/website/README.md @@ -5,8 +5,8 @@ The public site at [fretchen.eu](https://www.fretchen.eu). Two halves that share AI tools that settle on Optimism and Base. No accounts, no subscriptions: you connect a wallet and pay cents per image or per message. -Static-rendered and deployed to GitHub Pages; the paid endpoints live in `scw_js/` and -`x402_facilitator/`. +Static-rendered and deployed to GitHub Pages; the paid endpoints live in `bunny_js/` (the +x402 sellers + facilitator) and `scw_js/` / `x402_facilitator/` (the remaining crons). ## Stack diff --git a/x402-issue-corrective-402.md b/x402-issue-corrective-402.md deleted file mode 100644 index 881d584bc..000000000 --- a/x402-issue-corrective-402.md +++ /dev/null @@ -1,55 +0,0 @@ -# Corrective 402 drops `assetTransferMethod` and the challenge `extensions`, making Permit2-only tokens unpayable after a voucher-base mismatch - -> Upstream issue draft for `x402-foundation/x402` (packages `@x402/evm` / `@x402/core`). -> Verified against 2.28.0 on 2026-10-12. - -## Summary - -When a batch-settlement payment fails the voucher-base check -(`invalid_batch_settlement_evm_cumulative_amount_mismatch`), the corrective 402 that -`handleEnrichPaymentRequiredResponse` builds loses two fields the client needs: - -1. `accepts[].extra.assetTransferMethod` — the enrichment spreads the **facilitator-derived** - requirements, not the seller's original accept, so a Permit2-only token's - `assetTransferMethod: "permit2"` is gone. -2. The top-level `extensions` map — the corrective response carries no - `eip2612GasSponsoring`, so the client cannot sign the gas-sponsoring permit on retry. - -The client retry then falls back to the defaults in -`BatchSettlementEvmScheme.createPaymentPayload`: - -- `extra.assetTransferMethod ?? "eip3009"` → it signs an **EIP-3009 deposit for a token that - has no EIP-3009** (Monerium EURe), -- `trySignEip2612PermitExtension` returns `undefined` (`context.extensions` empty) → no permit, -- the facilitator's deposit simulation reverts → `invalid_batch_settlement_evm_deposit_simulation_failed`. - -## Reproduction - -Environment: `@x402/evm`, `@x402/core`, `@x402/mcp` 2.28.0. Seller offers Monerium EURe -(Base Sepolia, Permit2-only) with `extra: { name, version, assetTransferMethod: "permit2" }` -and `extensions: { eip2612GasSponsoring: {} }` in the 402. - -Trigger: facilitator channel snapshot `chargedCumulativeAmount` diverges from on-chain -`totalClaimed` (e.g. a client that cleared local storage recovers its voucher base from the -chain). Then, with payment capture on the client: - -- **Attempt 1** — permit2 deposit (top-up) + valid EIP-2612 permit extension - (`eip2612GasSponsoring`), correct nonce read from `nonces(owner)`. This payment is fine; - it is aborted by the voucher-base check before deposit verification runs. -- **Corrective 402** — `accept.extra` contains `name`, `version`, `receiverAuthorizer`, - `withdrawDelay`, `minDeposit`, `channelState`, `voucherState` — but **no - `assetTransferMethod`** — and the response has **no `extensions` map**. -- **Attempt 2** — client sends `deposit.authorization.erc3009Authorization` (EIP-3009!) for - EURe, no extensions. Facilitator returns `invalid_batch_settlement_evm_deposit_simulation_failed`. - -An offline `eth_call` of `BatchSettlement.deposit(...)` with the attempt-1 payload (Permit2 -witness + embedded EIP-2612 permit) succeeds, so the client's first-attempt construction is -correct — only the corrective round-trip degrades it. - -## Suggested fix - -When enriching the corrective response, derive the accept from the original challenge accept -matching `(scheme, network, asset)` (or at minimum carry over `assetTransferMethod`), and -re-emit the original challenge's `extensions` map on the corrective 402. Without this, any -Permit2-only token becomes unpayable for every client that enters the corrective path, while -the underlying state divergence is often transient and otherwise recoverable. diff --git a/x402_facilitator/AGENTS.md b/x402_facilitator/AGENTS.md index 0ba35e2c3..b0c8635b2 100644 --- a/x402_facilitator/AGENTS.md +++ b/x402_facilitator/AGENTS.md @@ -1,14 +1,27 @@ # x402_facilitator — agent rules -EIP-3009 USDC payment facilitator (Scaleway Functions, path-based routing: `/verify`, -`/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. +Weekly facilitator wallet report (`walletreportcron`, Scaleway Functions, Mondays 08:00 UTC). +See [`README.md`](./README.md) for what it reports, the TEM/secret setup, and the commands. +The x402 payment facilitator itself (verify/settle/supported) lives in +[`bunny_js/src/facilitator/`](../bunny_js/src/facilitator/) and serves +`facilitator.fretchen.eu` — its docs moved with it +([`bunny_js/README.md`](../bunny_js/README.md) → _Facilitator_ sections); this package's old +HTTP code and endpoint docs are retired (git history — including the move's plan, +`bunny-port-implementation-plan.md`). + +Two ways to break this package silently, both with a named guard: + +1. **A `logger.error` line with no alert phrase** — `logger.error` means "ours, and should + page", and the pages come from `FacilitatorNeedsAttention` in + `observability/alerts/payments.yaml`. `test/alert_coverage.test.ts` fails if a call site + is added without a matching phrase or an EXEMPT entry with a reason. Do not use bare + `console.*`. +2. **Untyped JS creeping back** — `test/no_stray_js.test.ts` fails on any `.js` outside the + two config files. + +When a change touches the fee model in `wallet_report_fee.ts`, port it to +`bunny_js/src/facilitator/x402_fee.ts` in the same sitting — that copy charges real fees; +this one only reports on them. ## Serverless conventions @@ -17,14 +30,5 @@ Console only. All responses include CORS headers. ## x402 role -This package sits between the scw*js **buyers** and the sellers — aside from the fee pull -right after settlement it never moves funds. Use the **`x402`** skill before anything that -names a role; the shared payment traps (EIP-712 domain names, the OpenAPI strictness rule) -are in the root [`AGENTS.md`](../AGENTS.md) → \_x402 payments*. - -## Recipient whitelist (OR logic) - -Recipient gating is **OR logic** across several sources, not a single list. The composition -and the reasons are in [`README.md`](./README.md) → _Whitelist Architecture_ — read it before -touching recipient gating, and keep the batch-settlement test-wallet bypass honored on -testnets only. +This package is payment-**adjacent** only: it reads balances and emails a report; it never +moves funds. Use the **`x402`** skill before anything that names a role. diff --git a/x402_facilitator/README.md b/x402_facilitator/README.md index c7ba914b4..dd53fe4b5 100644 --- a/x402_facilitator/README.md +++ b/x402_facilitator/README.md @@ -1,631 +1,68 @@ -# x402 Facilitator - -A production-ready x402 v2 Facilitator for Optimism, enabling EIP-3009 USDC payment verification and settlement via Scaleway Functions. - -**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: -> -> ```bash -> cd ../observability && npx tsx scripts/logs.ts facilitator --since 24h --grep "Settlement failed" -> ``` -> -> One Cockpit token covers the whole project, so the script reads this function from there. The -> `errorMessage` field on a failed settle carries the decoded EVM revert — deliberately logged -> only, never returned over HTTP. -> -> **Alerting.** `logger.error` in this package means "ours, and should page" (a caller's fault is -> `logger.warn`); `observability/alerts/payments.yaml` emails on the resulting phrases, and -> `test/alert_coverage.test.ts` fails CI if a new `logger.error` call has no matching rule. See -> [`observability/README.md`](../observability/README.md). - -## Overview - -The x402 Facilitator bridges the gap between Resource Servers and blockchain payments. It provides three core functions: - -1. **Verify** - Validates EIP-3009 and Permit2 payment authorizations off-chain -2. **Settle** - Executes verified payments on-chain (Optimism / Base L2) -3. **Supported** - Advertises accepted networks, assets, and payment schemes - -### Payment Schemes - -The facilitator supports two x402 schemes on the same `/verify` and `/settle` endpoints (it routes by the payload's `scheme` field): - -- **`exact`** — one transfer per request, moved wallet-to-wallet. USDC (any network) and EURC (Base) settle by EIP-3009 `transferWithAuthorization`; EURe (Base), which has no EIP-3009, settles through the x402 Permit2 proxy (see _EURe via Permit2_ below). Supported on all networks. A flat facilitator fee may be collected post-settlement. -- **`batch-settlement`** — payment channels: the payer escrows USDC once, signs an off-chain cumulative voucher per request, and the receiver claims many requests in one on-chain transaction. Only advertised on networks where the canonical batch-settlement contract is deployed (see `getBatchSettlementNetworks()` — Optimism/Base mainnet + Base Sepolia). Optimism Sepolia is **not** on that list because the contract isn't deployed there — but `exact` still works on it; the restriction is specific to `batch-settlement`. See [`notebooks/x402_batch_settlement.ipynb`](./notebooks/x402_batch_settlement.ipynb) for an end-to-end walkthrough. `deposit`/`voucher` payloads and claim-less `refund`s are open and fee-free — they fund, sign, or unwind a channel rather than realize a payment. Whatever pays out charges the same flat fee `exact` does, gated by the same USDC allowance check (see `x402_fee.ts`); an under-approved recipient gets `insufficient_fee_allowance` on `/settle`. That means `claim`, `settle`, **and** a `refund` carrying a non-empty `claims[]` — the SDK settles the latter as `multicall([claimWithSignature, refundWithSignature])`, so it performs the identical payout a `claim` does. The fee follows the claim, not the payload type: gating on `payload.type` alone let a relabelled claim skip the gate entirely. - -### Key Features - -- ✅ EIP-3009 `transferWithAuthorization` for USDC and EURC payments (`exact` scheme) -- ✅ Permit2 for EURe payments on Base (`exact` scheme), with or without an EIP-2612 permit (`eip2612GasSponsoring`) -- ✅ `batch-settlement` payment channels (`claim`/`settle` fee-gated like `exact`; `deposit`/`voucher`/`refund` free; deploy-gated per network) -- ✅ Optimism + Base, Mainnet and testnet support -- ✅ Flat facilitator fee, gated by a USDC allowance check on the recipient (`exact`, and `batch-settlement` `claim`/`settle`) -- ✅ Single Scaleway Function with path-based routing -- ✅ Custom domain with TLS termination -- ✅ CORS-enabled for browser-based applications - -## Architecture - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Client/Agent │ ◄────► │ Resource Server │ ◄────► │ Facilitator │ -│ │ │ │ │ (Scaleway Func) │ -└─────────────────┘ └──────────────────┘ └────────┬────────┘ - │ - ▼ - ┌─────────────────┐ - │ Optimism L2 │ - │ (EIP-3009 USDC) │ - └─────────────────┘ -``` - -### Recipient gating - -There is no whitelist. Both schemes gate recipients the same way: an allowance the -recipient has `approve()`d for the facilitator's wallet, **in the token the payment settles -in** — USDC on any network, EURC and EURe on Base. The fee is charged in that same token (0.01 -USDC, 0.01 EURC or 0.01 EURe), so a seller paid in several approves each. EURe has 18 decimals, -so its 0.01 is `10000000000000000` atomic units, not the `10000` of USDC/EURC — and an EURe -approval of `1000000` covers no fee at all. Any other token is refused with -`unsupported_fee_asset`. For `exact` the token is `requirements.asset`; for batch-settlement -it is the channel's token (`payload.token` for `settle`, the claims' channel token for -`claim`, `channelConfig.token` for a claim-carrying refund), and a batch mixing tokens is -refused. -`checkMerchantAllowance()` (`x402_fee.ts`) reads that allowance; an under-approved -recipient is rejected with `insufficient_fee_allowance` rather than settled for free. -See _Fee model history_ below for how batch-settlement got here — it started -whitelist-gated and fee-free, before Phase 3 moved `claim`/`settle` onto this same -allowance check. `BATCH_SETTLEMENT_TEST_WALLETS` (`x402_whitelist.ts`) is the one -remaining carve-out: a listed address skips the allowance check, but only on testnets. - -### Fee pull right after the settlement - -The fee is a second transaction from the facilitator wallet, sent within a second of the -settlement. On a load-balanced RPC that second send can reach a node that has not caught up: -fee pulls have reused the settlement's nonce (`replacement transaction underpriced`) and -simulated against a balance that did not yet include the payment. Two measures, both in -`x402_fee.ts`: - -- Every transaction is signed by an account from `loadFacilitatorAccount()`, which carries - viem's shared `nonceManager`, so the fee pull never takes a nonce below the settlement's. Build - no signer for this key any other way. -- A fee send that the node refused before broadcast (underpriced, nonce too low, or a reverted - simulation) is sent again, up to `FEE_SEND_ATTEMPTS` times, 2 s apart - (`FEE_RETRY_DELAY_MS`). Nothing that may have been broadcast is ever resent; a receipt timeout - stays `fee_collection_pending`. - -If a settlement ever hangs as `settlement_pending` on a healthy RPC after a deploy, suspect a -dropped transaction the nonce manager has already counted: a restart of the instance clears it. - -## Quick Start - -### Installation - -```bash -npm install -``` - -### Environment Variables - -Create a `.env` file: - -```bash -# Scaleway credentials (for deployment) -SCW_ACCESS_KEY=your_scaleway_access_key -SCW_SECRET_KEY=your_scaleway_secret_key -SCW_DEFAULT_ORGANIZATION_ID=your_org_id -SCW_DEFAULT_PROJECT_ID=your_project_id - -# batch-settlement test-wallet bypass — lets a testnet dev key skip the USDC allowance -# check on any paying payload (claim, settle, or a claim-carrying refund). Only honored -# on testnets (Optimism Sepolia, Base Sepolia), so a testnet entry can never bypass real -# funds. deposit/voucher/claim-less refunds need no env var — they're always fee-free. -BATCH_SETTLEMENT_TEST_WALLETS=0x1234...,0x5678... - -# RPC endpoints (optional - have defaults) -OPTIMISM_RPC_URL=https://mainnet.optimism.io -OPTIMISM_SEPOLIA_RPC_URL=https://sepolia.optimism.io -``` - -### Scaleway Secrets - -Set these in Scaleway Console (Functions → Secrets): - -```bash -FACILITATOR_WALLET_PRIVATE_KEY=0x... # Required for settlement -``` - -### Local Testing - -```bash -npm run dev - -# Test endpoints -curl http://localhost:8080/supported -curl -X POST http://localhost:8080/verify -H "Content-Type: application/json" -d @test-payload.json -``` - -## API Endpoints - -### GET /supported - -Returns supported networks and schemes, the advertised extension keys, and — when a -fee is configured — the fee disclosure. - -`extensions` is a list of extension **key strings** (per the x402 `SupportedResponse` -type). The machine-readable fee detail, including the facilitator address that collects -the fee, is carried in the top-level `facilitatorFees` object (x402 Fee Disclosure -proposal, coinbase/x402#1016). Both the keys and `facilitatorFees` are omitted when the -facilitator runs without a fee (no `FACILITATOR_WALLET_PRIVATE_KEY`, or fee amount 0). - -**Response:** - -```json -{ - "kinds": [ - { "x402Version": 2, "scheme": "exact", "network": "eip155:10" }, - { "x402Version": 2, "scheme": "batch-settlement", "network": "eip155:10" } - ], - "extensions": ["eip2612GasSponsoring", "facilitator_fee", "facilitatorFees"], - "signers": { - "eip155:*": ["0xFacilitatorAddress..."] - }, - "facilitatorFees": { - "version": "1", - "model": "flat", - "asset": "settled", - "flatFee": "10000", - "decimals": 6, - "recipient": "0xFacilitatorAddress...", - "networks": ["eip155:10", "eip155:8453", "eip155:11155420", "eip155:84532"], - "fee": { - "amount": "10000", - "description": "0.01 of the settled token (USDC; EURC and EURe on Base) per settlement", - "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 contract (Base) for the facilitator's address. 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": "0xFacilitatorAddress...", - "recommended_amount": "1000000" - }, - "assets": [ - { - "network": "eip155:8453", - "asset": "0x8335…2913", - "symbol": "USDC", - "decimals": 6, - "flatFee": "10000", - "recommended_amount": "1000000" - }, - { - "network": "eip155:8453", - "asset": "0x60a3…db42", - "symbol": "EURC", - "decimals": 6, - "flatFee": "10000", - "recommended_amount": "1000000" - }, - { - "network": "eip155:8453", - "asset": "0xbf6e…762C", - "symbol": "EURe", - "decimals": 18, - "flatFee": "10000000000000000", - "recommended_amount": "1000000000000000000" - } - ] - } -} -``` - -`flatFee`/`decimals`/`recommended_amount` at the top level state the nominal 0.01 in 6-decimal -units. `assets` lists every fee token per network (abridged above) with its exact atomic fee and -recommended approval — read it, not the top level, to approve EURe. `eip2612GasSponsoring` is -always advertised: it tells a resource server that this facilitator settles an EIP-2612 permit -carried in the payment, so it can offer the extension in its 402 and EURe buyers need no -separate `approve()`. - -### POST /verify - -Validates payment authorization off-chain. - -> **Scheme support:** the `exact` scheme accepts both payload variants: **EIP-3009** (an -> `authorization` object, as shown below — USDC, EURC) and **Permit2** (a -> `permit2Authorization` object — EURe; see _EURe via Permit2_). The `batch-settlement` scheme -> is supported on the networks listed by `getBatchSettlementNetworks()`. - -**Request:** - -```json -{ - "paymentPayload": { - "x402Version": 2, - "resource": { - "url": "https://api.example.com/data", - "description": "Premium data", - "mimeType": "application/json" - }, - "accepted": { - "scheme": "exact", - "network": "eip155:10", - "amount": "10000", - "asset": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - "payTo": "0x...", - "maxTimeoutSeconds": 60, - "extra": { - "name": "USDC", - "version": "2" - } - }, - "payload": { - "signature": "0x...", - "authorization": { - "from": "0x...", - "to": "0x...", - "value": "10000", - "validAfter": "1740672089", - "validBefore": "1740672154", - "nonce": "0x..." - } - } - }, - "paymentRequirements": { - "scheme": "exact", - "network": "eip155:10", - "amount": "10000", - "asset": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - "payTo": "0x...", - "maxTimeoutSeconds": 60 - } -} -``` - -**Success Response:** - -```json -{ - "isValid": true, - "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66" -} -``` - -**Error Response:** - -```json -{ - "isValid": false, - "invalidReason": "insufficient_funds", - "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66" -} -``` - -### POST /settle - -Executes verified payment on-chain. - -**Request:** Same as `/verify` - -**Response:** - -```json -{ - "transactionHash": "0x...", - "success": true -} -``` - -## Verification Checks - -The `/verify` endpoint validates: - -1. ✅ **Protocol Version** - Must be x402 v2 -2. ✅ **Scheme Support** - Must be `exact` or `batch-settlement` -3. ✅ **Network Support** - Must be a supported Optimism/Base network -4. ✅ **EIP-712 Signature** - Valid signature from payer -5. ✅ **Time Window** - validAfter ≤ now < validBefore -6. ✅ **Amount Match** - Authorization value ≥ required amount -7. ✅ **Recipient Match** - Authorization.to === paymentRequirements.payTo -8. ✅ **Nonce Check** - Nonce not already used on-chain -9. ✅ **Balance Check** - Payer has sufficient USDC balance -10. ✅ **Fee Allowance** (`exact` only, checked here) - recipient's USDC allowance for the facilitator must cover the flat fee, or verification fails with `insufficient_fee_allowance` - -> Note: `/verify` applies to `exact` payments and to `batch-settlement` deposit/voucher payloads. `batch-settlement` **claim** and **settle** payloads are settlement _commands_ (not future payments) and are handled directly by `/settle` without a verify step — the scheme validates them internally, including the equivalent fee-allowance check (see _Recipient gating_ above). - -## Error Codes - -Error-reason strings come from the `@x402/evm` SDK and are prefixed by scheme (`invalid_exact_evm_*` / `invalid_batch_settlement_evm_*`). Common ones: - -| Error Code | Description | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `invalid_exact_evm_insufficient_balance` | Payer doesn't have enough USDC (`exact`) | -| `invalid_exact_evm_signature` | Invalid EIP-712 signature | -| `invalid_exact_evm_network_mismatch` | Signed vs. settle network mismatch | -| `invalid_exact_evm_scheme` | Scheme not supported | -| `invalid_batch_settlement_evm_insufficient_balance` | Channel balance too low for the voucher | -| `invalid_batch_settlement_evm_payload_type` | Payload type not verifiable via `/verify`, or (on `/settle`) a paying payload missing a usable receiver, naming more than one, or carrying a non-array `claims` | -| `invalid_batch_settlement_evm_receiver_mismatch` | A `refund`'s `claims[]` pay out to a receiver other than its own verified channel | -| `invalid_batch_settlement_evm_token_mismatch` | A `refund`'s `claims[]` are in a token other than its own verified channel's | -| `insufficient_fee_allowance` | Recipient's allowance (in the settled token) for the facilitator is too low — `exact`, or any `batch-settlement` payload that executes a claim | -| `insufficient_merchant_balance` | The settlement succeeded, but the fee pull failed: the recipient's own **balance** of the settled token is below one fee (their allowance is fine). The fee is lost — it is not retried — and the recipient should hold a float of every token they are paid in (see the balance check in `scw_js`'s `fee-allowances` predeploy) | -| `unsupported_fee_asset` | The settled token is neither USDC nor (on Base) EURC or EURe, so no fee can be charged in it | -| `invalid_permit2_recipient_mismatch` | A Permit2 payment's `witness.to` is not `payTo` (`exact`, EURe) | -| `permit2_allowance_required` | Permit2 may not spend the payer's EURe: no prior `approve()` and no valid EIP-2612 permit in the payment | -| `permit2_insufficient_balance` | Payer doesn't have enough of the token (`exact`, Permit2) | -| `invalid_network` | Network not supported | -| `invalid_payload` | Malformed payload | -| `unexpected_verify_error` | Unexpected error | - -> These strings were renamed by `@x402/evm` in the 2.x line; the tests in `test/` are the source of truth for the current values. - -## Security Considerations - -### Trust Model - -The x402 protocol requires **explicit trust** in the Facilitator. Both the Payer (client) and the Resource Server must trust the Facilitator to act honestly. - -``` -┌─────────────────────────────────────────────────────────┐ -│ TRUST MODEL │ -│ │ -│ Payer ──────────── Facilitator ────────── Resource │ -│ trusts │ Server trusts │ -│ │ │ -│ ▼ │ -│ MUST BE TRUSTWORTHY │ -└─────────────────────────────────────────────────────────┘ -``` - -### What the Facilitator Controls - -| Control | Risk | -| ----------------------------- | ------------------------------ | -| EIP-3009 signature from Payer | Can trigger settlement | -| Verification result | Can lie ("invalid" when valid) | -| Settlement execution | Can delay or omit | -| Response to Resource Server | Can report false status | - -### What EIP-3009 Protects (Even with Malicious Facilitator) - -The cryptographic signature **binds** these fields: - -- **`to` (recipient)** → Facilitator **cannot** redirect funds to another address -- **`value` (amount)** → Facilitator **cannot** take more than signed -- **`validBefore`** → Signature expires automatically - -**Key insight:** A malicious Facilitator cannot **steal** funds – only send them to the designated `payTo` address. - -### Potential Attack Vectors - -1. **Settlement without Service (Fraud)** - - Facilitator executes settlement but reports failure to Resource Server - - Payer loses money, receives no service - -2. **Denial of Service** - - Facilitator accepts signature but never settles - - Signature expires (`validBefore` timeout) - -3. **Cross-Chain Replay** (Fixed in this implementation) - - Signature created for one chain, settled on another - - Mitigated by chain-bound viem clients (one `ExactEvmScheme` per network) - -### Recommendations - -1. **Use only known Facilitators** (e.g., verified x402 implementations) -2. **Implement Facilitator whitelist** in client applications -3. **Verify settlement on-chain** (don't trust Facilitator response alone) -4. **Start with small amounts** when testing new services -5. **Monitor for network mismatches** between signed and settled transactions - -### Multi-Chain Security - -This Facilitator creates **separate signers per network** to prevent cross-chain attacks: - -```javascript -// Each network gets its own chain-bound signer -for (const network of getSupportedNetworks()) { - const scheme = createSignerForNetwork(account, network); - facilitator.register(network, scheme); -} -``` - -This ensures that signatures for Mainnet (chainId 10) are validated with a Mainnet client, preventing accidental or malicious settlement on the wrong chain. - -## Fee model history - -Two fee models were implemented here. Neither is friction-free without protocol support; the choice is about _where_ the friction lands. - -- **Buyer-pays split** (`x402_splitter_*.js`, `EIP3009SplitterV1`) — retired, retained but not deployed or built. The buyer signs a single EIP-3009 authorization to a splitter contract, which atomically pays seller and facilitator. The seller needs no setup, but the buyer needs a non-stock client, and `payTo` shows the splitter rather than the actual recipient — so the buyer cannot see who they are paying from the payment requirements alone. The splitter (and its own tests, `test/x402_splitter_*.test.js`) is also the only intentional JS left in this package — every other module and its live-code tests are TypeScript under `strict: true`; `test/no_stray_js.test.ts` fails if a new `.js` file shows up outside this exception plus the root `*.config.js` files. -- **Merchant-pays post-settlement** (`x402_fee.ts`) — current. The buyer is entirely untouched and stock `@x402/fetch` works. The seller must `approve()` the facilitator wallet for USDC and trust it not to over-pull. - -The current model matches the industry norm — Stripe and Coinbase CDP bill the merchant, not the payer. It does not eliminate onboarding friction; it moves it from the buyer to the seller. - -The splitter contract at `0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946` (Optimism Sepolia) stays deployed: it is referenced from [x402#937](https://github.com/x402-foundation/x402/issues/937). - -## Deployment - -### Deploy to Scaleway - -```bash -# Deploy -npm run deploy - -# View info -npm run info - -# Check logs -npm run logs:verify -npm run logs:settle -npm run logs:supported - -# Remove deployment -npm run remove -``` - -### Custom Domain Setup - -The facilitator uses a single Scaleway Function with path-based routing: - -**Function URL:** `x402facilitatorjccmtmdr-facilitator.functions.fnc.fr-par.scw.cloud` - -**Custom Domain:** `facilitator.fretchen.eu` - -1. **Add DNS CNAME:** - - ``` - facilitator.fretchen.eu → x402facilitatorjccmtmdr-facilitator.functions.fnc.fr-par.scw.cloud - ``` - -2. **Wait for DNS propagation** (5-60 minutes) - -3. **Endpoints:** - - `https://facilitator.fretchen.eu/verify` - - `https://facilitator.fretchen.eu/settle` - - `https://facilitator.fretchen.eu/supported` - -TLS termination is handled automatically by Scaleway. - -### Production Checklist - -- [ ] Set `FACILITATOR_WALLET_PRIVATE_KEY` in Scaleway Secrets -- [ ] Fund facilitator wallet with ETH for gas (~0.01 ETH minimum) -- [ ] Each seller paid in EURe approves EURe for the facilitator on Base — `1000000000000000000` (1 EURe = 100 fees), not the `1000000` used for USDC/EURC -- [ ] Configure `BATCH_SETTLEMENT_TEST_WALLETS` if testnet dev convenience is needed -- [ ] Test all endpoints after deployment -- [ ] Set up monitoring and alerts in Scaleway Console -- [ ] Document endpoint URLs for client applications - -## Testing - -```bash -# Run the hermetic unit suite (no network) -npm test - -# Run with coverage -npm run test:coverage - -# Run specific test file -npm test -- x402_verify.test.js - -# Run the integration suite (real EIP-712 signatures against live Base Sepolia / -# Optimism RPC — network-dependent, kept out of `npm test`) -npm run test:integration -``` - -Unit tests (`npm test`) are hermetic — the `@x402/evm` SDK and viem are mocked. Tests that build a real signature and exercise on-chain reads live under `test/integration/` and run via `npm run test:integration` (see `vitest.integration.config.js`). - -## Supported Networks & Assets - -USDC addresses and EIP-712 domain names come from `@fretchen/chain-utils`; `chain_utils.ts` is the source of truth. `exact` works on all four networks; `batch-settlement` is gated to those with the contract deployed (last column). - -| Network | CAIP-2 | USDC | USDC domain name | batch-settlement | -| ---------------- | ----------------- | -------------------------------------------- | ---------------- | ---------------- | -| Optimism Mainnet | `eip155:10` | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` | `USD Coin` | ✅ | -| Optimism Sepolia | `eip155:11155420` | `0x5fd84259d66Cd46123540766Be93DFE6D43130D7` | `USDC` | ❌ (no contract) | -| Base Mainnet | `eip155:8453` | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `USD Coin` | ✅ | -| Base Sepolia | `eip155:84532` | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | `USDC` | ✅ | - -EURC (Circle) is deployed on Base only; the facilitator accepts it there alongside USDC. - -| Network | CAIP-2 | EURC | EURC domain name | -| ------------ | -------------- | -------------------------------------------- | ---------------- | -| Base Mainnet | `eip155:8453` | `0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42` | `EURC` | -| Base Sepolia | `eip155:84532` | `0x808456652fdb597867f38412077A9182bf77359F` | `EURC` | - -EURe (Monerium) is deployed on Base, not on Optimism; the facilitator accepts it on Base only. It -has **18 decimals** and no EIP-3009, so it is paid via Permit2 (below). Its EIP-712 domain is -needed for the EIP-2612 permit; verified on-chain on both networks (2026-10-04), by recomputing -`DOMAIN_SEPARATOR` from these values: - -| Network | CAIP-2 | EURe | EURe domain name | version | decimals | -| ------------ | -------------- | -------------------------------------------- | ---------------- | ------- | -------- | -| Base Mainnet | `eip155:8453` | `0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C` | `Monerium EURe` | `1` | 18 | -| Base Sepolia | `eip155:84532` | `0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85` | `Monerium EURe` | `1` | 18 | - -EURe is registered in this package only (`getFeeTokens()` in `chain_utils.ts`), not yet in -`@fretchen/chain-utils`: the sellers in `scw_js` do not offer it yet, and it moves to the shared -registry when they do. - -The canonical `batch-settlement` contract is deployed at the same address on every supported chain: `0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003`. - -### EURe via Permit2 - -The buyer signs a Permit2 `PermitWitnessTransferFrom` naming the x402 exact Permit2 proxy -(`0x402085c248EeA27D92E8b30b2C58ed07f9E20001`, deployed on Base, Base Sepolia and Optimism) as -spender and `payTo` as the witness recipient. Permit2 can only pull EURe the payer has approved -it for, which happens one of two ways: - -- **`eip2612GasSponsoring`** (the usual case): the payment also carries an EIP-2612 permit to - Permit2, signed against the EURe domain above. The facilitator settles with the proxy's - `settleWithPermit`, which applies the permit and the transfer in one transaction. The proxy - ignores a permit that fails, so a wrong domain shows up as `permit2_allowance_required`. -- **A prior `approve(Permit2, …)`** by the payer: the facilitator settles with `settle`. - -`@x402/evm` pins the payment to the requirements before our fee gate runs — `witness.to` must be -`payTo`, and token and amount must match — and the fee gate then reads `requirements` exactly -as for EIP-3009. The seller pays the fee by the same post-settlement `transferFrom`, in EURe. - -`batch-settlement` routes on `requirements.extra.assetTransferMethod` and is not blocked for -`"permit2"` here. EURe channels have run end to end on Base — the `/assistent` sellers offer -EURe, and 2026-10-26 production logs show a funded EURe channel, per-message voucher -verification at 0.03 EURe, a mid-session claim settle, and the 12-hourly cron collecting the -EURe fee. (An earlier revision of this note said to treat EURe channels as untested; that -stale caveat is obsolete.) - -## EIP-712 Signature Verification - -The facilitator uses [viem](https://viem.sh/) for EIP-712 signature verification: - -**Critical Implementation Details:** - -1. **Token Name**: The EIP-712 domain name is **per-network** — testnets use `"USDC"`, mainnets use `"USD Coin"` (see the table above). Never hardcode it; source it from `chain_utils.ts` / `@fretchen/chain-utils`. Getting this wrong silently breaks signature verification. -2. **Full EIP-712 Hash**: `keccak256("\x19\x01" || domainSeparator || messageHash)` -3. **BigInt Conversion**: All uint256 fields must be BigInt - -**Validation:** Reference tests in `eip712_reference.test.js` validate against official EIP-712 specification. - -## Project Structure - -``` -x402_facilitator/ -├── x402_facilitator.ts # Main handler (path-based routing) -├── x402_verify.ts # Verification logic -├── x402_settle.ts # Settlement logic -├── x402_fee.ts # Fee collection (merchant-pays, current model) -├── x402_supported.ts # Supported networks/schemes + fee disclosure -├── x402_whitelist.ts # batch-settlement claim/settle test-wallet bypass -├── facilitator_instance.ts # Shared x402Facilitator + onAfterVerify fee hook -├── chain_utils.ts # Centralized chain config -├── wallet_report_cron.ts # Weekly wallet balance report -├── serverless.yml # Scaleway deployment config -├── package.json -│ -│ # Retired — buyer-pays splitter. Not deployed, not built; see "Fee model history". -├── x402_splitter_facilitator.js -├── x402_splitter_verify.js -├── x402_splitter_settle.js -├── x402_splitter_supported.js -│ -└── test/ # incl. retained x402_splitter_*.test.js -``` - -## Links - -- [x402 Specification](https://github.com/coinbase/x402) -- [EIP-3009: Transfer With Authorization](https://eips.ethereum.org/EIPS/eip-3009) -- [Scaleway Functions Documentation](https://www.scaleway.com/en/docs/serverless/functions/) -- [viem Documentation](https://viem.sh/) - -## License - -MIT +# x402_facilitator + +Home of the **weekly facilitator wallet report** (`walletreportcron`, a Scaleway Function, +Mondays 08:00 UTC). The EIP-3009/EIP-712 payment facilitator itself moved to +bunny_js — it now serves `facilitator.fretchen.eu` as the `fretchen-facilitator` Edge Script: + +| Claim about the facilitator | Where it lives now | +| --------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Code (verify / settle / supported, fee model) | [`../bunny_js/src/facilitator/`](../bunny_js/src/facilitator/) | +| Routing + API reference | `../bunny_js/src/facilitator/facilitator_routes.ts` + `openapi.facilitator.json` | +| Recipient gating (the "whitelist architecture"), fee pull | [`../bunny_js/README.md`](../bunny_js/README.md) → _Facilitator_ sections | +| Payment-scheme walkthroughs (notebooks) | [`../bunny_js/notebooks/facilitator/`](../bunny_js/notebooks/facilitator/) | +| The move's decisions + cutover checklist | git history (the removed `bunny-port-implementation-plan.md`) | +| The old HTTP API/endpoint docs | git history (this file before the teardown) | + +## What `walletreportcron` does + +Every Monday it emails a one-pager about the facilitator's hot wallet, straight from chain +state (no database of facilitator activity exists): + +- native (ETH) balance per network — the real "will settlements keep working?" signal, + warned when below `LOW_GAS_THRESHOLD_ETH` (default 0.005 ETH) +- each fee token's balance — accumulated fee revenue (USDC on OP/Base; EURC, EURe on Base) +- week-over-week activity diff (transactions sent, fees earned, gas spent), read via + historical balance/nonce calls at a block ~7 days ago — deliberately not `eth_getLogs` + (the free-tier RPC caps those at 10-block ranges). The cost: no exact/batch-settlement + split; revisit if volume ever makes it interesting. + +The nominal fee arithmetic (`wallet_report_fee.ts`) is a verbatim extraction of the retired +`x402_fee.ts`'s fee-model pieces — the facilitator's fee source of truth is +`bunny_js/src/facilitator/x402_fee.ts`; keep them in sync when the fee changes. + +One network failing (RPC hiccup) degrades that network's row to an error, never the whole +report; a 500 is returned only when EVERY network failed. A report that cannot send at all +(email API failure, or `FACILITATOR_WALLET_PRIVATE_KEY` unset) logs a `logger.error` line — +`FacilitatorNeedsAttention` in `observability/alerts/payments.yaml` pages on it, because a +silent week is indistinguishable from a quiet one. + +## Commands + +- `npm run dev` — serve the cron's handler locally (port 8085, `NODE_ENV=test`). +- `npm test` — vitest: the report's unit tests, the alert-coverage guard + (`test/alert_coverage.test.ts`, pairing every `logger.error` with a rule phrase or an + EXEMPT reason), EIP-712 reference vectors, the no-stray-JS guard. +- `npm run check` — lint + format + typecheck + coverage. +- `npm run deploy` — build + `serverless deploy` (`walletreportcron` only). **The next + deploy also DELETES the retired `facilitator` function** — the plugin removes functions + missing from `serverless.yml`. That is the intended teardown step; run it deliberately. +- `npm run logs` — `serverless logs -f walletreportcron` (or, nicer: + `cd ../observability && npx tsx scripts/logs.ts walletreportcron --since 8d`). + +## Environment / Secrets + +Public env (`serverless.yml` → `env:`): `NODE_ENV`, `LOG_LEVEL`, +`FACILITATOR_FEE_AMOUNT` (read by the report's fee arithmetic), `LOW_GAS_THRESHOLD_ETH`. + +Secrets (Scaleway Console / `.env`, referenced via `secret:`): + +| Secret | Use | +| -------------------------------- | --------------------------------------------------------------------------------------------------- | +| `FACILITATOR_WALLET_PRIVATE_KEY` | derive the report's wallet address (never signs here; settlements run on the edge now) | +| `SCW_SECRET_KEY` | doubles as the TEM X-Auth-Token (same value `comment_service` uses) | +| `TEM_PROJECT_ID` | Scaleway Transactional Email — the report's send | +| `NOTIFICATION_EMAIL` | where the report lands | +| `RPC_URL_EIP155_*` | per-network RPCs; unset = viem's public, rate-limited defaults — UNSET RPCs WILL BREAK the balances | + +`BATCH_SETTLEMENT_TEST_WALLETS` is gone from this package: only the facilitator reads it, +and the facilitator lives on the edge now. diff --git a/x402_facilitator/bunny-port-implementation-plan.md b/x402_facilitator/bunny-port-implementation-plan.md deleted file mode 100644 index 27ff593dd..000000000 --- a/x402_facilitator/bunny-port-implementation-plan.md +++ /dev/null @@ -1,121 +0,0 @@ -# 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. diff --git a/x402_facilitator/facilitator_instance.ts b/x402_facilitator/facilitator_instance.ts deleted file mode 100644 index f1c185855..000000000 --- a/x402_facilitator/facilitator_instance.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * 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/x402_facilitator/notebooks/.gitignore b/x402_facilitator/notebooks/.gitignore deleted file mode 100644 index fa8019358..000000000 --- a/x402_facilitator/notebooks/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.venv/ -.ipynb_checkpoints/ -node_modules/ -deno.lock diff --git a/x402_facilitator/notebooks/README.md b/x402_facilitator/notebooks/README.md deleted file mode 100644 index ad8ebf087..000000000 --- a/x402_facilitator/notebooks/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# x402 Facilitator Notebooks - -Integration and demo notebooks for the x402 facilitator in the parent directory. - -There is **one notebook per payment scheme**, each testing that scheme's full flow -against a locally running facilitator (`localhost:8080`), plus one buyer-side notebook -that exercises a real deployed integration instead. Pick by what you want to test: - -| Notebook | Kernel | Tests what | -| ------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `x402_exact.ipynb` | Deno/TS | The **`exact`** scheme, seller-side: `/supported` → approve the fee allowance → `/verify` → `/settle` → confirm the fee was collected. One on-chain transfer per request. | -| `x402_exact_permit2.ipynb` | Deno/TS | The **`exact`** scheme's **Permit2** variant, paying **EURe** (no EIP-3009) on Base: `/supported` → seller approves the EURe fee (18 decimals) → buyer signs a Permit2 transfer plus an EIP-2612 permit → a misdirected payment is refused → `/verify` → `/settle`. `DRY_RUN=1` skips the on-chain writes. | -| `x402_batch_settlement.ipynb` | Deno/TS | The **`batch-settlement`** scheme: open a channel with an escrowed deposit, accumulate off-chain vouchers, claim them in one transaction. Sections are labelled by role (buyer signs, seller claims). Runs on **Base Sepolia** — the canonical contract isn't deployed on OP Sepolia. | -| `x402_batch_settlement_permit2.ipynb` | Deno/TS | The **`batch-settlement`** scheme with **EURe** on Base, short version: Permit2 deposit (+ EIP-2612 permit) → two off-chain vouchers → seller approves the EURe fee → one claim. `DRY_RUN=1` skips the on-chain writes. | -| `genimg_x402_buyer.ipynb` | Deno/TS | A **buyer's** view of a real production integration: pays `scw_js`'s deployed genimg endpoint via `wrapFetchWithPayment`. Doesn't call the facilitator directly at all — it's "does my live integration work," not "does the facilitator's logic work." | - -The two scheme notebooks are the ones to reach for when changing facilitator code. The -buyer notebook is for checking a deployed endpoint end-to-end. - -> Superseded notebooks (`x402_facilitator_demo{,_ts,_with_fees}.ipynb`, -> `x402_fee_facilitator_demo.ipynb`) were removed once `x402_exact.ipynb` covered the same -> ground — the first three predated fees being on by default, so they documented a -> configuration that is no longer the default. See git history if you need them. - -## Setup - -### 1. Configure the test wallet - -All notebooks load env from the **single `x402_facilitator/.env`** one level up — there is -no separate `.env` in this directory. Add the missing keys there: - -```bash -cd .. -$EDITOR .env # add TEST_WALLET_PRIVATE_KEY (and NFT_WALLET_PUBLIC_KEY as the recipient) -``` - -`TEST_WALLET_PRIVATE_KEY` must be a wallet funded with testnet USDC (Optimism Sepolia / -Base Sepolia — see `https://faucet.circle.com/`). - -Both scheme notebooks have a `TOKEN` switch (`"EURC"` by default, or `"USDC"`). EURC exists on -**Base Sepolia only** (Circle has no EURC on OP Sepolia), so for EURC the buyer needs testnet -EURC from the same faucet, and the seller approves EURC — the facilitator charges its fee in -the token that was paid. - -### Test EURe (Base Sepolia) - -`x402_exact_permit2.ipynb` pays in EURe. Test EURe on Base Sepolia -(`0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85`) comes from the Monerium sandbox, not a faucet: - -1. Sign up at . Sandbox KYC accepts test data. -2. Link the buyer address on Base Sepolia — `EURE_BUYER_PRIVATE_KEY` if set in `.env`, else - `TEST_WALLET_PRIVATE_KEY`. Linking signs a - message, so import that key into a wallet such as MetaMask first. -3. Receive → _Simulate bank transfer_ → amount → _Add money_. EURe is minted to that address. - -Monerium's sandbox guide names only Sepolia, Chiado and Amoy. Base Sepolia is in its token list -(`https://api.monerium.dev/tokens`), but linking there through the UI is untested so far. The -buyer needs no ETH for EURe: the facilitator submits the permit and the transfer. The seller -needs a little Base Sepolia ETH for its one EURe `approve()`. - -The deployed facilitator (`https://facilitator.fretchen.eu`) accepts Permit2 payments, so -point the notebook's `FACILITATOR_URL` at it — or run a local facilitator (`npm run dev`, -below) when testing facilitator changes. The notebook's default stays local. - -Deno does not search upward for `.env` by default, so each notebook loads it explicitly: -`load({ envPath: "../.env", examplePath: null, export: true })`. (`examplePath: null` -disables dotenv's "every key in a local `.env.example` must be present" check — this -directory intentionally has no `.env.example` of its own.) - -`x402_exact.ipynb` and `x402_batch_settlement.ipynb` also need `NFT_WALLET_PRIVATE_KEY` -(the seller's key) to sign the facilitator's USDC fee approval. - -### 2. Run a facilitator to talk to - -Most notebooks default to `localhost:8080`. Start one from the parent package: - -```bash -cd .. -npm install -npm run build -npm run dev # serves dist/x402_facilitator.js on :8080 -``` - -Sanity check: `curl http://localhost:8080/supported`. - -Alternatively, point the notebook's `FACILITATOR_URL` at the deployed -`https://facilitator.fretchen.eu` (already the default in `genimg_x402_buyer.ipynb`). - -### 3. Deno kernel - -This directory has its own scoped `deno.json` (`nodeModulesDir: "auto"`, `lock: false`) — Deno -manages a local `node_modules/` here automatically the first time a notebook imports an -`npm:` package, fully separate from the parent `x402_facilitator/node_modules` (which only has -the _facilitator's own_ deps and will not satisfy notebook-only imports like `@x402/fetch`). -No manual install step needed; just register the Jupyter kernel once: - -```bash -deno jupyter --install -``` - -Then open a notebook and select the **Deno** kernel. - -> Chosen over Deno's global npm-cache resolution (`nodeModulesDir: "none"`) after repeated -> `ERR_MODULE_NOT_FOUND` failures from that scheme's internal multi-version dependency -> deduplication (a real bug where Deno expected a duplicate `viem` install variant that was -> never actually written to its cache). A real, local `node_modules` sidesteps that entirely. diff --git a/x402_facilitator/notebooks/deno.json b/x402_facilitator/notebooks/deno.json deleted file mode 100644 index 0433ead6d..000000000 --- a/x402_facilitator/notebooks/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$comment": "Scoped to this dir only — does not affect the parent x402_facilitator npm/vitest project. nodeModulesDir: auto gives THIS folder its own local node_modules (x402_facilitator/notebooks/node_modules), fully installed and hoisted/deduped via standard npm resolution, driven purely by the npm: specifiers in the notebooks (no package.json needed). This is a DIFFERENT, complete, self-contained node_modules — not the ancestor ../node_modules (which only has the facilitator's own deps and caused an earlier, unrelated resolution failure). Chosen over Deno's global-npm-cache resolution (nodeModulesDir: none) after repeated ERR_MODULE_NOT_FOUND failures from that scheme's internal multi-version dedup (a real viem@2.55.1_1 duplicate-folder bug that kept recurring even after clean cache wipes). lock: false disables deno.lock creation/checking for this dir.", - "nodeModulesDir": "auto", - "lock": false -} diff --git a/x402_facilitator/notebooks/genimg_x402_buyer.ipynb b/x402_facilitator/notebooks/genimg_x402_buyer.ipynb deleted file mode 100644 index 89527c839..000000000 --- a/x402_facilitator/notebooks/genimg_x402_buyer.ipynb +++ /dev/null @@ -1,586 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "da976341", - "metadata": {}, - "source": [ - "# \ud83c\udfa8 GenImg x402 v2 Payment Demo\n", - "\n", - "This notebook demonstrates how to use the **x402 v2 payment protocol** to pay for AI-generated images with USDC on Optimism.\n", - "\n", - "## What is x402?\n", - "\n", - "[x402](https://github.com/coinbase/x402) is an open protocol by Coinbase that enables HTTP-native payments. When a server returns a `402 Payment Required` response, the client automatically:\n", - "\n", - "1. Parses the payment requirements from the response headers\n", - "2. Creates a cryptographic payment authorization (EIP-3009)\n", - "3. Retries the request with the payment attached\n", - "4. Receives the paid content\n", - "\n", - "**No approve transactions needed!** x402 uses EIP-3009 `transferWithAuthorization` for gasless, one-step payments.\n", - "\n", - "## \ud83d\udd27 Prerequisites\n", - "\n", - "Before running this notebook, you need:\n", - "\n", - "1. **Deno Jupyter Kernel** - Install with:\n", - " ```bash\n", - " deno jupyter --install\n", - " ```\n", - "\n", - "2. **A `.env` file** in the `notebooks/` directory with:\n", - " ```env\n", - " TEST_WALLET_PRIVATE_KEY=your_private_key_without_0x\n", - " ```\n", - "\n", - "3. **USDC on Optimism** - Either:\n", - " - **Testnet (Sepolia)**: Get test USDC from a faucet\n", - " - **Mainnet**: Real USDC (~$0.001 per image)\n", - "\n", - "4. **Running GenImg Server** - Start locally:\n", - " ```bash\n", - " cd scw_js\n", - " NODE_ENV=test node ./dist/genimg_x402_token.js\n", - " ```\n", - "\n", - "## \ud83d\udcda How to Use This Notebook\n", - "\n", - "1. **Cell 1**: Run to import dependencies\n", - "2. **Cell 2**: Configure network (testnet/mainnet) and prompt\n", - "3. **Cell 3**: Setup x402 client\n", - "4. **Cell 4**: Execute payment and generate image\n", - "5. **Cell 5**: Display the generated image\n", - "\n", - "Run the cells in order from top to bottom." - ] - }, - { - "cell_type": "markdown", - "id": "36a3ba75", - "metadata": {}, - "source": [ - "## \ud83d\udce6 Step 1: Import Dependencies\n", - "\n", - "First, we import the x402 client libraries and viem for wallet management. \n", - "\n", - "**Packages used:**\n", - "- `@x402/fetch` - HTTP client with automatic payment handling\n", - "- `@x402/evm` - EVM-specific payment scheme (EIP-3009)\n", - "- `viem` - Ethereum library for wallet and chain configuration" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "da456d3e", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u2705 Dependencies loaded!\n", - " x402Client: function\n", - " wrapFetchWithPayment: function\n", - " registerExactEvmScheme: function\n", - " createPublicClient: function\n", - " Chains: { optimism: \"OP Mainnet\", optimismSepolia: \"OP Sepolia\", base: \"Base\" }\n" - ] - } - ], - "source": [ - "// \ud83d\udce6 Import x402 Buyer Dependencies\n", - "// Following official Quickstart Guide exactly!\n", - "\n", - "import { x402Client, wrapFetchWithPayment, x402HTTPClient } from \"npm:@x402/fetch@^2.0.0\";\n", - "import { registerExactEvmScheme } from \"npm:@x402/evm@^2.0.0/exact/client\";\n", - "\n", - "// Viem for wallet and chains\n", - "import { privateKeyToAccount } from \"npm:viem@^2.0.0/accounts\";\n", - "import { createPublicClient, http } from \"npm:viem@^2.0.0\";\n", - "import { optimism, optimismSepolia, base } from \"npm:viem@^2.0.0/chains\";\n", - "\n", - "// Load environment variables\n", - "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", - "// Load from the package's single .env, one level up (x402_facilitator/.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", - "console.log(\"\u2705 Dependencies loaded!\");\n", - "console.log(\" x402Client:\", typeof x402Client);\n", - "console.log(\" wrapFetchWithPayment:\", typeof wrapFetchWithPayment);\n", - "console.log(\" registerExactEvmScheme:\", typeof registerExactEvmScheme);\n", - "console.log(\" createPublicClient:\", typeof createPublicClient);\n", - "console.log(\" Chains:\", { optimism: optimism.name, optimismSepolia: optimismSepolia.name, base: base.name });" - ] - }, - { - "cell_type": "markdown", - "id": "03ac19bd", - "metadata": {}, - "source": [ - "## \u2699\ufe0f Step 2: Configuration\n", - "\n", - "Configure your wallet, network, and prompt below.\n", - "\n", - "**Environment Variables** (loaded from `.env`):\n", - "- `TEST_WALLET_PRIVATE_KEY` - Your wallet private key (without `0x` prefix)\n", - "\n", - "**Network Options:**\n", - "| Network | Chain ID | USDC | Use Case |\n", - "|---------|----------|------|----------|\n", - "| Optimism Sepolia | `eip155:11155420` | Test USDC | Development/Testing |\n", - "| Optimism Mainnet | `eip155:10` | Real USDC | Production |\n", - "| Base Mainnet | `eip155:8453` | Real USDC | Production (Base L2) |\n", - "\n", - "\u26a0\ufe0f **Warning**: Using mainnet networks requires **real money**!\n", - "\n", - "**To test with Base:**\n", - "1. Set `NETWORK_SELECTION = \"base-mainnet\"`\n", - "2. Ensure you have USDC on Base\n", - "3. Make sure your backend supports Base (deployed with latest code)" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "fd89ef25", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\ud83d\udccb Configuration:\n", - " PRIVATE_KEY: 3572d2...446f\n", - " SERVICE_URL: http://localhost:8082\n", - " FACILITATOR_URL: https://facilitator.fretchen.eu\n", - "\n", - "\ud83d\udea8 WARNING: Using REAL MONEY on Base Mainnet!\n", - "\u26a0\ufe0f Make sure this is intentional!\n", - "\n", - "\ud83d\udccd Network: Base Mainnet (eip155:8453)\n", - "\ud83d\udcb0 USDC Contract: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", - "\ud83c\udfa8 Prompt: \"A cute robot painting a picture\"\n" - ] - } - ], - "source": [ - "// \u2699\ufe0f Configuration - l\u00e4dt aus .env Datei\n", - "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", - "//const PRIVATE_KEY = env.NFT_WALLET_PRIVATE_KEY;\n", - "const SERVICE_URL = \"http://localhost:8082\";\n", - "const FACILITATOR_URL = \"https://facilitator.fretchen.eu\";\n", - "const PROMPT = \"A cute robot painting a picture\";\n", - "\n", - "// \u26a0\ufe0f NETWORK SELECTION - Choose your network\n", - "const NETWORK_SELECTION = \"base-mainnet\"; // Options: \"optimism-sepolia\", \"optimism-mainnet\", \"base-mainnet\"\n", - "\n", - "// Network configurations\n", - "const NETWORKS = {\n", - " \"optimism-sepolia\": {\n", - " chain: optimismSepolia,\n", - " chainId: \"eip155:11155420\",\n", - " name: \"Optimism Sepolia\",\n", - " usdc: \"0x5fd84259d66Cd46123540766Be93DFE6D43130D7\",\n", - " decimals: 6,\n", - " isTestnet: true,\n", - " },\n", - " \"optimism-mainnet\": {\n", - " chain: optimism,\n", - " chainId: \"eip155:10\",\n", - " name: \"Optimism Mainnet\",\n", - " usdc: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\",\n", - " decimals: 6,\n", - " isTestnet: false,\n", - " },\n", - " \"base-mainnet\": {\n", - " chain: base,\n", - " chainId: \"eip155:8453\",\n", - " name: \"Base Mainnet\",\n", - " usdc: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", - " decimals: 6,\n", - " isTestnet: false,\n", - " },\n", - "};\n", - "\n", - "const NETWORK = NETWORKS[NETWORK_SELECTION];\n", - "\n", - "// Show configuration\n", - "console.log(\"\ud83d\udccb Configuration:\");\n", - "console.log(` PRIVATE_KEY: ${PRIVATE_KEY ? PRIVATE_KEY.slice(0, 6) + \"...\" + PRIVATE_KEY.slice(-4) : \"\u274c NOT FOUND\"}`);\n", - "console.log(` SERVICE_URL: ${SERVICE_URL}`);\n", - "console.log(` FACILITATOR_URL: ${FACILITATOR_URL}`);\n", - "\n", - "if (!NETWORK.isTestnet) {\n", - " console.log(`\\n\ud83d\udea8 WARNING: Using REAL MONEY on ${NETWORK.name}!`);\n", - " console.log(`\u26a0\ufe0f Make sure this is intentional!`);\n", - "} else {\n", - " console.log(`\\n\ud83e\uddea Using testnet: ${NETWORK.name}`);\n", - "}\n", - "\n", - "console.log(`\\n\ud83d\udccd Network: ${NETWORK.name} (${NETWORK.chainId})`);\n", - "console.log(`\ud83d\udcb0 USDC Contract: ${NETWORK.usdc}`);\n", - "console.log(`\ud83c\udfa8 Prompt: \"${PROMPT}\"`);" - ] - }, - { - "cell_type": "markdown", - "id": "7afb5168", - "metadata": {}, - "source": [ - "## \ud83d\udd11 Step 3: Setup x402 Client\n", - "\n", - "Create the x402 client and register the EVM payment scheme. This is the same pattern used in the [official x402 Quickstart](https://github.com/coinbase/x402).\n", - "\n", - "The `wrapFetchWithPayment` function wraps the standard `fetch` API to automatically handle 402 responses." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "951f14b9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\ud83d\udd27 Setting up x402 client...\n", - "\u2705 x402 client ready\n", - " Configured for: Base Mainnet (eip155:8453)\n", - " USDC Address: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n" - ] - } - ], - "source": [ - "// \ud83d\udd27 Setup x402 client - with specific chain configuration\n", - "\n", - "console.log(\"\ud83d\udd27 Setting up x402 client...\");\n", - "\n", - "// Create signer (wallet)\n", - "const signer = privateKeyToAccount(`0x${PRIVATE_KEY}`);\n", - "\n", - "// Create public client for the selected network\n", - "const publicClient = createPublicClient({\n", - " chain: NETWORK.chain,\n", - " transport: http(),\n", - "});\n", - "\n", - "// Create x402 client\n", - "const client = new x402Client();\n", - "\n", - "// Register EVM scheme with specific chain configuration\n", - "registerExactEvmScheme(client, { \n", - " signer,\n", - " publicClient // This tells x402 which network to use!\n", - "});\n", - "\n", - "// Wrap fetch with payment handling\n", - "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n", - "\n", - "console.log(\"\u2705 x402 client ready\");\n", - "console.log(` Configured for: ${NETWORK.name} (${NETWORK.chainId})`);\n", - "console.log(` USDC Address: ${NETWORK.usdc}`);" - ] - }, - { - "cell_type": "markdown", - "id": "ef1f6a53", - "metadata": {}, - "source": [ - "## \ud83d\udcb0 Step 3.5: Check USDC Balance\n", - "\n", - "Before making a payment, let's verify that your wallet has enough USDC on the selected network." - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "a08ec66f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\ud83d\udcb0 Checking USDC balance...\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\ud83d\udcca Balance Check:\n", - " Wallet: 0x553179556FC2A39e535D65b921e01fA995E79101\n", - " Network: Base Mainnet\n", - " USDC Balance: 1.000000 USDC\n", - " Required: 0.001000 USDC\n", - " \u2705 Sufficient funds!\n" - ] - } - ], - "source": [ - "// \ud83d\udcb0 Check USDC Balance\n", - "\n", - "const USDC_ABI = [\n", - " {\n", - " inputs: [{ name: \"account\", type: \"address\" }],\n", - " name: \"balanceOf\",\n", - " outputs: [{ name: \"\", type: \"uint256\" }],\n", - " stateMutability: \"view\",\n", - " type: \"function\",\n", - " },\n", - "] as const;\n", - "\n", - "console.log(\"\ud83d\udcb0 Checking USDC balance...\");\n", - "\n", - "const balance = await publicClient.readContract({\n", - " address: NETWORK.usdc as `0x${string}`,\n", - " abi: USDC_ABI,\n", - " functionName: \"balanceOf\",\n", - " args: [signer.address],\n", - "});\n", - "\n", - "// USDC has 6 decimals\n", - "const balanceFormatted = Number(balance) / 1_000_000;\n", - "const REQUIRED_USDC = 0.001; // $0.001 per image\n", - "\n", - "console.log(`\\n\ud83d\udcca Balance Check:`);\n", - "console.log(` Wallet: ${signer.address}`);\n", - "console.log(` Network: ${NETWORK.name}`);\n", - "console.log(` USDC Balance: ${balanceFormatted.toFixed(6)} USDC`);\n", - "console.log(` Required: ${REQUIRED_USDC.toFixed(6)} USDC`);\n", - "\n", - "if (balanceFormatted >= REQUIRED_USDC) {\n", - " console.log(` \u2705 Sufficient funds!`);\n", - "} else {\n", - " console.log(` \u274c INSUFFICIENT FUNDS!`);\n", - " console.log(` Need: ${(REQUIRED_USDC - balanceFormatted).toFixed(6)} more USDC`);\n", - " throw new Error(`Insufficient USDC balance. Need ${REQUIRED_USDC} USDC, have ${balanceFormatted.toFixed(6)} USDC`);\n", - "}" - ] - }, - { - "cell_type": "markdown", - "id": "9a89c3c7", - "metadata": {}, - "source": [ - "## \ud83d\ude80 Step 4: Generate Image with Payment\n", - "\n", - "This is the main action! The `fetchWithPayment` function:\n", - "\n", - "1. Sends initial POST request to the GenImg service\n", - "2. Receives `402 Payment Required` with USDC payment requirements\n", - "3. Creates EIP-3009 signature for the payment amount\n", - "4. Retries request with payment authorization header\n", - "5. Returns the generated image URL and NFT token ID\n", - "\n", - "**What you get back:**\n", - "- `imageUrl` - URL to the AI-generated image\n", - "- `tokenId` - Your NFT certificate on Optimism\n", - "- Payment receipt in response headers" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "c22113c6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\ud83d\ude80 Making paid request...\n", - "\ud83d\udce1 Response Status: 200\n", - "\n", - "\ud83c\udf89 SUCCESS!\n", - "\ud83d\uddbc\ufe0f Image URL: https://my-imagestore.s3.nl-ams.scw.cloud/images/image_1770102163172_61b90e96c972.jpg\n", - "\ud83c\udfab NFT Token ID: 1\n", - "\ud83d\udccd Network: eip155:8453\n", - "\ud83d\udcb0 Mint Price: 30000000000000 wei\n", - "\n", - "\u2705 Payment settled:\n", - " Transaction: undefined\n", - " Network: eip155:8453\n", - "\n", - "\ud83d\udca1 fetchWithPayment automatically:\n", - " 1. Received 402 Payment Required\n", - " 2. Parsed payment requirements\n", - " 3. Created EIP-3009 signature\n", - " 4. Retried with payment\n", - " 5. Got successful response!\n" - ] - } - ], - "source": [ - "// \ud83d\ude80 Make paid request\n", - "// (x402HTTPClient already imported in Cell 1)\n", - "\n", - "console.log(\"\ud83d\ude80 Making paid request...\");\n", - "\n", - "// Make request - payment is handled automatically (exactly like Quickstart!)\n", - "const response = await fetchWithPayment(`${SERVICE_URL}`, {\n", - " method: \"POST\",\n", - " headers: {\n", - " \"Content-Type\": \"application/json\",\n", - " },\n", - " body: JSON.stringify({ \n", - " prompt: PROMPT,\n", - " network: NETWORK.chainId // \ud83c\udfaf Server derives testnet/mainnet from this!\n", - " }),\n", - "});\n", - "\n", - "console.log(`\ud83d\udce1 Response Status: ${response.status}`);\n", - "\n", - "if (response.ok) {\n", - " const result = await response.json();\n", - " console.log(\"\\n\ud83c\udf89 SUCCESS!\");\n", - " console.log(`\ud83d\uddbc\ufe0f Image URL: ${result.image_url}`);\n", - " console.log(`\ud83c\udfab NFT Token ID: ${result.tokenId}`);\n", - " console.log(`\ud83d\udccd Network: ${result.network}`);\n", - " console.log(`\ud83d\udcb0 Mint Price: ${result.mintPrice} wei`);\n", - " \n", - " // Get payment receipt from response headers (exactly like Quickstart!)\n", - " const httpClient = new x402HTTPClient(client);\n", - " const paymentResponse = httpClient.getPaymentSettleResponse(\n", - " (name) => response.headers.get(name)\n", - " );\n", - " \n", - " if (paymentResponse) {\n", - " console.log(\"\\n\u2705 Payment settled:\");\n", - " console.log(` Transaction: ${paymentResponse.transaction}`);\n", - " console.log(` Network: ${paymentResponse.network}`);\n", - " }\n", - " \n", - " console.log(\"\\n\ud83d\udca1 fetchWithPayment automatically:\");\n", - " console.log(\" 1. Received 402 Payment Required\");\n", - " console.log(\" 2. Parsed payment requirements\");\n", - " console.log(\" 3. Created EIP-3009 signature\");\n", - " console.log(\" 4. Retried with payment\");\n", - " console.log(\" 5. Got successful response!\");\n", - "} else {\n", - " const error = await response.text();\n", - " console.error(`\u274c Error: ${error}`);\n", - " throw new Error(`Request failed: ${response.status}`);\n", - "}" - ] - }, - { - "cell_type": "markdown", - "id": "651cf22f", - "metadata": {}, - "source": [ - "## \ud83c\udfa8 Step 5: Display Generated Image\n", - "\n", - "After a successful payment, display the generated image. Copy the `imageUrl` from the output above into the cell below." - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "59c78c27", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "
\n", - "

\ud83c\udfa8 Generated Image

\n", - " \"Generated\n", - "

URL: https://my-imagestore.s3.nl-ams.scw.cloud/images/image_1770101331378_097c9848eafe.jpg

\n", - "
\n", - " " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "// Zeige das generierte Bild an\n", - "const imageUrl = \"https://my-imagestore.s3.nl-ams.scw.cloud/images/image_1770101331378_097c9848eafe.jpg\";\n", - "\n", - "if (imageUrl) {\n", - " // Deno Jupyter API f\u00fcr HTML-Anzeige\n", - " await Deno.jupyter.display({\n", - " \"text/html\": `\n", - "
\n", - "

\ud83c\udfa8 Generated Image

\n", - " \"Generated\n", - "

URL: ${imageUrl}

\n", - "
\n", - " `\n", - " }, { raw: true });\n", - "}" - ] - }, - { - "cell_type": "markdown", - "id": "6eea2a03", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## \ud83d\udcdd Troubleshooting\n", - "\n", - "### Common Errors\n", - "\n", - "| Error | Cause | Solution |\n", - "|-------|-------|----------|\n", - "| `Network not supported` | Facilitator doesn't support the chain | Check `/supported` endpoint |\n", - "| `Insufficient USDC balance` | Not enough USDC in wallet | Get testnet USDC or buy on mainnet |\n", - "| `Payment verification failed` | Invalid signature or expired | Retry the request |\n", - "| `Service configuration error` | Server wallet has no ETH for gas | Fund the server wallet |\n", - "\n", - "### Useful Links\n", - "\n", - "- [x402 Protocol Documentation](https://github.com/coinbase/x402)\n", - "- [Optimism Faucet](https://faucet.optimism.io/)\n", - "- [USDC Contract Addresses](https://developers.circle.com/stablecoins/docs/usdc-on-test-networks)\n", - "\n", - "### Architecture\n", - "\n", - "```\n", - "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 1. POST /genimg \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n", - "\u2502 Client \u2502 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6 \u2502 Server \u2502\n", - "\u2502 (this \u2502 \u2502 (genimg) \u2502\n", - "\u2502 notebook)\u2502 \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2502 \u2502\n", - "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 2. 402 + X-Payment \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n", - " \u2502 \u2502\n", - " \u2502 3. Create EIP-3009 signature \u2502\n", - " \u25bc \u2502\n", - "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 4. POST + payment \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n", - "\u2502 x402 \u2502 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6 \u2502 Facilitator \u2502\n", - "\u2502 Client \u2502 \u2502 (verifies) \u2502\n", - "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n", - " \u2502 \u2502\n", - " \u2502 5. Success + NFT tokenId \u2502\n", - " \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\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": "5.9.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/x402_facilitator/notebooks/x402_batch_settlement.ipynb b/x402_facilitator/notebooks/x402_batch_settlement.ipynb deleted file mode 100644 index 0197d6458..000000000 --- a/x402_facilitator/notebooks/x402_batch_settlement.ipynb +++ /dev/null @@ -1,928 +0,0 @@ -{ - "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 **`x402_facilitator/.env`** (one level 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, one level up: x402_facilitator/.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 x402_facilitator/.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 = \"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/x402_facilitator/notebooks/x402_batch_settlement_permit2.ipynb b/x402_facilitator/notebooks/x402_batch_settlement_permit2.ipynb deleted file mode 100644 index cda5aef9c..000000000 --- a/x402_facilitator/notebooks/x402_batch_settlement_permit2.ipynb +++ /dev/null @@ -1,429 +0,0 @@ -{ - "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 = \"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 x402_facilitator/.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/x402_facilitator/notebooks/x402_exact.ipynb b/x402_facilitator/notebooks/x402_exact.ipynb deleted file mode 100644 index 231773cb4..000000000 --- a/x402_facilitator/notebooks/x402_exact.ipynb +++ /dev/null @@ -1,1122 +0,0 @@ -{ - "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 `x402_facilitator/.env` (one level 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, one level up (x402_facilitator/.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": 2, - "id": "fee1aa4f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "🧪 Using testnet: Base Sepolia (Testnet)\n", - " Chain: Base\n", - " Chain ID: 84532\n", - " CAIP-2: eip155:84532\n", - " Token: 0x808456652fdb597867f38412077A9182bf77359F\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 = false; // 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": 3, - "id": "1c5d0c57", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "📡 GET http://localhost:8080/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, or EURC 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 contract (Base) for the facilitator's address. 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 = \"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": 4, - "id": "0f5709ba", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "💰 EURC Balances Before:\n", - " Buyer (0x553179556FC2A39e535D65b921e01fA995E79101): 19.94 EURC\n", - " Seller (0xAAEBC1441323B8ad6Bdf6793A8428166b510239C): 0.04 EURC\n", - " Facilitator (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206): 0.02 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": 5, - "id": "ab1d4474", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "🔍 Current Seller Allowance:\n", - " Allowance: 0.99 EURC\n", - " Fee per settlement: 0.01 EURC\n", - " Remaining settlements: 99\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": null, - "id": "876c6ee5", - "metadata": {}, - "outputs": [], - "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": 6, - "id": "d0c376a2", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✅ Payment payload created\n", - " Scheme: exact\n", - " Network: eip155:84532 (Base Sepolia (Testnet))\n", - " From: 0x553179556FC2A39e535D65b921e01fA995E79101\n", - " To (Seller): 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 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": 7, - "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\": 99\n", - "}\n", - "\n", - "✅ Payment is VALID!\n", - " Payer: 0x553179556FC2A39e535D65b921e01fA995E79101\n", - " Settlements left on your approval: 99\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": 8, - "id": "bac64ca4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "💸 Attempting Settlement...\n", - " Network: eip155:84532\n", - " Buyer pays: 0.02 EURC → Seller\n", - " Fee: 0.01 EURC (collected after settlement)\n", - "\n", - "📦 Settle Response (Status 200):\n", - "{\n", - " \"success\": true,\n", - " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"0x3fa6bba3d46e47aab2fbbc7804e28ac5cfd48d6f187e1e72cee8b6a9fd5b7ec3\",\n", - " \"network\": \"eip155:84532\",\n", - " \"fee\": {\n", - " \"collected\": true,\n", - " \"status\": \"collected\",\n", - " \"txHash\": \"0x3034038d4daf9fcc512eac65cc1b2693749f868593e98b399d1f4815d138b133\"\n", - " },\n", - " \"extensions\": {\n", - " \"facilitatorFees\": {\n", - " \"info\": {\n", - " \"version\": \"1\",\n", - " \"facilitatorFeePaid\": \"10000\",\n", - " \"asset\": \"eip155:84532/erc20:0x808456652fdb597867f38412077A9182bf77359F\",\n", - " \"model\": \"flat\",\n", - " \"collection\": {\n", - " \"status\": \"collected\",\n", - " \"txHash\": \"0x3034038d4daf9fcc512eac65cc1b2693749f868593e98b399d1f4815d138b133\"\n", - " }\n", - " }\n", - " }\n", - " }\n", - "}\n", - "\n", - "🎉 Settlement successful!\n", - " Transaction: 0x3fa6bba3d46e47aab2fbbc7804e28ac5cfd48d6f187e1e72cee8b6a9fd5b7ec3\n", - " Network: eip155:84532\n", - "\n", - "💰 Fee Collection:\n", - " Collected: true\n", - " Fee Tx: 0x3034038d4daf9fcc512eac65cc1b2693749f868593e98b399d1f4815d138b133\n", - " Fee asset: eip155:84532/erc20:0x808456652fdb597867f38412077A9182bf77359F ✅ (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": 9, - "id": "c4ca72d7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "💰 EURC Balances AFTER Settlement:\n", - " Buyer: 19.92 EURC\n", - " Seller: 0.05 EURC\n", - " Facilitator: 0.03 EURC\n", - "\n", - "📊 Balance Changes:\n", - " 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: 0.99 EURC\n", - " After: 0.98 EURC\n", - " Settlements remaining: 98\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": 10, - "id": "c85eea60", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "🔍 Settlement Transaction:\n", - " https://sepolia.basescan.org/tx/0x3fa6bba3d46e47aab2fbbc7804e28ac5cfd48d6f187e1e72cee8b6a9fd5b7ec3\n", - "\n", - "💰 Fee Collection Transaction:\n", - " https://sepolia.basescan.org/tx/0x3034038d4daf9fcc512eac65cc1b2693749f868593e98b399d1f4815d138b133\n", - "\n", - "📊 Summary:\n", - " Network: eip155:84532 (Base Sepolia (Testnet))\n", - " EURC: 0x808456652fdb597867f38412077A9182bf77359F\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": "5.9.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/x402_facilitator/notebooks/x402_exact_permit2.ipynb b/x402_facilitator/notebooks/x402_exact_permit2.ipynb deleted file mode 100644 index f9a037d9d..000000000 --- a/x402_facilitator/notebooks/x402_exact_permit2.ipynb +++ /dev/null @@ -1,626 +0,0 @@ -{ - "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", - " `x402_facilitator/.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, one level up (x402_facilitator/.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 = \"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/x402_facilitator/openapi.json b/x402_facilitator/openapi.json deleted file mode 100644 index e27d71284..000000000 --- a/x402_facilitator/openapi.json +++ /dev/null @@ -1,521 +0,0 @@ -{ - "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/x402_facilitator/package-lock.json b/x402_facilitator/package-lock.json index 197274c46..5188fbba8 100644 --- a/x402_facilitator/package-lock.json +++ b/x402_facilitator/package-lock.json @@ -10,12 +10,9 @@ "license": "MIT", "dependencies": { "@fretchen/chain-utils": "file:../shared/chain-utils", - "@x402/core": "^2.29.0", - "@x402/evm": "^2.29.0", "dotenv": "^16.0.0", "pino": "^9.0.0", - "viem": "^2.57.4", - "zod": "^4.6.6" + "viem": "^2.57.4" }, "devDependencies": { "@eslint/js": "^10.0.0", @@ -2157,26 +2154,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@x402/core": { - "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": "^4.6.6" - } - }, - "node_modules/@x402/evm": { - "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.29.0", - "viem": "^2.48.11", - "zod": "^4.6.6" - } - }, "node_modules/@yuku-core/binding-android-arm64": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@yuku-core/binding-android-arm64/-/binding-android-arm64-0.19.0.tgz", @@ -8748,15 +8725,6 @@ "engines": { "node": ">=14.18.0" } - }, - "node_modules/zod": { - "version": "4.6.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz", - "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/x402_facilitator/package.json b/x402_facilitator/package.json index d1ef6b520..ba3c27503 100644 --- a/x402_facilitator/package.json +++ b/x402_facilitator/package.json @@ -1,18 +1,16 @@ { "name": "x402-facilitator", "version": "1.0.0", - "description": "x402 v2 Payment Facilitator for Optimism and Base", + "description": "Weekly facilitator wallet report (cron). The x402 v2 payment facilitator itself moved to bunny_js/src/facilitator/.", "type": "module", - "main": "dist/x402_facilitator.js", + "main": "dist/wallet_report_cron.js", "scripts": { - "generate:openapi": "tsx scripts/generate-openapi.ts && prettier --write --log-level warn openapi.json", - "build": "npm run generate:openapi && tsdown", + "build": "tsdown", "predev": "npm run build", - "dev": "NODE_ENV=test node dist/x402_facilitator.js", + "dev": "NODE_ENV=test node dist/wallet_report_cron.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:integration": "vitest run --config vitest.integration.config.js", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,js,json,md}\"", @@ -22,7 +20,7 @@ "deploy": "npm run build && serverless deploy", "deploy:prod": "npm run build && serverless deploy --stage production", "info": "serverless info", - "logs": "serverless logs -f facilitator", + "logs": "serverless logs -f walletreportcron", "remove": "serverless remove" }, "keywords": [ @@ -36,12 +34,9 @@ "license": "MIT", "dependencies": { "@fretchen/chain-utils": "file:../shared/chain-utils", - "@x402/core": "^2.29.0", - "@x402/evm": "^2.29.0", "dotenv": "^16.0.0", "pino": "^9.0.0", - "viem": "^2.57.4", - "zod": "^4.6.6" + "viem": "^2.57.4" }, "devDependencies": { "@eslint/js": "^10.0.0", @@ -66,5 +61,8 @@ }, "overrides": { "@humanfs/node": "0.16.8" + }, + "allowScripts": { + "argon2@0.30.3": true } } diff --git a/x402_facilitator/revert_selectors.ts b/x402_facilitator/revert_selectors.ts deleted file mode 100644 index 33841081f..000000000 --- a/x402_facilitator/revert_selectors.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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/x402_facilitator/scripts/generate-openapi.ts b/x402_facilitator/scripts/generate-openapi.ts deleted file mode 100644 index 836e34017..000000000 --- a/x402_facilitator/scripts/generate-openapi.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Generates `openapi.json` from the Zod schemas in `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 "../x402_schemas"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const OUTPUT_PATH = join(__dirname, "..", "openapi.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/x402_facilitator/serverless.yml b/x402_facilitator/serverless.yml index 9ed35927f..1a3cafbfa 100644 --- a/x402_facilitator/serverless.yml +++ b/x402_facilitator/serverless.yml @@ -11,14 +11,10 @@ provider: env: NODE_ENV: production LOG_LEVEL: info - # Fee amount in USDC smallest unit (6 decimals): 10000 = 0.01 USDC + # Fee amount in USDC smallest unit (6 decimals): 10000 = 0.01 USDC. Read by the weekly + # report's nominal-fee arithmetic (wallet_report_fee.ts); the facilitator itself now + # carries its own copy on the bunny script. FACILITATOR_FEE_AMOUNT: "10000" - # batch-settlement claim/settle (the "usage" payload types — see x402_whitelist.ts) - # charge the same flat fee as exact, gated by the same USDC allowance check. This is - # a testnet-only convenience bypass: a listed address skips the allowance check on - # testnets, fenced behind isTestnet() so a testnet entry can never bypass real funds. - # deposit/voucher/refund are always open and fee-free; no env var gates them. - BATCH_SETTLEMENT_TEST_WALLETS: ${env:BATCH_SETTLEMENT_TEST_WALLETS, ''} # Weekly wallet report (walletreportcron): warn when native gas balance drops below # this many ETH. Public value — the actual send is gated on the TEM secrets below. LOW_GAS_THRESHOLD_ETH: ${env:LOW_GAS_THRESHOLD_ETH, '0.005'} @@ -56,19 +52,9 @@ package: - "!dist/**/*.map" functions: - # Single function with path-based routing - # Handles /verify, /settle, and /supported endpoints - facilitator: - handler: dist/x402_facilitator.handle - description: x402 v2 payment facilitator with path-based routing - # Memory and timeout for the combined function - memoryLimit: 512 - timeout: 60s - # Custom domain for x402 standard compliance - custom_domains: - - facilitator.fretchen.eu - # Weekly (Mondays 08:00 UTC) email report of wallet balances + 7-day USDC activity. + # The HTTP facilitator that used to live here moved to bunny_js (facilitator.fretchen.eu + # is served by the fretchen-facilitator Edge Script); this package deploys the cron only. walletreportcron: handler: dist/wallet_report_cron.handle description: "Weekly facilitator wallet balance + USDC activity email" diff --git a/x402_facilitator/test/alert_coverage.test.ts b/x402_facilitator/test/alert_coverage.test.ts index 2bc253506..d2d772f81 100644 --- a/x402_facilitator/test/alert_coverage.test.ts +++ b/x402_facilitator/test/alert_coverage.test.ts @@ -48,9 +48,11 @@ const EXEMPT: Record = { "so it already reaches an inbox without a Loki rule", }; -// Only rules whose selector names this package's two deployed functions — a rule scoped to -// llmx402cron or searchapi says nothing about whether the facilitator itself is covered. -const FACILITATOR_FUNCTIONS = ["facilitator", "walletreportcron"]; +// Only rules whose selector names this package's ONE deployed function — the HTTP facilitator +// moved to bunny_js, where BunnySellersNeedAttention (level="error") covers its call sites +// (this rule union deliberately does not: a rule scoped to the facilitator's old Scaleway +// function names nothing this package still deploys). +const FACILITATOR_FUNCTIONS = ["walletreportcron"]; const messages = extractLoggerErrorMessages(PACKAGE_ROOT); const rules = extractRules(ALERTS_DIR); @@ -59,8 +61,9 @@ const facilitatorRules = rulesForFunctions(rules, FACILITATOR_FUNCTIONS); describe("facilitator alert coverage", () => { it("found a realistic number of logger.error call sites", () => { // A floor, not an exact count — guards against the extractor silently matching nothing, - // which would make every other test in this file vacuously pass. - expect(messages.length).toBeGreaterThanOrEqual(8); + // which would make every other test in this file vacuously pass. Four sites now; the + // package shrank to the wallet-report cron when the facilitator moved to bunny_js. + expect(messages.length).toBeGreaterThanOrEqual(4); }); // A sanity check that the parser found anything, not a rule count: the rules are deliberately diff --git a/x402_facilitator/test/facilitator_instance.test.ts b/x402_facilitator/test/facilitator_instance.test.ts deleted file mode 100644 index 5a8870b26..000000000 --- a/x402_facilitator/test/facilitator_instance.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * 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("../x402_fee.js", async (importOriginal) => ({ - ...(await importOriginal()), - evaluateFeeGate: vi.fn(), - getFacilitatorAddress: vi.fn(), -})); - -import { - createFacilitator, - createReadOnlyFacilitator, - resetFacilitator, - getFacilitator, -} from "../facilitator_instance.js"; -import { evaluateFeeGate, getFacilitatorAddress } from "../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/x402_facilitator/test/helpers/batchPayloads.ts b/x402_facilitator/test/helpers/batchPayloads.ts deleted file mode 100644 index 72300d36d..000000000 --- a/x402_facilitator/test/helpers/batchPayloads.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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/x402_facilitator/test/helpers/fakeChain.ts b/x402_facilitator/test/helpers/fakeChain.ts deleted file mode 100644 index 2f3c03ad3..000000000 --- a/x402_facilitator/test/helpers/fakeChain.ts +++ /dev/null @@ -1,605 +0,0 @@ -/** - * 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/x402_facilitator/test/helpers/signPayment.ts b/x402_facilitator/test/helpers/signPayment.ts deleted file mode 100644 index 33e962488..000000000 --- a/x402_facilitator/test/helpers/signPayment.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 "../../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/x402_facilitator/test/integration/x402_batch_settlement.integration.test.ts b/x402_facilitator/test/integration/x402_batch_settlement.integration.test.ts deleted file mode 100644 index 0268f976b..000000000 --- a/x402_facilitator/test/integration/x402_batch_settlement.integration.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * 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 "../../x402_verify.js"; -import { resetFacilitator } from "../../facilitator_instance.js"; -import { getChainConfig } from "../../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/x402_facilitator/test/integration/x402_permit2_eure.integration.test.ts b/x402_facilitator/test/integration/x402_permit2_eure.integration.test.ts deleted file mode 100644 index 75b5c9121..000000000 --- a/x402_facilitator/test/integration/x402_permit2_eure.integration.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 "../../x402_verify.js"; -import { resetFacilitator } from "../../facilitator_instance.js"; -import { getRpcUrl } from "../../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/x402_facilitator/test/integration/x402_verify_signature.integration.test.ts b/x402_facilitator/test/integration/x402_verify_signature.integration.test.ts deleted file mode 100644 index 5fe22f96b..000000000 --- a/x402_facilitator/test/integration/x402_verify_signature.integration.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * 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 "../../x402_verify.js"; -import { resetFacilitator } from "../../facilitator_instance.js"; -import { getChainConfig } from "../../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/x402_facilitator/test/no_stray_js.test.ts b/x402_facilitator/test/no_stray_js.test.ts index 768401c66..5fd707385 100644 --- a/x402_facilitator/test/no_stray_js.test.ts +++ b/x402_facilitator/test/no_stray_js.test.ts @@ -6,31 +6,20 @@ import { join } from "node:path"; * Guards against untyped JS creeping back into this package. * * All production code and its live-code tests are TypeScript, checked under `strict: true` - * (see tsconfig.json). The only JS left is the retired buyer-pays splitter (source + its own - * tests, kept deliberately — see README.md → "Fee model history") and the root `*.config.js` - * files, which eslint.config.js's "JS files" block exists to configure, not to license new - * ones. A new `.js` test file would silently lose type checking the way the six files - * converted here did (see the x402_facilitator JS→TS cleanup) — this test fails loudly instead. + * (see tsconfig.json). The only JS left is the root `*.config.js` files, which eslint.config.js's + * "JS files" block exists to configure, not to license new ones — even the retired buyer-pays + * splitter JS (retired with the facilitator's move to bunny_js) is gone now. A new `.js` test + * file would silently lose type checking the way the six files converted here did (see the + * x402_facilitator JS→TS cleanup) — this test fails loudly instead. * * Modeled on website/test/styleConventions.test.ts's `sourceFiles()` walker. */ const ROOT = join(import.meta.dirname, ".."); -const SKIP = new Set(["node_modules", "dist", "coverage", ".serverless", "notebooks"]); +const SKIP = new Set(["node_modules", "dist", "coverage", ".serverless"]); // The only `.js` files this package intentionally keeps, as paths relative to ROOT. -const ALLOWED_JS = new Set([ - "eslint.config.js", - "vitest.config.js", - "vitest.integration.config.js", - "x402_splitter_facilitator.js", - "x402_splitter_settle.js", - "x402_splitter_supported.js", - "x402_splitter_verify.js", - "test/x402_splitter_settle.test.js", - "test/x402_splitter_supported.test.js", - "test/x402_splitter_verify.test.js", -]); +const ALLOWED_JS = new Set(["eslint.config.js", "vitest.config.js"]); function jsFiles(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { @@ -48,7 +37,7 @@ function jsFiles(dir: string, out: string[] = []): string[] { } describe("no stray JS", () => { - it("only the documented splitter + config files are JS — everything else must be TS", () => { + it("only the documented config files are JS — everything else must be TS", () => { const found = jsFiles(ROOT).map((path) => path.slice(ROOT.length + 1)); const unexpected = found.filter((path) => !ALLOWED_JS.has(path)); diff --git a/x402_facilitator/test/openapi_generation.test.ts b/x402_facilitator/test/openapi_generation.test.ts deleted file mode 100644 index 0e5a2e357..000000000 --- a/x402_facilitator/test/openapi_generation.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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 "../openapi.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/x402_facilitator/test/revert_selectors.test.ts b/x402_facilitator/test/revert_selectors.test.ts deleted file mode 100644 index 72205d1f1..000000000 --- a/x402_facilitator/test/revert_selectors.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { toFunctionSelector } from "viem"; -import { BATCH_SETTLEMENT_REVERT_SELECTORS, annotateDecodedRevert } from "../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/x402_facilitator/test/wallet_report_cron.test.ts b/x402_facilitator/test/wallet_report_cron.test.ts index e8ab19386..b29229ae7 100644 --- a/x402_facilitator/test/wallet_report_cron.test.ts +++ b/x402_facilitator/test/wallet_report_cron.test.ts @@ -18,8 +18,8 @@ const { // The real fee arithmetic (feeAmountFor) and token registry (getFeeTokens): the per-token // decimals they carry are what these tests are about. -vi.mock("../x402_fee", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../wallet_report_fee", async (importOriginal) => ({ + ...(await importOriginal()), getFacilitatorAddress: mockGetFacilitatorAddress, })); diff --git a/x402_facilitator/test/wallet_report_fee.test.ts b/x402_facilitator/test/wallet_report_fee.test.ts new file mode 100644 index 000000000..3e581b138 --- /dev/null +++ b/x402_facilitator/test/wallet_report_fee.test.ts @@ -0,0 +1,98 @@ +/** + * The fee/address extraction's own regression tests — ported from the retired x402_fee.test.ts + * when the facilitator moved to bunny_js (bunny_js/test/facilitator/x402_fee.test.ts covers the + * live copy; this guards the report-side copy). The cases that matter here: the env-fee + * validation branches (a corrupted FACILITATOR_FEE_AMOUNT must fall back, not throw or NaN) and + * the decimals scaling — especially EURe's 18 decimals, where 0.01 is 10¹⁶ atomic units, the + * shipped-wrong trap this arithmetic once embodied. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getFeeAmount, feeAmountFor, getFacilitatorAddress } from "../wallet_report_fee"; + +describe("wallet_report_fee", () => { + const originalEnv = { ...process.env }; + // Well-known anvil/hardhat key #0 — public knowledge, used for the address-derivation shape. + const VALID_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + beforeEach(() => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = VALID_PRIVATE_KEY; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe("getFeeAmount — env validation must fall back, never throw", () => { + 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 = "-1000"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("returns default for non-integer strings (e.g. text)", () => { + process.env.FACILITATOR_FEE_AMOUNT = "not-a-number"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("returns default for decimal values", () => { + process.env.FACILITATOR_FEE_AMOUNT = "100.5"; + expect(getFeeAmount()).toBe(10000n); + }); + + it("BigInt-trims whitespace-only values to 0n (fees disabled), which is safe", () => { + process.env.FACILITATOR_FEE_AMOUNT = " "; + expect(getFeeAmount()).toBe(0n); + }); + }); + + describe("feeAmountFor — the decimals scaling, incl. the EURe 18-decimals trap", () => { + it("returns the base amount for the base decimals (USDC/EURC, 6)", () => { + expect(feeAmountFor(6)).toBe(10000n); + }); + + it("scales UP for EURe's 18 decimals: 0.01 is 10^16, not 10000", () => { + expect(feeAmountFor(18)).toBe(10_000_000_000_000_000n); + }); + + it("scales DOWN below the base decimals (0.01 in a 2-decimals token = 1 unit)", () => { + expect(feeAmountFor(2)).toBe(1n); + }); + }); + + describe("getFacilitatorAddress", () => { + it("returns the address derived from the private key", () => { + expect(getFacilitatorAddress()).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + }); + + it("returns null when the private key is not set", () => { + delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; + expect(getFacilitatorAddress()).toBeNull(); + }); + + it("returns null for an invalid (too short) private key", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xinvalid"; + expect(getFacilitatorAddress()).toBeNull(); + }); + + it("handles a private key without the 0x prefix", () => { + process.env.FACILITATOR_WALLET_PRIVATE_KEY = + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + expect(getFacilitatorAddress()).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + }); + }); +}); diff --git a/x402_facilitator/test/x402_batch_route.test.ts b/x402_facilitator/test/x402_batch_route.test.ts deleted file mode 100644 index 7414537be..000000000 --- a/x402_facilitator/test/x402_batch_route.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 "../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/x402_facilitator/test/x402_facilitator.test.ts b/x402_facilitator/test/x402_facilitator.test.ts deleted file mode 100644 index 4ae366320..000000000 --- a/x402_facilitator/test/x402_facilitator.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; -import { handleVerify, handleSettle, handleSupported, handle } from "../x402_facilitator.js"; -import type { verifyPayment as verifyPaymentType } from "../x402_verify.js"; -import type { settlePayment as settlePaymentType } from "../x402_settle.js"; - -// Mock the dependencies -vi.mock("../x402_verify.js", () => ({ - verifyPayment: vi.fn(), -})); - -vi.mock("../x402_settle.js", () => ({ - settlePayment: vi.fn(), -})); - -vi.mock("../x402_supported.js", () => ({ - getSupportedCapabilities: vi.fn(() => ({ - x402Version: 2, - kinds: ["exact"], - networks: ["eip155:10", "eip155:11155420"], - assets: { - "eip155:10": ["USDC"], - "eip155:11155420": ["USDC"], - }, - })), -})); - -describe("x402_facilitator handlers", () => { - let verifyPayment: Mock; - let settlePayment: Mock; - - beforeEach(async () => { - vi.clearAllMocks(); - const verifyModule = await import("../x402_verify.js"); - const settleModule = await import("../x402_settle.js"); - verifyPayment = verifyModule.verifyPayment as Mock; - settlePayment = settleModule.settlePayment as Mock; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("handleVerify", () => { - it("should handle CORS preflight OPTIONS request", async () => { - const event = { httpMethod: "OPTIONS" }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.headers["Access-Control-Allow-Origin"]).toBe("*"); - expect(result.headers["Access-Control-Allow-Headers"]).toBe("Content-Type"); - expect(result.headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS"); - expect(result.body).toBe(""); - }); - - it("should reject non-POST requests", async () => { - const event = { httpMethod: "GET" }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(405); - expect(JSON.parse(result.body)).toEqual({ - error: "Method not allowed. Use POST.", - }); - }); - - it("should handle invalid JSON body", async () => { - const event = { - httpMethod: "POST", - body: "not-valid-json{", - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body)).toEqual({ - error: "Invalid JSON in request body", - }); - }); - - it("should reject missing paymentPayload", async () => { - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body)).toEqual({ - error: "Request must include both paymentPayload and paymentRequirements", - }); - }); - - it("should reject missing paymentRequirements", async () => { - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body)).toEqual({ - error: "Request must include both paymentPayload and paymentRequirements", - }); - }); - - it("should return valid payment result", async () => { - verifyPayment.mockResolvedValue({ - isValid: true, - payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }); - - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - // Verbatim: the seller reads these keys directly, so a reshaped or partial copy is as - // damaging as none at all. - expect(JSON.parse(result.body).extra).toEqual(channelState); - }); - - it("omits extra entirely when the scheme produced none", async () => { - verifyPayment.mockResolvedValue({ - isValid: true, - payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }); - - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - // Absent, not `"extra": null` — the seller tests `result.extra ?? {}`, and a null would - // read as "no state" just the same, but an explicit null is a claim we have none. - expect(JSON.parse(result.body)).not.toHaveProperty("extra"); - }); - - it("should return invalid payment result with reason", async () => { - verifyPayment.mockResolvedValue({ - isValid: false, - invalidReason: "insufficient_funds", - payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }); - - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(500); - const body = JSON.parse(result.body); - expect(body.error).toBe("Internal server error"); - expect(body.isValid).toBe(false); - expect(body.invalidReason).toBe("unexpected_verify_error"); - }); - - it("should handle object body (pre-parsed)", async () => { - verifyPayment.mockResolvedValue({ - isValid: true, - payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }); - - const event = { - httpMethod: "POST", - body: { - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }, - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(200); - expect(JSON.parse(result.body).isValid).toBe(true); - }); - - 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 event = { - httpMethod: "POST", - body: { - paymentPayload: { - accepted: { network: "eip155:10" }, - payload: { signature: "0xdeadbeef" }, - }, - paymentRequirements: { amount: "1000000" }, - }, - }; - await handleVerify(event, {}); - - expect(verifyPayment.mock.calls[0][0]).toBe(event.body.paymentPayload); - expect(verifyPayment.mock.calls[0][1]).toBe(event.body.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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { payload: { signature: "0xdeadbeef" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleVerify(event, {}); - - expect(result.statusCode).toBe(400); - expect(verifyPayment).not.toHaveBeenCalled(); - }); - }); - - describe("handleSettle", () => { - it("should handle CORS preflight OPTIONS request", async () => { - const event = { httpMethod: "OPTIONS" }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.headers["Access-Control-Allow-Origin"]).toBe("*"); - expect(result.headers["Access-Control-Allow-Headers"]).toBe("Content-Type"); - expect(result.headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS"); - expect(result.body).toBe(""); - }); - - it("should reject non-POST requests", async () => { - const event = { httpMethod: "GET" }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(405); - }); - - it("should return successful settlement", async () => { - settlePayment.mockResolvedValue({ - success: true, - payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - transaction: "0xabc123", - network: "eip155:10", - }); - - const event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { - httpMethod: "POST", - body: JSON.stringify({ - paymentPayload: { accepted: { network: "eip155:10" } }, - paymentRequirements: { amount: "1000000" }, - }), - }; - const result = await handleSettle(event, {}); - - expect(result.statusCode).toBe(500); - const body = JSON.parse(result.body); - expect(body.error).toBe("Internal server error"); - expect(body.success).toBe(false); - expect(body.errorReason).toBe("unexpected_settlement_error"); - }); - }); - - describe("handleSupported", () => { - it("should handle CORS preflight OPTIONS request", async () => { - const event = { httpMethod: "OPTIONS" }; - const result = await handleSupported(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.headers["Access-Control-Allow-Origin"]).toBe("*"); - expect(result.headers["Access-Control-Allow-Headers"]).toBe("Content-Type"); - expect(result.headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS"); - expect(result.body).toBe(""); - }); - - it("should reject non-GET requests", async () => { - const event = { httpMethod: "POST" }; - const result = await handleSupported(event, {}); - - expect(result.statusCode).toBe(405); - expect(JSON.parse(result.body)).toEqual({ - error: "Method not allowed. Use GET.", - }); - }); - - it("should return supported capabilities", async () => { - const event = { httpMethod: "GET" }; - const result = await handleSupported(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.x402Version).toBe(2); - expect(body.networks).toContain("eip155:10"); - }); - }); - - describe("handle (router)", () => { - it("should route /supported to handleSupported", async () => { - const event = { httpMethod: "GET", path: "/supported" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(JSON.parse(result.body).x402Version).toBe(2); - }); - - it("should route /verify to handleVerify", async () => { - const event = { httpMethod: "OPTIONS", path: "/verify" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.body).toBe(""); - }); - - it("should route /settle to handleSettle", async () => { - const event = { httpMethod: "OPTIONS", path: "/settle" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.body).toBe(""); - }); - - it("should use rawUrl for routing", async () => { - const event = { httpMethod: "GET", rawUrl: "https://api.example.com/supported" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - }); - - it("should return 404 for unknown endpoints", async () => { - const event = { httpMethod: "GET", path: "/unknown" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(404); - expect(JSON.parse(result.body)).toEqual({ - error: "Endpoint not found. Use /verify, /settle, or /supported", - }); - }); - - it("should return 404 for empty path", async () => { - const event = { httpMethod: "GET" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(404); - }); - - it("redirects a browser hitting the root to the documentation page", async () => { - const event = { httpMethod: "GET", path: "/", headers: { Accept: "text/html" } }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(302); - expect(result.headers.Location).toBe("https://www.fretchen.eu/x402/"); - }); - - it("matches the Accept header case-insensitively", async () => { - const event = { - httpMethod: "GET", - path: "/", - headers: { accept: "text/html,application/xhtml+xml" }, - }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(302); - }); - - it("gives a machine client at the root a JSON body with an onward path", async () => { - const event = { httpMethod: "GET", path: "/" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - 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 event = { httpMethod: "GET", path: "/", headers: { Accept: "application/json" } }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.headers.Location).toBeUndefined(); - }); - - it("routes /openapi.json to handleOpenApiSpec", async () => { - const event = { httpMethod: "GET", path: "/openapi.json" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.headers["Access-Control-Allow-Origin"]).toBe("*"); - const body = JSON.parse(result.body); - expect(body.openapi).toBe("3.1.0"); - expect(body.info.title).toBe("fretchen x402 Facilitator"); - }); - - it("handles CORS preflight on /openapi.json", async () => { - const event = { httpMethod: "OPTIONS", path: "/openapi.json" }; - const result = await handle(event, {}); - - expect(result.statusCode).toBe(200); - expect(result.body).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.js - // against the real getSupportedCapabilities() — this file mocks that module with a - // fixture that doesn't carry `links`, so asserting it here would test the mock. - const rootResult = await handle({ httpMethod: "GET", path: "/" }, {}); - expect(JSON.parse(rootResult.body).openapi).toBe("/openapi.json"); - }); - }); -}); diff --git a/x402_facilitator/test/x402_fee.test.ts b/x402_facilitator/test/x402_fee.test.ts deleted file mode 100644 index 673c1b5d7..000000000 --- a/x402_facilitator/test/x402_fee.test.ts +++ /dev/null @@ -1,788 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - ERC20_FEE_ABI, - getFeeAmount, - getFacilitatorAddress, - checkMerchantAllowance, - collectFee, - evaluateFeeGate, - feeAmountFor, - wasRejectedBeforeBroadcast, -} from "../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/x402_facilitator/test/x402_permit2_real_sdk.test.ts b/x402_facilitator/test/x402_permit2_real_sdk.test.ts deleted file mode 100644 index fc785c6fb..000000000 --- a/x402_facilitator/test/x402_permit2_real_sdk.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 "../x402_settle.js"; -import { verifyPayment } from "../x402_verify.js"; -import { resetFacilitator } from "../facilitator_instance.js"; -import { getFacilitatorAddress } from "../x402_fee.js"; -import { SettleResponseSchema } from "../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/x402_facilitator/test/x402_settle.test.ts b/x402_facilitator/test/x402_settle.test.ts deleted file mode 100644 index 59db443e4..000000000 --- a/x402_facilitator/test/x402_settle.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * 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 "../x402_settle.js"; -import * as facilitatorInstance from "../facilitator_instance.js"; -import type { getFacilitator } from "../facilitator_instance.js"; -import * as verifyModule from "../x402_verify.js"; -import * as feeModule from "../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/x402_facilitator/test/x402_settle_real_sdk.test.ts b/x402_facilitator/test/x402_settle_real_sdk.test.ts deleted file mode 100644 index 70e9ea5f2..000000000 --- a/x402_facilitator/test/x402_settle_real_sdk.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * 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 "../x402_settle.js"; -import { resetFacilitator } from "../facilitator_instance.js"; -import { FEE_SEND_ATTEMPTS, getFacilitatorAddress } from "../x402_fee.js"; -import { nonceManager } from "viem"; -import { SettleResponseSchema } from "../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/x402_facilitator/test/x402_splitter_settle.test.js b/x402_facilitator/test/x402_splitter_settle.test.js deleted file mode 100644 index dabee2147..000000000 --- a/x402_facilitator/test/x402_splitter_settle.test.js +++ /dev/null @@ -1,391 +0,0 @@ -// @ts-check - -/** - * Tests for x402 Splitter Facilitator /settle endpoint - * - * Key differences from whitelist facilitator: - * - No whitelist checks (public facilitator) - * - No TEST_WALLETS environment variable needed - * - Calls splitter.executeSplit() instead of token.transferWithAuthorization() - * - Extracts seller and salt from payload - * - Computes nonce as keccak256(seller, salt) - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { settleSplitterPayment } from "../x402_splitter_settle.js"; - -// Mock viem -vi.mock("viem", async () => { - const actual = await vi.importActual("viem"); - return { - ...actual, - createPublicClient: vi.fn(() => ({ - waitForTransactionReceipt: vi.fn(async ({ hash }) => ({ - status: "success", - blockNumber: 12345678n, - transactionHash: hash, - })), - })), - createWalletClient: vi.fn(() => ({ - writeContract: vi.fn( - async () => "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - ), - })), - }; -}); - -// Mock viem/accounts -vi.mock("viem/accounts", () => ({ - privateKeyToAccount: vi.fn(() => ({ - address: "0x1234567890123456789012345678901234567890", - })), -})); - -describe("x402_splitter_settle", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Set required environment variable - process.env.FACILITATOR_WALLET_PRIVATE_KEY = - "0x1234567890123456789012345678901234567890123456789012345678901234"; - // NO TEST_WALLETS - public facilitator - }); - - afterEach(() => { - // Restore original environment - process.env = { ...originalEnv }; - }); - - const paymentAmount = "100000"; // $0.10 in 6-decimal USDC - const tokenAddress = "0x5fd84259d66Cd46123540766Be93DFE6D43130D7"; // Sepolia USDC - const splitterAddress = "0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946"; // Sepolia splitter - const sellerAddress = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; - - const validPaymentPayload = { - x402Version: 2, - resource: { - url: "https://api.example.com/data", - description: "Premium data access", - mimeType: "application/json", - }, - accepted: { - scheme: "exact-split", - network: "eip155:11155420", - amount: paymentAmount, - asset: tokenAddress, - payTo: splitterAddress, // Payment to splitter - maxTimeoutSeconds: 60, - extra: { - name: "USDC", - version: "2", - }, - }, - payload: { - signature: - "0x82be15c8934c70f82322befd3ae22fef371a9265014fa5f2323368bf42b257db27f16284db18eff5b60bbf3415ab860a8edf54cd7927a1a124a0ddd9d687921b1b", - authorization: { - from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - to: splitterAddress, // To splitter contract - value: paymentAmount, - validAfter: "1740672089", - validBefore: "9999999999", - nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", - }, - seller: sellerAddress, // Final recipient - salt: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - }, - }; - - const validPaymentRequirements = { - scheme: "exact-split", - network: "eip155:11155420", - amount: paymentAmount, - asset: tokenAddress, - payTo: splitterAddress, - maxTimeoutSeconds: 60, - extra: { - name: "USDC", - version: "2", - }, - }; - - describe("settleSplitterPayment", () => { - it("returns error when verification fails", async () => { - // Invalid version should fail verification - const invalidPayload = { - ...validPaymentPayload, - x402Version: 1, - }; - - const result = await settleSplitterPayment(invalidPayload, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBe("invalid_exact_evm_signature"); - expect(result.transaction).toBe(""); - }); - - it("returns error when private key is missing", async () => { - delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; - - const result = await settleSplitterPayment(validPaymentPayload, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("returns error for unsupported network", async () => { - const unsupportedPayload = { - ...validPaymentPayload, - accepted: { - ...validPaymentPayload.accepted, - network: "eip155:999999", - }, - }; - - const unsupportedRequirements = { - ...validPaymentRequirements, - network: "eip155:999999", - }; - - const result = await settleSplitterPayment(unsupportedPayload, unsupportedRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("splits signature correctly", async () => { - // We can't fully test the on-chain execution without mocking, - // but we can verify the signature is processed - const signature = "0x" + "ab".repeat(65); // 130 hex chars - const payloadWithSig = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature, - }, - }; - - // Should not throw on signature splitting - await settleSplitterPayment(payloadWithSig, validPaymentRequirements); - }); - - it("rejects signature without 0x prefix", async () => { - const payloadWithoutPrefix = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature: "a".repeat(130), // Missing 0x - }, - }; - - const result = await settleSplitterPayment(payloadWithoutPrefix, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("rejects signature with invalid length", async () => { - const payloadWithShortSig = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature: "0xabcd", // Too short - }, - }; - - const result = await settleSplitterPayment(payloadWithShortSig, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("handles expired authorization", async () => { - const expiredPayload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - validBefore: Math.floor(Date.now() / 1000) - 100, // Already expired - }, - }, - }; - - const result = await settleSplitterPayment(expiredPayload, validPaymentRequirements); - - expect(result.success).toBe(false); - // Signature validation happens first, so we get signature error - expect(result.errorReason).toBe("invalid_exact_evm_signature"); - }); - - it("requires seller field in payload", async () => { - const payloadWithoutSeller = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - seller: undefined, // Missing seller - }, - }; - - const result = await settleSplitterPayment(payloadWithoutSeller, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("requires salt field in payload", async () => { - const payloadWithoutSalt = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - salt: undefined, // Missing salt - }, - }; - - const result = await settleSplitterPayment(payloadWithoutSalt, validPaymentRequirements); - - expect(result.success).toBe(false); - expect(result.errorReason).toBeDefined(); - }); - - it("extracts seller from payload", async () => { - // This test verifies that seller extraction works - // The actual extraction happens inside settleSplitterPayment - const customSeller = "0xCustomSellerAddress000000000000000000000000"; - - const payloadWithCustomSeller = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - seller: customSeller, - }, - }; - - // Should not throw - seller extraction should work - await settleSplitterPayment(payloadWithCustomSeller, validPaymentRequirements); - }); - - it("extracts salt from payload", async () => { - // This test verifies that salt extraction works - const customSalt = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; - - const payloadWithCustomSalt = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - salt: customSalt, - }, - }; - - // Should not throw - salt extraction should work - await settleSplitterPayment(payloadWithCustomSalt, validPaymentRequirements); - }); - - it("handles different amount values correctly", async () => { - const largeAmount = "1000000"; // $1.00 - - const payloadWithLargeAmount = { - ...validPaymentPayload, - accepted: { - ...validPaymentPayload.accepted, - amount: largeAmount, - }, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - value: largeAmount, - }, - }, - }; - - const requirementsWithLargeAmount = { - ...validPaymentRequirements, - amount: largeAmount, - }; - - // Should process without throwing - await settleSplitterPayment(payloadWithLargeAmount, requirementsWithLargeAmount); - }); - - it("handles Optimism Mainnet network", async () => { - const mainnetPayload = { - ...validPaymentPayload, - accepted: { - ...validPaymentPayload.accepted, - network: "eip155:10", // Optimism Mainnet - asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Mainnet USDC - }, - }; - - const mainnetRequirements = { - ...validPaymentRequirements, - network: "eip155:10", - asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - }; - - // Should accept Mainnet network - await settleSplitterPayment(mainnetPayload, mainnetRequirements); - }); - - it("handles different token addresses", async () => { - const customToken = "0xCustomTokenAddress0000000000000000000000000"; - - const payloadWithCustomToken = { - ...validPaymentPayload, - accepted: { - ...validPaymentPayload.accepted, - asset: customToken, - }, - }; - - const requirementsWithCustomToken = { - ...validPaymentRequirements, - asset: customToken, - }; - - // Should process without throwing - await settleSplitterPayment(payloadWithCustomToken, requirementsWithCustomToken); - }); - - it("accepts valid scheme exact-split", async () => { - // Verify that scheme validation accepts exact-split - const result = await settleSplitterPayment(validPaymentPayload, validPaymentRequirements); - - // Should process (may fail on verification, but not on scheme validation) - expect(result).toBeDefined(); - }); - - it("validates recipient is splitter contract", async () => { - const wrongRecipient = "0x0000000000000000000000000000000000000000"; - - const payloadWithWrongRecipient = { - ...validPaymentPayload, - accepted: { - ...validPaymentPayload.accepted, - payTo: wrongRecipient, // Wrong recipient - }, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - to: wrongRecipient, - }, - }, - }; - - const requirementsWithWrongRecipient = { - ...validPaymentRequirements, - payTo: wrongRecipient, - }; - - const result = await settleSplitterPayment( - payloadWithWrongRecipient, - requirementsWithWrongRecipient, - ); - - // Should fail verification (recipient validation) - expect(result.success).toBe(false); - }); - }); -}); diff --git a/x402_facilitator/test/x402_splitter_supported.test.js b/x402_facilitator/test/x402_splitter_supported.test.js deleted file mode 100644 index 6d0a6f3d8..000000000 --- a/x402_facilitator/test/x402_splitter_supported.test.js +++ /dev/null @@ -1,262 +0,0 @@ -// @ts-check - -/** - * Tests for x402 Splitter Facilitator /supported endpoint - * - * Key differences from whitelist facilitator: - * - Returns scheme "exact-split" instead of "exact" - * - No whitelist extension (public facilitator) - * - Includes splitter-specific fields in extra: facilitatorType, splitterAddress, fixedFee - * - Returns facilitator wallet as signer - */ - -import { describe, test, expect, afterEach } from "vitest"; -import { getSplitterCapabilities } from "../x402_splitter_supported.js"; - -describe("x402 Splitter /supported endpoint", () => { - const originalEnv = process.env.FACILITATOR_WALLET_PRIVATE_KEY; - const originalFixedFee = process.env.FIXED_FEE; - - afterEach(() => { - // Restore original env - if (originalEnv) { - process.env.FACILITATOR_WALLET_PRIVATE_KEY = originalEnv; - } else { - delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; - } - if (originalFixedFee) { - process.env.FIXED_FEE = originalFixedFee; - } else { - delete process.env.FIXED_FEE; - } - }); - - test("returns supported capabilities", () => { - const capabilities = getSplitterCapabilities(); - - 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("excludes Optimism Mainnet when splitter not deployed", () => { - // Mainnet splitter is not yet deployed (SPLITTER_ADDRESS = null in chain_utils) - const capabilities = getSplitterCapabilities(); - - const mainnetSupport = capabilities.kinds.find((k) => k.network === "eip155:10"); - - // Mainnet should NOT be included until splitter is deployed - expect(mainnetSupport).toBeUndefined(); - }); - - test("includes Optimism Sepolia support", () => { - const capabilities = getSplitterCapabilities(); - - const sepoliaSupport = capabilities.kinds.find((k) => k.network === "eip155:11155420"); - - expect(sepoliaSupport).toBeDefined(); - expect(sepoliaSupport.x402Version).toBe(2); - expect(sepoliaSupport.scheme).toBe("exact-split"); // Custom scheme - }); - - test("scheme is exact-split not exact", () => { - const capabilities = getSplitterCapabilities(); - - // Both networks should use exact-split - capabilities.kinds.forEach((kind) => { - expect(kind.scheme).toBe("exact-split"); - expect(kind.scheme).not.toBe("exact"); - }); - }); - - test("includes facilitatorType in extra field", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.extra).toBeDefined(); - expect(kind.extra.facilitatorType).toBe("splitter"); - }); - }); - - test("includes splitterAddress in extra field", () => { - const capabilities = getSplitterCapabilities(); - - const sepolia = capabilities.kinds.find((k) => k.network === "eip155:11155420"); - - expect(sepolia.extra.splitterAddress).toBeDefined(); - expect(sepolia.extra.splitterAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); // Valid address format - expect(sepolia.extra.splitterAddress).toBe("0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946"); - }); - - test("includes fixedFee in extra field", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.extra.fixedFee).toBeDefined(); - expect(kind.extra.fixedFee).toBe("10000"); // 0.01 USDC - }); - }); - - test("includes feeDescription in extra field", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.extra.feeDescription).toBeDefined(); - expect(kind.extra.feeDescription).toContain("0.01 USDC"); - expect(kind.extra.feeDescription).toContain("fixed fee"); - }); - }); - - test("includes asset in extra field", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.extra.asset).toBeDefined(); - expect(kind.extra.asset).toMatch(/^eip155:\d+\/erc20:0x[a-fA-F0-9]{40}$/); // CAIP-19 format - }); - }); - - test("does NOT include whitelist parameter", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.extra.whitelist).toBeUndefined(); - }); - }); - - test("extensions includes facilitatorFees for fee-aware routing (#1016)", () => { - // FIXED_FEE is read at module-load time as a top-level const (default "10000"). - // This assertion relies on that default; afterEach restores FIXED_FEE to prevent leaks. - const capabilities = getSplitterCapabilities(); - - expect(capabilities.extensions.length).toBeGreaterThan(0); - const feesExtension = capabilities.extensions.find((e) => e.name === "facilitatorFees"); - expect(feesExtension).toBeDefined(); - expect(feesExtension.version).toBe("1"); - expect(feesExtension.model).toBe("flat"); - expect(feesExtension.asset).toBe("USDC"); - expect(feesExtension.flatFee).toBe("10000"); - expect(feesExtension.decimals).toBe(6); - expect(feesExtension.collection).toBe("on_chain_split"); - expect(feesExtension.networks).toContain("eip155:11155420"); - }); - - test("signers is empty (payments signed by payers, not facilitator)", () => { - const capabilities = getSplitterCapabilities(); - - expect(capabilities.signers).toBeDefined(); - // Splitter facilitator doesn't sign payments, payers do - expect(capabilities.signers).toEqual({}); - }); - - test("returns facilitatorFees extension (no recipient whitelist)", () => { - const capabilities = getSplitterCapabilities(); - - // Has facilitatorFees but no whitelist - expect(capabilities.extensions.length).toBeGreaterThan(0); - - // Explicitly verify no whitelist extension exists - const whitelistExtension = capabilities.extensions.find( - (e) => e.name === "recipient_whitelist", - ); - expect(whitelistExtension).toBeUndefined(); - }); - - test("only includes networks with deployed splitter contracts", () => { - const capabilities = getSplitterCapabilities(); - - // Currently only Sepolia has a deployed splitter - const sepolia = capabilities.kinds.find((k) => k.network === "eip155:11155420"); - expect(sepolia).toBeDefined(); - expect(sepolia.extra.splitterAddress).toBe("0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946"); - - // Mainnet should not be included (splitter not deployed) - const mainnet = capabilities.kinds.find((k) => k.network === "eip155:10"); - expect(mainnet).toBeUndefined(); - }); - - test("Mainnet will use correct USDC asset when deployed", () => { - // This test documents expected behavior once mainnet is deployed - // Currently mainnet is excluded because splitter is not deployed - const capabilities = getSplitterCapabilities(); - const mainnet = capabilities.kinds.find((k) => k.network === "eip155:10"); - - // Mainnet not included yet - expect(mainnet).toBeUndefined(); - - // When deployed, it should use: eip155:10/erc20:0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 - }); - - test("Sepolia uses correct USDC asset", () => { - const capabilities = getSplitterCapabilities(); - - const sepolia = capabilities.kinds.find((k) => k.network === "eip155:11155420"); - - // Optimism Sepolia USDC: 0x5fd84259d66Cd46123540766Be93DFE6D43130D7 - expect(sepolia.extra.asset).toBe( - "eip155:11155420/erc20:0x5fd84259d66Cd46123540766Be93DFE6D43130D7", - ); - }); - - test("fixedFee matches contract configuration (10000 = 0.01 USDC)", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - const fixedFee = parseInt(kind.extra.fixedFee, 10); - expect(fixedFee).toBe(10000); // 0.01 USDC with 6 decimals - }); - }); - - test("includes only networks with deployed splitter contracts", () => { - const capabilities = getSplitterCapabilities(); - - // Currently only Sepolia has deployed splitter - expect(capabilities.kinds.length).toBe(1); - - // Verify only Sepolia is included - const networks = capabilities.kinds.map((k) => k.network); - expect(networks).not.toContain("eip155:10"); // Mainnet not deployed - expect(networks).toContain("eip155:11155420"); // Sepolia deployed - }); - - test("all kinds have x402Version 2", () => { - const capabilities = getSplitterCapabilities(); - - capabilities.kinds.forEach((kind) => { - expect(kind.x402Version).toBe(2); - }); - }); - - test("signers is empty object (no facilitator signing)", () => { - const capabilities = getSplitterCapabilities(); - - // Splitter doesn't require facilitator signatures - expect(Object.keys(capabilities.signers).length).toBe(0); - }); - - test("response structure matches x402 v2 SupportedResponse schema", () => { - const capabilities = getSplitterCapabilities(); - - // Top-level structure - expect(capabilities).toHaveProperty("kinds"); - expect(capabilities).toHaveProperty("extensions"); - expect(capabilities).toHaveProperty("signers"); - - // kinds structure - expect(Array.isArray(capabilities.kinds)).toBe(true); - capabilities.kinds.forEach((kind) => { - expect(kind).toHaveProperty("x402Version"); - expect(kind).toHaveProperty("scheme"); - expect(kind).toHaveProperty("network"); - expect(kind).toHaveProperty("extra"); - }); - - // extensions structure - expect(Array.isArray(capabilities.extensions)).toBe(true); - - // signers structure - expect(typeof capabilities.signers).toBe("object"); - }); -}); diff --git a/x402_facilitator/test/x402_splitter_verify.test.js b/x402_facilitator/test/x402_splitter_verify.test.js deleted file mode 100644 index 259ceceb0..000000000 --- a/x402_facilitator/test/x402_splitter_verify.test.js +++ /dev/null @@ -1,395 +0,0 @@ -/** - * Tests for x402 Splitter Facilitator /verify endpoint - * - * Key differences from whitelist facilitator: - * - No whitelist checks (public facilitator) - * - No TEST_WALLETS environment variable needed - * - Payment goes to splitter contract, not final recipient - * - Payload includes seller and salt fields - * - * Error codes are based on actual implementation behavior. - */ - -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { verifySplitterPayment } from "../x402_splitter_verify.js"; - -describe("x402 Splitter Verify", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Only need facilitator private key for signature validation - process.env.FACILITATOR_WALLET_PRIVATE_KEY = - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - // NO TEST_WALLETS - public facilitator - }); - - afterEach(() => { - // Restore original environment - process.env = { ...originalEnv }; - }); - - // Test data - const paymentAmount = "100000"; // $0.10 in 6-decimal USDC - const tokenAddress = "0x5fd84259d66Cd46123540766Be93DFE6D43130D7"; // Sepolia USDC - const splitterAddress = "0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946"; // Sepolia splitter - const recipientAddress = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; // Final seller - - const validPaymentPayload = { - x402Version: 2, - resource: { - url: "https://api.example.com/data", - description: "Premium data access", - mimeType: "application/json", - }, - accepted: { - scheme: "exact-split", - network: "eip155:11155420", - amount: paymentAmount, - asset: tokenAddress, - payTo: splitterAddress, // Payment to splitter, not seller - maxTimeoutSeconds: 60, - extra: { - name: "USDC", - version: "2", - }, - }, - payload: { - signature: - "0x82be15c8934c70f82322befd3ae22fef371a9265014fa5f2323368bf42b257db27f16284db18eff5b60bbf3415ab860a8edf54cd7927a1a124a0ddd9d687921b1b", - authorization: { - from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - to: splitterAddress, // Splitter receives the authorization - value: paymentAmount, - validAfter: "1740672089", - validBefore: "9999999999", - nonce: "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", - }, - seller: recipientAddress, // Final recipient extracted for splitting - salt: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - }, - }; - - const validPaymentRequirements = { - scheme: "exact-split", - network: "eip155:11155420", - amount: paymentAmount, - asset: tokenAddress, - payTo: splitterAddress, // Requirements also specify splitter - maxTimeoutSeconds: 60, - extra: { - name: "USDC", - version: "2", - }, - }; - - test("validates signature before other checks", async () => { - // Test with invalid signature format - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature: "invalid", - }, - }; - - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Invalid signature format throws error which becomes unexpected_verify_error - expect(result.invalidReason).toBe("unexpected_verify_error"); - }); - - test("rejects invalid x402 version", async () => { - const payload = { ...validPaymentPayload, x402Version: 1 }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Signature validation happens, test signature doesn't match modified payload - expect(result.invalidReason).toBe("invalid_exact_evm_signature"); - }); - - test("rejects unsupported scheme", async () => { - const payload = { - ...validPaymentPayload, - accepted: { ...validPaymentPayload.accepted, scheme: "deferred" }, - }; - const requirements = { - ...validPaymentRequirements, - scheme: "deferred", // Match payload to pass scheme check - }; - const result = await verifySplitterPayment(payload, requirements); - - expect(result.isValid).toBe(false); - // After passing scheme check, signature validation happens - expect(result.invalidReason).toBe("invalid_exact_evm_signature"); - }); - - test("rejects unsupported network", async () => { - const payload = { - ...validPaymentPayload, - accepted: { ...validPaymentPayload.accepted, network: "eip155:1" }, // Ethereum mainnet - }; - const requirements = { - ...validPaymentRequirements, - network: "eip155:1", // Match payload to pass network check - }; - const result = await verifySplitterPayment(payload, requirements); - - expect(result.isValid).toBe(false); - // After passing network check, unsupported_network is detected during chain validation - expect(result.invalidReason).toBe("unsupported_network"); - }); - - test("rejects expired authorization", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - validBefore: "1000000000", // Past timestamp - }, - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Signature validation happens first, so modified payload fails signature check - expect(result.invalidReason).toBe("invalid_exact_evm_signature"); - }); - - test("rejects not yet valid authorization", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - validAfter: "9999999999", // Future timestamp - }, - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Signature validation happens first - expect(result.invalidReason).toBe("invalid_exact_evm_signature"); - }); - - test("rejects insufficient amount (less than fixed fee)", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - value: "5000", // Less than 10000 fixed fee - }, - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Amount validation actually happens BEFORE signature check in this implementation - expect(result.invalidReason).toBe("invalid_exact_evm_payload_authorization_value"); - }); - - test("rejects mismatched recipient (not splitter)", async () => { - const differentRecipient = "0x0000000000000000000000000000000000000000"; - - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - authorization: { - ...validPaymentPayload.payload.authorization, - to: differentRecipient, - }, - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Recipient check happens before signature validation - expect(result.invalidReason).toBe("invalid_exact_evm_payload_recipient_mismatch"); - }); - - test("rejects missing payload", async () => { - const payload = { - ...validPaymentPayload, - payload: {}, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Missing authorization field - expect(result.invalidReason).toBe("missing_authorization"); - }); - - test("rejects signature without 0x prefix", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature: - "2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c", // Missing 0x - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Signature parsing error - expect(result.invalidReason).toBe("unexpected_verify_error"); - }); - - test("rejects signature with invalid length", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - signature: - "0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b57", // Too short - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Signature parsing error - expect(result.invalidReason).toBe("unexpected_verify_error"); - }); - - test("uses x402 v2 ExactEvmScheme for Optimism networks", async () => { - // Verify that the facilitator accepts and processes Optimism networks - const result = await verifySplitterPayment(validPaymentPayload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Should fail on signature validation (test signature doesn't match) - expect(result.invalidReason).toBe("invalid_exact_evm_signature"); - }); - - test("requires seller field in payload", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - seller: undefined, // Missing seller - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Will fail during verification checks - expect(result.invalidReason).toBeDefined(); - }); - - test("requires salt field in payload", async () => { - const payload = { - ...validPaymentPayload, - payload: { - ...validPaymentPayload.payload, - salt: undefined, // Missing salt - }, - }; - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - // Will fail during verification checks - expect(result.invalidReason).toBeDefined(); - }); - - // Payment Requirements Validation Tests - - test("rejects amount mismatch", async () => { - const mismatchedRequirements = { - ...validPaymentRequirements, - amount: "200000", // Different from payload's 100000 - }; - - const result = await verifySplitterPayment(validPaymentPayload, mismatchedRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("amount_mismatch"); - }); - - test("rejects recipient mismatch", async () => { - const wrongRecipient = "0x0000000000000000000000000000000000000001"; - const mismatchedRequirements = { - ...validPaymentRequirements, - payTo: wrongRecipient, - }; - - const result = await verifySplitterPayment(validPaymentPayload, mismatchedRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("recipient_mismatch"); - }); - - test("rejects network mismatch", async () => { - const mismatchedRequirements = { - ...validPaymentRequirements, - network: "eip155:10", // Mainnet instead of Sepolia - }; - - const result = await verifySplitterPayment(validPaymentPayload, mismatchedRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("invalid_exact_evm_network_mismatch"); - }); - - test("rejects asset mismatch", async () => { - const wrongAsset = "0x0000000000000000000000000000000000000002"; - const mismatchedRequirements = { - ...validPaymentRequirements, - asset: wrongAsset, - }; - - const result = await verifySplitterPayment(validPaymentPayload, mismatchedRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("asset_mismatch"); - }); - - test("rejects scheme mismatch", async () => { - const mismatchedRequirements = { - ...validPaymentRequirements, - scheme: "exact", // Standard scheme instead of exact-split - }; - - const result = await verifySplitterPayment(validPaymentPayload, mismatchedRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("scheme_mismatch"); - }); - - test("rejects missing accepted field", async () => { - const payload = { - ...validPaymentPayload, - accepted: undefined, - }; - - const result = await verifySplitterPayment(payload, validPaymentRequirements); - - expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("missing_accepted"); - }); - - test("accepts case-insensitive address matching", async () => { - // Should accept addresses with different casing - const upperCaseRequirements = { - ...validPaymentRequirements, - payTo: splitterAddress.toUpperCase(), - asset: tokenAddress.toUpperCase(), - }; - - const result = await verifySplitterPayment(validPaymentPayload, upperCaseRequirements); - - expect(result.isValid).toBe(false); - // Will fail on signature, but NOT on address matching - expect(result.invalidReason).not.toBe("recipient_mismatch"); - expect(result.invalidReason).not.toBe("asset_mismatch"); - }); - - // Note: E2E tests with real signatures would require @real-token/x402-client - // which is not installed in this environment. Those tests are covered by - // the Jupyter notebook end-to-end demonstrations. -}); diff --git a/x402_facilitator/test/x402_supported.test.ts b/x402_facilitator/test/x402_supported.test.ts deleted file mode 100644 index 7aa3f1785..000000000 --- a/x402_facilitator/test/x402_supported.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * 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 "../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/x402_facilitator/test/x402_verify.test.ts b/x402_facilitator/test/x402_verify.test.ts deleted file mode 100644 index 82996d69b..000000000 --- a/x402_facilitator/test/x402_verify.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * 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 "../x402_verify.js"; -import { resetFacilitator } from "../facilitator_instance.js"; -import { getFacilitatorAddress } from "../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/x402_facilitator/test/x402_whitelist.test.ts b/x402_facilitator/test/x402_whitelist.test.ts deleted file mode 100644 index 0d67d1d46..000000000 --- a/x402_facilitator/test/x402_whitelist.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Tests for the batch-settlement test-wallet bypass (x402_whitelist.ts) - */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { isTestWalletBypassed } from "../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/x402_facilitator/tsdown.config.ts b/x402_facilitator/tsdown.config.ts index 51cf19438..1d84aace1 100644 --- a/x402_facilitator/tsdown.config.ts +++ b/x402_facilitator/tsdown.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - // x402_splitter_facilitator.js is deliberately absent: the buyer-pays splitter is - // retired (see README → Fee model history). No function in serverless.yml routes to it, - // but `package: dist/**` means anything built here still ships in the deploy archive. - entry: ["x402_facilitator.ts", "wallet_report_cron.ts"], + // Only the wallet-report cron: the HTTP facilitator moved to bunny_js, and the buyer-pays + // splitter JS was retired with it. `package: dist/**` means anything built here ships in + // the deploy archive, so the entry list must stay this short. + entry: ["wallet_report_cron.ts"], format: "esm", platform: "node", target: "node22", diff --git a/x402_facilitator/vitest.integration.config.js b/x402_facilitator/vitest.integration.config.js deleted file mode 100644 index df24c0877..000000000 --- a/x402_facilitator/vitest.integration.config.js +++ /dev/null @@ -1,13 +0,0 @@ -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/integration/**/*.test.{js,ts}"], - testTimeout: 30000, // live RPC calls can be slow - }, -}); diff --git a/x402_facilitator/wallet_report_cron.ts b/x402_facilitator/wallet_report_cron.ts index 71e37e055..89ff2f74b 100644 --- a/x402_facilitator/wallet_report_cron.ts +++ b/x402_facilitator/wallet_report_cron.ts @@ -37,9 +37,28 @@ import { type Abi, } from "viem"; import { logger } from "./logger"; -import { feeAmountFor, getFacilitatorAddress } from "./x402_fee"; +import { feeAmountFor, getFacilitatorAddress } from "./wallet_report_fee"; import { getChainConfig, getFeeTokens, getRpcUrl } from "./chain_utils"; -import type { ScalewayEvent, ScalewayResponse } from "./x402_facilitator"; + +// The Scaleway Functions envelope types, inlined from the retired x402_facilitator.ts at the +// facilitator's move to bunny_js — this package stays a Scaleway Function, so the envelope +// stays with it. The HTTP facilitator has no envelope: bunny_js answers web Requests. +/** Scaleway Functions event object */ +interface ScalewayEvent { + httpMethod: string; + path?: string; + rawUrl?: string; + body?: string | Record; + headers?: Record; + queryStringParameters?: Record; +} + +/** Scaleway Functions response object */ +interface ScalewayResponse { + statusCode: number; + headers: Record; + body: string; +} // Mainnets only — the two networks where real fees accrue and gas actually matters. const REPORT_NETWORKS = ["eip155:10", "eip155:8453"] as const; @@ -51,7 +70,7 @@ const DEFAULT_LOW_GAS_THRESHOLD_ETH = "0.005"; // Revisit if REPORT_NETWORKS ever gains a network with a different block time. const LOOKBACK_BLOCKS = 302_400n; -// Minimal ERC-20 read ABI (balanceOf) — mirrors the shape used in x402_fee.ts. +// Minimal ERC-20 read ABI (balanceOf) — just the one function this report needs. const ERC20_BALANCE_ABI = [ { name: "balanceOf", diff --git a/x402_facilitator/wallet_report_fee.ts b/x402_facilitator/wallet_report_fee.ts new file mode 100644 index 000000000..d6f4f37c6 --- /dev/null +++ b/x402_facilitator/wallet_report_fee.ts @@ -0,0 +1,62 @@ +/** + * The weekly wallet report's fee/account view — the ONLY pieces of the retired facilitator + * core this package still needs now that the HTTP facilitator lives in bunny_js + * (bunny_js/src/facilitator/) and serves facilitator.fretchen.eu. Extracted verbatim from + * the old x402_fee.ts at the teardown; the fee model's source of truth is + * bunny_js/src/facilitator/x402_fee.ts — if the fee changes there, update this copy in the + * same sitting. The stakes are low and one-sided: nothing funds-bearing runs here anymore, + * a drift only mis-estimates the report's nominal-fee arithmetic. + */ +import type { Address } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { loadPrivateKey } from "@fretchen/chain-utils"; +import { logger } from "./logger"; + +/** + * The facilitator's flat per-settlement fee. Merchant-pays by design: the fee is pulled from + * the settled amount right after settlement, never charged to the buyer. Every place that + * charges, checks or reports the fee must therefore use `feeAmountFor(decimals)`, so the + * token's decimals and the base unit are scaled in exactly one place. + */ +const DEFAULT_FEE_AMOUNT = 10000n; + +/** The decimals `FACILITATOR_FEE_AMOUNT` / `DEFAULT_FEE_AMOUNT` are written in. */ +const FEE_BASE_DECIMALS = 6; + +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 facilitator's wallet address — the account the weekly report balances. Null = unset. */ +export function getFacilitatorAddress(): Address | null { + try { + return privateKeyToAccount(loadPrivateKey("FACILITATOR_WALLET_PRIVATE_KEY")).address; + } catch { + return null; + } +} diff --git a/x402_facilitator/x402_facilitator.ts b/x402_facilitator/x402_facilitator.ts deleted file mode 100644 index 946ea6f5e..000000000 --- a/x402_facilitator/x402_facilitator.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * x402 v2 Facilitator - Main Handler - * Handles POST /verify and POST /settle endpoints - */ - -import { verifyPayment } from "./x402_verify"; -import { settlePayment } from "./x402_settle"; -import { getSupportedCapabilities } from "./x402_supported"; -import { - PaymentRequestSchema, - type VerifyResponseBody, - type SettleResponseBody, -} from "./x402_schemas"; -import openapiSpec from "./openapi.json" with { type: "json" }; -import type { z } from "zod"; -import { logger } from "./logger"; - -/** - * Scaleway Functions event object - */ -export interface ScalewayEvent { - httpMethod: string; - path?: string; - rawUrl?: string; - body?: string | Record; - headers?: Record; - queryStringParameters?: Record; -} - -/** - * Scaleway Functions context object - */ -export interface ScalewayContext { - memoryLimitInMb?: number; - functionName?: string; - functionVersion?: string; -} - -/** - * Scaleway Functions response object - */ -export interface ScalewayResponse { - statusCode: number; - headers: Record; - body: string; -} - -/** - * 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 - */ -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", -}; - -/** Human-facing documentation for the facilitator — the root path redirects browsers here. */ -const DOCUMENTATION_URL = "https://www.fretchen.eu/x402/"; - -/** - * Case-insensitive header lookup. Header casing on Scaleway's event object is not - * guaranteed, so a direct `headers["Accept"]` read can silently miss a real header. - */ -function getHeader(headers: Record | undefined, name: string): string | undefined { - if (!headers) { - return undefined; - } - const lower = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === lower) { - return value; - } - } - return undefined; -} - -/** - * Handle /verify endpoint - off-chain verification - */ -export async function handleVerify( - event: ScalewayEvent, - _context: ScalewayContext, -): Promise { - return handlePaymentRequest(event, _context, false); -} - -/** - * Handle /settle endpoint - on-chain execution - */ -export async function handleSettle( - event: ScalewayEvent, - _context: ScalewayContext, -): Promise { - return handlePaymentRequest(event, _context, true); -} - -/** - * Handle /supported endpoint - capability discovery - */ -// eslint-disable-next-line @typescript-eslint/require-await -export async function handleSupported( - event: ScalewayEvent, - _context: ScalewayContext, -): Promise { - // Handle CORS preflight - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - if (event.httpMethod !== "GET") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use GET." }), - }; - } - - const capabilities = getSupportedCapabilities(); - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(capabilities), - }; -} - -// eslint-disable-next-line @typescript-eslint/require-await -export async function handleOpenApiSpec( - event: ScalewayEvent, - _context: ScalewayContext, -): Promise { - // Handle CORS preflight - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - if (event.httpMethod !== "GET") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use GET." }), - }; - } - - // No live values to patch in, unlike scw_js's openapi.json: /supported is already the - // live source of truth for the fee amount and recommended approval, so this document - // only ever describes shape. structuredClone still guards against any future caller - // mutating the shared import. - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(structuredClone(openapiSpec)), - }; -} - -/** - * Unified handler for verify and settle endpoints - */ -async function handlePaymentRequest( - event: ScalewayEvent, - _context: ScalewayContext, - isSettle: boolean, -): Promise { - // Handle CORS preflight requests - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - // Only accept POST requests - if (event.httpMethod !== "POST") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use POST." }), - }; - } - - // Two distinct failures, two distinct messages. Scaleway hands us either a raw string or an - // already-parsed object, and only the string path can fail at JSON.parse. - let rawBody: unknown; - if (typeof event.body === "string") { - try { - rawBody = JSON.parse(event.body); - } catch (error) { - // warn, not error: malformed JSON is the caller's fault, not ours. The alert-coverage - // convention is that logger.error means "ours, and should page" — see - // test/alert_coverage.test.ts. - logger.warn({ err: error }, "Failed to parse request body"); - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Invalid JSON in request body" }), - }; - } - } else { - rawBody = event.body; - } - - // Validate against the schema this service publishes, which until now 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 - // previously reached the SDK and came back isValid:false — an invalid payment, when it was - // really a malformed request. - const validation = PaymentRequestSchema.safeParse(rawBody); - if (!validation.success) { - logger.warn({ issues: validation.error.issues }, "Rejected malformed payment request"); - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ - 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 { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(responseBody), - }; - } else { - logger.warn( - { - errorReason: result.errorReason, - // Underlying SDK detail (e.g. the decoded revert reason). Logged only — - // deliberately omitted from the response body below, which returns the - // stable `errorReason` code instead. See SettleResult.errorMessage. - errorMessage: result.errorMessage, - payer: result.payer, - }, - "Settlement failed", - ); - - const failureBody: SettleResponseBody = { - success: false, - errorReason: result.errorReason, - payer: result.payer, - // 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. - transaction: result.transaction ?? "", - network: result.network, - }; - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(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 { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(responseBody), - }; - } else { - logger.warn( - { - invalidReason: result.invalidReason, - payer: result.payer, - }, - "Payment verification failed", - ); - - const responseBody: VerifyResponseBody = { - isValid: false, - invalidReason: result.invalidReason, - payer: result.payer, - }; - return { - statusCode: 200, // Still return 200, but with isValid: false - headers: CORS_HEADERS, - body: JSON.stringify(responseBody), - }; - } - } catch (error) { - logger.error({ err: error }, "Unexpected error in handler"); - return { - statusCode: 500, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Internal server error", - [isSettle ? "success" : "isValid"]: false, - [isSettle ? "errorReason" : "invalidReason"]: isSettle - ? "unexpected_settlement_error" - : "unexpected_verify_error", - }), - }; - } -} - -/** - * Local development server with routing - * This simulates the separate Scaleway Functions deployment locally - */ -export async function handle( - event: ScalewayEvent, - context: ScalewayContext, -): Promise { - const path = event.path || event.rawUrl || ""; - - if (path.includes("/openapi.json")) { - return handleOpenApiSpec(event, context); - } - if (path.includes("/supported")) { - return handleSupported(event, context); - } - if (path.includes("/settle")) { - return handleSettle(event, context); - } - if (path.includes("/verify")) { - return handleVerify(event, context); - } - - // Only the literal root ("/") — NOT the empty string. A real root request (browser, - // curl, an agent probing the domain) always carries path "/"; an event with neither - // `path` nor `rawUrl` set is a malformed/absent-info request, which stays a 404 below. - if (path === "/") { - const accept = getHeader(event.headers, "Accept") ?? ""; - if (accept.includes("text/html")) { - // A human followed a link (e.g. from a facilitator listing) — send them to the - // docs instead of a JSON error object. - return { - statusCode: 302, - headers: { ...CORS_HEADERS, Location: DOCUMENTATION_URL }, - body: "", - }; - } - // No Accept: text/html — an agent, curl, or a machine client. Give it an onward - // path rather than nothing to act on. - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify({ - service: "x402 facilitator", - documentation: DOCUMENTATION_URL, - supported: "/supported", - openapi: "/openapi.json", - endpoints: ["/verify", "/settle", "/supported", "/openapi.json"], - }), - }; - } - - // Default 404 - return { - statusCode: 404, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Endpoint not found. Use /verify, /settle, or /supported", - }), - }; -} - -/* This is used to test locally and will not be executed on Scaleway Functions */ -if (process.env.NODE_ENV === "test") { - (async () => { - const dotenvModule = await import("dotenv"); - dotenvModule.config(); - - const scw_fnc_node = await import("@scaleway/serverless-functions"); - // The local dev server's Handler type models Scaleway's raw Lambda-style event - // (headers: Record | null), which is narrower than our - // ScalewayEvent — this cast is a dev-only harness boundary, not a real runtime risk. - scw_fnc_node.serveHandler(handle as Parameters[0], 8080); - - logger.info("🚀 Local server started at http://localhost:8080"); - logger.info(" POST http://localhost:8080/verify"); - logger.info(" POST http://localhost:8080/settle"); - logger.info(" GET http://localhost:8080/supported"); - })().catch((err: Error) => logger.error({ err }, "Error starting local server")); -} diff --git a/x402_facilitator/x402_fee.ts b/x402_facilitator/x402_fee.ts deleted file mode 100644 index 9aa1da1b3..000000000 --- a/x402_facilitator/x402_fee.ts +++ /dev/null @@ -1,619 +0,0 @@ -/** - * 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/x402_facilitator/x402_schemas.ts b/x402_facilitator/x402_schemas.ts deleted file mode 100644 index f750ac7fb..000000000 --- a/x402_facilitator/x402_schemas.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * 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/x402_facilitator/x402_settle.ts b/x402_facilitator/x402_settle.ts deleted file mode 100644 index 45ef0e678..000000000 --- a/x402_facilitator/x402_settle.ts +++ /dev/null @@ -1,653 +0,0 @@ -/** - * 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, @typescript-eslint/no-unsafe-argument - 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, @typescript-eslint/no-unsafe-argument - 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/x402_facilitator/x402_splitter_facilitator.js b/x402_facilitator/x402_splitter_facilitator.js deleted file mode 100644 index 34cf4666c..000000000 --- a/x402_facilitator/x402_splitter_facilitator.js +++ /dev/null @@ -1,362 +0,0 @@ -// @ts-check - -/** - * ⚠️ RETIRED — superseded by the merchant-pays model in `x402_fee.ts`. - * - * Buyer-pays splitter: the buyer signs one EIP-3009 authorization to EIP3009SplitterV1, - * which atomically pays seller and facilitator. The seller needs no setup, but the buyer - * needs a non-stock client and `payTo` shows the splitter rather than the real recipient. - * See README → "Fee model history" for why the merchant-pays model won. - * - * Not deployed and not built: no function in `serverless.yml` routes here, and it is - * excluded from the tsdown `entry` list so it does not ship in the deploy archive. - * - * Retained, not dead: this is a working implementation of the alternative, and the - * contract at 0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946 (Optimism Sepolia) is referenced - * from x402-foundation/x402#937, so that deployment stays up. Its tests still run. - */ - -/** - * x402 Splitter Facilitator - Main Handler - * Handles /verify, /settle, /supported endpoints - * - * No whitelist - public facilitator with fixed fee - */ - -import { verifySplitterPayment } from "./x402_splitter_verify.js"; -import { settleSplitterPayment } from "./x402_splitter_settle.js"; -import { getSplitterCapabilities } from "./x402_splitter_supported.js"; -import pino from "pino"; - -const logger = pino({ level: process.env.LOG_LEVEL || "info" }); - -/** - * Common headers for all responses - */ -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "*", - "Content-Type": "application/json", -}; - -/** - * Handle /verify endpoint - off-chain verification - */ -export async function handleVerify(event) { - // Handle CORS preflight - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - // Only accept POST requests - if (event.httpMethod !== "POST") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use POST." }), - }; - } - - // Parse request body - let body; - try { - body = typeof event.body === "string" ? JSON.parse(event.body) : event.body; - } catch (error) { - logger.error({ err: error }, "Failed to parse request body"); - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Invalid JSON in request body" }), - }; - } - - // Validate request structure - if (!body.paymentPayload || !body.paymentRequirements) { - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Request must include both paymentPayload and paymentRequirements", - }), - }; - } - - const { paymentPayload, paymentRequirements } = body; - - logger.info( - { - endpoint: "verify", - network: paymentPayload.accepted?.network, - amount: paymentPayload.payload?.authorization?.value, - scheme: paymentPayload.accepted?.scheme, - }, - "Processing verify request", - ); - - try { - const result = await verifySplitterPayment(paymentPayload, paymentRequirements); - - if (result.isValid) { - logger.info({ payer: result.payer }, "Payment verification successful (no whitelist)"); - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify({ - isValid: true, - payer: result.payer, - }), - }; - } else { - logger.warn( - { - invalidReason: result.invalidReason, - payer: result.payer, - }, - "Payment verification failed", - ); - - return { - statusCode: 200, // Still return 200, but with isValid: false - headers: CORS_HEADERS, - body: JSON.stringify({ - isValid: false, - invalidReason: result.invalidReason, - payer: result.payer, - }), - }; - } - } catch (error) { - logger.error({ err: error, message: error.message }, "Unexpected error in verify handler"); - return { - statusCode: 500, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Internal server error", - isValid: false, - invalidReason: "unexpected_verify_error", - }), - }; - } -} - -/** - * Handle /settle endpoint - on-chain execution - */ -export async function handleSettle(event) { - // Handle CORS preflight - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - // Only accept POST requests - if (event.httpMethod !== "POST") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use POST." }), - }; - } - - // Parse request body - let body; - try { - body = typeof event.body === "string" ? JSON.parse(event.body) : event.body; - } catch (error) { - logger.error({ err: error }, "Failed to parse request body"); - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Invalid JSON in request body" }), - }; - } - - // Validate request structure - if (!body.paymentPayload || !body.paymentRequirements) { - return { - statusCode: 400, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Request must include both paymentPayload and paymentRequirements", - }), - }; - } - - const { paymentPayload, paymentRequirements } = body; - - logger.info( - { - endpoint: "settle", - network: paymentPayload.accepted?.network, - amount: paymentPayload.payload?.authorization?.value, - seller: paymentPayload.payload?.seller, - }, - "Processing settlement request", - ); - - try { - const result = await settleSplitterPayment(paymentPayload, paymentRequirements); - - if (result.success) { - logger.info( - { - transaction: result.transaction, - payer: result.payer, - network: result.network, - }, - "Settlement successful", - ); - - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify({ - success: true, - payer: result.payer, - transaction: result.transaction, - network: result.network, - ...(result.extensions ? { extensions: result.extensions } : {}), - }), - }; - } else { - logger.warn( - { - errorReason: result.errorReason, - payer: result.payer, - network: result.network, - }, - "Settlement failed", - ); - - return { - statusCode: 200, // Still return 200, but with success: false - headers: CORS_HEADERS, - body: JSON.stringify({ - success: false, - errorReason: result.errorReason, - payer: result.payer, - transaction: result.transaction || "", - network: result.network, - }), - }; - } - } catch (error) { - logger.error({ err: error, message: error.message }, "Unexpected error in settle handler"); - return { - statusCode: 500, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Internal server error", - success: false, - errorReason: "unexpected_settle_error", - }), - }; - } -} - -/** - * Handle /supported endpoint - capability discovery - */ -export async function handleSupported(event) { - // Handle CORS preflight - if (event.httpMethod === "OPTIONS") { - return { - statusCode: 200, - headers: CORS_HEADERS, - body: "", - }; - } - - if (event.httpMethod !== "GET") { - return { - statusCode: 405, - headers: CORS_HEADERS, - body: JSON.stringify({ error: "Method not allowed. Use GET." }), - }; - } - - logger.info({ endpoint: "supported" }, "Processing supported capabilities request"); - - try { - const capabilities = getSplitterCapabilities(); - - logger.info( - { - networks: capabilities.kinds.map((k) => k.network), - schemes: capabilities.kinds.map((k) => k.scheme), - }, - "Returning supported capabilities", - ); - - return { - statusCode: 200, - headers: CORS_HEADERS, - body: JSON.stringify(capabilities), - }; - } catch (error) { - logger.error({ err: error, message: error.message }, "Unexpected error in supported handler"); - return { - statusCode: 500, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Internal server error", - }), - }; - } -} - -/** - * Main handler with routing - * Routes requests to /verify, /settle, /supported endpoints - */ -export async function handle(event, context) { - const path = event.path || event.rawUrl || ""; - - logger.debug({ path, method: event.httpMethod }, "Incoming request"); - - if (path.includes("/supported")) { - return handleSupported(event, context); - } - if (path.includes("/settle")) { - return handleSettle(event, context); - } - if (path.includes("/verify")) { - return handleVerify(event, context); - } - - // Default 404 - return { - statusCode: 404, - headers: CORS_HEADERS, - body: JSON.stringify({ - error: "Endpoint not found. Use /verify, /settle, or /supported", - }), - }; -} - -/** - * Local development server for testing - * Start with: NODE_ENV=test node x402_splitter_facilitator.js - */ -if (process.env.NODE_ENV === "test") { - (async () => { - const dotenvModule = await import("dotenv"); - dotenvModule.config(); - - const scw_fnc_node = await import("@scaleway/serverless-functions"); - scw_fnc_node.serveHandler(handle, 8081); // Different port from main facilitator - - logger.info("🚀 Splitter Facilitator local server started at http://localhost:8081"); - logger.info(" POST http://localhost:8081/verify"); - logger.info(" POST http://localhost:8081/settle"); - logger.info(" GET http://localhost:8081/supported"); - })().catch((err) => logger.error({ err }, "Error starting local server")); -} diff --git a/x402_facilitator/x402_splitter_settle.js b/x402_facilitator/x402_splitter_settle.js deleted file mode 100644 index 68c4461b1..000000000 --- a/x402_facilitator/x402_splitter_settle.js +++ /dev/null @@ -1,293 +0,0 @@ -// @ts-check - -/** - * ⚠️ RETIRED — superseded by the merchant-pays model in `x402_fee.ts`. - * - * Buyer-pays splitter: the buyer signs one EIP-3009 authorization to EIP3009SplitterV1, - * which atomically pays seller and facilitator. The seller needs no setup, but the buyer - * needs a non-stock client and `payTo` shows the splitter rather than the real recipient. - * See README → "Fee model history" for why the merchant-pays model won. - * - * Not deployed and not built: no function in `serverless.yml` routes here, and it is - * excluded from the tsdown `entry` list so it does not ship in the deploy archive. - * - * Retained, not dead: this is a working implementation of the alternative, and the - * contract at 0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946 (Optimism Sepolia) is referenced - * from x402-foundation/x402#937, so that deployment stays up. Its tests still run. - */ - -/** - * x402 Splitter Facilitator - Settlement Logic - * Executes payments via EIP3009SplitterV1 contract - * - * Settlement Flow: - * 1. Verify payment first (mandatory gatekeeper) - * 2. Extract seller and salt from payload - * 3. Compute nonce = keccak256(abi.encode(seller, salt)) - * 4. Call splitter.executeSplit() on-chain - * 5. Wait for transaction confirmation - * 6. Return transaction hash and network - * - * Key Features: - * - Automatic fee split (seller gets totalAmount - fixedFee) - * - No whitelist check (public facilitator) - * - Nonce computed from seller+salt (prevents replay) - */ - -import { - createWalletClient, - createPublicClient, - http, - getContract, - encodeAbiParameters, - keccak256, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import pino from "pino"; -import { - EIP3009SplitterV1ABI, - getEIP3009SplitterAddress, - getViemChain, - getUSDCAddress, -} from "@fretchen/chain-utils"; -import { verifySplitterPayment } from "./x402_splitter_verify.js"; - -// Alias for backward compatibility -const SPLITTER_ABI = EIP3009SplitterV1ABI; -const getSplitterAddress = getEIP3009SplitterAddress; -const FIXED_FEE = process.env.FIXED_FEE || "10000"; // 0.01 USDC - -const logger = pino({ level: process.env.LOG_LEVEL || "info" }); - -/** - * Settle payment via Splitter Contract - * @param {Object} paymentPayload - Payment payload from client - * @param {Object} paymentRequirements - Payment requirements from resource server - * @returns {Promise<{success: boolean, payer?: string, transaction?: string, network?: string, errorReason?: string}>} - */ -export async function settleSplitterPayment(paymentPayload, paymentRequirements) { - try { - // Step 1: Verify payment first (mandatory gatekeeper) - logger.info("Verifying payment before settlement"); - const verifyResult = await verifySplitterPayment(paymentPayload, paymentRequirements); - - if (!verifyResult.isValid) { - logger.warn({ invalidReason: verifyResult.invalidReason }, "Payment verification failed"); - return { - success: false, - errorReason: verifyResult.invalidReason || "verification_failed", - payer: verifyResult.payer, - transaction: "", - network: paymentPayload.accepted?.network, - }; - } - - // Step 2: Extract data from payload - const network = paymentPayload.accepted?.network; - const chain = getViemChain(network); - - const auth = paymentPayload.payload.authorization; - const { from: buyer, value: totalAmountStr, validAfter, validBefore } = auth; - const totalAmount = BigInt(totalAmountStr); - - // Extract signature (x402 v2 format: signature is separate field) - const signature = paymentPayload.payload.signature; - if (!signature) { - throw new Error("Missing signature in payload"); - } - - // Parse signature (viem format: 0x prefixed hex string) - // v, r, s can be extracted from signature - const sig = signature.startsWith("0x") ? signature : `0x${signature}`; - - // Extract v, r, s from signature (EIP-2098 compact or standard format) - const r = `0x${sig.slice(2, 66)}`; - const s = `0x${sig.slice(66, 130)}`; - const v = parseInt(sig.slice(130, 132) || "1b", 16); - - // Extract seller and salt from payload - // The ExactSplitEvmScheme adds these fields to payload - const seller = paymentPayload.payload?.seller; - const salt = paymentPayload.payload?.salt; - - if (!seller) { - throw new Error("Missing seller address in payload (required for splitter settlement)"); - } - - if (!salt) { - throw new Error("Missing salt in payload (required for nonce computation)"); - } - - logger.info( - { - buyer, - seller, - totalAmount: totalAmount.toString(), - salt: salt.slice(0, 20) + "...", - }, - "Extracted settlement data", - ); - - // Step 3: Compute nonce = keccak256(abi.encode(seller, salt)) - // CRITICAL: This must match what the buyer signed! - const nonce = keccak256( - encodeAbiParameters([{ type: "address" }, { type: "bytes32" }], [seller, salt]), - ); - - logger.info( - { nonce, seller, salt: salt.slice(0, 10) + "..." }, - "Computed nonce from seller+salt", - ); - - // Get contract addresses - const splitterAddress = getSplitterAddress(network); - const usdcAddress = getUSDCAddress(network); - - logger.info( - { - splitter: splitterAddress, - token: usdcAddress, - network, - }, - "Settlement contract addresses", - ); - - // Step 4: Setup wallet client for on-chain transaction - const facilitatorPrivateKey = process.env.FACILITATOR_WALLET_PRIVATE_KEY; - if (!facilitatorPrivateKey) { - throw new Error("FACILITATOR_WALLET_PRIVATE_KEY not configured"); - } - - const account = privateKeyToAccount( - facilitatorPrivateKey.startsWith("0x") ? facilitatorPrivateKey : `0x${facilitatorPrivateKey}`, - ); - - const walletClient = createWalletClient({ - account, - chain, - transport: http(), - }); - - const publicClient = createPublicClient({ - chain, - transport: http(), - }); - - logger.info({ facilitatorWallet: account.address }, "Using facilitator wallet"); - - // Step 5: Get splitter contract instance - const splitter = getContract({ - address: splitterAddress, - abi: SPLITTER_ABI, - client: { public: publicClient, wallet: walletClient }, - }); - - // Step 6: Call executeSplit on-chain - logger.info( - { - token: tokenInfo.address, - buyer, - seller, - totalAmount: totalAmount.toString(), - nonce, - }, - "Calling splitter.executeSplit()", - ); - - const hash = await splitter.write.executeSplit([ - tokenInfo.address, // token (USDC) - buyer, // buyer (payer) - seller, // seller (recipient) - salt, // salt (for nonce computation) - totalAmount, // totalAmount (seller amount + fee) - BigInt(validAfter), // validAfter - BigInt(validBefore), // validBefore - nonce, // nonce (keccak256(seller, salt)) - v, // v (signature component) - r, // r (signature component) - s, // s (signature component) - ]); - - logger.info( - { - hash, - buyer, - seller, - totalAmount: totalAmount.toString(), - }, - "Settlement transaction submitted", - ); - - // Step 7: Wait for confirmation - const receipt = await publicClient.waitForTransactionReceipt({ - hash, - confirmations: 1, - }); - - if (receipt.status === "success") { - logger.info( - { - hash, - blockNumber: receipt.blockNumber, - gasUsed: receipt.gasUsed.toString(), - }, - "Transaction confirmed successfully", - ); - - return { - success: true, - payer: buyer, - transaction: hash, - network, - // x402 Fee Disclosure receipt (coinbase/x402#1016) - extensions: { - facilitatorFees: { - info: { - version: "1", - facilitatorFeePaid: FIXED_FEE, - asset: `${network}/erc20:${usdcAddress}`, - model: "flat", - }, - }, - }, - }; - } else { - logger.warn({ hash, status: receipt.status }, "Transaction failed on-chain"); - return { - success: false, - errorReason: "transaction_reverted", - payer: buyer, - transaction: hash, - network, - }; - } - } catch (error) { - logger.error({ err: error, message: error.message }, "Settlement failed"); - - // Extract meaningful error reason from error message - let errorReason = "settlement_failed"; - const errorMsg = error.message?.toLowerCase() || ""; - - if (errorMsg.includes("insufficient")) { - errorReason = "insufficient_funds"; - } else if (errorMsg.includes("nonce") || errorMsg.includes("authorization used")) { - errorReason = "authorization_already_used"; - } else if (errorMsg.includes("expired") || errorMsg.includes("invalid time")) { - errorReason = "authorization_expired"; - } else if (errorMsg.includes("signature") || errorMsg.includes("signer")) { - errorReason = "invalid_signature"; - } else if (errorMsg.includes("seller") || errorMsg.includes("verification")) { - errorReason = "seller_verification_failed"; - } else if (errorMsg.includes("fee")) { - errorReason = "insufficient_amount_for_fee"; - } - - return { - success: false, - errorReason, - payer: paymentPayload.payload?.authorization?.from, - transaction: "", - network: paymentPayload.accepted?.network, - }; - } -} diff --git a/x402_facilitator/x402_splitter_supported.js b/x402_facilitator/x402_splitter_supported.js deleted file mode 100644 index 1799b37d7..000000000 --- a/x402_facilitator/x402_splitter_supported.js +++ /dev/null @@ -1,85 +0,0 @@ -// @ts-check - -/** - * ⚠️ RETIRED — superseded by the merchant-pays model in `x402_fee.ts`. - * - * Buyer-pays splitter: the buyer signs one EIP-3009 authorization to EIP3009SplitterV1, - * which atomically pays seller and facilitator. The seller needs no setup, but the buyer - * needs a non-stock client and `payTo` shows the splitter rather than the real recipient. - * See README → "Fee model history" for why the merchant-pays model won. - * - * Not deployed and not built: no function in `serverless.yml` routes here, and it is - * excluded from the tsdown `entry` list so it does not ship in the deploy archive. - * - * Retained, not dead: this is a working implementation of the alternative, and the - * contract at 0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946 (Optimism Sepolia) is referenced - * from x402-foundation/x402#937, so that deployment stays up. Its tests still run. - */ - -/** - * x402 Splitter Facilitator - Supported Capabilities - * Returns x402 v2 compliant capability discovery response - * - * Uses custom "exact-split" scheme with fee splitting via smart contract - * Only includes networks where the splitter contract is actually deployed. - */ - -import { getChainConfig, getSupportedNetworks } from "./chain_utils.js"; - -const FIXED_FEE = process.env.FIXED_FEE || "10000"; // 0.01 USDC - -/** - * Get supported capabilities for splitter facilitator - * Only returns networks where SPLITTER_ADDRESS is configured (not null) - * @returns {Object} x402 v2 SupportedResponse - */ -export function getSplitterCapabilities() { - const kinds = []; - - for (const network of getSupportedNetworks()) { - const config = getChainConfig(network); - - // Only include networks with deployed splitter contract - if (config.SPLITTER_ADDRESS) { - kinds.push({ - x402Version: 2, - scheme: "exact-split", - network, - extra: { - facilitatorType: "splitter", - splitterAddress: config.SPLITTER_ADDRESS, - fixedFee: FIXED_FEE, - feeCurrency: "USDC", - feeDescription: - network === "eip155:10" - ? "0.01 USDC fixed fee per transaction" - : "0.01 USDC fixed fee per transaction (testnet)", - asset: `${network}/erc20:${config.USDC_ADDRESS}`, - }, - }); - } - } - - return { - // x402 v2 spec: /supported endpoint returns "kinds" array - kinds, - // x402 v2 spec: extensions array - extensions: - kinds.length > 0 - ? [ - { - name: "facilitatorFees", - version: "1", - model: "flat", - asset: "USDC", - flatFee: FIXED_FEE, - decimals: 6, - collection: "on_chain_split", - networks: kinds.map((k) => k.network), - }, - ] - : [], - // x402 v2 spec: signers map (empty - payments are signed by payers, not facilitator) - signers: {}, - }; -} diff --git a/x402_facilitator/x402_splitter_verify.js b/x402_facilitator/x402_splitter_verify.js deleted file mode 100644 index 20fa7dec3..000000000 --- a/x402_facilitator/x402_splitter_verify.js +++ /dev/null @@ -1,330 +0,0 @@ -// @ts-check - -/** - * ⚠️ RETIRED — superseded by the merchant-pays model in `x402_fee.ts`. - * - * Buyer-pays splitter: the buyer signs one EIP-3009 authorization to EIP3009SplitterV1, - * which atomically pays seller and facilitator. The seller needs no setup, but the buyer - * needs a non-stock client and `payTo` shows the splitter rather than the real recipient. - * See README → "Fee model history" for why the merchant-pays model won. - * - * Not deployed and not built: no function in `serverless.yml` routes here, and it is - * excluded from the tsdown `entry` list so it does not ship in the deploy archive. - * - * Retained, not dead: this is a working implementation of the alternative, and the - * contract at 0x7e67bf96ADbf4a813DD7b0A3Ca3060a937018946 (Optimism Sepolia) is referenced - * from x402-foundation/x402#937, so that deployment stays up. Its tests still run. - */ - -/** - * x402 Splitter Facilitator - Verification (NO WHITELIST) - * Validates EIP-3009 payments without seller whitelist check - * - * Key differences from whitelist version: - * - ❌ NO isAgentWhitelisted() check - * - ✅ Validates EIP-3009 signature correctness - * - ✅ Validates amount >= fixedFee (10000) - * - ✅ Validates token is USDC - * - ✅ Validates network supported - */ - -import { verifyTypedData, createPublicClient, http } from "viem"; -import pino from "pino"; -import { - getViemChain, - getUSDCName, - getUSDCAddress, - getEIP3009SplitterAddress, -} from "@fretchen/chain-utils"; - -const logger = pino({ level: process.env.LOG_LEVEL || "info" }); - -const FIXED_FEE = BigInt(process.env.FIXED_FEE || "10000"); // 0.01 USDC - -/** - * Verify EIP-3009 payment without whitelist check - * @param {Object} paymentPayload - Payment payload from client - * @param {Object} paymentRequirements - Payment requirements from resource server - * @returns {Promise<{isValid: boolean, invalidReason?: string, payer?: string}>} - */ -export async function verifySplitterPayment(paymentPayload, paymentRequirements) { - try { - // Validate payment payload matches requirements - const accepted = paymentPayload.accepted; - if (!accepted) { - logger.warn("Missing accepted field in payment payload"); - return { isValid: false, invalidReason: "missing_accepted" }; - } - - // Check amount matches - if (accepted.amount !== paymentRequirements.amount) { - logger.warn( - { expected: paymentRequirements.amount, received: accepted.amount }, - "Amount mismatch", - ); - return { isValid: false, invalidReason: "amount_mismatch" }; - } - - // Check recipient (payTo) matches - if (accepted.payTo?.toLowerCase() !== paymentRequirements.payTo?.toLowerCase()) { - logger.warn( - { expected: paymentRequirements.payTo, received: accepted.payTo }, - "Recipient mismatch", - ); - return { isValid: false, invalidReason: "recipient_mismatch" }; - } - - // Check network matches - if (accepted.network !== paymentRequirements.network) { - logger.warn( - { expected: paymentRequirements.network, received: accepted.network }, - "Network mismatch", - ); - return { isValid: false, invalidReason: "invalid_exact_evm_network_mismatch" }; - } - - // Check asset matches - if (accepted.asset?.toLowerCase() !== paymentRequirements.asset?.toLowerCase()) { - logger.warn( - { expected: paymentRequirements.asset, received: accepted.asset }, - "Asset mismatch", - ); - return { isValid: false, invalidReason: "asset_mismatch" }; - } - - // Check scheme matches - if (accepted.scheme !== paymentRequirements.scheme) { - logger.warn( - { expected: paymentRequirements.scheme, received: accepted.scheme }, - "Scheme mismatch", - ); - return { isValid: false, invalidReason: "scheme_mismatch" }; - } - - // Extract authorization - const auth = paymentPayload.payload?.authorization; - if (!auth) { - logger.warn("Missing authorization in payment payload"); - return { isValid: false, invalidReason: "missing_authorization" }; - } - - const { from, to, value, validAfter, validBefore, nonce } = auth; - - // Extract signature (can be in different formats) - // x402 v2: signature is a separate field at payload.payload.signature - const signature = paymentPayload.payload?.signature; - if (!signature) { - logger.warn("Missing signature in payment payload"); - return { isValid: false, invalidReason: "invalid_signature" }; - } - - // Validate required fields - if (!from || !to || !value || !validAfter || !validBefore || !nonce) { - logger.warn("Missing required authorization fields"); - return { isValid: false, invalidReason: "invalid_authorization" }; - } - - // Validate network - const network = paymentPayload.accepted?.network; - if (!["eip155:10", "eip155:11155420"].includes(network)) { - logger.warn({ network }, "Unsupported network"); - return { isValid: false, invalidReason: "unsupported_network" }; - } - - // Get chain config - const chain = getViemChain(network); - - // Validate token is USDC - const tokenAddress = paymentPayload.accepted?.asset; - if (!tokenAddress) { - logger.warn("Missing asset address"); - return { isValid: false, invalidReason: "invalid_payload" }; - } - - // Validate token is USDC on this network - let usdcAddress; - try { - usdcAddress = getUSDCAddress(network); - } catch { - logger.warn({ network }, "Unsupported network"); - return { isValid: false, invalidReason: "unsupported_network" }; - } - - if (tokenAddress.toLowerCase() !== usdcAddress.toLowerCase()) { - logger.warn( - { tokenAddress, expected: usdcAddress, network }, - "Unsupported token - only USDC accepted", - ); - return { isValid: false, invalidReason: "invalid_token_address" }; - } - - // Validate recipient address matches splitter contract - // For splitter facilitator: to = splitter address (not token address!) - // The splitter contract will receive the funds and split them - let splitterAddress; - try { - splitterAddress = getEIP3009SplitterAddress(network); - } catch { - logger.warn({ network }, "Splitter not deployed on network"); - return { isValid: false, invalidReason: "unsupported_network" }; - } - - if (to.toLowerCase() !== splitterAddress.toLowerCase()) { - logger.warn({ to, expectedSplitter: splitterAddress }, "Recipient must be splitter contract"); - return { isValid: false, invalidReason: "invalid_exact_evm_payload_recipient_mismatch" }; - } - - // Validate amount >= fixedFee - const totalAmount = BigInt(value); - if (totalAmount < FIXED_FEE) { - logger.warn( - { totalAmount: totalAmount.toString(), fixedFee: FIXED_FEE.toString() }, - "Payment amount too low", - ); - return { isValid: false, invalidReason: "invalid_exact_evm_payload_authorization_value" }; - } - - // Verify EIP-712 signature - // USDC version is always "2" across all supported chains - const domain = { - name: getUSDCName(network), - version: "2", - chainId: chain.id, - verifyingContract: tokenAddress, - }; - - const types = { - TransferWithAuthorization: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "value", type: "uint256" }, - { name: "validAfter", type: "uint256" }, - { name: "validBefore", type: "uint256" }, - { name: "nonce", type: "bytes32" }, - ], - }; - - const message = { - from, - to, - value: BigInt(value), - validAfter: BigInt(validAfter), - validBefore: BigInt(validBefore), - nonce, - }; - - // Log domain and message for debugging (debug level only) - if (logger.isLevelEnabled("debug")) { - logger.debug( - { - domain, - message: { - ...message, - value: message.value.toString(), - validAfter: message.validAfter.toString(), - validBefore: message.validBefore.toString(), - }, - }, - "EIP-712 signature verification", - ); - } - - const isValidSignature = await verifyTypedData({ - address: from, - domain, - types, - primaryType: "TransferWithAuthorization", - message, - signature, // Use signature directly (already in correct format) - }); - - if (!isValidSignature) { - logger.warn({ from }, "Invalid EIP-712 signature"); - return { isValid: false, invalidReason: "invalid_exact_evm_signature", payer: from }; - } - - // Check time window - const now = Math.floor(Date.now() / 1000); - const validAfterNum = Number(validAfter); - const validBeforeNum = Number(validBefore); - - if (now < validAfterNum) { - logger.warn({ now, validAfter: validAfterNum }, "Authorization not yet valid"); - return { - isValid: false, - invalidReason: "invalid_exact_evm_payload_authorization_valid_after", - payer: from, - }; - } - - if (now > validBeforeNum) { - logger.warn({ now, validBefore: validBeforeNum }, "Authorization expired"); - return { - isValid: false, - invalidReason: "invalid_exact_evm_payload_authorization_valid_before", - payer: from, - }; - } - - // Check if authorization already used (requires splitter contract call) - // For verify, we'll check this on-chain during settlement - // The contract will revert if nonce is reused - const publicClient = createPublicClient({ - chain, - transport: http(), - }); - - // Check buyer has sufficient balance - try { - const balance = await publicClient.readContract({ - address: tokenAddress, - abi: [ - { - name: "balanceOf", - type: "function", - stateMutability: "view", - inputs: [{ name: "account", type: "address" }], - outputs: [{ name: "", type: "uint256" }], - }, - ], - functionName: "balanceOf", - args: [from], - }); - - if (balance < totalAmount) { - logger.warn( - { balance: balance.toString(), required: totalAmount.toString() }, - "Insufficient balance", - ); - return { isValid: false, invalidReason: "insufficient_funds", payer: from }; - } - } catch (error) { - logger.error({ err: error }, "Failed to check balance"); - // Continue - balance check will happen on-chain - } - - logger.info( - { from, value: totalAmount.toString(), network }, - "Payment verified (no whitelist)", - ); - - return { - isValid: true, - payer: from, - }; - } catch (error) { - logger.error( - { - err: error, - message: error.message, - stack: error.stack, - name: error.name, - }, - "Verification error - check RPC connectivity and environment variables", - ); - return { - isValid: false, - invalidReason: "unexpected_verify_error", - }; - } -} diff --git a/x402_facilitator/x402_supported.ts b/x402_facilitator/x402_supported.ts deleted file mode 100644 index c6fbfa00f..000000000 --- a/x402_facilitator/x402_supported.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * 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/x402_facilitator/x402_verify.ts b/x402_facilitator/x402_verify.ts deleted file mode 100644 index 8bcdd9a7b..000000000 --- a/x402_facilitator/x402_verify.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * 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, @typescript-eslint/no-unsafe-argument - 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/x402_facilitator/x402_whitelist.ts b/x402_facilitator/x402_whitelist.ts deleted file mode 100644 index 7eccd78da..000000000 --- a/x402_facilitator/x402_whitelist.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 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); -}